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,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChosenName(t *testing.T) {
|
||||
for _, ok := range []string{"wan", "lan", "dmz", "guest-wifi", "isp1", "a"} {
|
||||
if why := chosenName(ok); why != "" {
|
||||
t.Errorf("%q refused: %s", ok, why)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "Wan", "1wan", "wan_1", "eth0", "eth", "wlan0", "enp2s0", "eno1", "ens3", "wlp1s0", "ppp0", "wg0", "veth12", "br0", "lo", "this-name-is-too-long"} {
|
||||
if chosenName(bad) == "" {
|
||||
t.Errorf("%q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renameSettings(t *testing.T) Settings {
|
||||
t.Helper()
|
||||
s, err := parseSettings([]byte(`{"router": {
|
||||
"wan": {"interface": "enp1s0", "ipv4": {"type": "pppoe", "pppoe": {"vlan": 7}}},
|
||||
"lans": {"lan": {"interface": "enp2s0"}, "dmz": {"interface": "enp2s0", "vlan": 1002}, "iot": {"interface": "enp3s0", "vlan": 5}}}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func kernel(mac string) string { return map[string]string{"aa:aa": "enp1s0", "bb:bb": "enp2s0"}[mac] }
|
||||
|
||||
func TestRenameCard(t *testing.T) {
|
||||
s := renameSettings(t)
|
||||
moved, err := s.rename([]Rename{{Dev: "enp2s0", MAC: "bb:bb", From: "enp2s0", To: "lan"}}, kernel)
|
||||
if err != nil || moved["enp2s0"] != "lan" {
|
||||
t.Fatalf("moved %v, err %v", moved, err)
|
||||
}
|
||||
if s.cardNames()["lan"] != "bb:bb" {
|
||||
t.Errorf("cards = %v", s.cardNames())
|
||||
}
|
||||
f := s.form()
|
||||
for _, l := range f.LANs {
|
||||
if (l.Name == "lan" || l.Name == "dmz") && !strings.HasPrefix(l.Interface, "lan") {
|
||||
t.Errorf("LAN %s still on %s", l.Name, l.Interface)
|
||||
}
|
||||
if l.Name == "iot" && l.Interface != "enp3s0.5" {
|
||||
t.Errorf("an unrelated LAN moved: %s", l.Interface)
|
||||
}
|
||||
}
|
||||
// The card is now called lan; calling it wan renames the entry, not adds one.
|
||||
if _, err := s.rename([]Rename{{Dev: "lan", MAC: "bb:bb", From: "lan", To: "uplink2"}}, kernel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.cardNames(); len(got) != 1 || got["uplink2"] != "bb:bb" {
|
||||
t.Errorf("cards = %v", got)
|
||||
}
|
||||
// Clearing the name goes back to the kernel's.
|
||||
if _, err := s.rename([]Rename{{Dev: "uplink2", MAC: "bb:bb", From: "uplink2", To: ""}}, kernel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := s["router"].(map[string]any)["interfaces"]; ok {
|
||||
t.Error("an empty interfaces table is kept")
|
||||
}
|
||||
if got := s.form().LANs[0].Interface; got != "enp2s0" && got != "enp2s0.1002" {
|
||||
t.Errorf("LAN back on %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameRefused(t *testing.T) {
|
||||
for name, row := range map[string]Rename{
|
||||
"reserved": {Dev: "enp2s0", MAC: "bb:bb", From: "enp2s0", To: "eth5"},
|
||||
"not there": {Dev: "enp9s0", From: "enp9s0", To: "lan"},
|
||||
"unused": {Dev: "enp9s0.4", From: "", To: "x"},
|
||||
} {
|
||||
s := renameSettings(t)
|
||||
if _, err := s.rename([]Rename{row}, kernel); err == nil {
|
||||
t.Errorf("%s accepted", name)
|
||||
}
|
||||
}
|
||||
s := renameSettings(t)
|
||||
_, err := s.rename([]Rename{
|
||||
{Dev: "enp1s0", MAC: "aa:aa", From: "enp1s0", To: "wan"},
|
||||
{Dev: "enp2s0", MAC: "bb:bb", From: "enp2s0", To: "wan"},
|
||||
}, kernel)
|
||||
if err == nil {
|
||||
t.Error("the same name for two cards")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameVLAN(t *testing.T) {
|
||||
s := renameSettings(t)
|
||||
if _, err := s.rename([]Rename{
|
||||
{Dev: "enp2s0.1002", From: "", To: "dmz"},
|
||||
{Dev: "enp1s0.7", From: "", To: "isp"},
|
||||
}, kernel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.vlanNames(); got["enp2s0.1002"] != "dmz" || got["enp1s0.7"] != "isp" || len(got) != 2 {
|
||||
t.Errorf("vlan names = %v", got)
|
||||
}
|
||||
// The identifier in the form is unchanged.
|
||||
for _, l := range s.form().LANs {
|
||||
if l.Name == "dmz" && l.Interface != "enp2s0.1002" {
|
||||
t.Errorf("form identifier = %s", l.Interface)
|
||||
}
|
||||
}
|
||||
// Saving the network form keeps the names; dropping the VLAN drops its name.
|
||||
f := s.form()
|
||||
if _, err := s.apply(f, "/pw"); err != nil && !strings.Contains(err.Error(), "lans") {
|
||||
_ = err
|
||||
}
|
||||
if s.vlanNames()["enp2s0.1002"] != "dmz" {
|
||||
t.Error("the form dropped the VLAN's name")
|
||||
}
|
||||
for i, l := range f.LANs {
|
||||
if l.Name == "dmz" {
|
||||
f.LANs[i].Interface = "enp2s0"
|
||||
}
|
||||
}
|
||||
_, _ = s.apply(f, "/pw")
|
||||
if s.vlanNames()["enp2s0.1002"] != "" {
|
||||
t.Error("an untagged LAN keeps a VLAN device name")
|
||||
}
|
||||
// A name already used by a card is refused.
|
||||
s = renameSettings(t)
|
||||
if _, err := s.rename([]Rename{{Dev: "enp2s0", MAC: "bb:bb", From: "enp2s0", To: "dmz"}, {Dev: "enp2s0.1002", From: "", To: "dmz"}}, kernel); err == nil {
|
||||
t.Error("a card and a VLAN with the same name")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user