diff --git a/.github/workflows/nix-module-test.yml b/.github/workflows/nix-module-test.yml new file mode 100644 index 00000000..18f40f91 --- /dev/null +++ b/.github/workflows/nix-module-test.yml @@ -0,0 +1,56 @@ +name: NixOS Module Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + nix-module-check: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 2 + + - name: Get changed files + id: changed-files + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + with: + filters: | + nix: + - 'nix/**' + - 'flake.nix' + - 'flake.lock' + go: + - 'go.*' + - '**/*.go' + - 'cmd/**' + - 'hscontrol/**' + + - uses: nixbuild/nix-quick-install-action@889f3180bb5f064ee9e3201428d04ae9e41d54ad # v31 + if: steps.changed-files.outputs.nix == 'true' || steps.changed-files.outputs.go == 'true' + + - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 + if: steps.changed-files.outputs.nix == 'true' || steps.changed-files.outputs.go == 'true' + with: + primary-key: + nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', + '**/flake.lock') }} + restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} + + - name: Run NixOS module tests + if: steps.changed-files.outputs.nix == 'true' || steps.changed-files.outputs.go == 'true' + run: | + echo "Running NixOS module integration test..." + nix build .#checks.x86_64-linux.headscale -L diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e43192e..15467f79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,8 @@ ### Changes - Add NixOS module in repository for faster iteration [#2857](https://github.com/juanfont/headscale/pull/2857) -- Add favicon to webpages - [#2858](https://github.com/juanfont/headscale/pull/2858) -- Reclaim IPs from the IP allocator when nodes are deleted - [#2831](https://github.com/juanfont/headscale/pull/2831) +- Add favicon to webpages [#2858](https://github.com/juanfont/headscale/pull/2858) +- Reclaim IPs from the IP allocator when nodes are deleted [#2831](https://github.com/juanfont/headscale/pull/2831) ## 0.27.1 (2025-11-11) diff --git a/README.md b/README.md index dbde74d9..7381c372 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ and container to run Headscale.** Please have a look at the [`documentation`](https://headscale.net/stable/). +For NixOS users, a module is available in [`nix/`](./nix/). + ## Talks - Fosdem 2023 (video): [Headscale: How we are using integration testing to reimplement Tailscale](https://fosdem.org/2023/schedule/event/goheadscale/) diff --git a/flake.nix b/flake.nix index 86f8b005..8d16f609 100644 --- a/flake.nix +++ b/flake.nix @@ -17,6 +17,12 @@ commitHash = self.rev or self.dirtyRev; in { + # NixOS module + nixosModules = rec { + headscale = import ./nix/module.nix; + default = headscale; + }; + overlay = _: prev: let pkgs = nixpkgs.legacyPackages.${prev.system}; @@ -38,12 +44,9 @@ subPackages = [ "cmd/headscale" ]; - ldflags = [ - "-s" - "-w" - "-X github.com/juanfont/headscale/hscontrol/types.Version=${headscaleVersion}" - "-X github.com/juanfont/headscale/hscontrol/types.GitCommitHash=${commitHash}" - ]; + meta = { + mainProgram = "headscale"; + }; }; hi = buildGo { @@ -228,24 +231,7 @@ apps.default = apps.headscale; checks = { - format = - pkgs.runCommand "check-format" - { - buildInputs = with pkgs; [ - gnumake - nixpkgs-fmt - golangci-lint - nodePackages.prettier - golines - clang-tools - ]; - } '' - ${pkgs.nixpkgs-fmt}/bin/nixpkgs-fmt ${./.} - ${pkgs.golangci-lint}/bin/golangci-lint run --fix --timeout 10m - ${pkgs.nodePackages.prettier}/bin/prettier --write '**/**.{ts,js,md,yaml,yml,sass,css,scss,html}' - ${pkgs.golines}/bin/golines --max-len=88 --base-formatter=gofumpt -w ${./.} - ${pkgs.clang-tools}/bin/clang-format -i ${./.} - ''; + headscale = pkgs.nixosTest (import ./nix/tests/headscale.nix); }; }); } diff --git a/nix/README.md b/nix/README.md new file mode 100644 index 00000000..533e4b5e --- /dev/null +++ b/nix/README.md @@ -0,0 +1,41 @@ +# Headscale NixOS Module + +This directory contains the NixOS module for Headscale. + +## Rationale + +The module is maintained in this repository to keep the code and module +synchronized at the same commit. This allows faster iteration and ensures the +module stays compatible with the latest Headscale changes. All changes should +aim to be upstreamed to nixpkgs. + +## Files + +- **[`module.nix`](./module.nix)** - The NixOS module implementation +- **[`example-configuration.nix`](./example-configuration.nix)** - Example + configuration demonstrating all major features +- **[`tests/`](./tests/)** - NixOS integration tests + +## Usage + +Add to your flake inputs: + +```nix +inputs.headscale.url = "github:juanfont/headscale"; +``` + +Then import the module: + +```nix +imports = [ inputs.headscale.nixosModules.default ]; +``` + +See [`example-configuration.nix`](./example-configuration.nix) for configuration +options. + +## Upstream + +- [nixpkgs module](https://github.com/NixOS/nixpkgs/blob/master/nixos/modules/services/networking/headscale.nix) +- [nixpkgs package](https://github.com/NixOS/nixpkgs/blob/master/pkgs/by-name/he/headscale/package.nix) + +The module in this repository may be newer than the nixpkgs version. diff --git a/nix/example-configuration.nix b/nix/example-configuration.nix new file mode 100644 index 00000000..e1f6cec7 --- /dev/null +++ b/nix/example-configuration.nix @@ -0,0 +1,145 @@ +# Example NixOS configuration using the headscale module +# +# This file demonstrates how to use the headscale NixOS module from this flake. +# To use in your own configuration, add this to your flake.nix inputs: +# +# inputs.headscale.url = "github:juanfont/headscale"; +# +# Then import the module: +# +# imports = [ inputs.headscale.nixosModules.default ]; +# + +{ config, pkgs, ... }: + +{ + # Import the headscale module + # In a real configuration, this would come from the flake input + # imports = [ inputs.headscale.nixosModules.default ]; + + services.headscale = { + enable = true; + + # Optional: Use a specific package (defaults to pkgs.headscale) + # package = pkgs.headscale; + + # Listen on all interfaces (default is 127.0.0.1) + address = "0.0.0.0"; + port = 8080; + + settings = { + # The URL clients will connect to + server_url = "https://headscale.example.com"; + + # IP prefixes for the tailnet + # These use the freeform settings - you can set any headscale config option + prefixes = { + v4 = "100.64.0.0/10"; + v6 = "fd7a:115c:a1e0::/48"; + allocation = "sequential"; + }; + + # DNS configuration with MagicDNS + dns = { + magic_dns = true; + base_domain = "tailnet.example.com"; + + # Whether to override client's local DNS settings (default: true) + # When true, nameservers.global must be set + override_local_dns = true; + + nameservers = { + global = [ "1.1.1.1" "8.8.8.8" ]; + }; + }; + + # DERP (relay) configuration + derp = { + # Use default Tailscale DERP servers + urls = [ "https://controlplane.tailscale.com/derpmap/default" ]; + auto_update_enabled = true; + update_frequency = "24h"; + + # Optional: Run your own DERP server + # server = { + # enabled = true; + # region_id = 999; + # stun_listen_addr = "0.0.0.0:3478"; + # }; + }; + + # Database configuration (SQLite is recommended) + database = { + type = "sqlite"; + sqlite = { + path = "/var/lib/headscale/db.sqlite"; + write_ahead_log = true; + }; + + # PostgreSQL example (not recommended for new deployments) + # type = "postgres"; + # postgres = { + # host = "localhost"; + # port = 5432; + # name = "headscale"; + # user = "headscale"; + # password_file = "/run/secrets/headscale-db-password"; + # }; + }; + + # Logging configuration + log = { + level = "info"; + format = "text"; + }; + + # Optional: OIDC authentication + # oidc = { + # issuer = "https://accounts.google.com"; + # client_id = "your-client-id"; + # client_secret_path = "/run/secrets/oidc-client-secret"; + # scope = [ "openid" "profile" "email" ]; + # allowed_domains = [ "example.com" ]; + # }; + + # Optional: Let's Encrypt TLS certificates + # tls_letsencrypt_hostname = "headscale.example.com"; + # tls_letsencrypt_challenge_type = "HTTP-01"; + + # Optional: Provide your own TLS certificates + # tls_cert_path = "/path/to/cert.pem"; + # tls_key_path = "/path/to/key.pem"; + + # ACL policy configuration + policy = { + mode = "file"; + path = "/var/lib/headscale/policy.hujson"; + }; + + # You can add ANY headscale configuration option here thanks to freeform settings + # For example, experimental features or settings not explicitly defined above: + # experimental_feature = true; + # custom_setting = "value"; + }; + }; + + # Optional: Open firewall ports + networking.firewall = { + allowedTCPPorts = [ 8080 ]; + # If running a DERP server: + # allowedUDPPorts = [ 3478 ]; + }; + + # Optional: Use with nginx reverse proxy for TLS termination + # services.nginx = { + # enable = true; + # virtualHosts."headscale.example.com" = { + # enableACME = true; + # forceSSL = true; + # locations."/" = { + # proxyPass = "http://127.0.0.1:8080"; + # proxyWebsockets = true; + # }; + # }; + # }; +} diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 00000000..a75398fb --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,727 @@ +{ config +, lib +, pkgs +, ... +}: +let + cfg = config.services.headscale; + + dataDir = "/var/lib/headscale"; + runDir = "/run/headscale"; + + cliConfig = { + # Turn off update checks since the origin of our package + # is nixpkgs and not Github. + disable_check_updates = true; + + unix_socket = "${runDir}/headscale.sock"; + }; + + settingsFormat = pkgs.formats.yaml { }; + configFile = settingsFormat.generate "headscale.yaml" cfg.settings; + cliConfigFile = settingsFormat.generate "headscale.yaml" cliConfig; + + assertRemovedOption = option: message: { + assertion = !lib.hasAttrByPath option cfg; + message = + "The option `services.headscale.${lib.options.showOption option}` was removed. " + message; + }; +in +{ + # Disable the upstream NixOS module to prevent conflicts + disabledModules = [ "services/networking/headscale.nix" ]; + + options = { + services.headscale = { + enable = lib.mkEnableOption "headscale, Open Source coordination server for Tailscale"; + + package = lib.mkPackageOption pkgs "headscale" { }; + + user = lib.mkOption { + default = "headscale"; + type = lib.types.str; + description = '' + User account under which headscale runs. + + ::: {.note} + If left as the default value this user will automatically be created + on system activation, otherwise you are responsible for + ensuring the user exists before the headscale service starts. + ::: + ''; + }; + + group = lib.mkOption { + default = "headscale"; + type = lib.types.str; + description = '' + Group under which headscale runs. + + ::: {.note} + If left as the default value this group will automatically be created + on system activation, otherwise you are responsible for + ensuring the user exists before the headscale service starts. + ::: + ''; + }; + + address = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + description = '' + Listening address of headscale. + ''; + example = "0.0.0.0"; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 8080; + description = '' + Listening port of headscale. + ''; + example = 443; + }; + + settings = lib.mkOption { + description = '' + Overrides to {file}`config.yaml` as a Nix attribute set. + Check the [example config](https://github.com/juanfont/headscale/blob/main/config-example.yaml) + for possible options. + ''; + type = lib.types.submodule { + freeformType = settingsFormat.type; + + options = { + server_url = lib.mkOption { + type = lib.types.str; + default = "http://127.0.0.1:8080"; + description = '' + The url clients will connect to. + ''; + example = "https://myheadscale.example.com:443"; + }; + + noise.private_key_path = lib.mkOption { + type = lib.types.path; + default = "${dataDir}/noise_private.key"; + description = '' + Path to noise private key file, generated automatically if it does not exist. + ''; + }; + + prefixes = + let + prefDesc = '' + Each prefix consists of either an IPv4 or IPv6 address, + and the associated prefix length, delimited by a slash. + It must be within IP ranges supported by the Tailscale + client - i.e., subnets of 100.64.0.0/10 and fd7a:115c:a1e0::/48. + ''; + in + { + v4 = lib.mkOption { + type = lib.types.str; + default = "100.64.0.0/10"; + description = prefDesc; + }; + + v6 = lib.mkOption { + type = lib.types.str; + default = "fd7a:115c:a1e0::/48"; + description = prefDesc; + }; + + allocation = lib.mkOption { + type = lib.types.enum [ + "sequential" + "random" + ]; + example = "random"; + default = "sequential"; + description = '' + Strategy used for allocation of IPs to nodes, available options: + - sequential (default): assigns the next free IP from the previous given IP. + - random: assigns the next free IP from a pseudo-random IP generator (crypto/rand). + ''; + }; + }; + + derp = { + urls = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ "https://controlplane.tailscale.com/derpmap/default" ]; + description = '' + List of urls containing DERP maps. + See [How Tailscale works](https://tailscale.com/blog/how-tailscale-works/) for more information on DERP maps. + ''; + }; + + paths = lib.mkOption { + type = lib.types.listOf lib.types.path; + default = [ ]; + description = '' + List of file paths containing DERP maps. + See [How Tailscale works](https://tailscale.com/blog/how-tailscale-works/) for more information on DERP maps. + ''; + }; + + auto_update_enabled = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Whether to automatically update DERP maps on a set frequency. + ''; + example = false; + }; + + update_frequency = lib.mkOption { + type = lib.types.str; + default = "24h"; + description = '' + Frequency to update DERP maps. + ''; + example = "5m"; + }; + + server.private_key_path = lib.mkOption { + type = lib.types.path; + default = "${dataDir}/derp_server_private.key"; + description = '' + Path to derp private key file, generated automatically if it does not exist. + ''; + }; + }; + + ephemeral_node_inactivity_timeout = lib.mkOption { + type = lib.types.str; + default = "30m"; + description = '' + Time before an inactive ephemeral node is deleted. + ''; + example = "5m"; + }; + + database = { + type = lib.mkOption { + type = lib.types.enum [ + "sqlite" + "sqlite3" + "postgres" + ]; + example = "postgres"; + default = "sqlite"; + description = '' + Database engine to use. + Please note that using Postgres is highly discouraged as it is only supported for legacy reasons. + All new development, testing and optimisations are done with SQLite in mind. + ''; + }; + + sqlite = { + path = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = "${dataDir}/db.sqlite"; + description = "Path to the sqlite3 database file."; + }; + + write_ahead_log = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Enable WAL mode for SQLite. This is recommended for production environments. + + ''; + example = true; + }; + }; + + postgres = { + host = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "127.0.0.1"; + description = "Database host address."; + }; + + port = lib.mkOption { + type = lib.types.nullOr lib.types.port; + default = null; + example = 3306; + description = "Database host port."; + }; + + name = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "headscale"; + description = "Database name."; + }; + + user = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "headscale"; + description = "Database user."; + }; + + password_file = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/run/keys/headscale-dbpassword"; + description = '' + A file containing the password corresponding to + {option}`database.user`. + ''; + }; + }; + }; + + log = { + level = lib.mkOption { + type = lib.types.str; + default = "info"; + description = '' + headscale log level. + ''; + example = "debug"; + }; + + format = lib.mkOption { + type = lib.types.str; + default = "text"; + description = '' + headscale log format. + ''; + example = "json"; + }; + }; + + dns = { + magic_dns = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Whether to use [MagicDNS](https://tailscale.com/kb/1081/magicdns/). + ''; + example = false; + }; + + base_domain = lib.mkOption { + type = lib.types.str; + default = ""; + description = '' + Defines the base domain to create the hostnames for MagicDNS. + This domain must be different from the {option}`server_url` + domain. + {option}`base_domain` must be a FQDN, without the trailing dot. + The FQDN of the hosts will be `hostname.base_domain` (e.g. + `myhost.tailnet.example.com`). + ''; + example = "tailnet.example.com"; + }; + + override_local_dns = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Whether to use the local DNS settings of a node or override + the local DNS settings and force the use of Headscale's DNS + configuration. + ''; + example = false; + }; + + nameservers = { + global = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = '' + List of nameservers to pass to Tailscale clients. + Required when {option}`override_local_dns` is true. + ''; + }; + }; + + search_domains = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = '' + Search domains to inject to Tailscale clients. + ''; + example = [ "mydomain.internal" ]; + }; + }; + + oidc = { + issuer = lib.mkOption { + type = lib.types.str; + default = ""; + description = '' + URL to OpenID issuer. + ''; + example = "https://openid.example.com"; + }; + + client_id = lib.mkOption { + type = lib.types.str; + default = ""; + description = '' + OpenID Connect client ID. + ''; + }; + + client_secret_path = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + Path to OpenID Connect client secret file. Expands environment variables in format ''${VAR}. + ''; + }; + + scope = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ + "openid" + "profile" + "email" + ]; + description = '' + Scopes used in the OIDC flow. + ''; + }; + + extra_params = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + description = '' + Custom query parameters to send with the Authorize Endpoint request. + ''; + example = { + domain_hint = "example.com"; + }; + }; + + allowed_domains = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = '' + Allowed principal domains. if an authenticated user's domain + is not in this list authentication request will be rejected. + ''; + example = [ "example.com" ]; + }; + + allowed_users = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = '' + Users allowed to authenticate even if not in allowedDomains. + ''; + example = [ "alice@example.com" ]; + }; + + pkce = { + enabled = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Enable or disable PKCE (Proof Key for Code Exchange) support. + PKCE adds an additional layer of security to the OAuth 2.0 + authorization code flow by preventing authorization code + interception attacks + See https://datatracker.ietf.org/doc/html/rfc7636 + ''; + example = true; + }; + + method = lib.mkOption { + type = lib.types.str; + default = "S256"; + description = '' + PKCE method to use: + - plain: Use plain code verifier + - S256: Use SHA256 hashed code verifier (default, recommended) + ''; + }; + }; + }; + + tls_letsencrypt_hostname = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = ""; + description = '' + Domain name to request a TLS certificate for. + ''; + }; + + tls_letsencrypt_challenge_type = lib.mkOption { + type = lib.types.enum [ + "TLS-ALPN-01" + "HTTP-01" + ]; + default = "HTTP-01"; + description = '' + Type of ACME challenge to use, currently supported types: + `HTTP-01` or `TLS-ALPN-01`. + ''; + }; + + tls_letsencrypt_listen = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = ":http"; + description = '' + When HTTP-01 challenge is chosen, letsencrypt must set up a + verification endpoint, and it will be listening on: + `:http = port 80`. + ''; + }; + + tls_cert_path = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + Path to already created certificate. + ''; + }; + + tls_key_path = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + Path to key for already created certificate. + ''; + }; + + policy = { + mode = lib.mkOption { + type = lib.types.enum [ + "file" + "database" + ]; + default = "file"; + description = '' + The mode can be "file" or "database" that defines + where the ACL policies are stored and read from. + ''; + }; + + path = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + If the mode is set to "file", the path to a + HuJSON file containing ACL policies. + ''; + }; + }; + }; + }; + }; + }; + }; + + imports = with lib; [ + (mkRenamedOptionModule + [ "services" "headscale" "derp" "autoUpdate" ] + [ "services" "headscale" "settings" "derp" "auto_update_enabled" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "derp" "auto_update_enable" ] + [ "services" "headscale" "settings" "derp" "auto_update_enabled" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "derp" "paths" ] + [ "services" "headscale" "settings" "derp" "paths" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "derp" "updateFrequency" ] + [ "services" "headscale" "settings" "derp" "update_frequency" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "derp" "urls" ] + [ "services" "headscale" "settings" "derp" "urls" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "ephemeralNodeInactivityTimeout" ] + [ "services" "headscale" "settings" "ephemeral_node_inactivity_timeout" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "logLevel" ] + [ "services" "headscale" "settings" "log" "level" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "openIdConnect" "clientId" ] + [ "services" "headscale" "settings" "oidc" "client_id" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "openIdConnect" "clientSecretFile" ] + [ "services" "headscale" "settings" "oidc" "client_secret_path" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "openIdConnect" "issuer" ] + [ "services" "headscale" "settings" "oidc" "issuer" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "serverUrl" ] + [ "services" "headscale" "settings" "server_url" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "tls" "certFile" ] + [ "services" "headscale" "settings" "tls_cert_path" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "tls" "keyFile" ] + [ "services" "headscale" "settings" "tls_key_path" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "tls" "letsencrypt" "challengeType" ] + [ "services" "headscale" "settings" "tls_letsencrypt_challenge_type" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "tls" "letsencrypt" "hostname" ] + [ "services" "headscale" "settings" "tls_letsencrypt_hostname" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "tls" "letsencrypt" "httpListen" ] + [ "services" "headscale" "settings" "tls_letsencrypt_listen" ] + ) + + (mkRemovedOptionModule [ "services" "headscale" "openIdConnect" "domainMap" ] '' + 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. + '') + ]; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = with cfg.settings; dns.magic_dns -> dns.base_domain != ""; + message = "dns.base_domain must be set when using MagicDNS"; + } + { + assertion = with cfg.settings; dns.override_local_dns -> (dns.nameservers.global != [ ]); + message = "dns.nameservers.global must be set when dns.override_local_dns is true"; + } + (assertRemovedOption [ "settings" "acl_policy_path" ] "Use `policy.path` instead.") + (assertRemovedOption [ "settings" "db_host" ] "Use `database.postgres.host` instead.") + (assertRemovedOption [ "settings" "db_name" ] "Use `database.postgres.name` instead.") + (assertRemovedOption [ + "settings" + "db_password_file" + ] "Use `database.postgres.password_file` instead.") + (assertRemovedOption [ "settings" "db_path" ] "Use `database.sqlite.path` instead.") + (assertRemovedOption [ "settings" "db_port" ] "Use `database.postgres.port` instead.") + (assertRemovedOption [ "settings" "db_type" ] "Use `database.type` instead.") + (assertRemovedOption [ "settings" "db_user" ] "Use `database.postgres.user` instead.") + (assertRemovedOption [ "settings" "dns_config" ] "Use `dns` instead.") + (assertRemovedOption [ "settings" "dns_config" "domains" ] "Use `dns.search_domains` instead.") + (assertRemovedOption [ + "settings" + "dns_config" + "nameservers" + ] "Use `dns.nameservers.global` instead.") + (assertRemovedOption [ + "settings" + "oidc" + "strip_email_domain" + ] "The strip_email_domain option got removed upstream") + ]; + + services.headscale.settings = lib.mkMerge [ + cliConfig + { + listen_addr = lib.mkDefault "${cfg.address}:${toString cfg.port}"; + + tls_letsencrypt_cache_dir = "${dataDir}/.cache"; + } + ]; + + environment = { + # Headscale CLI needs a minimal config to be able to locate the unix socket + # to talk to the server instance. + etc."headscale/config.yaml".source = cliConfigFile; + + systemPackages = [ cfg.package ]; + }; + + users.groups.headscale = lib.mkIf (cfg.group == "headscale") { }; + + users.users.headscale = lib.mkIf (cfg.user == "headscale") { + description = "headscale user"; + home = dataDir; + group = cfg.group; + isSystemUser = true; + }; + + systemd.services.headscale = { + description = "headscale coordination server for Tailscale"; + wants = [ "network-online.target" ]; + after = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + + script = '' + ${lib.optionalString (cfg.settings.database.postgres.password_file != null) '' + export HEADSCALE_DATABASE_POSTGRES_PASS="$(head -n1 ${lib.escapeShellArg cfg.settings.database.postgres.password_file})" + ''} + + exec ${lib.getExe cfg.package} serve --config ${configFile} + ''; + + serviceConfig = + let + capabilityBoundingSet = [ "CAP_CHOWN" ] ++ lib.optional (cfg.port < 1024) "CAP_NET_BIND_SERVICE"; + in + { + Restart = "always"; + RestartSec = "5s"; + Type = "simple"; + User = cfg.user; + Group = cfg.group; + + # Hardening options + RuntimeDirectory = "headscale"; + # Allow headscale group access so users can be added and use the CLI. + RuntimeDirectoryMode = "0750"; + + StateDirectory = "headscale"; + StateDirectoryMode = "0750"; + + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectKernelTunables = true; + ProtectControlGroups = true; + RestrictSUIDSGID = true; + PrivateMounts = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectHostname = true; + ProtectClock = true; + ProtectProc = "invisible"; + ProcSubset = "pid"; + RestrictNamespaces = true; + RemoveIPC = true; + UMask = "0077"; + + CapabilityBoundingSet = capabilityBoundingSet; + AmbientCapabilities = capabilityBoundingSet; + NoNewPrivileges = true; + LockPersonality = true; + RestrictRealtime = true; + SystemCallFilter = [ + "@system-service" + "~@privileged" + "@chown" + ]; + SystemCallArchitectures = "native"; + RestrictAddressFamilies = "AF_INET AF_INET6 AF_UNIX"; + }; + }; + }; + + meta.maintainers = with lib.maintainers; [ + kradalby + misterio77 + ]; +} diff --git a/nix/tests/headscale.nix b/nix/tests/headscale.nix new file mode 100644 index 00000000..7dc93870 --- /dev/null +++ b/nix/tests/headscale.nix @@ -0,0 +1,102 @@ +{ pkgs, lib, ... }: +let + tls-cert = pkgs.runCommand "selfSignedCerts" { buildInputs = [ pkgs.openssl ]; } '' + openssl req \ + -x509 -newkey rsa:4096 -sha256 -days 365 \ + -nodes -out cert.pem -keyout key.pem \ + -subj '/CN=headscale' -addext "subjectAltName=DNS:headscale" + + mkdir -p $out + cp key.pem cert.pem $out + ''; +in +{ + name = "headscale"; + meta.maintainers = with lib.maintainers; [ + kradalby + misterio77 + ]; + + nodes = + let + headscalePort = 8080; + stunPort = 3478; + peer = { + services.tailscale.enable = true; + security.pki.certificateFiles = [ "${tls-cert}/cert.pem" ]; + }; + in + { + peer1 = peer; + peer2 = peer; + + headscale = { + services = { + headscale = { + enable = true; + port = headscalePort; + settings = { + server_url = "https://headscale"; + ip_prefixes = [ "100.64.0.0/10" ]; + derp.server = { + enabled = true; + region_id = 999; + stun_listen_addr = "0.0.0.0:${toString stunPort}"; + }; + dns = { + base_domain = "tailnet"; + extra_records = [ + { + name = "foo.bar"; + type = "A"; + value = "100.64.0.2"; + } + ]; + override_local_dns = false; + }; + }; + }; + nginx = { + enable = true; + virtualHosts.headscale = { + addSSL = true; + sslCertificate = "${tls-cert}/cert.pem"; + sslCertificateKey = "${tls-cert}/key.pem"; + locations."/" = { + proxyPass = "http://127.0.0.1:${toString headscalePort}"; + proxyWebsockets = true; + }; + }; + }; + }; + networking.firewall = { + allowedTCPPorts = [ + 80 + 443 + ]; + allowedUDPPorts = [ stunPort ]; + }; + environment.systemPackages = [ pkgs.headscale ]; + }; + }; + + testScript = '' + start_all() + headscale.wait_for_unit("headscale") + headscale.wait_for_open_port(443) + + # Create headscale user and preauth-key + headscale.succeed("headscale users create test") + authkey = headscale.succeed("headscale preauthkeys -u 1 create --reusable") + + # Connect peers + up_cmd = f"tailscale up --login-server 'https://headscale' --auth-key {authkey}" + peer1.execute(up_cmd) + peer2.execute(up_cmd) + + # Check that they are reachable from the tailnet + peer1.wait_until_succeeds("tailscale ping peer2") + peer2.wait_until_succeeds("tailscale ping peer1.tailnet") + assert (res := peer1.wait_until_succeeds("${lib.getExe pkgs.dig} +short foo.bar").strip()) == "100.64.0.2", f"Domain {res} did not match 100.64.0.2" + ''; +}