A page under System for router-upgrade: the channel to follow, Check, Preview (the package versions that change, the download size, what would be built here, a reboot note for a new kernel), Download now and Upgrade. The steps run in router-upgrade.service, which the page asks through a request file; the last run's output is kept in upgrade/last.log because a finished one-shot unit has no journal to look up. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
194 lines
6.1 KiB
Go
194 lines
6.1 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// System is how the UI acts on the box. Everything that changes the running
|
|
// system goes through systemd units (router-apply, router-confirm), so a
|
|
// switch that restarts the UI cannot cut an apply short.
|
|
type System struct {
|
|
ConfigDir string // the configuration flake, settings.json in it
|
|
StateDir string // /var/lib/router: secrets, UI state
|
|
// ReleaseFile describes the installed nixos-router; /etc/router-release if empty.
|
|
ReleaseFile string
|
|
// VersionFile holds the running NixOS version; /run/current-system/nixos-version if empty.
|
|
VersionFile string
|
|
// LeasesDNSDir is where leases-dns leaves its status; /run/router-leases-dns if empty.
|
|
LeasesDNSDir string
|
|
ACMEDir string // where the ACME client keeps the web UI's certificate (fullchain.pem, key.pem), if any
|
|
}
|
|
|
|
func (s System) leasesDNSDir() string {
|
|
if s.LeasesDNSDir != "" {
|
|
return s.LeasesDNSDir
|
|
}
|
|
return "/run/router-leases-dns"
|
|
}
|
|
|
|
func (s System) settingsPath() string { return filepath.Join(s.ConfigDir, "settings.json") }
|
|
func (s System) confirmedPath() string { return filepath.Join(s.StateDir, "settings.confirmed.json") }
|
|
func (s System) passwordPath() string { return filepath.Join(s.StateDir, "webui-password") }
|
|
func (s System) pppoePath() string { return filepath.Join(s.StateDir, "pppoe-password") }
|
|
|
|
func command(name string, args ...string) (string, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
|
|
return strings.TrimSpace(string(out)), err
|
|
}
|
|
|
|
func unitProperty(unit, prop string) string {
|
|
out, _ := command("systemctl", "show", "--value", "-p", prop, unit)
|
|
return out
|
|
}
|
|
|
|
// Status is what the overview shows.
|
|
type Status struct {
|
|
Current string
|
|
Confirmed string
|
|
Applying bool
|
|
ApplyResult string // "success", "exit-code", ... of the last apply ("" if none)
|
|
Log string
|
|
RollbackIn time.Duration // > 0 while a change awaits confirmation
|
|
Unapplied bool // settings.json differs from the confirmed settings
|
|
TokenEdit string // who edited them through the API, when the saved settings are still that edit
|
|
ConfirmRunning bool
|
|
}
|
|
|
|
func (s System) Status() Status {
|
|
st := Status{}
|
|
st.Current, _ = filepath.EvalSymlinks("/run/current-system")
|
|
st.Confirmed, _ = filepath.EvalSymlinks("/nix/var/nix/profiles/system")
|
|
state := unitProperty("router-apply.service", "ActiveState")
|
|
st.Applying = state == "activating"
|
|
if inv := unitProperty("router-apply.service", "InvocationID"); inv != "" {
|
|
st.ApplyResult = unitProperty("router-apply.service", "Result")
|
|
if st.Applying {
|
|
st.ApplyResult = ""
|
|
}
|
|
st.Log, _ = command("journalctl", "--no-pager", "-o", "cat", "-n", "200",
|
|
"_SYSTEMD_INVOCATION_ID="+inv)
|
|
}
|
|
st.ConfirmRunning = unitProperty("router-confirm.service", "ActiveState") == "activating"
|
|
st.RollbackIn = rollbackIn()
|
|
cur, err1 := os.ReadFile(s.settingsPath())
|
|
conf, err2 := os.ReadFile(s.confirmedPath())
|
|
st.Unapplied = err1 == nil && err2 == nil && !sameJSON(cur, conf)
|
|
if st.Unapplied {
|
|
st.TokenEdit = s.tokenEditNote(cur)
|
|
}
|
|
return st
|
|
}
|
|
|
|
func sameJSON(a, b []byte) bool {
|
|
var x, y any
|
|
if json.Unmarshal(a, &x) != nil || json.Unmarshal(b, &y) != nil {
|
|
return bytes.Equal(a, b)
|
|
}
|
|
xa, _ := json.Marshal(x)
|
|
ya, _ := json.Marshal(y)
|
|
return bytes.Equal(xa, ya)
|
|
}
|
|
|
|
// rollbackIn is the time left on router-rollback.timer, armed by router-apply.
|
|
func rollbackIn() time.Duration {
|
|
out, err := command("systemctl", "list-timers", "--output=json", "router-rollback.timer")
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
var timers []struct {
|
|
Next uint64 `json:"next"`
|
|
Unit string `json:"unit"`
|
|
}
|
|
if json.Unmarshal([]byte(out), &timers) != nil {
|
|
return 0
|
|
}
|
|
for _, t := range timers {
|
|
if t.Unit == "router-rollback.timer" && t.Next > 0 {
|
|
left := time.Until(time.UnixMicro(int64(t.Next)))
|
|
if left > 0 {
|
|
return left
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (s System) Apply() error {
|
|
_, err := command("systemctl", "start", "--no-block", "router-apply.service")
|
|
return err
|
|
}
|
|
|
|
// Reboot restarts the router a few seconds from now, so the page that asked
|
|
// for it is still answered.
|
|
func (s System) Reboot() error {
|
|
_, err := command("systemd-run", "--quiet", "--on-active=3s", "systemctl", "reboot")
|
|
return err
|
|
}
|
|
|
|
func (s System) rootPasswordPath() string { return filepath.Join(s.StateDir, "root-password") }
|
|
|
|
// setRootPassword is replaced in tests.
|
|
var setRootPassword = func(s System, password string) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "mkpasswd", "-s")
|
|
cmd.Stdin = strings.NewReader(password + "\n")
|
|
hash, err := cmd.Output()
|
|
if err != nil || !strings.HasPrefix(string(hash), "$") {
|
|
return fmt.Errorf("hashing the password for root: %v", err)
|
|
}
|
|
if err := writeFileAtomic(s.rootPasswordPath(), hash, 0o600); err != nil {
|
|
return err
|
|
}
|
|
// A unit does the part that needs more than the web UI may touch: /etc/shadow.
|
|
if out, err := command("systemctl", "start", "router-root-password.service"); err != nil {
|
|
return fmt.Errorf("setting root's password: %v %s", err, out)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s System) Confirm() error {
|
|
_, err := command("systemctl", "start", "--no-block", "router-confirm.service")
|
|
return err
|
|
}
|
|
|
|
// Discard puts back the settings of the confirmed system.
|
|
func (s System) Discard() error {
|
|
data, err := os.ReadFile(s.confirmedPath())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeFileAtomic(s.settingsPath(), data, 0o600)
|
|
}
|
|
|
|
// Interfaces lists the physical-looking network devices, for the forms.
|
|
func Interfaces() []string {
|
|
entries, _ := os.ReadDir("/sys/class/net")
|
|
var names []string
|
|
for _, e := range entries {
|
|
n := e.Name()
|
|
if n == "lo" {
|
|
continue
|
|
}
|
|
// Skip virtual devices (VLANs, PPP, WireGuard): they are not chosen
|
|
// as a LAN's or the uplink's interface.
|
|
if _, err := os.Stat(filepath.Join("/sys/class/net", n, "device")); err != nil {
|
|
continue
|
|
}
|
|
names = append(names, n)
|
|
}
|
|
slices.Sort(names)
|
|
return names
|
|
}
|