Add the appliance web UI look, interface names, MCP and REST API, local zone

Web UI: dark/light themes (appliance layout, sidebar, switch checkboxes),
theme preference on the Advanced page, Changes page with the pending diff.

Interface names: router.interfaces.<name>.mac, lans.<l>.deviceName and
pppoe.vlanName; a rename is staged for the next reboot by router-apply.

Remote access: an MCP server (POST /mcp) and an OpenAPI REST API
(/api/v1, /api/openapi.json) with the same tokens, scopes, limits and audit
log; tools to read status, settings, leases, logs (get_logs), and to edit
and check the saved settings (never applied). Tokens on the API access page.

Firewall: a reserved `local` zone (the router itself) as the destination of
rules, so the web UI or SSH can be allowed from the WAN.

PLAN.md lists what is still left.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
2026-11-01 16:56:59 +01:00
co-authored by Claude Sonnet 5.5
parent 95ab7223b9
commit 586274af4e
41 changed files with 3138 additions and 152 deletions
+106 -9
View File
@@ -83,6 +83,7 @@ func main() {
stateDir := flag.String("state", "/var/lib/router", "state directory (secrets, UI state)")
acmeDir := flag.String("acme-dir", "", "directory of a certificate from an ACME client (fullchain.pem, key.pem), served when present")
leases := flag.String("leases", "/var/lib/kea/dhcp4.leases", "Kea's DHCPv4 lease file")
options := flag.String("options", "", "JSON documentation of the router options (offered to MCP clients)")
dev := flag.String("assets", "", "serve templates/ and static/ from this directory, re-read on every request (development)")
flag.Parse()
if *dev != "" {
@@ -106,7 +107,7 @@ func main() {
metrics := newCollector(sys, *leases)
srv := &http.Server{
Addr: *listen,
Handler: newApp(sys, metrics, *dev != "").routes(),
Handler: withOptions(newApp(sys, metrics, *dev != ""), *options).routes(),
TLSConfig: &tls.Config{GetCertificate: (&certSource{fallback: cert, dir: *acmeDir}).get, MinVersion: tls.VersionTLS12},
ReadHeaderTimeout: 10 * time.Second,
}
@@ -217,10 +218,19 @@ type app struct {
pagesMu sync.Mutex
pages map[string]*template.Template
loginMu sync.Mutex // one login attempt at a time
tokens *tokenStore
settingsMu sync.Mutex // edits through the API read, patch and write the settings file
optionsFile string // JSON documentation of the router options, for the MCP resource ("" if none)
}
func withOptions(a *app, file string) *app {
a.optionsFile = file
return a
}
func newApp(sys System, metrics *Collector, dev bool) *app {
a := &app{sys: sys, metrics: metrics, dev: dev, sessions: newSessions(30 * time.Minute)}
a := &app{sys: sys, metrics: metrics, dev: dev, sessions: newSessions(30 * time.Minute), tokens: newTokenStore(sys.tokensPath())}
a.funcs = template.FuncMap{
"short": func(p string) string {
if i := strings.LastIndex(p, "/"); i >= 0 && len(p) > i+34 {
@@ -257,7 +267,7 @@ func newApp(sys System, metrics *Collector, dev bool) *app {
func (a *app) parseTemplates() (map[string]*template.Template, error) {
files := map[string][]string{"overview": {"templates/dashboard.html"}}
pages := map[string]*template.Template{}
for _, page := range []string{"login", "overview", "network", "dhcp", "dns", "wireguard", "firewall", "ddns", "dnsproviders", "certificate", "advanced", "update"} {
for _, page := range []string{"login", "overview", "network", "dhcp", "dns", "wireguard", "firewall", "ddns", "dnsproviders", "certificate", "advanced", "update", "tokens", "changes"} {
t, err := template.New("layout.html").Funcs(a.funcs).
ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...)
if err != nil {
@@ -301,6 +311,7 @@ func (a *app) routes() http.Handler {
mux.HandleFunc("GET /advanced", a.auth(a.advancedPage))
mux.HandleFunc("POST /advanced", a.auth(a.advanced))
mux.HandleFunc("POST /vlan", a.auth(a.vlan))
mux.HandleFunc("POST /interfaces", a.auth(a.interfaces))
mux.HandleFunc("GET /dns", a.auth(a.dnsPage))
mux.HandleFunc("POST /dns", a.auth(a.dns))
mux.HandleFunc("GET /wireguard", a.auth(a.wireguardPage))
@@ -326,6 +337,12 @@ func (a *app) routes() http.Handler {
mux.HandleFunc("POST /update/check", a.auth(a.updateCheck))
mux.HandleFunc("POST /update/install", a.auth(a.updateInstall))
mux.HandleFunc("POST /update/upload", a.updateUpload)
mux.HandleFunc("/mcp", a.mcp)
a.registerAPI(mux)
mux.HandleFunc("GET /tokens", a.auth(a.tokensPage))
mux.HandleFunc("POST /tokens", a.auth(a.tokenCreate))
mux.HandleFunc("POST /tokens/revoke", a.auth(a.tokenRevoke))
mux.HandleFunc("GET /changes", a.auth(a.changesPage))
return securityHeaders(mux)
}
@@ -349,9 +366,11 @@ type page struct {
Error string
LoggedIn bool
Data any
Theme string // set by render: "", "light" or "dark"
}
func (a *app) render(w http.ResponseWriter, name string, p page) {
p.Theme = a.sys.loadPrefs().Theme
if err := a.page(name).Execute(w, p); err != nil {
log.Printf("rendering %s: %v", name, err)
}
@@ -555,8 +574,85 @@ type networkData struct {
}
// devices lists what the interface dropdowns offer for the form f.
func (a *app) devices(f Form) []Device {
return f.devices(Interfaces(), a.sys.loadPrefs().VLANs)
func (a *app) devices(s Settings, f Form) []Device {
names := s.cardNames()
var physical []string
nics := map[string]NIC{}
for _, n := range NICs() {
eff := effectiveName(names, n)
physical = append(physical, eff)
nics[eff] = n
}
devs := f.devices(physical, a.sys.loadPrefs().VLANs)
custom := s.vlanNames()
for i, d := range devs {
if d.Kind == "VLAN" {
devs[i].Custom = custom[d.Name]
} else if n, ok := nics[d.Name]; ok {
devs[i].Kernel, devs[i].MAC = n.Kernel, n.MAC
}
}
return devs
}
// kernelName is the kernel's name for the card with this MAC address.
func kernelName(mac string) string {
for _, n := range NICs() {
if n.MAC == mac {
return n.Kernel
}
}
return ""
}
// interfaces saves the names typed into the interface table. A new name is
// used by the router after the next apply and reboot.
func (a *app) interfaces(w http.ResponseWriter, r *http.Request, _ session) {
s, err := loadSettings(a.sys.settingsPath())
if err != nil {
http.Redirect(w, r, "/network?msg="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
n, _ := strconv.Atoi(r.PostFormValue("rows"))
var rows []Rename
for i := range min(n, 256) {
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("%s.%d", k, i)) }
rows = append(rows, Rename{Dev: v("dev"), MAC: v("mac"), From: v("from"), To: v("name")})
}
moved, err := s.rename(rows, kernelName)
msg := ""
if err == nil {
err = s.save(a.sys.settingsPath())
}
if err == nil {
// The UI's defined VLANs follow a renamed card.
prefs := a.sys.loadPrefs()
for i, v := range prefs.VLANs {
if parent, id := splitVLAN(v); id != 0 && moved[parent] != "" {
prefs.VLANs[i] = fmt.Sprintf("%s.%d", moved[parent], id)
}
}
slices.Sort(prefs.VLANs)
err = a.sys.savePrefs(prefs)
}
switch {
case err != nil:
msg = "Names not saved: " + err.Error()
case !renamedAny(rows):
msg = "Nothing to change."
default:
msg = "Saved. Apply, then reboot: the new names only exist after a restart."
}
http.Redirect(w, r, "/network?msg="+url.QueryEscape(msg), http.StatusSeeOther)
}
func renamedAny(rows []Rename) bool {
for _, r := range rows {
if strings.TrimSpace(r.To) != r.From {
return true
}
}
return false
}
// vlan defines or removes a VLAN device for the dropdowns. It lives in the
@@ -588,7 +684,7 @@ func (a *app) vlan(w http.ResponseWriter, r *http.Request, _ session) {
}
case r.PostFormValue("remove") != "":
dev := r.PostFormValue("remove")
for _, d := range s.form().devices(Interfaces(), prefs.VLANs) {
for _, d := range a.devices(s, s.form()) {
if d.Name == dev && !d.Removable {
msg = dev + " is in use."
}
@@ -627,7 +723,7 @@ func (a *app) networkPage(w http.ResponseWriter, r *http.Request, sess session)
}
f := s.form()
f.LANs = append(f.LANs, LANForm{DHCP: true}) // a row to add a LAN
p.Data = networkData{Form: f, Interfaces: Interfaces(), Devices: a.devices(f)}
p.Data = networkData{Form: f, Interfaces: Interfaces(), Devices: a.devices(s, f)}
a.render(w, "network", p)
}
@@ -678,7 +774,7 @@ func (a *app) network(w http.ResponseWriter, r *http.Request, sess session) {
f.WAN.PPPoEPassword = ""
f.WAN.PasswordSet = s.form().WAN.PasswordSet
p.Error = "Please correct the marked fields."
p.Data = networkData{Form: f, Errors: fe, Interfaces: Interfaces(), Devices: a.devices(f)}
p.Data = networkData{Form: f, Errors: fe, Interfaces: Interfaces(), Devices: a.devices(s, f)}
w.WriteHeader(http.StatusUnprocessableEntity)
a.render(w, "network", p)
return
@@ -701,6 +797,7 @@ func (a *app) network(w http.ResponseWriter, r *http.Request, sess session) {
type advancedData struct {
Settings string
PingTargets string
Theme string
}
func (a *app) advancedPage(w http.ResponseWriter, r *http.Request, sess session) {
@@ -714,7 +811,7 @@ func (a *app) advancedPage(w http.ResponseWriter, r *http.Request, sess session)
}
func (a *app) advancedData(settings string) advancedData {
return advancedData{Settings: settings, PingTargets: strings.Join(a.sys.loadPrefs().PingTargets, ", ")}
return advancedData{Settings: settings, PingTargets: strings.Join(a.sys.loadPrefs().PingTargets, ", "), Theme: a.sys.loadPrefs().Theme}
}
func (a *app) advanced(w http.ResponseWriter, r *http.Request, sess session) {