Files
nixos-router/pkgs/router-webui/settings.go
T
nilsandClaude Sonnet 5.5 586274af4e 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
2026-11-01 16:56:59 +01:00

494 lines
13 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"maps"
"net/netip"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
)
// Settings is settings.json kept generic: the forms change the keys they know
// and everything else (WireGuard, port forwards, ...) survives untouched.
type Settings map[string]any
func loadSettings(path string) (Settings, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return parseSettings(data)
}
func parseSettings(data []byte) (Settings, error) {
var s Settings
if err := json.Unmarshal(data, &s); err != nil {
return nil, fmt.Errorf("settings are not valid JSON: %w", err)
}
if s == nil {
return nil, fmt.Errorf("settings must be a JSON object")
}
return s, nil
}
func (s Settings) marshal() ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetIndent("", " ")
enc.SetEscapeHTML(false)
if err := enc.Encode(s); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// writeFileAtomic replaces path so that readers never see a partial file.
func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Chmod(mode); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}
func (s Settings) save(path string) error {
data, err := s.marshal()
if err != nil {
return err
}
return writeFileAtomic(path, data, 0o600)
}
// obj returns the object at key, creating it if needed.
func obj(m map[string]any, key string) map[string]any {
if v, ok := m[key].(map[string]any); ok {
return v
}
v := map[string]any{}
m[key] = v
return v
}
func str(m map[string]any, keys ...string) string {
for _, k := range keys[:len(keys)-1] {
next, ok := m[k].(map[string]any)
if !ok {
return ""
}
m = next
}
switch v := m[keys[len(keys)-1]].(type) {
case string:
return v
case float64: // as decoded from JSON
return strconv.FormatFloat(v, 'f', -1, 64)
case int: // as set by apply
return strconv.Itoa(v)
}
return ""
}
func boolean(m map[string]any, def bool, keys ...string) bool {
for _, k := range keys[:len(keys)-1] {
next, ok := m[k].(map[string]any)
if !ok {
return def
}
m = next
}
if v, ok := m[keys[len(keys)-1]].(bool); ok {
return v
}
return def
}
// Form is what the network page edits.
type Form struct {
HostName string
Domain string // the local domain (router.dns.domain)
WAN WANForm
LANs []LANForm
}
type WANForm struct {
Interface string // a device: "enp2s0", or "enp2s0.1001" for a VLAN
Type string // "dhcp" or "pppoe"
IPv6 bool
PPPoEUser string
PPPoEPassword string // write-only: empty keeps the stored one
PasswordSet bool
}
type LANForm struct {
Name string
Interface string // a device: "eth2", or "eth2.20" for a VLAN
Address string
Prefix string
Internet bool
IPv6PD bool // delegate the ISP's prefix to this LAN
DHCP bool
PoolStart string
PoolEnd string
Remove bool
}
func (s Settings) form() Form {
router, _ := s["router"].(map[string]any)
if router == nil {
router = map[string]any{}
}
f := Form{
HostName: str(s, "system", "hostName"),
Domain: str(router, "dns", "domain"),
WAN: WANForm{
Interface: str(router, "wan", "interface"),
Type: str(router, "wan", "ipv4", "type"),
IPv6: boolean(router, false, "wan", "ipv6", "enable"),
PPPoEUser: str(router, "wan", "ipv4", "pppoe", "username"),
},
}
if vlan := str(router, "wan", "ipv4", "pppoe", "vlan"); vlan != "" && f.WAN.Type == "pppoe" {
f.WAN.Interface += "." + vlan
}
if f.WAN.Type == "" {
f.WAN.Type = "dhcp"
}
f.WAN.PasswordSet = str(router, "wan", "ipv4", "pppoe", "passwordFile") != ""
lans, _ := router["lans"].(map[string]any)
names := make([]string, 0, len(lans))
for name := range lans {
names = append(names, name)
}
slices.Sort(names)
for _, name := range names {
l, _ := lans[name].(map[string]any)
if l == nil {
continue
}
dev := str(l, "interface")
if vlan := str(l, "vlan"); vlan != "" {
dev += "." + vlan
}
f.LANs = append(f.LANs, LANForm{
Name: name,
Interface: dev,
Address: str(l, "ipv4", "address"),
Prefix: str(l, "ipv4", "prefixLength"),
Internet: boolean(l, false, "internet"),
IPv6PD: boolean(l, false, "ipv6", "pd"),
DHCP: boolean(l, true, "dhcp", "enable"),
PoolStart: str(l, "dhcp", "pool", "start"),
PoolEnd: str(l, "dhcp", "pool", "end"),
})
}
return f
}
var (
nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,14}$`)
// A DNS name of up to 253 characters.
domainRe = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
ifaceRe = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,15}$`)
)
// FormErrors maps a field to what is wrong with it.
type FormErrors map[string]string
func (e FormErrors) Error() string {
keys := make([]string, 0, len(e))
for k := range e {
keys = append(keys, k)
}
slices.Sort(keys)
var b strings.Builder
for _, k := range keys {
fmt.Fprintf(&b, "%s: %s; ", k, e[k])
}
return strings.TrimSuffix(b.String(), "; ")
}
func optionalInt(v string, lo, hi int) (int, bool, error) {
v = strings.TrimSpace(v)
if v == "" {
return 0, false, nil
}
n, err := strconv.Atoi(v)
if err != nil || n < lo || n > hi {
return 0, false, fmt.Errorf("must be a number from %d to %d", lo, hi)
}
return n, true, nil
}
// splitVLAN splits "enp2s0.1001" into the interface and its VLAN ID; a name
// without one has VLAN 0.
func splitVLAN(dev string) (string, int) {
dev = strings.TrimSpace(dev)
i := strings.LastIndex(dev, ".")
if i <= 0 {
return dev, 0
}
id, err := strconv.Atoi(dev[i+1:])
if err != nil || id < 1 || id > 4094 {
return dev, 0
}
return dev[:i], id
}
// badVLAN reports a device name that looks like a VLAN (interface.digits) but
// whose ID is not from 1 to 4094.
func badVLAN(dev string) bool {
i := strings.LastIndex(dev, ".")
if i <= 0 {
return false
}
if _, err := strconv.Atoi(dev[i+1:]); err != nil {
return false
}
_, id := splitVLAN(dev)
return id == 0
}
// Device is a row of the interface list: a physical NIC or a VLAN on one.
type Device struct {
Name string
Kind string // "physical" or "VLAN"
Role string // "WAN", "LAN lan", or ""
Removable bool // a VLAN defined in the UI that nothing uses
// Set by the app from the machine and the settings.
Kernel string // physical: the name the kernel gives the card now
MAC string // physical: of the card, when it is on this machine
Custom string // VLAN: the name chosen for its device
}
// devices lists the physical interfaces plus the VLAN devices the settings
// use or the UI defined (defined), each with the role it plays.
func (f Form) devices(physical, defined []string) []Device {
roles := map[string][]string{}
add := func(dev, role string) { roles[dev] = append(roles[dev], role) }
add(f.WAN.Interface, "WAN")
for _, l := range f.LANs {
if l.Name == "" || l.Remove {
continue
}
add(l.Interface, "LAN "+l.Name)
}
// Interfaces the settings or the defined VLANs refer to are listed even
// when this machine lacks them (other hardware).
names := slices.Clone(physical)
var vlans, extra []string
for _, dev := range append(slices.Collect(maps.Keys(roles)), defined...) {
base, id := splitVLAN(dev)
if id != 0 && !slices.Contains(vlans, dev) {
vlans = append(vlans, dev)
}
if !slices.Contains(names, base) && !slices.Contains(extra, base) {
extra = append(extra, base)
}
}
slices.Sort(vlans)
slices.Sort(extra)
names = append(names, extra...)
var out []Device
for _, n := range names {
out = append(out, Device{Name: n, Kind: "physical", Role: strings.Join(roles[n], ", ")})
for _, v := range vlans {
if parent, _ := splitVLAN(v); parent == n {
out = append(out, Device{Name: v, Kind: "VLAN", Role: strings.Join(roles[v], ", "),
Removable: len(roles[v]) == 0 && slices.Contains(defined, v)})
}
}
}
return out
}
func ipv4(v string) (netip.Addr, bool) {
a, err := netip.ParseAddr(strings.TrimSpace(v))
return a, err == nil && a.Is4()
}
// apply writes the form into the settings. Only basic checks happen here; the
// build on the device validates the rest (and the router's assertions).
// It returns the PPPoE password to store, if one was entered.
func (s Settings) apply(f Form, passwordFile string) (string, error) {
errs := FormErrors{}
system := obj(s, "system")
router := obj(s, "router")
wan := obj(router, "wan")
if f.HostName = strings.TrimSpace(f.HostName); f.HostName != "" {
if !regexp.MustCompile(`^[a-zA-Z0-9-]{1,63}$`).MatchString(f.HostName) {
errs["hostName"] = "letters, digits and dashes only"
}
system["hostName"] = f.HostName
}
if f.Domain = strings.ToLower(strings.TrimSpace(f.Domain)); f.Domain == "" {
if dns, ok := router["dns"].(map[string]any); ok {
delete(dns, "domain")
}
} else if !domainRe.MatchString(f.Domain) {
errs["domain"] = "letters, digits and dashes, in labels separated by dots (home.example.org)"
} else {
obj(router, "dns")["domain"] = f.Domain
}
// A VLAN device (enp2s0.1001) is the physical interface plus the
// PPPoE session's VLAN.
wanDev, wanVLAN := splitVLAN(f.WAN.Interface)
if !ifaceRe.MatchString(f.WAN.Interface) {
errs["wan.interface"] = "an interface name is required"
} else if badVLAN(f.WAN.Interface) {
errs["wan.interface"] = "the VLAN ID must be from 1 to 4094"
}
wan["interface"] = wanDev
ipv4cfg := obj(wan, "ipv4")
if wanVLAN != 0 && f.WAN.Type != "pppoe" {
errs["wan.interface"] = "an uplink on a VLAN needs PPPoE"
}
switch f.WAN.Type {
case "dhcp":
ipv4cfg["type"] = "dhcp"
case "pppoe":
ipv4cfg["type"] = "pppoe"
pppoe := obj(ipv4cfg, "pppoe")
pppoe["username"] = strings.TrimSpace(f.WAN.PPPoEUser)
if pppoe["username"] == "" {
errs["wan.pppoe.username"] = "required for PPPoE"
}
if wanVLAN != 0 {
pppoe["vlan"] = wanVLAN
} else {
delete(pppoe, "vlan")
delete(pppoe, "vlanName")
}
if f.WAN.PPPoEPassword != "" {
pppoe["passwordFile"] = passwordFile
} else if str(pppoe, "passwordFile") == "" {
errs["wan.pppoe.password"] = "required for PPPoE"
}
default:
errs["wan.type"] = "must be dhcp or pppoe"
}
if f.WAN.IPv6 {
v6 := obj(wan, "ipv6")
v6["enable"] = true
// PPPoE carries IPv6 inside the session; otherwise it is native.
if f.WAN.Type == "pppoe" {
v6["transport"] = "ppp"
} else {
v6["transport"] = "native"
}
} else if v6, ok := wan["ipv6"].(map[string]any); ok {
v6["enable"] = false
}
lans := obj(router, "lans")
keep := map[string]bool{}
for i, lf := range f.LANs {
field := func(n string) string { return fmt.Sprintf("lans[%d].%s", i, n) }
lf.Name = strings.TrimSpace(lf.Name)
if lf.Name == "" && lf.Interface == "" && lf.Address == "" {
continue // the empty "add a LAN" row
}
if !nameRe.MatchString(lf.Name) {
errs[field("name")] = "lowercase letters, digits and dashes, starting with a letter"
continue
}
if keep[lf.Name] {
errs[field("name")] = "used twice"
continue
}
if lf.Remove {
continue
}
keep[lf.Name] = true
l := obj(lans, lf.Name)
if !ifaceRe.MatchString(lf.Interface) {
errs[field("interface")] = "an interface name is required"
} else if badVLAN(lf.Interface) {
errs[field("interface")] = "the VLAN ID must be from 1 to 4094"
}
lanDev, lanVLAN := splitVLAN(lf.Interface)
l["interface"] = lanDev
if lanVLAN != 0 {
l["vlan"] = lanVLAN
} else {
delete(l, "vlan")
delete(l, "deviceName")
}
l["internet"] = lf.Internet
if lf.IPv6PD {
obj(l, "ipv6")["pd"] = true
} else if v6, ok := l["ipv6"].(map[string]any); ok {
delete(v6, "pd")
if len(v6) == 0 {
delete(l, "ipv6")
}
}
v4 := obj(l, "ipv4")
if _, ok := ipv4(lf.Address); !ok {
errs[field("address")] = "an IPv4 address is required"
}
v4["address"] = strings.TrimSpace(lf.Address)
if p, ok, err := optionalInt(lf.Prefix, 8, 30); err != nil {
errs[field("prefix")] = err.Error()
} else if ok {
v4["prefixLength"] = p
} else {
delete(v4, "prefixLength")
}
dhcp := obj(l, "dhcp")
dhcp["enable"] = lf.DHCP
if lf.DHCP {
start, ok1 := ipv4(lf.PoolStart)
end, ok2 := ipv4(lf.PoolEnd)
if !ok1 {
errs[field("poolStart")] = "an IPv4 address is required"
}
if !ok2 {
errs[field("poolEnd")] = "an IPv4 address is required"
}
if ok1 && ok2 && end.Less(start) {
errs[field("poolEnd")] = "must not be before the start"
}
pool := obj(dhcp, "pool")
pool["start"] = strings.TrimSpace(lf.PoolStart)
pool["end"] = strings.TrimSpace(lf.PoolEnd)
}
}
for name := range lans {
if !keep[name] {
delete(lans, name)
}
}
if len(keep) == 0 {
errs["lans"] = "at least one LAN is required"
}
if len(errs) > 0 {
return "", errs
}
return f.WAN.PPPoEPassword, nil
}