main
  1{
  2  den,
  3  lib,
  4  ...
  5}: {
  6  den.aspects.services.headscale = {
  7    settings.host = {
  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      reverseProxy,
 56      ...
 57    }: let
 58      cfg = host.settings.services.headscale;
 59      hostAddr = host.address;
 60
 61      configFilePath = "/var/lib/headscale/config.yaml";
 62      dnsRecordsFilePath = "/var/lib/headscale/dns-records.json";
 63
 64      tailnetDomain = den.aspects.services.caddy.tailnetDomain;
 65
 66      # Tailnet entries (from all hosts): emit A/AAAA records pointing at the
 67      # owning host's tailscale addresses so MagicDNS resolves them.
 68      tailnetEntries =
 69        lib.concatMap (
 70          r:
 71            lib.mapAttrsToList (domain: conf: {
 72              inherit domain;
 73              source = r.source.host;
 74            }) (
 75              lib.filterAttrs (_: conf: conf.tailscale or false) r.value
 76            )
 77        )
 78        reverseProxy;
 79
 80      duplicateDomains =
 81        lib.filter (domain: builtins.length (lib.filter (e: e.domain == domain) tailnetEntries) > 1)
 82        (lib.unique (map (e: e.domain) tailnetEntries));
 83
 84      dnsRecords = let
 85        assertNoDuplicates =
 86          lib.assertMsg (duplicateDomains == [])
 87          "Multiple hosts declare the same tailnet domain: ${lib.concatStringsSep ", " duplicateDomains}";
 88
 89        assertDomainMatches =
 90          lib.assertMsg (cfg.dns.domain == tailnetDomain)
 91          ("Headscale MagicDNS base domain '${cfg.dns.domain}' does not match the tailnet domain"
 92            + "'${tailnetDomain}' used by the caddy tailnet module (hardcoded in modules/services/caddy/quirks.nix).");
 93      in
 94        assert assertNoDuplicates;
 95        assert assertDomainMatches;
 96          lib.concatMap (e: let
 97            v4 = e.source.address.ipv4.tailscale;
 98            v6 = e.source.address.ipv6.tailscale;
 99          in
100            assert lib.assertMsg (v4 != null || v6 != null)
101            ("Tailnet domain '${e.domain}' is declared by host '${e.source.name}' which has no tailscale address configured"
102              + "(see `address` in modules/hosts/schema.nix).");
103              lib.optionals (v4 != null) [
104                {
105                  name = e.domain;
106                  type = "A";
107                  value = v4;
108                }
109              ]
110              ++ lib.optionals (v6 != null) [
111                {
112                  name = e.domain;
113                  type = "AAAA";
114                  value = v6;
115                }
116              ])
117          tailnetEntries;
118    in {
119      services.headscale = {
120        enable = true;
121        address = "127.0.0.1";
122        port = cfg.port;
123        settings = {
124          server_url = "https://${cfg.domain}";
125          database.type = "sqlite";
126          tls_cert_path = null; # Use webserver for TLS instead.
127          tls_key_path = null;
128          prefixes = {
129            v4 = "100.64.0.0/10";
130            v6 = "fd7a:115c:a1e0::/48";
131            allocation = "random";
132          };
133          derp.server = lib.optionalAttrs (cfg.derp.enable) {
134            enabled = true;
135            stun_listen_addr = "0.0.0.0:${toString cfg.derp.port}";
136            verify_clients = true;
137            region_id = 999;
138            region_code = "headscale";
139            region_name = "Headscale Embedded DERP";
140            ipv4 = lib.mkIf (hostAddr.ipv4.clearText != null) hostAddr.ipv4.clearText;
141            ipv6 = lib.mkIf (hostAddr.ipv6.clearText != null) hostAddr.ipv6.clearText;
142          };
143          dns = {
144            magic_dns = cfg.dns.enable;
145            override_local_dns = cfg.dns.enable;
146            base_domain = cfg.dns.domain;
147            nameservers.global = [
148              # IPv4
149              "119.29.29.29" # DNSPod
150              "223.5.5.5" # AliDNS
151              # IPv6
152              "2400:3200::1" # AliDNS
153              "2606:4700:4700::1111" # Cloudflare
154            ];
155            extra_records_path = dnsRecordsFilePath;
156          };
157        };
158      };
159
160      networking.firewall.allowedUDPPorts = lib.optional (cfg.derp.enable) cfg.derp.port;
161
162      systemd.services.headscale = let
163        nixConfig = config.services.headscale.configFile;
164        hsCfg = config.services.headscale;
165
166        nixDnsRecords =
167          pkgs.writeText "headscale-dns-records.json"
168          (builtins.toJSON dnsRecords);
169
170        mergeHeadscaleState = pkgs.writeShellScript "merge-headscale-state" ''
171          set -euo pipefail
172
173          # Merge main configuration
174          if [ -f "${configFilePath}" ]; then
175            ${lib.getExe pkgs.yq-go} eval-all '. as $item ireduce ({}; . * $item)' \
176              "${configFilePath}" \
177              "${nixConfig}" \
178              > "${configFilePath}.tmp" \
179              && mv "${configFilePath}.tmp" "${configFilePath}"
180          else
181            cp "${nixConfig}" "${configFilePath}"
182            chmod 0640 "${configFilePath}"
183          fi
184
185          # Merge DNS extra records
186          if [ -f "${dnsRecordsFilePath}" ]; then
187            ${lib.getExe pkgs.yq-go} eval-all '. as $item ireduce ([]; . + $item) | unique_by(.name + "|" + .type)' \
188              "${nixDnsRecords}" \
189              "${dnsRecordsFilePath}" \
190              > "${dnsRecordsFilePath}.tmp" \
191              && mv "${dnsRecordsFilePath}.tmp" "${dnsRecordsFilePath}"
192          else
193            cp "${nixDnsRecords}" "${dnsRecordsFilePath}"
194            chmod 0640 "${dnsRecordsFilePath}"
195          fi
196        '';
197      in {
198        serviceConfig = {
199          ExecStartPre = [mergeHeadscaleState];
200          EnvironmentFile =
201            lib.mkIf (hostAddr.ipv4.secret.name != null || hostAddr.ipv6.secret.name != null)
202            config.vaultix.templates.headscale-env.path;
203        };
204
205        script = lib.mkForce ''
206          ${lib.optionalString (hsCfg.settings.database.postgres.password_file != null) ''
207            export HEADSCALE_DATABASE_POSTGRES_PASS="$(head -n1 ${lib.escapeShellArg hsCfg.settings.database.postgres.password_file})"
208          ''}
209          exec ${lib.getExe hsCfg.package} serve --config ${configFilePath}
210        '';
211      };
212
213      vaultix.templates.headscale-env =
214        lib.mkIf (hostAddr.ipv4.secret.name != null || hostAddr.ipv6.secret.name != null)
215        {
216          content = lib.concatLines (
217            (
218              lib.optional
219              (hostAddr.ipv4.secret.name != null)
220              "HEADSCALE_DERP_SERVER_IPV4=${config.vaultix.placeholder.${hostAddr.ipv4.secret.name}}"
221            )
222            ++ (
223              lib.optional
224              (hostAddr.ipv6.secret.name != null)
225              "HEADSCALE_DERP_SERVER_IPV4=${config.vaultix.placeholder.${hostAddr.ipv6.secret.name}}"
226            )
227          );
228          owner = config.services.headscale.user;
229          group = config.services.headscale.group;
230          mode = "0400";
231        };
232    };
233  };
234}