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}