Files
nixos-router/pkgs/router-webui/interfaces.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

219 lines
6.5 KiB
Go

package main
import (
"fmt"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
)
// Names for network cards and VLAN devices. A card is named by its MAC address
// (router.interfaces.<name>.mac), a VLAN device by its LAN (lans.<l>.deviceName)
// or the PPPoE session (wan.ipv4.pppoe.vlanName). Linux cannot rename a device
// that is up, so the router only starts using a new name after a reboot;
// router-apply stages such a change instead of activating it.
// reservedNameRe matches the names the kernel and udev hand out themselves:
// a rename onto one could collide with a card that gets it at boot.
// Keep in step with chosenNameOk in modules/router/assertions.nix.
var reservedNameRe = regexp.MustCompile(`^((eth|wlan|ppp|wg|veth|bond|tun|tap|dummy|ifb|docker|virbr|br|lo)[0-9]*|(en|wl|ww|sl)[opsx][0-9a-f].*)$`)
// chosenName says what is wrong with a name given to a card or VLAN device.
func chosenName(n string) string {
switch {
case !nameRe.MatchString(n):
return "lower case letters, digits and dashes, starting with a letter, at most 15 characters"
case reservedNameRe.MatchString(n):
return "looks like a name the kernel hands out (eth0, wlan0, enp2s0, ...)"
}
return ""
}
// NIC is a physical network card as the kernel names it now.
type NIC struct {
Kernel string
MAC string
}
// NICs lists the physical interfaces with their MAC addresses.
func NICs() []NIC {
var out []NIC
for _, n := range Interfaces() {
addr, _ := os.ReadFile(filepath.Join("/sys/class/net", n, "address"))
out = append(out, NIC{Kernel: n, MAC: strings.ToLower(strings.TrimSpace(string(addr)))})
}
return out
}
// cardNames returns router.interfaces as name -> MAC.
func (s Settings) cardNames() map[string]string {
out := map[string]string{}
router, _ := s["router"].(map[string]any)
cards, _ := router["interfaces"].(map[string]any)
for name, v := range cards {
if c, ok := v.(map[string]any); ok {
out[name] = str(c, "mac")
}
}
return out
}
// vlanNames returns the chosen VLAN device names, keyed "parent.id".
func (s Settings) vlanNames() map[string]string {
out := map[string]string{}
router, _ := s["router"].(map[string]any)
lans, _ := router["lans"].(map[string]any)
for _, v := range lans {
if l, ok := v.(map[string]any); ok && str(l, "deviceName") != "" {
out[str(l, "interface")+"."+str(l, "vlan")] = str(l, "deviceName")
}
}
if v := str(router, "wan", "ipv4", "pppoe", "vlanName"); v != "" && str(router, "wan", "ipv4", "type") == "pppoe" {
out[str(router, "wan", "interface")+"."+str(router, "wan", "ipv4", "pppoe", "vlan")] = v
}
return out
}
// effectiveName is what the settings call a card: the name chosen for its MAC,
// else the kernel's.
func effectiveName(names map[string]string, n NIC) string {
for name, mac := range names {
if mac != "" && mac == n.MAC {
return name
}
}
return n.Kernel
}
// Rename is one edited row of the interface table.
type Rename struct {
Dev string // the device as the settings call it: "lan", or "lan.1002" for a VLAN
MAC string // of a physical card; empty for a VLAN
From string // the name shown before editing: the card's name, the VLAN device's custom name or ""
To string // what the user typed; empty: back to the kernel's name / the default
}
// rename applies the edited rows to the settings. kernel gives the kernel's
// name for a MAC address (to undo a rename). The changes take effect after a
// reboot, so references to a card in the settings follow its new name at once.
// It returns the references to rewrite in the UI's own preferences too:
// old device name -> new.
func (s Settings) rename(rows []Rename, kernel func(mac string) string) (map[string]string, error) {
errs := FormErrors{}
moved := map[string]string{}
router := obj(s, "router")
for i, r := range rows {
field := fmt.Sprintf("name[%d]", i)
to := strings.TrimSpace(r.To)
if to == r.From {
continue
}
if r.MAC == "" && !strings.Contains(r.Dev, ".") {
errs[field] = "this interface is not on this machine"
continue
}
if to != "" {
if why := chosenName(to); why != "" {
errs[field] = why
continue
}
}
if r.MAC != "" {
s.renameCard(router, r, to, kernel, moved, errs, field)
} else {
s.renameVLAN(router, r, to, errs, field)
}
}
if len(errs) > 0 {
return nil, errs
}
// Two names must not be the same, whatever was typed.
if dup := s.duplicateName(); dup != "" {
return nil, FormErrors{"name": dup + " is used twice"}
}
return moved, nil
}
func (s Settings) renameCard(router map[string]any, r Rename, to string, kernel func(string) string, moved map[string]string, errs FormErrors, field string) {
cards := obj(router, "interfaces")
if to == "" {
to = kernel(r.MAC)
if to == "" {
errs[field] = "the kernel's name for this card is unknown"
return
}
delete(cards, r.Dev)
} else {
if c, ok := cards[to].(map[string]any); ok && str(c, "mac") != r.MAC {
errs[field] = to + " is the name of another interface"
return
}
delete(cards, r.Dev) // it may have a name already
obj(cards, to)["mac"] = r.MAC
}
if len(cards) == 0 {
delete(router, "interfaces")
}
moved[r.Dev] = to
if str(router, "wan", "interface") == r.Dev {
obj(router, "wan")["interface"] = to
}
lans, _ := router["lans"].(map[string]any)
for _, v := range lans {
if l, ok := v.(map[string]any); ok && str(l, "interface") == r.Dev {
l["interface"] = to
}
}
}
func (s Settings) renameVLAN(router map[string]any, r Rename, to string, errs FormErrors, field string) {
parent, id := splitVLAN(r.Dev)
if id == 0 {
errs[field] = "not a VLAN"
return
}
used := false
set := func(m map[string]any, key string) {
used = true
if to == "" {
delete(m, key)
} else {
m[key] = to
}
}
lans, _ := router["lans"].(map[string]any)
for _, v := range lans {
if l, ok := v.(map[string]any); ok && str(l, "interface") == parent && str(l, "vlan") == strconv.Itoa(id) {
set(l, "deviceName")
}
}
if str(router, "wan", "interface") == parent && str(router, "wan", "ipv4", "type") == "pppoe" &&
str(router, "wan", "ipv4", "pppoe", "vlan") == strconv.Itoa(id) {
set(obj(obj(obj(router, "wan"), "ipv4"), "pppoe"), "vlanName")
}
if !used {
errs[field] = "a VLAN that nothing uses has no device to name"
}
}
// duplicateName returns a name that two cards or VLAN devices share.
func (s Settings) duplicateName() string {
var all []string
for n := range s.cardNames() {
all = append(all, n)
}
for _, n := range s.vlanNames() {
all = append(all, n)
}
slices.Sort(all)
for i := 1; i < len(all); i++ {
if all[i] == all[i-1] {
return all[i]
}
}
return ""
}