Files
nixos-router/pkgs/router-webui/system.go
T
nilsandClaude Sonnet 5.5 2eab735f59 Publish DHCP clients' names in DNS
router-webui leases-dns reads Kea's leases and publishes <name>.<domain>
with reverse records in the running Unbound (router.dns.dynamicLeases).
Names the configuration publishes are never taken over; a client's name
is cut to its first label and kept inside the local domain; of two
clients the newer lease wins. The DNS page switches it on and shows what
is published and what was skipped; router-setup enables it with a domain.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-30 00:18:01 +01:00

157 lines
4.6 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
// 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
}