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
114 lines
3.7 KiB
Nix
114 lines
3.7 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";
|
|
};
|
|
};
|
|
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)
|
|
'';
|
|
}
|