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