old
  1{
  2  den,
  3  lib,
  4  ...
  5}: {
  6  den.aspects.services.headscale = {
  7    settings = {
  8      domain = lib.mkOption {
  9        type = lib.types.nullOr lib.types.str;
 10        default = null;
 11      };
 12      port = lib.mkOption {
 13        type = lib.types.port;
 14        default = 8080;
 15      };
 16      derp = {
 17        enable = lib.mkEnableOption "Enable Headscale's builtin DERP server";
 18        port = lib.mkOption {
 19          type = lib.types.port;
 20          default = 3478;
 21        };
 22      };
 23      dns = {
 24        enable = lib.mkEnableOption "Enable Headscale's Magic DNS";
 25        domain = lib.mkOption {
 26          type = lib.types.str;
 27          default = "ts.net";
 28        };
 29      };
 30    };
 31
 32    persist = {
 33      directories = [
 34        {
 35          directory = "/var/lib/headscale";
 36          user = "headscale";
 37          group = "headscale";
 38          mode = "0700";
 39        }
 40      ];
 41    };
 42
 43    reverseProxy = {host, ...}: let
 44      cfg = host.settings.services.headscale;
 45    in {
 46      ${cfg.domain} = {
 47        port = cfg.port;
 48      };
 49    };
 50
 51    nixos = {
 52      host,
 53      pkgs,
 54      config,
 55      reverseProxyTailscale,
 56      ...
 57    }: let
 58      cfg = host.settings.services.headscale;
 59      hostAddr = host.address;
 60
 61      # Publish a Magic DNS record for every host's Tailscale-only reverse proxy,
 62      # pointing each domain at the owning host's Tailscale IP.
 63      tailscaleRecords =
 64        lib.concatMap (
 65          r: let
 66            srcAddr = r.source.host.address;
 67          in
 68            lib.concatMap (
 69              domain:
 70                (lib.optional (srcAddr.ipv4.tailscale != null) {
 71                  name = domain;
 72                  type = "A";
 73                  value = srcAddr.ipv4.tailscale;
 74                })
 75                ++ (lib.optional (srcAddr.ipv6.tailscale != null) {
 76                  name = domain;
 77                  type = "AAAA";
 78                  value = srcAddr.ipv6.tailscale;
 79                })
 80            )
 81            (builtins.attrNames r.value)
 82        )
 83        reverseProxyTailscale;
 84
 85      dnsRecordsFile = "/var/lib/headscale/dns-records.json";
 86    in {
 87      services.headscale = {
 88        enable = true;
 89        address =
 90          if (host.hasAspect den.aspects.services.webserver)
 91          then "127.0.0.1"
 92          else abort "Not implemented for outside host webserver";
 93        port = cfg.port;
 94        settings = {
 95          server_url = "https://${cfg.domain}";
 96          database.type = "sqlite";
 97          tls_cert_path = null; # Use webserver for TLS instead.
 98          tls_key_path = null;
 99          prefixes = {
100            v4 = "100.64.0.0/10";
101            v6 = "fd7a:115c:a1e0::/48";
102            allocation = "random";
103          };
104          derp.server = lib.optionalAttrs (cfg.derp.enable) {
105            enabled = true;
106            stun_listen_addr = "0.0.0.0:${toString cfg.derp.port}";
107            verify_clients = true;
108            region_id = 999;
109            region_code = "headscale";
110            region_name = "Headscale Embedded DERP";
111            ipv4 = lib.mkIf (hostAddr.ipv4.clearText != null) hostAddr.ipv4.clearText;
112            ipv6 = lib.mkIf (hostAddr.ipv6.clearText != null) hostAddr.ipv6.clearText;
113          };
114          dns = {
115            magic_dns = cfg.dns.enable;
116            override_local_dns = cfg.dns.enable;
117            base_domain = cfg.dns.domain;
118            nameservers.global = [
119              # IPv4
120              "119.29.29.29" # DNSPod
121              "223.5.5.5" # AliDNS
122              # IPv6
123              "2400:3200::1" # AliDNS
124              "2606:4700:4700::1111" # Cloudflare
125            ];
126            extra_records_path = dnsRecordsFile;
127          };
128        };
129      };
130
131      networking.firewall.allowedUDPPorts = lib.optional (cfg.derp.enable) cfg.derp.port;
132
133      systemd.services.headscale = let
134        mutableConfig = "/var/lib/headscale/config.yaml";
135        nixConfig = config.services.headscale.configFile;
136        hsCfg = config.services.headscale;
137
138        nixDnsRecords =
139          pkgs.writeText "headscale-dns-records.json"
140          (builtins.toJSON tailscaleRecords);
141
142        mergeHeadscaleState = pkgs.writeShellScript "merge-headscale-state" ''
143          set -euo pipefail
144
145          # Merge main configuration
146          if [ -f "${mutableConfig}" ]; then
147            ${lib.getExe pkgs.yq-go} eval-all '. as $item ireduce ({}; . * $item)' \
148              "${mutableConfig}" \
149              "${nixConfig}" \
150              > "${mutableConfig}.tmp" \
151              && mv "${mutableConfig}.tmp" "${mutableConfig}"
152          else
153            cp "${nixConfig}" "${mutableConfig}"
154            chmod 0640 "${mutableConfig}"
155          fi
156
157          # Merge DNS extra records
158          if [ -f "${dnsRecordsFile}" ]; then
159            ${lib.getExe pkgs.yq-go} eval-all '. as $item ireduce ([]; . + $item) | unique_by(.name + "|" + .type)' \
160              "${nixDnsRecords}" \
161              "${dnsRecordsFile}" \
162              > "${dnsRecordsFile}.tmp" \
163              && mv "${dnsRecordsFile}.tmp" "${dnsRecordsFile}"
164          else
165            cp "${nixDnsRecords}" "${dnsRecordsFile}"
166            chmod 0640 "${dnsRecordsFile}"
167          fi
168        '';
169      in {
170        serviceConfig = {
171          ExecStartPre = [mergeHeadscaleState];
172          EnvironmentFile =
173            lib.mkIf (hostAddr.ipv4.secret.name != null || hostAddr.ipv6.secret.name != null)
174            config.vaultix.templates.headscale-env.path;
175        };
176
177        script = lib.mkForce ''
178          ${lib.optionalString (hsCfg.settings.database.postgres.password_file != null) ''
179            export HEADSCALE_DATABASE_POSTGRES_PASS="$(head -n1 ${lib.escapeShellArg hsCfg.settings.database.postgres.password_file})"
180          ''}
181          exec ${lib.getExe hsCfg.package} serve --config ${mutableConfig}
182        '';
183      };
184
185      vaultix.templates.headscale-env =
186        lib.mkIf (hostAddr.ipv4.secret.name != null || hostAddr.ipv6.secret.name != null)
187        {
188          content = lib.concatLines (
189            (
190              lib.optional
191              (hostAddr.ipv4.secret.name != null)
192              "HEADSCALE_DERP_SERVER_IPV4=${config.vaultix.placeholder.${hostAddr.ipv4.secret.name}}"
193            )
194            ++ (
195              lib.optional
196              (hostAddr.ipv6.secret.name != null)
197              "HEADSCALE_DERP_SERVER_IPV4=${config.vaultix.placeholder.${hostAddr.ipv6.secret.name}}"
198            )
199          );
200          owner = config.services.headscale.user;
201          group = config.services.headscale.group;
202          mode = "0400";
203        };
204    };
205  };
206}