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>
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user