Files
nixos-router/tests/ddns.nix
T
nilsandClaude Sonnet 5.5 caf2c6b26d Add DNS accounts, Let's Encrypt, password change, reboot, HTTP redirect
- router.dnsProviders: named accounts at DNS providers, used by dynamic
  DNS and certificates (ddclient and lego provider lists in
  pkgs/router-webui/providers.json). Schema 2 migrates router.ddns.
- router.acme: a Let's Encrypt certificate for the web UI via
  security.acme (DNS-01, resolver 1.1.1.1), served and reloaded by the
  web UI; Certificate page.
- Web UI: password change (web UI and root), reboot button, HTTP to HTTPS
  redirect (router.webui.httpPort), grouped menu.
- Latest kernel (linuxPackages_latest); 7.3 and 7.3.0 are the same kernel
  for the reboot hint.
- SPECS.md: wanted later (REST API, GraphQL, MCP).

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

117 lines
3.9 KiB
Nix

# 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";
};
};
dnsProviders.provider = {
type = "dyndns2";
server = "203.0.113.1";
username = "router";
secretFile = "/run/ddns-secret";
};
ddns = {
enable = true;
provider = "provider";
ssl = false;
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)
# One request for all names or one each, depending on the provider's answer.
for name in ("home.example.test", "vpn.example.test"):
upstream.wait_until_succeeds(f"grep -q '{name}.*myip=203.0.113.100' /var/log/nginx/access.log", timeout=30)
# 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)
'';
}