Add dynamic DNS and show the running kernel on the overview

router.ddns keeps provider records on the uplink's addresses with ddclient,
run by a timer and by a watcher that starts it when an address changes. The
Dynamic DNS page edits it; the secret is a private file on the router. The
overview shows the running kernel and says when the current system boots a
different one. PLAN.md records why the A/B image approach was not pursued.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
2026-10-30 13:35:57 +01:00
co-authored by Claude Sonnet 5.5
parent c198ffbe37
commit a36cebc827
19 changed files with 732 additions and 8 deletions
+44
View File
@@ -310,6 +310,50 @@ let
};
expect = [ "ports are only valid with protocol tcp or udp" ];
};
ddnsValid = {
router = {
lans.main = lan { };
ddns = {
enable = true;
domains = [ "home.example.org" ];
passwordFile = "/var/lib/router/ddns-secret";
};
};
expect = [ ];
};
ddnsSecretInTheStore = {
router = {
lans.main = lan { };
ddns = {
enable = true;
domains = [ "home.example.org" ];
passwordFile = "/nix/store/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-secret";
};
};
expect = [ "outside the Nix store" ];
};
ddnsWithoutNames = {
router = {
lans.main = lan { };
ddns = {
enable = true;
passwordFile = "/var/lib/router/ddns-secret";
};
};
expect = [ "no domains to update" ];
};
ddnsWithoutAnyFamily = {
router = {
lans.main = lan { };
ddns = {
enable = true;
domains = [ "home.example.org" ];
passwordFile = "/var/lib/router/ddns-secret";
ipv4 = false;
};
};
expect = [ "neither IPv4 nor IPv6" ];
};
ruleSourcePortsWithoutProtocol = {
router = {
lans = {
+113
View File
@@ -0,0 +1,113 @@
# Dynamic DNS: the router tells its provider about the uplink's address, at
# boot and as soon as the address changes (the timer is set to an hour, so
# only the watcher can explain the second update).
#
# upstream (ISP: DHCP, a dyndns2 update server) -- router
{ self, pkgs }:
let
common = import ./common.nix;
in
pkgs.testers.runNixOSTest {
name = "router-ddns";
defaults.imports = [ common.tools ];
interactive.defaults = common.interactiveDefaults;
nodes = {
upstream =
{ lib, ... }:
{
virtualisation.vlans = [ 1 ];
networking.useNetworkd = true;
networking.useDHCP = false;
networking.firewall.enable = false;
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
systemd.network.networks."10-wan" = {
matchConfig.Name = "eth1";
address = [ "203.0.113.1/24" ];
};
services.dnsmasq = {
enable = true;
settings = {
interface = "eth1";
bind-interfaces = true;
dhcp-range = "203.0.113.100,203.0.113.100,255.255.255.0,1h";
dhcp-option = [ "option:router,203.0.113.1" ];
};
};
# What a dyndns2 provider answers; the access log is the test's record.
services.nginx = {
enable = true;
virtualHosts.default = {
default = true;
locations."/nic/update".return = ''200 "good $arg_myip"'';
};
};
};
router =
{ lib, ... }:
{
imports = [ self.nixosModules.router ];
virtualisation.vlans = [
1
2
];
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
networking.interfaces.eth2.ipv4.addresses = lib.mkForce [ ];
router = {
enable = true;
wan.interface = "eth1";
lans.main = {
interface = "eth2";
ipv4.address = "192.168.10.1";
dhcp.pool = {
start = "192.168.10.100";
end = "192.168.10.199";
};
};
ddns = {
enable = true;
protocol = "dyndns2";
server = "203.0.113.1";
ssl = false;
username = "router";
passwordFile = "/run/ddns-secret";
domains = [
"home.example.test"
"vpn.example.test"
];
interval = "1h";
};
};
# The secret is a file on the router, as the web UI leaves it.
systemd.tmpfiles.rules = [ "f+ /run/ddns-secret 0600 root root - hunter2" ];
};
};
testScript = ''
start_all()
upstream.wait_for_unit("nginx.service")
router.wait_for_unit("network-online.target")
router.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q 203.0.113.100")
router.wait_for_unit("router-ddns-watch.service")
def updated(addr):
return f"grep -q 'myip={addr}' /var/log/nginx/access.log"
with subtest("the first address is published, for every name"):
upstream.wait_until_succeeds(updated("203.0.113.100"), timeout=90)
out = upstream.succeed("cat /var/log/nginx/access.log")
for name in ("home.example.test", "vpn.example.test"):
assert f"hostname={name}" in out or name in out, out
# The login arrives as basic auth, not in the URL, and the secret stays off the command line.
router.fail("grep -r hunter2 /etc/ddclient* /nix/store/*ddclient.conf 2>/dev/null")
with subtest("a new address is published at once, not at the next timer"):
router.succeed("ip addr add 203.0.113.101/24 dev eth1")
router.succeed("ip addr del 203.0.113.100/24 dev eth1")
upstream.wait_until_succeeds(updated("203.0.113.101"), timeout=60)
'';
}
+1
View File
@@ -9,6 +9,7 @@
onestick = import ./onestick.nix { inherit self pkgs; };
rollback = import ./rollback.nix { inherit self pkgs; };
dns = import ./dns.nix { inherit self pkgs; };
ddns = import ./ddns.nix { inherit self pkgs; };
installer = import ./installer.nix { inherit self pkgs; };
apply = import ./apply.nix { inherit self pkgs; };
webui = import ./webui.nix { inherit self pkgs; };
+10
View File
@@ -144,6 +144,16 @@ pkgs.testers.runNixOSTest {
saved = router.succeed(f"cat {cfg}")
assert '"dnat": true' in saved and '"from": "wan"' in saved, saved
with subtest("dynamic DNS is saved with its secret kept out of the settings"):
dd = {"enabled": "on", "protocol": "cloudflare", "username": "token", "zone": "example.org",
"domains": "home.example.org", "ipv4": "on", "ssl": "on", "interval": "5min"}
assert post("/ddns", {"csrf": csrf("/ddns"), **dd}) == "422"
assert post("/ddns", {"csrf": csrf("/ddns"), "secret": "tok3n-abc", **dd}) == "303"
saved = router.succeed(f"cat {cfg}")
assert '"home.example.org"' in saved and "tok3n-abc" not in saved, saved
assert router.succeed("stat -c '%a' /var/lib/router/ddns-secret").strip() == "600"
assert "tok3n-abc" not in curl(f"{ui}/ddns")
with subtest("the web UI hands out a backup of the router's state"):
curl(f"-sf -d csrf={csrf('/advanced')} -o /tmp/backup.tar.gz {ui}/backup")
files = client.succeed("tar -tzf /tmp/backup.tar.gz")