1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7with lib; let
8 cfg = config.services.headscale;
9
10 dataDir = "/var/lib/headscale";
11 runDir = "/run/headscale";
12
13 settingsFormat = pkgs.formats.yaml {};
14 configFile = settingsFormat.generate "headscale.yaml" cfg.settings;
15in {
16 options = {
17 services.headscale = {
18 enable = mkEnableOption "headscale, Open Source coordination server for Tailscale";
19
20 package = mkPackageOption pkgs "headscale" { };
21
22 user = mkOption {
23 default = "headscale";
24 type = types.str;
25 description = ''
26 User account under which headscale runs.
27
28 ::: {.note}
29 If left as the default value this user will automatically be created
30 on system activation, otherwise you are responsible for
31 ensuring the user exists before the headscale service starts.
32 :::
33 '';
34 };
35
36 group = mkOption {
37 default = "headscale";
38 type = types.str;
39 description = ''
40 Group under which headscale runs.
41
42 ::: {.note}
43 If left as the default value this group will automatically be created
44 on system activation, otherwise you are responsible for
45 ensuring the user exists before the headscale service starts.
46 :::
47 '';
48 };
49
50 address = mkOption {
51 type = types.str;
52 default = "127.0.0.1";
53 description = ''
54 Listening address of headscale.
55 '';
56 example = "0.0.0.0";
57 };
58
59 port = mkOption {
60 type = types.port;
61 default = 8080;
62 description = ''
63 Listening port of headscale.
64 '';
65 example = 443;
66 };
67
68 settings = mkOption {
69 description = ''
70 Overrides to {file}`config.yaml` as a Nix attribute set.
71 Check the [example config](https://github.com/juanfont/headscale/blob/main/config-example.yaml)
72 for possible options.
73 '';
74 type = types.submodule {
75 freeformType = settingsFormat.type;
76
77 options = {
78 server_url = mkOption {
79 type = types.str;
80 default = "http://127.0.0.1:8080";
81 description = ''
82 The url clients will connect to.
83 '';
84 example = "https://myheadscale.example.com:443";
85 };
86
87 private_key_path = mkOption {
88 type = types.path;
89 default = "${dataDir}/private.key";
90 description = ''
91 Path to private key file, generated automatically if it does not exist.
92 '';
93 };
94
95 noise.private_key_path = mkOption {
96 type = types.path;
97 default = "${dataDir}/noise_private.key";
98 description = ''
99 Path to noise private key file, generated automatically if it does not exist.
100 '';
101 };
102
103 derp = {
104 urls = mkOption {
105 type = types.listOf types.str;
106 default = ["https://controlplane.tailscale.com/derpmap/default"];
107 description = ''
108 List of urls containing DERP maps.
109 See [How Tailscale works](https://tailscale.com/blog/how-tailscale-works/) for more information on DERP maps.
110 '';
111 };
112
113 paths = mkOption {
114 type = types.listOf types.path;
115 default = [];
116 description = ''
117 List of file paths containing DERP maps.
118 See [How Tailscale works](https://tailscale.com/blog/how-tailscale-works/) for more information on DERP maps.
119 '';
120 };
121
122 auto_update_enable = mkOption {
123 type = types.bool;
124 default = true;
125 description = ''
126 Whether to automatically update DERP maps on a set frequency.
127 '';
128 example = false;
129 };
130
131 update_frequency = mkOption {
132 type = types.str;
133 default = "24h";
134 description = ''
135 Frequency to update DERP maps.
136 '';
137 example = "5m";
138 };
139 };
140
141 ephemeral_node_inactivity_timeout = mkOption {
142 type = types.str;
143 default = "30m";
144 description = ''
145 Time before an inactive ephemeral node is deleted.
146 '';
147 example = "5m";
148 };
149
150 db_type = mkOption {
151 type = types.enum ["sqlite3" "postgres"];
152 example = "postgres";
153 default = "sqlite3";
154 description = "Database engine to use.";
155 };
156
157 db_host = mkOption {
158 type = types.nullOr types.str;
159 default = null;
160 example = "127.0.0.1";
161 description = "Database host address.";
162 };
163
164 db_port = mkOption {
165 type = types.nullOr types.port;
166 default = null;
167 example = 3306;
168 description = "Database host port.";
169 };
170
171 db_name = mkOption {
172 type = types.nullOr types.str;
173 default = null;
174 example = "headscale";
175 description = "Database name.";
176 };
177
178 db_user = mkOption {
179 type = types.nullOr types.str;
180 default = null;
181 example = "headscale";
182 description = "Database user.";
183 };
184
185 db_password_file = mkOption {
186 type = types.nullOr types.path;
187 default = null;
188 example = "/run/keys/headscale-dbpassword";
189 description = ''
190 A file containing the password corresponding to
191 {option}`database.user`.
192 '';
193 };
194
195 db_path = mkOption {
196 type = types.nullOr types.str;
197 default = "${dataDir}/db.sqlite";
198 description = "Path to the sqlite3 database file.";
199 };
200
201 log.level = mkOption {
202 type = types.str;
203 default = "info";
204 description = ''
205 headscale log level.
206 '';
207 example = "debug";
208 };
209
210 log.format = mkOption {
211 type = types.str;
212 default = "text";
213 description = ''
214 headscale log format.
215 '';
216 example = "json";
217 };
218
219 dns_config = {
220 nameservers = mkOption {
221 type = types.listOf types.str;
222 default = ["1.1.1.1"];
223 description = ''
224 List of nameservers to pass to Tailscale clients.
225 '';
226 };
227
228 override_local_dns = mkOption {
229 type = types.bool;
230 default = false;
231 description = ''
232 Whether to use [Override local DNS](https://tailscale.com/kb/1054/dns/).
233 '';
234 example = true;
235 };
236
237 domains = mkOption {
238 type = types.listOf types.str;
239 default = [];
240 description = ''
241 Search domains to inject to Tailscale clients.
242 '';
243 example = ["mydomain.internal"];
244 };
245
246 magic_dns = mkOption {
247 type = types.bool;
248 default = true;
249 description = ''
250 Whether to use [MagicDNS](https://tailscale.com/kb/1081/magicdns/).
251 Only works if there is at least a nameserver defined.
252 '';
253 example = false;
254 };
255
256 base_domain = mkOption {
257 type = types.str;
258 default = "";
259 description = ''
260 Defines the base domain to create the hostnames for MagicDNS.
261 {option}`baseDomain` must be a FQDNs, without the trailing dot.
262 The FQDN of the hosts will be
263 `hostname.namespace.base_domain` (e.g.
264 `myhost.mynamespace.example.com`).
265 '';
266 };
267 };
268
269 oidc = {
270 issuer = mkOption {
271 type = types.str;
272 default = "";
273 description = ''
274 URL to OpenID issuer.
275 '';
276 example = "https://openid.example.com";
277 };
278
279 client_id = mkOption {
280 type = types.str;
281 default = "";
282 description = ''
283 OpenID Connect client ID.
284 '';
285 };
286
287 client_secret_path = mkOption {
288 type = types.nullOr types.str;
289 default = null;
290 description = ''
291 Path to OpenID Connect client secret file. Expands environment variables in format ''${VAR}.
292 '';
293 };
294
295 scope = mkOption {
296 type = types.listOf types.str;
297 default = ["openid" "profile" "email"];
298 description = ''
299 Scopes used in the OIDC flow.
300 '';
301 };
302
303 extra_params = mkOption {
304 type = types.attrsOf types.str;
305 default = { };
306 description = ''
307 Custom query parameters to send with the Authorize Endpoint request.
308 '';
309 example = {
310 domain_hint = "example.com";
311 };
312 };
313
314 allowed_domains = mkOption {
315 type = types.listOf types.str;
316 default = [ ];
317 description = ''
318 Allowed principal domains. if an authenticated user's domain
319 is not in this list authentication request will be rejected.
320 '';
321 example = [ "example.com" ];
322 };
323
324 allowed_users = mkOption {
325 type = types.listOf types.str;
326 default = [ ];
327 description = ''
328 Users allowed to authenticate even if not in allowedDomains.
329 '';
330 example = [ "alice@example.com" ];
331 };
332
333 strip_email_domain = mkOption {
334 type = types.bool;
335 default = true;
336 description = ''
337 Whether the domain part of the email address should be removed when generating namespaces.
338 '';
339 };
340 };
341
342 tls_letsencrypt_hostname = mkOption {
343 type = types.nullOr types.str;
344 default = "";
345 description = ''
346 Domain name to request a TLS certificate for.
347 '';
348 };
349
350 tls_letsencrypt_challenge_type = mkOption {
351 type = types.enum ["TLS-ALPN-01" "HTTP-01"];
352 default = "HTTP-01";
353 description = ''
354 Type of ACME challenge to use, currently supported types:
355 `HTTP-01` or `TLS-ALPN-01`.
356 '';
357 };
358
359 tls_letsencrypt_listen = mkOption {
360 type = types.nullOr types.str;
361 default = ":http";
362 description = ''
363 When HTTP-01 challenge is chosen, letsencrypt must set up a
364 verification endpoint, and it will be listening on:
365 `:http = port 80`.
366 '';
367 };
368
369 tls_cert_path = mkOption {
370 type = types.nullOr types.path;
371 default = null;
372 description = ''
373 Path to already created certificate.
374 '';
375 };
376
377 tls_key_path = mkOption {
378 type = types.nullOr types.path;
379 default = null;
380 description = ''
381 Path to key for already created certificate.
382 '';
383 };
384
385 acl_policy_path = mkOption {
386 type = types.nullOr types.path;
387 default = null;
388 description = ''
389 Path to a file containing ACL policies.
390 '';
391 };
392 };
393 };
394 };
395 };
396 };
397
398 imports = [
399 # TODO address + port = listen_addr
400 (mkRenamedOptionModule ["services" "headscale" "serverUrl"] ["services" "headscale" "settings" "server_url"])
401 (mkRenamedOptionModule ["services" "headscale" "privateKeyFile"] ["services" "headscale" "settings" "private_key_path"])
402 (mkRenamedOptionModule ["services" "headscale" "derp" "urls"] ["services" "headscale" "settings" "derp" "urls"])
403 (mkRenamedOptionModule ["services" "headscale" "derp" "paths"] ["services" "headscale" "settings" "derp" "paths"])
404 (mkRenamedOptionModule ["services" "headscale" "derp" "autoUpdate"] ["services" "headscale" "settings" "derp" "auto_update_enable"])
405 (mkRenamedOptionModule ["services" "headscale" "derp" "updateFrequency"] ["services" "headscale" "settings" "derp" "update_frequency"])
406 (mkRenamedOptionModule ["services" "headscale" "ephemeralNodeInactivityTimeout"] ["services" "headscale" "settings" "ephemeral_node_inactivity_timeout"])
407 (mkRenamedOptionModule ["services" "headscale" "database" "type"] ["services" "headscale" "settings" "db_type"])
408 (mkRenamedOptionModule ["services" "headscale" "database" "path"] ["services" "headscale" "settings" "db_path"])
409 (mkRenamedOptionModule ["services" "headscale" "database" "host"] ["services" "headscale" "settings" "db_host"])
410 (mkRenamedOptionModule ["services" "headscale" "database" "port"] ["services" "headscale" "settings" "db_port"])
411 (mkRenamedOptionModule ["services" "headscale" "database" "name"] ["services" "headscale" "settings" "db_name"])
412 (mkRenamedOptionModule ["services" "headscale" "database" "user"] ["services" "headscale" "settings" "db_user"])
413 (mkRenamedOptionModule ["services" "headscale" "database" "passwordFile"] ["services" "headscale" "settings" "db_password_file"])
414 (mkRenamedOptionModule ["services" "headscale" "logLevel"] ["services" "headscale" "settings" "log" "level"])
415 (mkRenamedOptionModule ["services" "headscale" "dns" "nameservers"] ["services" "headscale" "settings" "dns_config" "nameservers"])
416 (mkRenamedOptionModule ["services" "headscale" "dns" "domains"] ["services" "headscale" "settings" "dns_config" "domains"])
417 (mkRenamedOptionModule ["services" "headscale" "dns" "magicDns"] ["services" "headscale" "settings" "dns_config" "magic_dns"])
418 (mkRenamedOptionModule ["services" "headscale" "dns" "baseDomain"] ["services" "headscale" "settings" "dns_config" "base_domain"])
419 (mkRenamedOptionModule ["services" "headscale" "openIdConnect" "issuer"] ["services" "headscale" "settings" "oidc" "issuer"])
420 (mkRenamedOptionModule ["services" "headscale" "openIdConnect" "clientId"] ["services" "headscale" "settings" "oidc" "client_id"])
421 (mkRenamedOptionModule ["services" "headscale" "openIdConnect" "clientSecretFile"] ["services" "headscale" "settings" "oidc" "client_secret_path"])
422 (mkRenamedOptionModule ["services" "headscale" "tls" "letsencrypt" "hostname"] ["services" "headscale" "settings" "tls_letsencrypt_hostname"])
423 (mkRenamedOptionModule ["services" "headscale" "tls" "letsencrypt" "challengeType"] ["services" "headscale" "settings" "tls_letsencrypt_challenge_type"])
424 (mkRenamedOptionModule ["services" "headscale" "tls" "letsencrypt" "httpListen"] ["services" "headscale" "settings" "tls_letsencrypt_listen"])
425 (mkRenamedOptionModule ["services" "headscale" "tls" "certFile"] ["services" "headscale" "settings" "tls_cert_path"])
426 (mkRenamedOptionModule ["services" "headscale" "tls" "keyFile"] ["services" "headscale" "settings" "tls_key_path"])
427 (mkRenamedOptionModule ["services" "headscale" "aclPolicyFile"] ["services" "headscale" "settings" "acl_policy_path"])
428
429 (mkRemovedOptionModule ["services" "headscale" "openIdConnect" "domainMap"] ''
430 Headscale no longer uses domain_map. If you're using an old version of headscale you can still set this option via services.headscale.settings.oidc.domain_map.
431 '')
432 ];
433
434 config = mkIf cfg.enable {
435 services.headscale.settings = {
436 listen_addr = mkDefault "${cfg.address}:${toString cfg.port}";
437
438 # Turn off update checks since the origin of our package
439 # is nixpkgs and not Github.
440 disable_check_updates = true;
441
442 unix_socket = "${runDir}/headscale.sock";
443
444 tls_letsencrypt_cache_dir = "${dataDir}/.cache";
445 };
446
447 environment = {
448 # Setup the headscale configuration in a known path in /etc to
449 # allow both the Server and the Client use it to find the socket
450 # for communication.
451 etc."headscale/config.yaml".source = configFile;
452
453 systemPackages = [ cfg.package ];
454 };
455
456 users.groups.headscale = mkIf (cfg.group == "headscale") {};
457
458 users.users.headscale = mkIf (cfg.user == "headscale") {
459 description = "headscale user";
460 home = dataDir;
461 group = cfg.group;
462 isSystemUser = true;
463 };
464
465 systemd.services.headscale = {
466 description = "headscale coordination server for Tailscale";
467 wants = [ "network-online.target" ];
468 after = ["network-online.target"];
469 wantedBy = ["multi-user.target"];
470 restartTriggers = [configFile];
471
472 environment.GIN_MODE = "release";
473
474 script = ''
475 ${optionalString (cfg.settings.db_password_file != null) ''
476 export HEADSCALE_DB_PASS="$(head -n1 ${escapeShellArg cfg.settings.db_password_file})"
477 ''}
478
479 exec ${cfg.package}/bin/headscale serve
480 '';
481
482 serviceConfig = let
483 capabilityBoundingSet = ["CAP_CHOWN"] ++ optional (cfg.port < 1024) "CAP_NET_BIND_SERVICE";
484 in {
485 Restart = "always";
486 Type = "simple";
487 User = cfg.user;
488 Group = cfg.group;
489
490 # Hardening options
491 RuntimeDirectory = "headscale";
492 # Allow headscale group access so users can be added and use the CLI.
493 RuntimeDirectoryMode = "0750";
494
495 StateDirectory = "headscale";
496 StateDirectoryMode = "0750";
497
498 ProtectSystem = "strict";
499 ProtectHome = true;
500 PrivateTmp = true;
501 PrivateDevices = true;
502 ProtectKernelTunables = true;
503 ProtectControlGroups = true;
504 RestrictSUIDSGID = true;
505 PrivateMounts = true;
506 ProtectKernelModules = true;
507 ProtectKernelLogs = true;
508 ProtectHostname = true;
509 ProtectClock = true;
510 ProtectProc = "invisible";
511 ProcSubset = "pid";
512 RestrictNamespaces = true;
513 RemoveIPC = true;
514 UMask = "0077";
515
516 CapabilityBoundingSet = capabilityBoundingSet;
517 AmbientCapabilities = capabilityBoundingSet;
518 NoNewPrivileges = true;
519 LockPersonality = true;
520 RestrictRealtime = true;
521 SystemCallFilter = ["@system-service" "~@privileged" "@chown"];
522 SystemCallArchitectures = "native";
523 RestrictAddressFamilies = "AF_INET AF_INET6 AF_UNIX";
524 };
525 };
526 };
527
528 meta.maintainers = with maintainers; [kradalby misterio77];
529}