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