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
+24 -5
View File
@@ -26,6 +26,8 @@ type Prefs struct {
// VLANs defined in the network page ("enp2s0.1001") that may not be used
// yet; the interface dropdowns offer them.
VLANs []string `json:"vlans,omitempty"`
// Theme is "light" or "dark"; empty follows the browser's setting.
Theme string `json:"theme,omitempty"`
}
var defaultPrefs = Prefs{PingTargets: []string{"gateway", "1.1.1.1", "9.9.9.9"}}
@@ -400,18 +402,35 @@ func (a *app) dashboardFragment(w http.ResponseWriter, r *http.Request, sess ses
}
}
// savePrefs takes the Advanced page's two small forms: the ping targets, or
// the theme.
func (a *app) savePrefs(w http.ResponseWriter, r *http.Request, sess session) {
targets, err := parsePingTargets(r.PostFormValue("pingTargets"))
_ = r.ParseForm()
prefs := a.sys.loadPrefs()
what := "Ping targets"
var err error
if r.PostForm.Has("theme") {
what = "Appearance"
switch t := r.PostFormValue("theme"); t {
case "", "light", "dark":
prefs.Theme = t
default:
err = fmt.Errorf("unknown theme %q", t)
}
} else {
var targets []string
if targets, err = parsePingTargets(r.PostFormValue("pingTargets")); err == nil {
prefs.PingTargets = targets
}
}
if err == nil {
prefs := a.sys.loadPrefs()
prefs.PingTargets = targets
err = a.sys.savePrefs(prefs)
}
if err != nil {
http.Redirect(w, r, "/advanced?msg="+url.QueryEscape("Ping targets not saved: "+err.Error()), http.StatusSeeOther)
http.Redirect(w, r, "/advanced?msg="+url.QueryEscape(what+" not saved: "+err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/advanced?msg="+url.QueryEscape("Ping targets saved."), http.StatusSeeOther)
http.Redirect(w, r, "/advanced?msg="+url.QueryEscape(what+" saved."), http.StatusSeeOther)
}
// backup streams the state directory (settings, secrets, certificate, metric