Commit a270c97
Changed files (5)
modules
services
modules/services/headscale/_lib.nix
@@ -0,0 +1,109 @@
+{lib}: let
+ inherit (import ../direct-dns/_lib.nix {inherit lib;}) mkDirectEntries;
+
+ hexDigitValue = c:
+ lib.lists.findFirstIndex (d: d == c) 0 (lib.stringToCharacters "0123456789abcdef");
+
+ mkDerpRegionId = domain:
+ 900
+ + (
+ builtins.foldl' (acc: c: lib.mod (acc * 16 + hexDigitValue c) 99) 0
+ (lib.stringToCharacters (builtins.hashString "sha256" domain))
+ );
+
+ mkDerpNodeLines = r:
+ lib.concatStringsSep "\n" (
+ [" - name: ${r.h.name}"]
+ ++ [" regionid: ${toString r.regionId}"]
+ ++ [" hostname: ${r.dcfg.domain}"]
+ ++ lib.optionals (r.v4 != null) [" ipv4: \"${r.v4}\""]
+ ++ lib.optionals (r.v6 != null) [" ipv6: \"${r.v6}\""]
+ ++ [" stunport: 3478"]
+ ++ [" derpport: 443"]
+ ++ [" stunonly: false"]
+ );
+
+ mkDerpRegionBlock = r:
+ lib.concatStringsSep "\n" (
+ [" ${toString r.regionId}:"]
+ ++ [" regionid: ${toString r.regionId}"]
+ ++ [" regioncode: ${r.h.name}"]
+ ++ [" regionname: ${r.h.name}"]
+ ++ [" nodes:"]
+ ++ [(mkDerpNodeLines r)]
+ );
+
+ mkTailnetEntries = reverseProxy: let
+ uniqueByDomain = lib.foldl' (acc: e:
+ if builtins.any (x: x.domain == e.domain) acc
+ then acc
+ else acc ++ [e]) [];
+ in
+ uniqueByDomain (
+ lib.concatMap (
+ r:
+ lib.mapAttrsToList (domain: conf: {
+ inherit domain;
+ source = r.source.host;
+ }) (
+ lib.filterAttrs (_: conf: conf.tailscale or false) r.value
+ )
+ )
+ reverseProxy
+ );
+
+ mkDnsRecords = {
+ reverseProxy,
+ tailnetDomain,
+ dnsDomain,
+ }: let
+ tailnetEntries = mkTailnetEntries reverseProxy;
+
+ duplicateDomains =
+ lib.filter (domain: builtins.length (lib.filter (e: e.domain == domain) tailnetEntries) > 1)
+ (lib.unique (map (e: e.domain) tailnetEntries));
+
+ assertNoDuplicates =
+ lib.assertMsg (duplicateDomains == [])
+ "Multiple hosts declare the same tailnet domain: ${lib.concatStringsSep ", " duplicateDomains}";
+
+ assertDomainMatches =
+ lib.assertMsg (dnsDomain == tailnetDomain)
+ ("Headscale MagicDNS base domain '${dnsDomain}' does not match the tailnet domain"
+ + "'${tailnetDomain}' used by the caddy tailnet module (hardcoded in modules/services/caddy/quirks.nix).");
+ in
+ assert assertNoDuplicates;
+ assert assertDomainMatches;
+ lib.concatMap (e: let
+ v4 = e.source.address.ipv4.tailscale;
+ v6 = e.source.address.ipv6.tailscale;
+ in
+ assert lib.assertMsg (v4 != null || v6 != null)
+ ("Tailnet domain '${e.domain}' is declared by host '${e.source.name}' which has no tailscale address configured"
+ + "(see `address` in modules/hosts/schema.nix).");
+ lib.optionals (v4 != null) [
+ {
+ name = e.domain;
+ type = "A";
+ value = v4;
+ }
+ ]
+ ++ lib.optionals (v6 != null) [
+ {
+ name = e.domain;
+ type = "AAAA";
+ value = v6;
+ }
+ ])
+ tailnetEntries;
+in {
+ inherit
+ mkDirectEntries
+ mkDnsRecords
+ ;
+
+ configFilePath = "/var/lib/headscale/config.yaml";
+ dnsRecordsFilePath = "/var/lib/headscale/dns-records.json";
+
+ inherit mkDerpRegionId mkDerpRegionBlock;
+}
modules/services/headscale/default.nix
@@ -0,0 +1,150 @@
+{
+ den,
+ lib,
+ ...
+}: {
+ den.aspects.services.headscale = {
+ includes = [
+ den.aspects.services.headscale.derp
+ den.aspects.services.headscale.dns
+ ];
+
+ settings.host = {
+ domain = lib.mkOption {
+ type = lib.types.nullOr lib.types.str;
+ default = null;
+ };
+ port = lib.mkOption {
+ type = lib.types.port;
+ default = 8080;
+ };
+ derp = {
+ enable = lib.mkEnableOption "Enable Headscale's builtin DERP server";
+ port = lib.mkOption {
+ type = lib.types.port;
+ default = 3478;
+ };
+ };
+ dns = {
+ enable = lib.mkEnableOption "Enable Headscale's Magic DNS";
+ domain = lib.mkOption {
+ type = lib.types.str;
+ default = "ts.net";
+ };
+ };
+ };
+
+ persist = {
+ directories = [
+ {
+ directory = "/var/lib/headscale";
+ user = "headscale";
+ group = "headscale";
+ mode = "0700";
+ }
+ ];
+ };
+
+ reverseProxy = {host, ...}: let
+ cfg = host.settings.services.headscale;
+ in {
+ ${cfg.domain} = {
+ port = cfg.port;
+ };
+ };
+
+ nixos = {
+ host,
+ pkgs,
+ config,
+ reverseProxy,
+ ...
+ }: let
+ inherit
+ (import ./_lib.nix {inherit lib;})
+ configFilePath
+ dnsRecordsFilePath
+ mkDnsRecords
+ ;
+
+ cfg = host.settings.services.headscale;
+ hostAddr = host.address;
+
+ tailnetDomain = den.aspects.services.caddy.tailnetDomain;
+
+ dnsRecords = mkDnsRecords {
+ inherit reverseProxy tailnetDomain;
+ dnsDomain = cfg.dns.domain;
+ };
+
+ nixDnsRecords =
+ pkgs.writeText "headscale-dns-records.json"
+ (builtins.toJSON dnsRecords);
+
+ mergeHeadscaleState = pkgs.writeShellScript "merge-headscale-state" ''
+ set -euo pipefail
+
+ # Merge main configuration
+ if [ -f "${configFilePath}" ]; then
+ ${lib.getExe pkgs.yq-go} eval-all '. as $item ireduce ({}; . * $item)' \
+ "${configFilePath}" \
+ "${config.services.headscale.configFile}" \
+ > "${configFilePath}.tmp" \
+ && mv "${configFilePath}.tmp" "${configFilePath}"
+ else
+ cp "${config.services.headscale.configFile}" "${configFilePath}"
+ chmod 0640 "${configFilePath}"
+ fi
+
+ # Merge DNS extra records
+ if [ ! -f "${dnsRecordsFilePath}" ]; then
+ cp "${nixDnsRecords}" "${dnsRecordsFilePath}"
+ chmod 0640 "${dnsRecordsFilePath}"
+ fi
+
+ ${lib.getExe pkgs.yq-go} eval-all '. as $item ireduce ([]; . + $item) | unique_by(.name + "|" + .type)' \
+ "${nixDnsRecords}" \
+ "${config.vaultix.templates.headscale-direct-records.path}" \
+ "${dnsRecordsFilePath}" \
+ > "${dnsRecordsFilePath}.tmp" \
+ && mv "${dnsRecordsFilePath}.tmp" "${dnsRecordsFilePath}"
+ '';
+ in {
+ services.headscale = {
+ enable = true;
+ address = "127.0.0.1";
+ port = cfg.port;
+ settings = {
+ server_url = "https://${cfg.domain}";
+ database.type = "sqlite";
+ tls_cert_path = null; # Use webserver for TLS instead.
+ tls_key_path = null;
+ prefixes = {
+ v4 = "100.64.0.0/10";
+ v6 = "fd7a:115c:a1e0::/48";
+ allocation = "random";
+ };
+ derp.paths = [config.vaultix.templates.headscale-derp-map.path];
+ };
+ };
+
+ systemd.services.headscale = let
+ hsCfg = config.services.headscale;
+ in {
+ serviceConfig = {
+ ExecStartPre = [mergeHeadscaleState];
+ EnvironmentFile =
+ lib.mkIf (hostAddr.ipv4.secret.name != null || hostAddr.ipv6.secret.name != null)
+ config.vaultix.templates.headscale-env.path;
+ };
+
+ script = lib.mkForce ''
+ ${lib.optionalString (hsCfg.settings.database.postgres.password_file != null) ''
+ export HEADSCALE_DATABASE_POSTGRES_PASS="$(head -n1 ${lib.escapeShellArg hsCfg.settings.database.postgres.password_file})"
+ ''}
+ exec ${lib.getExe hsCfg.package} serve --config ${configFilePath}
+ '';
+ };
+ };
+ };
+}
modules/services/headscale/derp.nix
@@ -0,0 +1,127 @@
+{den, ...}: {
+ den.aspects.services.headscale.derp = {host, ...}: {
+ nixos = {
+ lib,
+ config,
+ ...
+ }: let
+ inherit
+ (import ./_lib.nix {inherit lib;})
+ mkDerpRegionBlock
+ mkDerpRegionId
+ ;
+
+ cfg = host.settings.services.headscale;
+ hostAddr = host.address;
+
+ derpHosts = let
+ allHosts = builtins.concatMap builtins.attrValues (builtins.attrValues den.hosts);
+ in
+ builtins.filter (h: h.hasAspect den.aspects.services.tailscale-derp) allHosts;
+
+ derpRegions =
+ map (h: let
+ dcfg = h.settings.services.tailscale-derp;
+ v4 = h.address.ipv4;
+ v6 = h.address.ipv6;
+ in {
+ inherit h dcfg;
+ regionId = mkDerpRegionId dcfg.domain;
+ v4 =
+ if v4.clearText != null
+ then v4.clearText
+ else if v4.secret.name != null && v4.secret.file != null
+ then config.vaultix.placeholder.${v4.secret.name}
+ else null;
+ v6 =
+ if v6.clearText != null
+ then v6.clearText
+ else if v6.secret.name != null && v6.secret.file != null
+ then config.vaultix.placeholder.${v6.secret.name}
+ else null;
+ })
+ derpHosts;
+
+ duplicateDerpDomains = let
+ domains = map (r: r.dcfg.domain) derpRegions;
+ in
+ lib.filter (d: builtins.length (lib.filter (r: r.dcfg.domain == d) derpRegions) > 1) (lib.unique domains);
+
+ collidingDerpRegionIds = lib.filter (r: builtins.length (lib.filter (r': r'.regionId == r.regionId) derpRegions) > 1) derpRegions;
+
+ derpersWithoutAddress = lib.filter (r: r.v4 == null && r.v6 == null) derpRegions;
+
+ assertUniqueDerpDomains =
+ lib.assertMsg (duplicateDerpDomains == [])
+ "Multiple DERP hosts declare the same domain: ${lib.concatStringsSep ", " duplicateDerpDomains}";
+
+ assertUniqueDerpRegionIds =
+ lib.assertMsg (collidingDerpRegionIds == [])
+ ("DERP region ID collision between hosts: "
+ + lib.concatStringsSep ", " (lib.unique (map (r: r.h.name) collidingDerpRegionIds)));
+
+ assertDerpersHaveAddress =
+ lib.assertMsg (derpersWithoutAddress == [])
+ ("DERP hosts have no public IPv4 or IPv6 (clearText or secret): "
+ + lib.concatStringsSep ", " (map (r: r.h.name) derpersWithoutAddress));
+
+ derpMapYaml = assert assertUniqueDerpDomains;
+ assert assertUniqueDerpRegionIds;
+ assert assertDerpersHaveAddress; (
+ if derpRegions == []
+ then "regions: {}\n"
+ else "regions:\n" + lib.concatStringsSep "\n" (map mkDerpRegionBlock derpRegions) + "\n"
+ );
+
+ hostsWithIpv4Secret =
+ builtins.filter (h: h.address.ipv4.secret.name != null && h.address.ipv4.secret.file != null) derpHosts;
+ hostsWithIpv6Secret =
+ builtins.filter (h: h.address.ipv6.secret.name != null && h.address.ipv6.secret.file != null) derpHosts;
+ in {
+ services.headscale.settings.derp.server = lib.optionalAttrs (cfg.derp.enable) {
+ enabled = true;
+ stun_listen_addr = "0.0.0.0:${toString cfg.derp.port}";
+ verify_clients = true;
+ region_id = 999;
+ region_code = "headscale";
+ region_name = "Headscale Embedded DERP";
+ ipv4 = lib.mkIf (hostAddr.ipv4.clearText != null) hostAddr.ipv4.clearText;
+ ipv6 = lib.mkIf (hostAddr.ipv6.clearText != null) hostAddr.ipv6.clearText;
+ };
+
+ networking.firewall.allowedUDPPorts = lib.optional (cfg.derp.enable) cfg.derp.port;
+
+ vaultix.secrets = lib.mkMerge (
+ (map (h: {${h.address.ipv4.secret.name}.file = h.address.ipv4.secret.file;}) hostsWithIpv4Secret)
+ ++ (map (h: {${h.address.ipv6.secret.name}.file = h.address.ipv6.secret.file;}) hostsWithIpv6Secret)
+ );
+
+ vaultix.templates.headscale-derp-map = {
+ content = derpMapYaml;
+ owner = config.services.headscale.user;
+ group = config.services.headscale.group;
+ mode = "0400";
+ };
+
+ vaultix.templates.headscale-env =
+ lib.mkIf (hostAddr.ipv4.secret.name != null || hostAddr.ipv6.secret.name != null)
+ {
+ content = lib.concatLines (
+ (
+ lib.optional
+ (hostAddr.ipv4.secret.name != null)
+ "HEADSCALE_DERP_SERVER_IPV4=${config.vaultix.placeholder.${hostAddr.ipv4.secret.name}}"
+ )
+ ++ (
+ lib.optional
+ (hostAddr.ipv6.secret.name != null)
+ "HEADSCALE_DERP_SERVER_IPV6=${config.vaultix.placeholder.${hostAddr.ipv6.secret.name}}"
+ )
+ );
+ owner = config.services.headscale.user;
+ group = config.services.headscale.group;
+ mode = "0400";
+ };
+ };
+ };
+}
modules/services/headscale/dns.nix
@@ -0,0 +1,72 @@
+{
+ den.aspects.services.headscale.dns = {
+ nixos = {
+ host,
+ reverseProxy,
+ lib,
+ config,
+ ...
+ }: let
+ inherit (import ./_lib.nix {inherit lib;}) dnsRecordsFilePath mkDirectEntries;
+
+ cfg = host.settings.services.headscale;
+
+ directEntries = mkDirectEntries reverseProxy;
+
+ mkRecordValue = ip:
+ if ip.kind == "clear"
+ then ip.value
+ else config.vaultix.placeholder.${ip.name};
+
+ directRecords = lib.concatMap (e:
+ lib.optionals (e.v4 != null) [
+ {
+ name = e.domain;
+ type = "A";
+ value = mkRecordValue e.v4;
+ }
+ ]
+ ++ lib.optionals (e.v6 != null) [
+ {
+ name = e.domain;
+ type = "AAAA";
+ value = mkRecordValue e.v6;
+ }
+ ])
+ directEntries;
+
+ directSecretIps = lib.unique (lib.concatMap (e:
+ lib.optional (e.v4 != null && e.v4.kind == "secret") e.v4
+ ++ lib.optional (e.v6 != null && e.v6.kind == "secret") e.v6)
+ directEntries);
+ in {
+ services.headscale.settings.dns = {
+ magic_dns = cfg.dns.enable;
+ override_local_dns = cfg.dns.enable;
+ base_domain = cfg.dns.domain;
+ nameservers.global = [
+ # IPv4
+ "119.29.29.29" # DNSPod
+ "223.5.5.5" # AliDNS
+ # IPv6
+ "2400:3200::1" # AliDNS
+ "2606:4700:4700::1111" # Cloudflare
+ ];
+ extra_records_path = dnsRecordsFilePath;
+ };
+
+ vaultix.secrets = lib.listToAttrs (map (s: {
+ name = s.name;
+ value.file = s.file;
+ })
+ directSecretIps);
+
+ vaultix.templates.headscale-direct-records = {
+ content = builtins.toJSON directRecords;
+ owner = config.services.headscale.user;
+ group = config.services.headscale.group;
+ mode = "0400";
+ };
+ };
+ };
+}
modules/services/headscale.nix
@@ -1,390 +0,0 @@
-{
- den,
- lib,
- ...
-}: {
- den.aspects.services.headscale = {
- settings.host = {
- domain = lib.mkOption {
- type = lib.types.nullOr lib.types.str;
- default = null;
- };
- port = lib.mkOption {
- type = lib.types.port;
- default = 8080;
- };
- derp = {
- enable = lib.mkEnableOption "Enable Headscale's builtin DERP server";
- port = lib.mkOption {
- type = lib.types.port;
- default = 3478;
- };
- };
- dns = {
- enable = lib.mkEnableOption "Enable Headscale's Magic DNS";
- domain = lib.mkOption {
- type = lib.types.str;
- default = "ts.net";
- };
- };
- };
-
- persist = {
- directories = [
- {
- directory = "/var/lib/headscale";
- user = "headscale";
- group = "headscale";
- mode = "0700";
- }
- ];
- };
-
- reverseProxy = {host, ...}: let
- cfg = host.settings.services.headscale;
- in {
- ${cfg.domain} = {
- port = cfg.port;
- };
- };
-
- nixos = {
- host,
- pkgs,
- config,
- reverseProxy,
- ...
- }: let
- cfg = host.settings.services.headscale;
- hostAddr = host.address;
-
- configFilePath = "/var/lib/headscale/config.yaml";
- dnsRecordsFilePath = "/var/lib/headscale/dns-records.json";
-
- tailnetDomain = den.aspects.services.caddy.tailnetDomain;
-
- inherit (import ./direct-dns/_lib.nix {inherit lib;}) mkDirectEntries;
-
- derpHosts = let
- allHosts = builtins.concatMap builtins.attrValues (builtins.attrValues den.hosts);
- in
- builtins.filter (h: h.hasAspect den.aspects.services.tailscale-derp) allHosts;
-
- hexDigitValue = c:
- lib.lists.findFirstIndex (d: d == c) 0 (lib.stringToCharacters "0123456789abcdef");
-
- mkDerpRegionId = domain:
- 900
- + (
- builtins.foldl' (acc: c: lib.mod (acc * 16 + hexDigitValue c) 99) 0
- (lib.stringToCharacters (builtins.hashString "sha256" domain))
- );
-
- derpRegions =
- map (h: let
- dcfg = h.settings.services.tailscale-derp;
- v4 = h.address.ipv4;
- v6 = h.address.ipv6;
- in {
- inherit h dcfg;
- regionId = mkDerpRegionId dcfg.domain;
- v4 =
- if v4.clearText != null
- then v4.clearText
- else if v4.secret.name != null && v4.secret.file != null
- then config.vaultix.placeholder.${v4.secret.name}
- else null;
- v6 =
- if v6.clearText != null
- then v6.clearText
- else if v6.secret.name != null && v6.secret.file != null
- then config.vaultix.placeholder.${v6.secret.name}
- else null;
- })
- derpHosts;
-
- duplicateDerpDomains = let
- domains = map (r: r.dcfg.domain) derpRegions;
- in
- lib.filter (d: builtins.length (lib.filter (r: r.dcfg.domain == d) derpRegions) > 1) (lib.unique domains);
-
- collidingDerpRegionIds = lib.filter (r: builtins.length (lib.filter (r': r'.regionId == r.regionId) derpRegions) > 1) derpRegions;
-
- derpersWithoutAddress = lib.filter (r: r.v4 == null && r.v6 == null) derpRegions;
-
- assertUniqueDerpDomains =
- lib.assertMsg (duplicateDerpDomains == [])
- "Multiple DERP hosts declare the same domain: ${lib.concatStringsSep ", " duplicateDerpDomains}";
-
- assertUniqueDerpRegionIds =
- lib.assertMsg (collidingDerpRegionIds == [])
- ("DERP region ID collision between hosts: "
- + lib.concatStringsSep ", " (lib.unique (map (r: r.h.name) collidingDerpRegionIds)));
-
- assertDerpersHaveAddress =
- lib.assertMsg (derpersWithoutAddress == [])
- ("DERP hosts have no public IPv4 or IPv6 (clearText or secret): "
- + lib.concatStringsSep ", " (map (r: r.h.name) derpersWithoutAddress));
-
- mkDerpNodeLines = r:
- lib.concatStringsSep "\n" (
- [" - name: ${r.h.name}"]
- ++ [" regionid: ${toString r.regionId}"]
- ++ [" hostname: ${r.dcfg.domain}"]
- ++ lib.optionals (r.v4 != null) [" ipv4: \"${r.v4}\""]
- ++ lib.optionals (r.v6 != null) [" ipv6: \"${r.v6}\""]
- ++ [" stunport: 3478"]
- ++ [" derpport: 443"]
- ++ [" stunonly: false"]
- );
-
- mkDerpRegionBlock = r:
- lib.concatStringsSep "\n" (
- [" ${toString r.regionId}:"]
- ++ [" regionid: ${toString r.regionId}"]
- ++ [" regioncode: ${r.h.name}"]
- ++ [" regionname: ${r.h.name}"]
- ++ [" nodes:"]
- ++ [(mkDerpNodeLines r)]
- );
-
- derpMapYaml = assert assertUniqueDerpDomains;
- assert assertUniqueDerpRegionIds;
- assert assertDerpersHaveAddress; (
- if derpRegions == []
- then "regions: {}\n"
- else "regions:\n" + lib.concatStringsSep "\n" (map mkDerpRegionBlock derpRegions) + "\n"
- );
-
- hostsWithIpv4Secret =
- builtins.filter (h: h.address.ipv4.secret.name != null && h.address.ipv4.secret.file != null) derpHosts;
- hostsWithIpv6Secret =
- builtins.filter (h: h.address.ipv6.secret.name != null && h.address.ipv6.secret.file != null) derpHosts;
-
- # Tailnet entries (from all hosts): emit A/AAAA records pointing at the
- # owning host's tailscale addresses so MagicDNS resolves them.
- tailnetEntries = let
- uniqueByDomain = lib.foldl' (acc: e:
- if builtins.any (x: x.domain == e.domain) acc
- then acc
- else acc ++ [e]) [];
- in
- uniqueByDomain (
- lib.concatMap (
- r:
- lib.mapAttrsToList (domain: conf: {
- inherit domain;
- source = r.source.host;
- }) (
- lib.filterAttrs (_: conf: conf.tailscale or false) r.value
- )
- )
- reverseProxy
- );
-
- duplicateDomains =
- lib.filter (domain: builtins.length (lib.filter (e: e.domain == domain) tailnetEntries) > 1)
- (lib.unique (map (e: e.domain) tailnetEntries));
-
- dnsRecords = let
- assertNoDuplicates =
- lib.assertMsg (duplicateDomains == [])
- "Multiple hosts declare the same tailnet domain: ${lib.concatStringsSep ", " duplicateDomains}";
-
- assertDomainMatches =
- lib.assertMsg (cfg.dns.domain == tailnetDomain)
- ("Headscale MagicDNS base domain '${cfg.dns.domain}' does not match the tailnet domain"
- + "'${tailnetDomain}' used by the caddy tailnet module (hardcoded in modules/services/caddy/quirks.nix).");
- in
- assert assertNoDuplicates;
- assert assertDomainMatches;
- lib.concatMap (e: let
- v4 = e.source.address.ipv4.tailscale;
- v6 = e.source.address.ipv6.tailscale;
- in
- assert lib.assertMsg (v4 != null || v6 != null)
- ("Tailnet domain '${e.domain}' is declared by host '${e.source.name}' which has no tailscale address configured"
- + "(see `address` in modules/hosts/schema.nix).");
- lib.optionals (v4 != null) [
- {
- name = e.domain;
- type = "A";
- value = v4;
- }
- ]
- ++ lib.optionals (v6 != null) [
- {
- name = e.domain;
- type = "AAAA";
- value = v6;
- }
- ])
- tailnetEntries;
-
- directEntries = mkDirectEntries reverseProxy;
-
- mkRecordValue = ip:
- if ip.kind == "clear"
- then ip.value
- else config.vaultix.placeholder.${ip.name};
-
- directRecords = lib.concatMap (e:
- lib.optionals (e.v4 != null) [
- {
- name = e.domain;
- type = "A";
- value = mkRecordValue e.v4;
- }
- ]
- ++ lib.optionals (e.v6 != null) [
- {
- name = e.domain;
- type = "AAAA";
- value = mkRecordValue e.v6;
- }
- ])
- directEntries;
-
- directSecretIps = lib.unique (lib.concatMap (e:
- lib.optional (e.v4 != null && e.v4.kind == "secret") e.v4
- ++ lib.optional (e.v6 != null && e.v6.kind == "secret") e.v6)
- directEntries);
- in {
- services.headscale = {
- enable = true;
- address = "127.0.0.1";
- port = cfg.port;
- settings = {
- server_url = "https://${cfg.domain}";
- database.type = "sqlite";
- tls_cert_path = null; # Use webserver for TLS instead.
- tls_key_path = null;
- prefixes = {
- v4 = "100.64.0.0/10";
- v6 = "fd7a:115c:a1e0::/48";
- allocation = "random";
- };
- derp = {
- server = lib.optionalAttrs (cfg.derp.enable) {
- enabled = true;
- stun_listen_addr = "0.0.0.0:${toString cfg.derp.port}";
- verify_clients = true;
- region_id = 999;
- region_code = "headscale";
- region_name = "Headscale Embedded DERP";
- ipv4 = lib.mkIf (hostAddr.ipv4.clearText != null) hostAddr.ipv4.clearText;
- ipv6 = lib.mkIf (hostAddr.ipv6.clearText != null) hostAddr.ipv6.clearText;
- };
- paths = [config.vaultix.templates.headscale-derp-map.path];
- };
- dns = {
- magic_dns = cfg.dns.enable;
- override_local_dns = cfg.dns.enable;
- base_domain = cfg.dns.domain;
- nameservers.global = [
- # IPv4
- "119.29.29.29" # DNSPod
- "223.5.5.5" # AliDNS
- # IPv6
- "2400:3200::1" # AliDNS
- "2606:4700:4700::1111" # Cloudflare
- ];
- extra_records_path = dnsRecordsFilePath;
- };
- };
- };
-
- networking.firewall.allowedUDPPorts = lib.optional (cfg.derp.enable) cfg.derp.port;
-
- vaultix.secrets = lib.mkMerge (
- (map (h: {${h.address.ipv4.secret.name}.file = h.address.ipv4.secret.file;}) hostsWithIpv4Secret)
- ++ (map (h: {${h.address.ipv6.secret.name}.file = h.address.ipv6.secret.file;}) hostsWithIpv6Secret)
- ++ (map (s: {${s.name}.file = s.file;}) directSecretIps)
- );
- vaultix.templates.headscale-derp-map = {
- content = derpMapYaml;
- owner = config.services.headscale.user;
- group = config.services.headscale.group;
- mode = "0400";
- };
- vaultix.templates.headscale-direct-records = {
- content = builtins.toJSON directRecords;
- owner = config.services.headscale.user;
- group = config.services.headscale.group;
- mode = "0400";
- };
-
- systemd.services.headscale = let
- nixConfig = config.services.headscale.configFile;
- hsCfg = config.services.headscale;
-
- nixDnsRecords =
- pkgs.writeText "headscale-dns-records.json"
- (builtins.toJSON dnsRecords);
-
- mergeHeadscaleState = pkgs.writeShellScript "merge-headscale-state" ''
- set -euo pipefail
-
- # Merge main configuration
- if [ -f "${configFilePath}" ]; then
- ${lib.getExe pkgs.yq-go} eval-all '. as $item ireduce ({}; . * $item)' \
- "${configFilePath}" \
- "${nixConfig}" \
- > "${configFilePath}.tmp" \
- && mv "${configFilePath}.tmp" "${configFilePath}"
- else
- cp "${nixConfig}" "${configFilePath}"
- chmod 0640 "${configFilePath}"
- fi
-
- # Merge DNS extra records
- if [ ! -f "${dnsRecordsFilePath}" ]; then
- cp "${nixDnsRecords}" "${dnsRecordsFilePath}"
- chmod 0640 "${dnsRecordsFilePath}"
- fi
-
- ${lib.getExe pkgs.yq-go} eval-all '. as $item ireduce ([]; . + $item) | unique_by(.name + "|" + .type)' \
- "${nixDnsRecords}" \
- "${config.vaultix.templates.headscale-direct-records.path}" \
- "${dnsRecordsFilePath}" \
- > "${dnsRecordsFilePath}.tmp" \
- && mv "${dnsRecordsFilePath}.tmp" "${dnsRecordsFilePath}"
- '';
- in {
- serviceConfig = {
- ExecStartPre = [mergeHeadscaleState];
- EnvironmentFile =
- lib.mkIf (hostAddr.ipv4.secret.name != null || hostAddr.ipv6.secret.name != null)
- config.vaultix.templates.headscale-env.path;
- };
-
- script = lib.mkForce ''
- ${lib.optionalString (hsCfg.settings.database.postgres.password_file != null) ''
- export HEADSCALE_DATABASE_POSTGRES_PASS="$(head -n1 ${lib.escapeShellArg hsCfg.settings.database.postgres.password_file})"
- ''}
- exec ${lib.getExe hsCfg.package} serve --config ${configFilePath}
- '';
- };
-
- vaultix.templates.headscale-env =
- lib.mkIf (hostAddr.ipv4.secret.name != null || hostAddr.ipv6.secret.name != null)
- {
- content = lib.concatLines (
- (
- lib.optional
- (hostAddr.ipv4.secret.name != null)
- "HEADSCALE_DERP_SERVER_IPV4=${config.vaultix.placeholder.${hostAddr.ipv4.secret.name}}"
- )
- ++ (
- lib.optional
- (hostAddr.ipv6.secret.name != null)
- "HEADSCALE_DERP_SERVER_IPV6=${config.vaultix.placeholder.${hostAddr.ipv6.secret.name}}"
- )
- );
- owner = config.services.headscale.user;
- group = config.services.headscale.group;
- mode = "0400";
- };
- };
- };
-}