{ config, lib, pkgs, ... }: with lib; let cfg = config.sane.services.dyn-dns; # getIp = pkgs.writeShellScript "dyn-dns-query-wan" '' # # preferred method and fallback # # OPNsense router broadcasts its UPnP endpoint every 30s # timeout 60 ${lib.getExe pkgs.sane-scripts.ip-check} --json || \ # ${lib.getExe pkgs.sane-scripts.ip-check} --json --no-upnp # ''; getIp = "${lib.getExe pkgs.sane-scripts.ip-check} --json"; in { options = { sane.services.dyn-dns = { enable = mkEnableOption "keep track of the public WAN address of this machine, as viewed externally"; ipPath = mkOption { default = "/var/lib/uninsane/wan.txt"; type = types.str; description = "where to store the latest WAN IPv4 address"; }; upnpPath = mkOption { default = "/var/lib/uninsane/upnp.txt"; type = types.str; description = '' where to store the address of the UPNP device (if any) that can be used to create port forwards. ''; }; ipCmd = mkOption { default = getIp; type = types.path; description = "command to run to query the current WAN IP"; }; interval = mkOption { type = types.str; default = "10min"; description = "systemd time string for how frequently to re-check the IP"; }; restartOnChange = mkOption { type = types.listOf types.str; default = []; description = "list of systemd units to restart when the IP changes"; }; requireForStart = mkOption { type = types.listOf types.str; default = []; description = "list of systemd units that should not be started until after we know an IP"; }; }; }; config = mkIf cfg.enable { systemd.services.dyn-dns = { description = "update this host's record of its WAN IP"; serviceConfig.Type = "oneshot"; restartTriggers = [(builtins.toJSON cfg)]; after = [ "network.target" ]; serviceConfig.Restart = "on-failure"; serviceConfig.RestartSec = "60s"; # XXX(2024-11-11): OPNsense *used* to broadcast its UPnP endpoint every 30s; now it's every 5-10m! serviceConfig.TimeoutStartSec = "600s"; script = let jq = lib.getExe pkgs.jq; in '' set -e mkdir -p "$(dirname '${cfg.ipPath}')" mkdir -p "$(dirname '${cfg.upnpPath}')" newIpDetails=$(${cfg.ipCmd}) newIp=$(echo "$newIpDetails" | ${jq} -r ".wan") newUpnp=$(echo "$newIpDetails" | ${jq} -r ".upnp") oldIp=$(cat '${cfg.ipPath}' || echo) oldUpnp=$(cat '${cfg.upnpPath}' || echo) # systemd path units are triggered on any file write action, # regardless of content change. so only update the file if our IP *actually* changed if [[ -n "$newIp" && "$newIp" != "$oldIp" ]]; then echo "$newIp" > '${cfg.ipPath}' echo "WAN ip changed $oldIp -> $newIp" fi if [[ -n "$newUpnp" && "$newUpnp" != "$oldUpnp" ]]; then echo "$newUpnp" > '${cfg.upnpPath}' echo "UPNP changed $oldUpnp -> $newUpnp" fi [[ -f '${cfg.ipPath}' && -f '${cfg.upnpPath}' ]] ''; }; systemd.timers.dyn-dns = { wantedBy = [ "dyn-dns.service" ]; timerConfig.OnUnitInactiveSec = cfg.interval; }; systemd.targets.dyn-dns-exists = { # consumers depend directly on this target for permission to *start* # once active, this target should never de-activate description = "initial acquisition of dynamic DNS data"; wants = [ "dyn-dns.service" ]; after = [ "dyn-dns.service" ]; wantedBy = cfg.requireForStart; before = cfg.requireForStart; # prevent user service from starting until after we have dyn dns }; systemd.targets.dyn-dns-events = { # consumers depend on this to be restarted whenever DNS changes (after acquisition) description = "event system that notifies via restart whenever dynamic DNS mapping changes"; requiredBy = cfg.restartOnChange; # this just propagates the restart events to the user service }; systemd.paths.dyn-dns-changed = { wantedBy = cfg.restartOnChange; wants = [ "dyn-dns.service" ]; before = [ "dyn-dns.service" "dyn-dns-events.target" ]; pathConfig.PathChanged = [ cfg.ipPath ]; }; systemd.services.dyn-dns-changed = { description = "dynamic DNS change notifier"; serviceConfig.Type = "oneshot"; serviceConfig.ExecStart = "${lib.getExe' pkgs.systemd "systemctl"} restart dyn-dns-events.target"; }; }; }