Files
headscale/nix
Kristoffer Dalby 00663fbbfe nix: run the headscale VM test on the test kit
Drops nginx, the cert and dead ip_prefixes. Proves DERP relay and TLS noise
after a restart, and puts nix/module.nix under CI.
2026-09-28 21:42:19 +02:00
..

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

Usage

Add to your flake inputs:

inputs.headscale.url = "github:juanfont/headscale";

Then import the module:

imports = [ inputs.headscale.nixosModules.default ];

See example-configuration.nix for configuration options.

Test kit

nixosModules.testkit turns a node of a NixOS VM test into a headscale control server. Projects that implement or embed Tailscale (tailscaled, tsnet, tailscale-rs, …) use it to test against a real control server without writing one. Go clients join without a CA, a hosts entry or an env var. nixosModules.testkit-peer is an optional tailscaled peer that joins it in one line.

pkgs.testers.runNixOSTest {
  name = "my-app";
  nodes.headscale.imports = [ inputs.headscale.nixosModules.testkit ];
  nodes.peer.imports = [ inputs.headscale.nixosModules.testkit-peer ];
  testScript = ''
    start_all()
    key = headscale.succeed("hs-authkey alice").strip()
    peer.succeed(f"hs-join {key}")
  '';
}

It works with nixosTest, runNixOSTest and runTest. The headscale package must come from the same release or newer: the kit relies on its preauthkeys create --user NAME and HEADSCALE_DEBUG_INSECURE_TLS_LISTEN_ADDR.

Contract

Breaking changes to anything below get a "NixOS test kit" BREAKING entry in the CHANGELOG.

  • Control: http://<node hostname>, so http://headscale for a node named headscale. Use it for tailscale up --login-server, tsnet.Server.ControlURL and tailscale-rs TS_CONTROL_URL.

  • The same router is also served on TLS :443 with a throwaway self-signed certificate. DERP is region 999 (headscale) on :443, marked InsecureForTests, with STUN on UDP 3478.

  • hs-authkey USER [headscale preauthkeys create flags], on the control node:

    • waits for headscale for up to HEADSCALE_CLI_TIMEOUT (default 60s);
    • creates USER if missing;
    • prints one reusable 24h key.

    Extra flags pass through, e.g. --ephemeral or --tags tag:ci. In a policy, refer to these users as USER@, because they have no email.

  • hs-join KEY [tailscale up flags], on a testkit-peer node, waits for tailscaled, then joins the control node named headscale with a 60s tailscale up --timeout, so a broken control plane fails the test instead of hanging it. A trailing --login-server overrides the URL.

  • Defaults you can override:

    • policy.mode = "database": nothing stored allows all, and headscale policy set -f FILE applies live. For a fixed policy, set settings.policy = { mode = "file"; path = pkgs.writeText "policy.json" (builtins.toJSON { … }); }. A policy may name users before hs-authkey creates them.
    • MagicDNS with dns.base_domain = "tailnet".
    • dns.override_local_dns = false.
  • Everything else is plain services.headscale.* on that node.

    • Override the package with a plain assignment, e.g. to carry a patch: services.headscale.package = inputs.headscale.packages.${system}.headscale.overrideAttrs (o: { patches = (o.patches or [ ]) ++ [ ./fix.patch ]; });
    • Don't bind :80 or :443 on that node yourself.

Clients

  • tailscaled: import testkit-peer and use hs-join, or run tailscale up --login-server http://headscale --auth-key KEY yourself. Its log warns that it "could not establish an encrypted connection": that is the untrusted certificate on :443, and it is expected.
  • tsnet: set ControlURL to http://headscale and hand it the key, e.g. TS_AUTHKEY in an EnvironmentFile that the test writes before it starts the unit.
  • tailscale-rs: build with its ts_control/insecure-keyfetch and ts_control/insecure-derp features, which allow plain-HTTP /key and honour InsecureForTests. See its examples for the control URL, auth key and hostname flags.
  • Proving relay: set TS_DEBUG_ALWAYS_USE_DERP=1 on a Go peer.
    • Between Go peers, tailscale ping --until-direct=false PEER reports via DERP(headscale).
    • A non-Go peer may not answer pings over DERP. Check its home DERP from a Go peer with tailscale status --json | jq -e '.Peer[] | select(.HostName == "NAME") | .Relay == "headscale"' and send application traffic.
  • Logs: testkit-peer turns off tailscaled's log uploads. Inside the Nix build sandbox other clients' uploads just fail; set TS_NO_LOGS_NO_SUPPORT=1 for tsnet if interactive runs should not upload either.
  • Unprivileged tsnet: without CAP_NET_ADMIN, tsnet binds its sockets to the default-route interface. In a test VM that is the user-net NIC, not the VLAN, so control is unreachable. The same thing happens on multi-homed production hosts, so fix it in the service: grant CAP_NET_ADMIN, or call netns.SetEnabled(false).
  • Versions: headscale supports the Tailscale client versions named at the top of each CHANGELOG release, with no upper bound. Pin a headscale release that accepts your client.

Without Nix

A harness that runs the headscale binary itself, such as an Android emulator test, can use the same shape:

  • Serve plain HTTP.

  • Set HEADSCALE_DEBUG_INSECURE_TLS_LISTEN_ADDR to serve the router and DERP over a throwaway TLS certificate.

  • Mint keys:

    headscale -c CONFIG users create NAME
    headscale -c CONFIG preauthkeys create --user NAME --reusable --expiration 24h
    

A minimal config:

# Clients dial this host for DERP and STUN too, so all of them must reach it.
server_url: http://127.0.0.1:8080
listen_addr: 127.0.0.1:8080
noise: { private_key_path: /tmp/hs/noise.key }
database: { type: sqlite, sqlite: { path: /tmp/hs/db.sqlite } }
prefixes: { v4: 100.64.0.0/10, v6: "fd7a:115c:a1e0::/48" }
dns: { magic_dns: false, override_local_dns: false }
unix_socket: /tmp/hs/headscale.sock
disable_check_updates: true
derp:
  urls: []
  server:
    enabled: true
    region_id: 999
    region_code: headscale
    stun_listen_addr: 127.0.0.1:3478
    private_key_path: /tmp/hs/derp.key
  • Wait for HEADSCALE_CLI_TIMEOUT=60s headscale -c CONFIG health.
  • If server_url is a hostname rather than an IP, Go clients redial noise only on :443 after a recent dial. Put the TLS listener on :443 then.
  • If clients reach headscale at different addresses, e.g. an emulator behind NAT, set derp.server.enabled: false. Instead, list a DERP map of your own in derp.paths, as .yaml, .json or .hujson.

Migrating a hand-rolled control node

Delete these and import the kit:

  • the self-signed cert, security.pki.certificateFiles and the nginx TLS proxy;
  • the embedded DERP block (region_id = 999, urls = [ ]) and its firewall ports;
  • ip_prefixes, which headscale no longer reads;
  • /etc/hosts pins for the control node;
  • jq lookups of the user ID before preauthkeys create.

Cost

  • headscale builds from source with its own pinned nixpkgs and Go.
  • Setting inputs.headscale.inputs.nixpkgs.follows works only if your nixpkgs has the Go version go.mod requires. Compare nix eval --raw nixpkgs#go_latest.version against it.
  • The input also brings headscale's development inputs into your lock file. If you already depend on them, follows them.

Upstream

The module in this repository may be newer than the nixpkgs version.