at master 24 kB view raw
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}