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
494 lines
13 KiB
Go
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
|
|
}
|