1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 cfg = config.services.headscale;
9
10 dataDir = "/var/lib/headscale";
11 runDir = "/run/headscale";
12
13 cliConfig = {
14 # Turn off update checks since the origin of our package
15 # is nixpkgs and not Github.
16 disable_check_updates = true;
17
18 unix_socket = "${runDir}/headscale.sock";
19 };
20
21 settingsFormat = pkgs.formats.yaml { };
22 configFile = settingsFormat.generate "headscale.yaml" cfg.settings;
23 cliConfigFile = settingsFormat.generate "headscale.yaml" cliConfig;
24
25 assertRemovedOption = option: message: {
26 assertion = !lib.hasAttrByPath option cfg;
27 message =
28 "The option `services.headscale.${lib.options.showOption option}` was removed. " + message;
29 };
30in
31{
32 options = {
33 services.headscale = {
34 enable = lib.mkEnableOption "headscale, Open Source coordination server for Tailscale";
35
36 package = lib.mkPackageOption pkgs "headscale" { };
37
38 user = lib.mkOption {
39 default = "headscale";
40 type = lib.types.str;
41 description = ''
42 User account under which headscale runs.
43
44 ::: {.note}
45 If left as the default value this user will automatically be created
46 on system activation, otherwise you are responsible for
47 ensuring the user exists before the headscale service starts.
48 :::
49 '';
50 };
51
52 group = lib.mkOption {
53 default = "headscale";
54 type = lib.types.str;
55 description = ''
56 Group under which headscale runs.
57
58 ::: {.note}
59 If left as the default value this group will automatically be created
60 on system activation, otherwise you are responsible for
61 ensuring the user exists before the headscale service starts.
62 :::
63 '';
64 };
65
66 address = lib.mkOption {
67 type = lib.types.str;
68 default = "127.0.0.1";
69 description = ''
70 Listening address of headscale.
71 '';
72 example = "0.0.0.0";
73 };
74
75 port = lib.mkOption {
76 type = lib.types.port;
77 default = 8080;
78 description = ''
79 Listening port of headscale.
80 '';
81 example = 443;
82 };
83
84 settings = lib.mkOption {
85 description = ''
86 Overrides to {file}`config.yaml` as a Nix attribute set.
87 Check the [example config](https://github.com/juanfont/headscale/blob/main/config-example.yaml)
88 for possible options.
89 '';
90 type = lib.types.submodule {
91 freeformType = settingsFormat.type;
92
93 options = {
94 server_url = lib.mkOption {
95 type = lib.types.str;
96 default = "http://127.0.0.1:8080";
97 description = ''
98 The url clients will connect to.
99 '';
100 example = "https://myheadscale.example.com:443";
101 };
102
103 noise.private_key_path = lib.mkOption {
104 type = lib.types.path;
105 default = "${dataDir}/noise_private.key";
106 description = ''
107 Path to noise private key file, generated automatically if it does not exist.
108 '';
109 };
110
111 prefixes =
112 let
113 prefDesc = ''
114 Each prefix consists of either an IPv4 or IPv6 address,
115 and the associated prefix length, delimited by a slash.
116 It must be within IP ranges supported by the Tailscale
117 client - i.e., subnets of 100.64.0.0/10 and fd7a:115c:a1e0::/48.
118 '';
119 in
120 {
121 v4 = lib.mkOption {
122 type = lib.types.str;
123 default = "100.64.0.0/10";
124 description = prefDesc;
125 };
126
127 v6 = lib.mkOption {
128 type = lib.types.str;
129 default = "fd7a:115c:a1e0::/48";
130 description = prefDesc;
131 };
132
133 allocation = lib.mkOption {
134 type = lib.types.enum [
135 "sequential"
136 "random"
137 ];
138 example = "random";
139 default = "sequential";
140 description = ''
141 Strategy used for allocation of IPs to nodes, available options:
142 - sequential (default): assigns the next free IP from the previous given IP.
143 - random: assigns the next free IP from a pseudo-random IP generator (crypto/rand).
144 '';
145 };
146 };
147
148 derp = {
149 urls = lib.mkOption {
150 type = lib.types.listOf lib.types.str;
151 default = [ "https://controlplane.tailscale.com/derpmap/default" ];
152 description = ''
153 List of urls containing DERP maps.
154 See [How Tailscale works](https://tailscale.com/blog/how-tailscale-works/) for more information on DERP maps.
155 '';
156 };
157
158 paths = lib.mkOption {
159 type = lib.types.listOf lib.types.path;
160 default = [ ];
161 description = ''
162 List of file paths containing DERP maps.
163 See [How Tailscale works](https://tailscale.com/blog/how-tailscale-works/) for more information on DERP maps.
164 '';
165 };
166
167 auto_update_enabled = lib.mkOption {
168 type = lib.types.bool;
169 default = true;
170 description = ''
171 Whether to automatically update DERP maps on a set frequency.
172 '';
173 example = false;
174 };
175
176 update_frequency = lib.mkOption {
177 type = lib.types.str;
178 default = "24h";
179 description = ''
180 Frequency to update DERP maps.
181 '';
182 example = "5m";
183 };
184
185 server.private_key_path = lib.mkOption {
186 type = lib.types.path;
187 default = "${dataDir}/derp_server_private.key";
188 description = ''
189 Path to derp private key file, generated automatically if it does not exist.
190 '';
191 };
192 };
193
194 ephemeral_node_inactivity_timeout = lib.mkOption {
195 type = lib.types.str;
196 default = "30m";
197 description = ''
198 Time before an inactive ephemeral node is deleted.
199 '';
200 example = "5m";
201 };
202
203 database = {
204 type = lib.mkOption {
205 type = lib.types.enum [
206 "sqlite"
207 "sqlite3"
208 "postgres"
209 ];
210 example = "postgres";
211 default = "sqlite";
212 description = ''
213 Database engine to use.
214 Please note that using Postgres is highly discouraged as it is only supported for legacy reasons.
215 All new development, testing and optimisations are done with SQLite in mind.
216 '';
217 };
218
219 sqlite = {
220 path = lib.mkOption {
221 type = lib.types.nullOr lib.types.str;
222 default = "${dataDir}/db.sqlite";
223 description = "Path to the sqlite3 database file.";
224 };
225
226 write_ahead_log = lib.mkOption {
227 type = lib.types.bool;
228 default = true;
229 description = ''
230 Enable WAL mode for SQLite. This is recommended for production environments.
231 <https://www.sqlite.org/wal.html>
232 '';
233 example = true;
234 };
235 };
236
237 postgres = {
238 host = lib.mkOption {
239 type = lib.types.nullOr lib.types.str;
240 default = null;
241 example = "127.0.0.1";
242 description = "Database host address.";
243 };
244
245 port = lib.mkOption {
246 type = lib.types.nullOr lib.types.port;
247 default = null;
248 example = 3306;
249 description = "Database host port.";
250 };
251
252 name = lib.mkOption {
253 type = lib.types.nullOr lib.types.str;
254 default = null;
255 example = "headscale";
256 description = "Database name.";
257 };
258
259 user = lib.mkOption {
260 type = lib.types.nullOr lib.types.str;
261 default = null;
262 example = "headscale";
263 description = "Database user.";
264 };
265
266 password_file = lib.mkOption {
267 type = lib.types.nullOr lib.types.path;
268 default = null;
269 example = "/run/keys/headscale-dbpassword";
270 description = ''
271 A file containing the password corresponding to
272 {option}`database.user`.
273 '';
274 };
275 };
276 };
277
278 log = {
279 level = lib.mkOption {
280 type = lib.types.str;
281 default = "info";
282 description = ''
283 headscale log level.
284 '';
285 example = "debug";
286 };
287
288 format = lib.mkOption {
289 type = lib.types.str;
290 default = "text";
291 description = ''
292 headscale log format.
293 '';
294 example = "json";
295 };
296 };
297
298 dns = {
299 magic_dns = lib.mkOption {
300 type = lib.types.bool;
301 default = true;
302 description = ''
303 Whether to use [MagicDNS](https://tailscale.com/kb/1081/magicdns/).
304 '';
305 example = false;
306 };
307
308 base_domain = lib.mkOption {
309 type = lib.types.str;
310 default = "";
311 description = ''
312 Defines the base domain to create the hostnames for MagicDNS.
313 This domain must be different from the {option}`server_url`
314 domain.
315 {option}`base_domain` must be a FQDN, without the trailing dot.
316 The FQDN of the hosts will be `hostname.base_domain` (e.g.
317 `myhost.tailnet.example.com`).
318 '';
319 example = "tailnet.example.com";
320 };
321
322 nameservers = {
323 global = lib.mkOption {
324 type = lib.types.listOf lib.types.str;
325 default = [ ];
326 description = ''
327 List of nameservers to pass to Tailscale clients.
328 '';
329 };
330 };
331
332 search_domains = lib.mkOption {
333 type = lib.types.listOf lib.types.str;
334 default = [ ];
335 description = ''
336 Search domains to inject to Tailscale clients.
337 '';
338 example = [ "mydomain.internal" ];
339 };
340 };
341
342 oidc = {
343 issuer = lib.mkOption {
344 type = lib.types.str;
345 default = "";
346 description = ''
347 URL to OpenID issuer.
348 '';
349 example = "https://openid.example.com";
350 };
351
352 client_id = lib.mkOption {
353 type = lib.types.str;
354 default = "";
355 description = ''
356 OpenID Connect client ID.
357 '';
358 };
359
360 client_secret_path = lib.mkOption {
361 type = lib.types.nullOr lib.types.str;
362 default = null;
363 description = ''
364 Path to OpenID Connect client secret file. Expands environment variables in format ''${VAR}.
365 '';
366 };
367
368 scope = lib.mkOption {
369 type = lib.types.listOf lib.types.str;
370 default = [
371 "openid"
372 "profile"
373 "email"
374 ];
375 description = ''
376 Scopes used in the OIDC flow.
377 '';
378 };
379
380 extra_params = lib.mkOption {
381 type = lib.types.attrsOf lib.types.str;
382 default = { };
383 description = ''
384 Custom query parameters to send with the Authorize Endpoint request.
385 '';
386 example = {
387 domain_hint = "example.com";
388 };
389 };
390
391 allowed_domains = lib.mkOption {
392 type = lib.types.listOf lib.types.str;
393 default = [ ];
394 description = ''
395 Allowed principal domains. if an authenticated user's domain
396 is not in this list authentication request will be rejected.
397 '';
398 example = [ "example.com" ];
399 };
400
401 allowed_users = lib.mkOption {
402 type = lib.types.listOf lib.types.str;
403 default = [ ];
404 description = ''
405 Users allowed to authenticate even if not in allowedDomains.
406 '';
407 example = [ "alice@example.com" ];
408 };
409
410 pkce = {
411 enabled = lib.mkOption {
412 type = lib.types.bool;
413 default = false;
414 description = ''
415 Enable or disable PKCE (Proof Key for Code Exchange) support.
416 PKCE adds an additional layer of security to the OAuth 2.0
417 authorization code flow by preventing authorization code
418 interception attacks
419 See https://datatracker.ietf.org/doc/html/rfc7636
420 '';
421 example = true;
422 };
423
424 method = lib.mkOption {
425 type = lib.types.str;
426 default = "S256";
427 description = ''
428 PKCE method to use:
429 - plain: Use plain code verifier
430 - S256: Use SHA256 hashed code verifier (default, recommended)
431 '';
432 };
433 };
434 };
435
436 tls_letsencrypt_hostname = lib.mkOption {
437 type = lib.types.nullOr lib.types.str;
438 default = "";
439 description = ''
440 Domain name to request a TLS certificate for.
441 '';
442 };
443
444 tls_letsencrypt_challenge_type = lib.mkOption {
445 type = lib.types.enum [
446 "TLS-ALPN-01"
447 "HTTP-01"
448 ];
449 default = "HTTP-01";
450 description = ''
451 Type of ACME challenge to use, currently supported types:
452 `HTTP-01` or `TLS-ALPN-01`.
453 '';
454 };
455
456 tls_letsencrypt_listen = lib.mkOption {
457 type = lib.types.nullOr lib.types.str;
458 default = ":http";
459 description = ''
460 When HTTP-01 challenge is chosen, letsencrypt must set up a
461 verification endpoint, and it will be listening on:
462 `:http = port 80`.
463 '';
464 };
465
466 tls_cert_path = lib.mkOption {
467 type = lib.types.nullOr lib.types.path;
468 default = null;
469 description = ''
470 Path to already created certificate.
471 '';
472 };
473
474 tls_key_path = lib.mkOption {
475 type = lib.types.nullOr lib.types.path;
476 default = null;
477 description = ''
478 Path to key for already created certificate.
479 '';
480 };
481
482 policy = {
483 mode = lib.mkOption {
484 type = lib.types.enum [
485 "file"
486 "database"
487 ];
488 default = "file";
489 description = ''
490 The mode can be "file" or "database" that defines
491 where the ACL policies are stored and read from.
492 '';
493 };
494
495 path = lib.mkOption {
496 type = lib.types.nullOr lib.types.path;
497 default = null;
498 description = ''
499 If the mode is set to "file", the path to a
500 HuJSON file containing ACL policies.
501 '';
502 };
503 };
504 };
505 };
506 };
507 };
508 };
509
510 imports = with lib; [
511 (mkRenamedOptionModule
512 [ "services" "headscale" "derp" "autoUpdate" ]
513 [ "services" "headscale" "settings" "derp" "auto_update_enabled" ]
514 )
515 (mkRenamedOptionModule
516 [ "services" "headscale" "derp" "auto_update_enable" ]
517 [ "services" "headscale" "settings" "derp" "auto_update_enabled" ]
518 )
519 (mkRenamedOptionModule
520 [ "services" "headscale" "derp" "paths" ]
521 [ "services" "headscale" "settings" "derp" "paths" ]
522 )
523 (mkRenamedOptionModule
524 [ "services" "headscale" "derp" "updateFrequency" ]
525 [ "services" "headscale" "settings" "derp" "update_frequency" ]
526 )
527 (mkRenamedOptionModule
528 [ "services" "headscale" "derp" "urls" ]
529 [ "services" "headscale" "settings" "derp" "urls" ]
530 )
531 (mkRenamedOptionModule
532 [ "services" "headscale" "ephemeralNodeInactivityTimeout" ]
533 [ "services" "headscale" "settings" "ephemeral_node_inactivity_timeout" ]
534 )
535 (mkRenamedOptionModule
536 [ "services" "headscale" "logLevel" ]
537 [ "services" "headscale" "settings" "log" "level" ]
538 )
539 (mkRenamedOptionModule
540 [ "services" "headscale" "openIdConnect" "clientId" ]
541 [ "services" "headscale" "settings" "oidc" "client_id" ]
542 )
543 (mkRenamedOptionModule
544 [ "services" "headscale" "openIdConnect" "clientSecretFile" ]
545 [ "services" "headscale" "settings" "oidc" "client_secret_path" ]
546 )
547 (mkRenamedOptionModule
548 [ "services" "headscale" "openIdConnect" "issuer" ]
549 [ "services" "headscale" "settings" "oidc" "issuer" ]
550 )
551 (mkRenamedOptionModule
552 [ "services" "headscale" "serverUrl" ]
553 [ "services" "headscale" "settings" "server_url" ]
554 )
555 (mkRenamedOptionModule
556 [ "services" "headscale" "tls" "certFile" ]
557 [ "services" "headscale" "settings" "tls_cert_path" ]
558 )
559 (mkRenamedOptionModule
560 [ "services" "headscale" "tls" "keyFile" ]
561 [ "services" "headscale" "settings" "tls_key_path" ]
562 )
563 (mkRenamedOptionModule
564 [ "services" "headscale" "tls" "letsencrypt" "challengeType" ]
565 [ "services" "headscale" "settings" "tls_letsencrypt_challenge_type" ]
566 )
567 (mkRenamedOptionModule
568 [ "services" "headscale" "tls" "letsencrypt" "hostname" ]
569 [ "services" "headscale" "settings" "tls_letsencrypt_hostname" ]
570 )
571 (mkRenamedOptionModule
572 [ "services" "headscale" "tls" "letsencrypt" "httpListen" ]
573 [ "services" "headscale" "settings" "tls_letsencrypt_listen" ]
574 )
575
576 (mkRemovedOptionModule [ "services" "headscale" "openIdConnect" "domainMap" ] ''
577 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.
578 '')
579 ];
580
581 config = lib.mkIf cfg.enable {
582 assertions = [
583 {
584 assertion = with cfg.settings; dns.magic_dns -> dns.base_domain != "";
585 message = "dns.base_domain must be set when using MagicDNS";
586 }
587 (assertRemovedOption [ "settings" "acl_policy_path" ] "Use `policy.path` instead.")
588 (assertRemovedOption [ "settings" "db_host" ] "Use `database.postgres.host` instead.")
589 (assertRemovedOption [ "settings" "db_name" ] "Use `database.postgres.name` instead.")
590 (assertRemovedOption [
591 "settings"
592 "db_password_file"
593 ] "Use `database.postgres.password_file` instead.")
594 (assertRemovedOption [ "settings" "db_path" ] "Use `database.sqlite.path` instead.")
595 (assertRemovedOption [ "settings" "db_port" ] "Use `database.postgres.port` instead.")
596 (assertRemovedOption [ "settings" "db_type" ] "Use `database.type` instead.")
597 (assertRemovedOption [ "settings" "db_user" ] "Use `database.postgres.user` instead.")
598 (assertRemovedOption [ "settings" "dns_config" ] "Use `dns` instead.")
599 (assertRemovedOption [ "settings" "dns_config" "domains" ] "Use `dns.search_domains` instead.")
600 (assertRemovedOption [
601 "settings"
602 "dns_config"
603 "nameservers"
604 ] "Use `dns.nameservers.global` instead.")
605 (assertRemovedOption [
606 "settings"
607 "oidc"
608 "strip_email_domain"
609 ] "The strip_email_domain option got removed upstream")
610 ];
611
612 services.headscale.settings = lib.mkMerge [
613 cliConfig
614 {
615 listen_addr = lib.mkDefault "${cfg.address}:${toString cfg.port}";
616
617 tls_letsencrypt_cache_dir = "${dataDir}/.cache";
618 }
619 ];
620
621 environment = {
622 # Headscale CLI needs a minimal config to be able to locate the unix socket
623 # to talk to the server instance.
624 etc."headscale/config.yaml".source = cliConfigFile;
625
626 systemPackages = [ cfg.package ];
627 };
628
629 users.groups.headscale = lib.mkIf (cfg.group == "headscale") { };
630
631 users.users.headscale = lib.mkIf (cfg.user == "headscale") {
632 description = "headscale user";
633 home = dataDir;
634 group = cfg.group;
635 isSystemUser = true;
636 };
637
638 systemd.services.headscale = {
639 description = "headscale coordination server for Tailscale";
640 wants = [ "network-online.target" ];
641 after = [ "network-online.target" ];
642 wantedBy = [ "multi-user.target" ];
643
644 script = ''
645 ${lib.optionalString (cfg.settings.database.postgres.password_file != null) ''
646 export HEADSCALE_DATABASE_POSTGRES_PASS="$(head -n1 ${lib.escapeShellArg cfg.settings.database.postgres.password_file})"
647 ''}
648
649 exec ${lib.getExe cfg.package} serve --config ${configFile}
650 '';
651
652 serviceConfig =
653 let
654 capabilityBoundingSet = [ "CAP_CHOWN" ] ++ lib.optional (cfg.port < 1024) "CAP_NET_BIND_SERVICE";
655 in
656 {
657 Restart = "always";
658 RestartSec = "5s";
659 Type = "simple";
660 User = cfg.user;
661 Group = cfg.group;
662
663 # Hardening options
664 RuntimeDirectory = "headscale";
665 # Allow headscale group access so users can be added and use the CLI.
666 RuntimeDirectoryMode = "0750";
667
668 StateDirectory = "headscale";
669 StateDirectoryMode = "0750";
670
671 ProtectSystem = "strict";
672 ProtectHome = true;
673 PrivateTmp = true;
674 PrivateDevices = true;
675 ProtectKernelTunables = true;
676 ProtectControlGroups = true;
677 RestrictSUIDSGID = true;
678 PrivateMounts = true;
679 ProtectKernelModules = true;
680 ProtectKernelLogs = true;
681 ProtectHostname = true;
682 ProtectClock = true;
683 ProtectProc = "invisible";
684 ProcSubset = "pid";
685 RestrictNamespaces = true;
686 RemoveIPC = true;
687 UMask = "0077";
688
689 CapabilityBoundingSet = capabilityBoundingSet;
690 AmbientCapabilities = capabilityBoundingSet;
691 NoNewPrivileges = true;
692 LockPersonality = true;
693 RestrictRealtime = true;
694 SystemCallFilter = [
695 "@system-service"
696 "~@privileged"
697 "@chown"
698 ];
699 SystemCallArchitectures = "native";
700 RestrictAddressFamilies = "AF_INET AF_INET6 AF_UNIX";
701 };
702 };
703 };
704
705 meta.maintainers = with lib.maintainers; [
706 kradalby
707 misterio77
708 ];
709}