From 5ddff44a3525501e281564dd721f24153220bd26 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Thu, 29 Oct 2026 09:19:14 +0100 Subject: [PATCH] Serve DHCP on LANs plugged in after boot; accept browser logins Kea skips interfaces without carrier when it opens its sockets and only looks again on a reload, so a LAN cable plugged in after Kea started got no DHCP until a restart. networkd-dispatcher now restarts Kea when a DHCP LAN gets carrier (a restart: a SIGHUP while Kea is starting ends it). The rule script is the same for every configuration and reads the interfaces from /etc/router/dhcp-interfaces, so the device never has to rebuild it (writeShellApplication's shellcheck could not be built offline). The web UI refused every login from a browser with "forbidden": under Referrer-Policy no-referrer browsers send "Origin: null" with form posts. Use http.CrossOriginProtection (Sec-Fetch-Site, then Origin) and a same-origin referrer policy. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F --- modules/router/dhcp.nix | 32 +++++++++++++++++++++++++++++++- pkgs/router-webui/main.go | 14 +++++++------- pkgs/router-webui/main_test.go | 23 +++++++++++++++++++++-- tests/basic.nix | 11 +++++++++++ 4 files changed, 70 insertions(+), 10 deletions(-) diff --git a/modules/router/dhcp.nix b/modules/router/dhcp.nix index aa4cce3..8e487ec 100644 --- a/modules/router/dhcp.nix +++ b/modules/router/dhcp.nix @@ -1,4 +1,9 @@ -{ config, lib, ... }: +{ + config, + lib, + pkgs, + ... +}: let cfg = config.router; dhcpLans = lib.filter (l: l.dhcp.enable) (lib.attrValues cfg.lans); @@ -6,6 +11,31 @@ let in { config = lib.mkIf (cfg.enable && dhcpLans != [ ]) { + # Kea skips interfaces without carrier when it opens its sockets and only + # looks again on a reload: a LAN cable plugged in after boot would get no + # DHCP until a restart. So restart it when a DHCP LAN gets carrier. + services.networkd-dispatcher = { + enable = true; + rules.kea-carrier = { + onState = [ + "carrier" + "degraded" + "routable" + ]; + # The same for every configuration (the interfaces are in /etc): the + # module builds it with writeShellApplication, whose shellcheck run + # could not be rebuilt offline on the device. + # Not a reload: a SIGHUP while Kea is still starting ends it (cleanly, + # so systemd does not restart it). + script = '' + if ${pkgs.gnugrep}/bin/grep -qxF "$IFACE" /etc/router/dhcp-interfaces; then + ${config.systemd.package}/bin/systemctl try-restart --no-block kea-dhcp4-server.service + fi + ''; + }; + }; + environment.etc."router/dhcp-interfaces".text = lib.concatMapStrings (l: "${l.device}\n") dhcpLans; + services.kea.dhcp4 = { enable = true; settings = { diff --git a/pkgs/router-webui/main.go b/pkgs/router-webui/main.go index 779a38b..d4b24fc 100644 --- a/pkgs/router-webui/main.go +++ b/pkgs/router-webui/main.go @@ -189,7 +189,8 @@ func securityHeaders(next http.Handler) http.Handler { h := w.Header() h.Set("Content-Security-Policy", "default-src 'none'; style-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'") h.Set("X-Content-Type-Options", "nosniff") - h.Set("Referrer-Policy", "no-referrer") + // Not no-referrer: under it browsers send "Origin: null" with form posts. + h.Set("Referrer-Policy", "same-origin") h.Set("Cache-Control", "no-store") next.ServeHTTP(w, r) }) @@ -237,13 +238,12 @@ func (a *app) auth(h func(http.ResponseWriter, *http.Request, session)) http.Han } } +// Sec-Fetch-Site first (browsers may send "Origin: null" for same-origin +// form posts), then Origin; requests with neither (curl) pass. +var crossOrigin = http.NewCrossOriginProtection() + func sameOrigin(r *http.Request) bool { - origin := r.Header.Get("Origin") - if origin == "" { - return true - } - u, err := url.Parse(origin) - return err == nil && u.Host == r.Host + return crossOrigin.Check(r) == nil } func (a *app) loginPage(w http.ResponseWriter, r *http.Request) { diff --git a/pkgs/router-webui/main_test.go b/pkgs/router-webui/main_test.go index 4b164fa..9f749df 100644 --- a/pkgs/router-webui/main_test.go +++ b/pkgs/router-webui/main_test.go @@ -48,7 +48,26 @@ func TestLoginAndSave(t *testing.T) { if rec := do(h, "POST", "/login", url.Values{"password": {"nope"}}, nil); rec.Code != http.StatusUnauthorized { t.Fatalf("wrong password: %d", rec.Code) } - rec := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil) + // What Firefox and Chrome send for a same-origin form post (seen as 403). + req := httptest.NewRequest("POST", "https://192.168.1.1/login", strings.NewReader("password=hunter2hunter2")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", "null") + req.Header.Set("Sec-Fetch-Site", "same-origin") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther || len(rec.Result().Cookies()) == 0 { + t.Fatalf("browser login: %d", rec.Code) + } + req = httptest.NewRequest("POST", "https://192.168.1.1/login", strings.NewReader("password=hunter2hunter2")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Sec-Fetch-Site", "cross-site") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("cross-site login: %d", rec.Code) + } + + rec = do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil) if rec.Code != http.StatusSeeOther || len(rec.Result().Cookies()) == 0 { t.Fatalf("login failed: %d", rec.Code) } @@ -80,7 +99,7 @@ func TestLoginAndSave(t *testing.T) { t.Fatal("the form dropped the reservation") } - req := httptest.NewRequest("POST", "/advanced", strings.NewReader(url.Values{"csrf": {m[1]}, "settings": {"{}"}}.Encode())) + req = httptest.NewRequest("POST", "/advanced", strings.NewReader(url.Values{"csrf": {m[1]}, "settings": {"{}"}}.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", "https://evil.example") req.AddCookie(cookie) diff --git a/tests/basic.nix b/tests/basic.nix index 4c5fc6b..a8bfc5e 100644 --- a/tests/basic.nix +++ b/tests/basic.nix @@ -512,5 +512,16 @@ pkgs.testers.runNixOSTest { klog = router.succeed("journalctl -k --no-pager | grep 'router fwd#'") assert "accepted: " in klog, klog assert "rate-limited: " in klog, klog + + with subtest("a LAN cable plugged in after Kea started still gets DHCP"): + # Like booting with nothing plugged in: Kea starts while the LAN NIC + # (eth2, QEMU netdev vlan2) has no carrier and skips it. + router.send_monitor_command("set_link vlan2 off") + router.wait_until_succeeds("grep -qx 0 /sys/class/net/eth2/carrier") + router.succeed("systemctl restart kea-dhcp4-server.service") + router.wait_until_succeeds("journalctl -u kea-dhcp4-server --since=-1min | grep -q 'eth2 is not running'") + router.send_monitor_command("set_link vlan2 on") + client.succeed("networkctl down eth1 && ip -4 addr flush dev eth1 && networkctl up eth1") + client.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q 192.168.10.", timeout=60) ''; }