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
+11 -6
View File
@@ -206,8 +206,8 @@ func (s Settings) applyZones(zf ZoneForm, errs FormErrors) {
case !nameRe.MatchString(m.Zone):
errs[fmt.Sprintf("member[%d]", i)] = "letters, digits and dashes, starting with a letter"
continue
case m.Zone == "wan":
errs[fmt.Sprintf("member[%d]", i)] = "wan is the uplink's zone and cannot be assigned"
case m.Zone == "wan" || m.Zone == "local":
errs[fmt.Sprintf("member[%d]", i)] = m.Zone + " is a reserved zone (the uplink, the router) and cannot be assigned"
continue
}
used[m.Zone] = true
@@ -226,8 +226,8 @@ func (s Settings) applyZones(zf ZoneForm, errs FormErrors) {
declared := slices.Clone(s.declaredZones())
for _, z := range splitList(zf.New) {
switch {
case !nameRe.MatchString(z) || z == "wan":
errs["newzone"] = z + ": letters, digits and dashes, starting with a letter (wan is taken)"
case !nameRe.MatchString(z) || z == "wan" || z == "local":
errs["newzone"] = z + ": letters, digits and dashes, starting with a letter (wan and local are taken)"
case !slices.Contains(declared, z):
declared = append(declared, z)
}
@@ -279,9 +279,12 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, nats []NATRule
if !slices.Contains(zones, r.From) && r.From != "wan" {
errs[field("from")] = "choose a zone"
}
if !slices.Contains(zones, r.To) {
if !slices.Contains(zones, r.To) && r.To != "local" {
errs[field("to")] = "choose a zone"
}
if r.To == "local" && r.NATOnly {
errs[field("to")] = "a port forward leads to a host in a zone, not to the router"
}
if r.From == r.To && r.From != "" {
errs[field("to")] = "traffic inside a zone is already allowed"
}
@@ -312,7 +315,7 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, nats []NATRule
}
if r.From == "wan" {
// What the module requires, so that a typo cannot open a whole zone.
if r.Destination == "" {
if r.Destination == "" && r.To != "local" {
errs[field("destination")] = "rules from the internet must name the host or network inside"
}
if r.Protocol == "any" {
@@ -402,6 +405,7 @@ type firewallData struct {
Rows []FirewallRule
NATs []NATRule
FromZones []string // the zones and wan
ToZones []string // the zones and local (the router itself)
Other []string
Zones []string
Members []ZoneMember
@@ -414,6 +418,7 @@ func (s Settings) firewallData(rows []FirewallRule, nats []NATRule, members []Zo
_, other := s.firewallRules()
d := firewallData{Rows: rows, NATs: nats, Other: other, Zones: s.zones(), Members: members, Errors: errs}
d.FromZones = append([]string{"wan"}, d.Zones...)
d.ToZones = append(slices.Clone(d.Zones), "local")
if d.Members == nil {
d.Members = s.zoneMembers()
}