Files
nixos-router/pkgs/router-webui/interfaces.go
T
nilsandClaude Sonnet 5.5 b1fa7b1396 Add token expiry, edit notice, log allowlist, local-zone test; merge PLAN.md into SPECS.md
get_logs needs a unit from an allowlist (router-*, kea, unbound, networkd,
nftables, ddclient, acme, sshd, kernel). API tokens are valid for 1, 7, 30
(default) or 90 days, or without limit. The overview and the Changes page
say which token made the unapplied changes.

options.json (router://options) no longer holds the options' source paths:
they differ between builds of a release, so a router rebuilding itself
offline had to build the file and failed (found by the update test).

Web UI: panels flow into columns on wide screens and the width is no longer
capped; on phones the navigation is a menu button; the Advanced page uses
panels; VLAN dropdowns show their chosen names; the duplicate WireGuard
heading is gone.

basic: a subtest for wan -> local rules.

SPECS.md is now the only document (decisions, how each part works, and what
is not done yet); PLAN.md is gone.

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

230 lines
6.8 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 ""
}
// devLabel is how a device is shown in a list: a VLAN with a chosen name as
// "dmz (lan.1002)", anything else as it is.
func devLabel(devices []Device, name string) string {
for _, d := range devices {
if d.Name == name && d.Custom != "" {
return d.Custom + " (" + name + ")"
}
}
return name
}