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 // LeasesDNSDir is where leases-dns leaves its status; /run/router-leases-dns if empty. LeasesDNSDir string } 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 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 }