Files
nixos-router/pkgs/router-webui/settings.go
T
nilsandClaude Sonnet 5.5 574748806f Add IPv6 and VLAN devices to the web UI and router-setup
The web UI lists physical and VLAN interfaces and takes a VLAN uplink as
a device name (enp2s0.1001); it has IPv6 checkboxes for the WAN and each
LAN. router-setup asks whether to create VLANs before WAN and LAN are
picked, and whether to enable IPv6. --config-only writes the
configuration without installing, which the setup test uses for a PPPoE
uplink on a VLAN.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-29 21:11:11 +01:00

448 lines
11 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 // 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
VLAN string
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"),
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
}
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"),
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}$`)
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
}
// 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 ""
}
// devices lists the physical interfaces plus the VLAN devices the settings
// use, each with the role it plays.
func (f Form) devices(physical []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
}
dev := l.Interface
if l.VLAN != "" {
dev += "." + l.VLAN
}
add(dev, "LAN "+l.Name)
}
names := slices.Clone(physical)
var vlans []string
for dev := range roles {
if _, id := splitVLAN(dev); id != 0 && !slices.Contains(names, dev) {
vlans = append(vlans, dev)
}
}
slices.Sort(vlans)
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], ", ")})
}
}
}
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
}
// 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"
}
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")
}
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"
}
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
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
}