Files
nixos-router/pkgs/router-webui/settings.go
T
nilsandClaude Opus 5.5 753459f5d7 Add a web UI for settings-configured routers
router-webui (Go, standard library only) edits settings.json: host
name, WAN (DHCP or PPPoE), LANs with DHCP, and the raw JSON for the
rest; keys the forms do not know are kept. It applies and confirms by
starting router-apply.service and router-confirm.service, which a
switch never restarts, and shows the build log and the rollback
countdown. HTTPS with a self-signed certificate, a PBKDF2 password
hash, CSRF tokens and an Origin check; reachable from management LANs
only (new router.managementPorts).

router-apply records the settings it built and router-confirm keeps
them as settings.confirmed.json (new rollback.onConfirm hook), so the
UI can tell unapplied changes and discard them.

checks.apply now also logs in from a LAN client, discards the change
left by the rollback, edits the DHCP pool, applies and confirms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:43:58 +01:00

361 lines
8.6 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"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
WAN WANForm
LANs []LANForm
}
type WANForm struct {
Interface string
Type string // "dhcp" or "pppoe"
PPPoEUser string
PPPoEVLAN string
PPPoEPassword string // write-only: empty keeps the stored one
PasswordSet bool
}
type LANForm struct {
Name string
Interface string
VLAN string
Address string
Prefix string
Internet bool
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"),
WAN: WANForm{
Interface: str(router, "wan", "interface"),
Type: str(router, "wan", "ipv4", "type"),
PPPoEUser: str(router, "wan", "ipv4", "pppoe", "username"),
PPPoEVLAN: str(router, "wan", "ipv4", "pppoe", "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
}
f.LANs = append(f.LANs, LANForm{
Name: name,
Interface: str(l, "interface"),
VLAN: str(l, "vlan"),
Address: str(l, "ipv4", "address"),
Prefix: str(l, "ipv4", "prefixLength"),
Internet: boolean(l, false, "internet"),
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}$`)
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
}
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 !ifaceRe.MatchString(f.WAN.Interface) {
errs["wan.interface"] = "an interface name is required"
}
wan["interface"] = f.WAN.Interface
ipv4cfg := obj(wan, "ipv4")
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 vlan, ok, err := optionalInt(f.WAN.PPPoEVLAN, 1, 4094); err != nil {
errs["wan.pppoe.vlan"] = err.Error()
} else if ok {
pppoe["vlan"] = vlan
} else {
delete(pppoe, "vlan")
}
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"
}
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"
}
l["interface"] = lf.Interface
if vlan, ok, err := optionalInt(lf.VLAN, 1, 4094); err != nil {
errs[field("vlan")] = err.Error()
} else if ok {
l["vlan"] = vlan
} else {
delete(l, "vlan")
}
l["internet"] = lf.Internet
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
}