router-release signs a release as a binary cache; router-update verifies it against the router's update keys, migrates settings.json, and applies it with commit-confirm. Includes the web UI page and a VM test. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
148 lines
4.4 KiB
Go
148 lines
4.4 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"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
|
|
}
|
|
|
|
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
|
|
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)
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|