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}