1 { config, lib, pkgs, ... }:
4 cfg = config.services.headscale;
6 dataDir = "/var/lib/headscale";
7 runDir = "/run/headscale";
9 settingsFormat = pkgs.formats.yaml { };
10 configFile = settingsFormat.generate "headscale.yaml" cfg.settings;
14 services.headscale = {
15 enable = mkEnableOption (lib.mdDoc "headscale, Open Source coordination server for Tailscale");
19 default = pkgs.headscale;
20 defaultText = literalExpression "pkgs.headscale";
21 description = lib.mdDoc ''
22 Which headscale package to use for the running server.
27 default = "headscale";
29 description = lib.mdDoc ''
30 User account under which headscale runs.
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.
41 default = "headscale";
43 description = lib.mdDoc ''
44 Group under which headscale runs.
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.
54 serverUrl = mkOption {
56 default = "http://127.0.0.1:8080";
57 description = lib.mdDoc ''
58 The url clients will connect to.
60 example = "https://myheadscale.example.com:443";
65 default = "127.0.0.1";
66 description = lib.mdDoc ''
67 Listening address of headscale.
75 description = lib.mdDoc ''
76 Listening port of headscale.
81 privateKeyFile = mkOption {
83 default = "${dataDir}/private.key";
84 description = lib.mdDoc ''
85 Path to private key file, generated automatically if it does not exist.
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.
100 type = types.listOf types.path;
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.
109 autoUpdate = mkOption {
112 description = lib.mdDoc ''
113 Whether to automatically update DERP maps on a set frequency.
118 updateFrequency = mkOption {
121 description = lib.mdDoc ''
122 Frequency to update DERP maps.
129 ephemeralNodeInactivityTimeout = mkOption {
132 description = lib.mdDoc ''
133 Time before an inactive ephemeral node is deleted.
140 type = types.enum [ "sqlite3" "postgres" ];
141 example = "postgres";
143 description = lib.mdDoc "Database engine to use.";
147 type = types.nullOr types.str;
149 example = "127.0.0.1";
150 description = lib.mdDoc "Database host address.";
154 type = types.nullOr types.port;
157 description = lib.mdDoc "Database host port.";
161 type = types.nullOr types.str;
163 example = "headscale";
164 description = lib.mdDoc "Database name.";
168 type = types.nullOr types.str;
170 example = "headscale";
171 description = lib.mdDoc "Database user.";
174 passwordFile = mkOption {
175 type = types.nullOr types.path;
177 example = "/run/keys/headscale-dbpassword";
178 description = lib.mdDoc ''
179 A file containing the password corresponding to
180 {option}`database.user`.
185 type = types.nullOr types.str;
186 default = "${dataDir}/db.sqlite";
187 description = lib.mdDoc "Path to the sqlite3 database file.";
191 logLevel = mkOption {
194 description = lib.mdDoc ''
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.
210 type = types.listOf types.str;
212 description = lib.mdDoc ''
213 Search domains to inject to Tailscale clients.
215 example = [ "mydomain.internal" ];
218 magicDns = mkOption {
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.
228 baseDomain = mkOption {
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`).
245 description = lib.mdDoc ''
246 URL to OpenID issuer.
248 example = "https://openid.example.com";
251 clientId = mkOption {
254 description = lib.mdDoc ''
255 OpenID Connect client ID.
259 clientSecretFile = mkOption {
260 type = types.nullOr types.path;
262 description = lib.mdDoc ''
263 Path to OpenID Connect client secret file.
267 domainMap = mkOption {
268 type = types.attrsOf types.str;
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.
275 ".*" = "default-namespace";
283 hostname = mkOption {
284 type = types.nullOr types.str;
286 description = lib.mdDoc ''
287 Domain name to request a TLS certificate for.
290 challengeType = mkOption {
291 type = types.enum [ "TLS-ALPN-01" "HTTP-01" ];
293 description = lib.mdDoc ''
294 Type of ACME challenge to use, currently supported types:
295 `HTTP-01` or `TLS-ALPN-01`.
298 httpListen = mkOption {
299 type = types.nullOr types.str;
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:
309 certFile = mkOption {
310 type = types.nullOr types.path;
312 description = lib.mdDoc ''
313 Path to already created certificate.
317 type = types.nullOr types.path;
319 description = lib.mdDoc ''
320 Path to key for already created certificate.
325 aclPolicyFile = mkOption {
326 type = types.nullOr types.path;
328 description = lib.mdDoc ''
329 Path to a file containg ACL policies.
333 settings = mkOption {
334 type = settingsFormat.type;
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.
348 config = mkIf cfg.enable {
350 services.headscale.settings = {
351 server_url = mkDefault cfg.serverUrl;
352 listen_addr = mkDefault "${cfg.address}:${toString cfg.port}";
354 private_key_path = mkDefault cfg.privateKeyFile;
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;
363 # Turn off update checks since the origin of our package
364 # is nixpkgs and not Github.
365 disable_check_updates = true;
367 ephemeral_node_inactivity_timeout = mkDefault cfg.ephemeralNodeInactivityTimeout;
369 db_type = mkDefault cfg.database.type;
370 db_path = mkDefault cfg.database.path;
372 log_level = mkDefault cfg.logLevel;
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;
381 unix_socket = "${runDir}/headscale.sock";
385 issuer = mkDefault cfg.openIdConnect.issuer;
386 client_id = mkDefault cfg.openIdConnect.clientId;
387 domain_map = mkDefault cfg.openIdConnect.domainMap;
390 tls_letsencrypt_cache_dir = "${dataDir}/.cache";
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;
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
417 environment.etc."headscale/config.yaml".source = configFile;
419 users.groups.headscale = mkIf (cfg.group == "headscale") { };
421 users.users.headscale = mkIf (cfg.user == "headscale") {
422 description = "headscale user";
428 systemd.services.headscale = {
429 description = "headscale coordination server for Tailscale";
430 after = [ "network-online.target" ];
431 wantedBy = [ "multi-user.target" ];
432 restartTriggers = [ configFile ];
434 environment.GIN_MODE = "release";
437 ${optionalString (cfg.database.passwordFile != null) ''
438 export HEADSCALE_DB_PASS="$(head -n1 ${escapeShellArg cfg.database.passwordFile})"
441 ${optionalString (cfg.openIdConnect.clientSecretFile != null) ''
442 export HEADSCALE_OIDC_CLIENT_SECRET="$(head -n1 ${escapeShellArg cfg.openIdConnect.clientSecretFile})"
444 exec ${cfg.package}/bin/headscale serve
449 capabilityBoundingSet = [ "CAP_CHOWN" ] ++ optional (cfg.port < 1024) "CAP_NET_BIND_SERVICE";
458 RuntimeDirectory = "headscale";
459 # Allow headscale group access so users can be added and use the CLI.
460 RuntimeDirectoryMode = "0750";
462 StateDirectory = "headscale";
463 StateDirectoryMode = "0750";
465 ProtectSystem = "strict";
468 PrivateDevices = true;
469 ProtectKernelTunables = true;
470 ProtectControlGroups = true;
471 RestrictSUIDSGID = true;
472 PrivateMounts = true;
473 ProtectKernelModules = true;
474 ProtectKernelLogs = true;
475 ProtectHostname = true;
477 ProtectProc = "invisible";
479 RestrictNamespaces = true;
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";
495 meta.maintainers = with maintainers; [ kradalby ];