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 strip_email_domain = lib.mkOption {
411 type = lib.types.bool;
412 default = true;
413 description = ''
414 Whether the domain part of the email address should be removed when generating namespaces.
415 '';
416 };
417 };
418
419 tls_letsencrypt_hostname = lib.mkOption {
420 type = lib.types.nullOr lib.types.str;
421 default = "";
422 description = ''
423 Domain name to request a TLS certificate for.
424 '';
425 };
426
427 tls_letsencrypt_challenge_type = lib.mkOption {
428 type = lib.types.enum [
429 "TLS-ALPN-01"
430 "HTTP-01"
431 ];
432 default = "HTTP-01";
433 description = ''
434 Type of ACME challenge to use, currently supported types:
435 `HTTP-01` or `TLS-ALPN-01`.
436 '';
437 };
438
439 tls_letsencrypt_listen = lib.mkOption {
440 type = lib.types.nullOr lib.types.str;
441 default = ":http";
442 description = ''
443 When HTTP-01 challenge is chosen, letsencrypt must set up a
444 verification endpoint, and it will be listening on:
445 `:http = port 80`.
446 '';
447 };
448
449 tls_cert_path = lib.mkOption {
450 type = lib.types.nullOr lib.types.path;
451 default = null;
452 description = ''
453 Path to already created certificate.
454 '';
455 };
456
457 tls_key_path = lib.mkOption {
458 type = lib.types.nullOr lib.types.path;
459 default = null;
460 description = ''
461 Path to key for already created certificate.
462 '';
463 };
464
465 policy = {
466 mode = lib.mkOption {
467 type = lib.types.enum [
468 "file"
469 "database"
470 ];
471 default = "file";
472 description = ''
473 The mode can be "file" or "database" that defines
474 where the ACL policies are stored and read from.
475 '';
476 };
477
478 path = lib.mkOption {
479 type = lib.types.nullOr lib.types.path;
480 default = null;
481 description = ''
482 If the mode is set to "file", the path to a
483 HuJSON file containing ACL policies.
484 '';
485 };
486 };
487 };
488 };
489 };
490 };
491 };
492
493 imports = with lib; [
494 (mkRenamedOptionModule
495 [ "services" "headscale" "derp" "autoUpdate" ]
496 [ "services" "headscale" "settings" "derp" "auto_update_enabled" ]
497 )
498 (mkRenamedOptionModule
499 [ "services" "headscale" "derp" "auto_update_enable" ]
500 [ "services" "headscale" "settings" "derp" "auto_update_enabled" ]
501 )
502 (mkRenamedOptionModule
503 [ "services" "headscale" "derp" "paths" ]
504 [ "services" "headscale" "settings" "derp" "paths" ]
505 )
506 (mkRenamedOptionModule
507 [ "services" "headscale" "derp" "updateFrequency" ]
508 [ "services" "headscale" "settings" "derp" "update_frequency" ]
509 )
510 (mkRenamedOptionModule
511 [ "services" "headscale" "derp" "urls" ]
512 [ "services" "headscale" "settings" "derp" "urls" ]
513 )
514 (mkRenamedOptionModule
515 [ "services" "headscale" "ephemeralNodeInactivityTimeout" ]
516 [ "services" "headscale" "settings" "ephemeral_node_inactivity_timeout" ]
517 )
518 (mkRenamedOptionModule
519 [ "services" "headscale" "logLevel" ]
520 [ "services" "headscale" "settings" "log" "level" ]
521 )
522 (mkRenamedOptionModule
523 [ "services" "headscale" "openIdConnect" "clientId" ]
524 [ "services" "headscale" "settings" "oidc" "client_id" ]
525 )
526 (mkRenamedOptionModule
527 [ "services" "headscale" "openIdConnect" "clientSecretFile" ]
528 [ "services" "headscale" "settings" "oidc" "client_secret_path" ]
529 )
530 (mkRenamedOptionModule
531 [ "services" "headscale" "openIdConnect" "issuer" ]
532 [ "services" "headscale" "settings" "oidc" "issuer" ]
533 )
534 (mkRenamedOptionModule
535 [ "services" "headscale" "serverUrl" ]
536 [ "services" "headscale" "settings" "server_url" ]
537 )
538 (mkRenamedOptionModule
539 [ "services" "headscale" "tls" "certFile" ]
540 [ "services" "headscale" "settings" "tls_cert_path" ]
541 )
542 (mkRenamedOptionModule
543 [ "services" "headscale" "tls" "keyFile" ]
544 [ "services" "headscale" "settings" "tls_key_path" ]
545 )
546 (mkRenamedOptionModule
547 [ "services" "headscale" "tls" "letsencrypt" "challengeType" ]
548 [ "services" "headscale" "settings" "tls_letsencrypt_challenge_type" ]
549 )
550 (mkRenamedOptionModule
551 [ "services" "headscale" "tls" "letsencrypt" "hostname" ]
552 [ "services" "headscale" "settings" "tls_letsencrypt_hostname" ]
553 )
554 (mkRenamedOptionModule
555 [ "services" "headscale" "tls" "letsencrypt" "httpListen" ]
556 [ "services" "headscale" "settings" "tls_letsencrypt_listen" ]
557 )
558
559 (mkRemovedOptionModule [ "services" "headscale" "openIdConnect" "domainMap" ] ''
560 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.
561 '')
562 ];
563
564 config = lib.mkIf cfg.enable {
565 assertions = [
566 {
567 assertion = with cfg.settings; dns.magic_dns -> dns.base_domain != "";
568 message = "dns.base_domain must be set when using MagicDNS";
569 }
570 (assertRemovedOption [ "settings" "acl_policy_path" ] "Use `policy.path` instead.")
571 (assertRemovedOption [ "settings" "db_host" ] "Use `database.postgres.host` instead.")
572 (assertRemovedOption [ "settings" "db_name" ] "Use `database.postgres.name` instead.")
573 (assertRemovedOption [
574 "settings"
575 "db_password_file"
576 ] "Use `database.postgres.password_file` instead.")
577 (assertRemovedOption [ "settings" "db_path" ] "Use `database.sqlite.path` instead.")
578 (assertRemovedOption [ "settings" "db_port" ] "Use `database.postgres.port` instead.")
579 (assertRemovedOption [ "settings" "db_type" ] "Use `database.type` instead.")
580 (assertRemovedOption [ "settings" "db_user" ] "Use `database.postgres.user` instead.")
581 (assertRemovedOption [ "settings" "dns_config" ] "Use `dns` instead.")
582 (assertRemovedOption [ "settings" "dns_config" "domains" ] "Use `dns.search_domains` instead.")
583 (assertRemovedOption [
584 "settings"
585 "dns_config"
586 "nameservers"
587 ] "Use `dns.nameservers.global` instead.")
588 ];
589
590 services.headscale.settings = lib.mkMerge [
591 cliConfig
592 {
593 listen_addr = lib.mkDefault "${cfg.address}:${toString cfg.port}";
594
595 tls_letsencrypt_cache_dir = "${dataDir}/.cache";
596 }
597 ];
598
599 environment = {
600 # Headscale CLI needs a minimal config to be able to locate the unix socket
601 # to talk to the server instance.
602 etc."headscale/config.yaml".source = cliConfigFile;
603
604 systemPackages = [ cfg.package ];
605 };
606
607 users.groups.headscale = lib.mkIf (cfg.group == "headscale") { };
608
609 users.users.headscale = lib.mkIf (cfg.user == "headscale") {
610 description = "headscale user";
611 home = dataDir;
612 group = cfg.group;
613 isSystemUser = true;
614 };
615
616 systemd.services.headscale = {
617 description = "headscale coordination server for Tailscale";
618 wants = [ "network-online.target" ];
619 after = [ "network-online.target" ];
620 wantedBy = [ "multi-user.target" ];
621
622 script = ''
623 ${lib.optionalString (cfg.settings.database.postgres.password_file != null) ''
624 export HEADSCALE_DATABASE_POSTGRES_PASS="$(head -n1 ${lib.escapeShellArg cfg.settings.database.postgres.password_file})"
625 ''}
626
627 exec ${lib.getExe cfg.package} serve --config ${configFile}
628 '';
629
630 serviceConfig =
631 let
632 capabilityBoundingSet = [ "CAP_CHOWN" ] ++ lib.optional (cfg.port < 1024) "CAP_NET_BIND_SERVICE";
633 in
634 {
635 Restart = "always";
636 RestartSec = "5s";
637 Type = "simple";
638 User = cfg.user;
639 Group = cfg.group;
640
641 # Hardening options
642 RuntimeDirectory = "headscale";
643 # Allow headscale group access so users can be added and use the CLI.
644 RuntimeDirectoryMode = "0750";
645
646 StateDirectory = "headscale";
647 StateDirectoryMode = "0750";
648
649 ProtectSystem = "strict";
650 ProtectHome = true;
651 PrivateTmp = true;
652 PrivateDevices = true;
653 ProtectKernelTunables = true;
654 ProtectControlGroups = true;
655 RestrictSUIDSGID = true;
656 PrivateMounts = true;
657 ProtectKernelModules = true;
658 ProtectKernelLogs = true;
659 ProtectHostname = true;
660 ProtectClock = true;
661 ProtectProc = "invisible";
662 ProcSubset = "pid";
663 RestrictNamespaces = true;
664 RemoveIPC = true;
665 UMask = "0077";
666
667 CapabilityBoundingSet = capabilityBoundingSet;
668 AmbientCapabilities = capabilityBoundingSet;
669 NoNewPrivileges = true;
670 LockPersonality = true;
671 RestrictRealtime = true;
672 SystemCallFilter = [
673 "@system-service"
674 "~@privileged"
675 "@chown"
676 ];
677 SystemCallArchitectures = "native";
678 RestrictAddressFamilies = "AF_INET AF_INET6 AF_UNIX";
679 };
680 };
681 };
682
683 meta.maintainers = with lib.maintainers; [
684 kradalby
685 misterio77
686 ];
687}