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