Files
nixos-router/modules/router/ddns.nix
T
nilsandClaude Sonnet 5.5 a36cebc827 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
2026-10-30 13:35:57 +01:00

86 lines
2.7 KiB
Nix

# Dynamic DNS: ddclient keeps the provider's records on the uplink's addresses.
# It runs from a timer; a watcher starts it as soon as an address of the uplink
# changes, so a new PPPoE session or lease is published within seconds.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.router;
ddns = cfg.ddns;
# Where the addresses are: IPv4 on the PPP device, IPv6 on the physical uplink
# (or the PPP device when IPv6 runs over it).
dev4 = cfg.wan.ipv4.interface;
dev6 = if cfg.wan.ipv6.enable && cfg.wan.ipv6.transport == "ppp" then dev4 else cfg.wan.interface;
devices = lib.unique (lib.optional ddns.ipv4 dev4 ++ lib.optional ddns.ipv6 dev6);
watch = pkgs.writeShellApplication {
name = "router-ddns-watch";
runtimeInputs = [
pkgs.iproute2
pkgs.gnugrep
pkgs.systemd
];
text = ''
# `ip monitor` also reports the lines of devices that do not exist yet.
ip -o monitor address \
| grep --line-buffered ${lib.concatMapStringsSep " " (d: "-e ' ${d} '") devices} \
| while read -r _; do
sleep 5 # an address change comes as several events
systemctl start --no-block ddclient.service
done
'';
};
in
{
config = lib.mkIf (cfg.enable && ddns.enable) {
assertions = [
{
assertion = ddns.domains != [ ];
message = "router.ddns: no domains to update.";
}
{
assertion = ddns.ipv4 || ddns.ipv6;
message = "router.ddns: neither IPv4 nor IPv6 is enabled.";
}
{
assertion = ddns.passwordFile != null && !lib.hasPrefix "/nix/store" ddns.passwordFile;
message = "router.ddns.passwordFile must name a file outside the Nix store (the secret must not be copied into it).";
}
];
services.ddclient = {
enable = true;
inherit (ddns)
protocol
username
passwordFile
domains
ssl
interval
;
server = lib.optionalString (ddns.server != null) ddns.server;
zone = lib.optionalString (ddns.zone != null) ddns.zone;
usev4 = lib.optionalString ddns.ipv4 "ifv4, ifv4=${dev4}";
usev6 = lib.optionalString ddns.ipv6 "ifv6, ifv6=${dev6}";
# ddclient only sends a changed address; its default of 30 s between
# updates would hold back a new one that follows closely on another
# (a PPP session that drops and comes back).
extraConfig = "min-interval=1s";
};
systemd.services.router-ddns-watch = {
description = "Update dynamic DNS when the uplink's address changes";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = lib.getExe watch;
Restart = "always";
RestartSec = 5;
};
};
};
}