Drops nginx, the cert and dead ip_prefixes. Proves DERP relay and TLS noise after a restart, and puts nix/module.nix under CI.
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- The NixOS module implementationexample-configuration.nix- Example configuration demonstrating all major featurestestkit.nix,testkit-peer.nix- Test kit: a control node for NixOS VM tests of Tailscale clients, and a peer that joins it (see below)tests/- NixOS integration tests
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>, sohttp://headscalefor a node namedheadscale. Use it fortailscale up --login-server,tsnet.Server.ControlURLand tailscale-rsTS_CONTROL_URL. -
The same router is also served on TLS :443 with a throwaway self-signed certificate. DERP is region 999 (
headscale) on :443, markedInsecureForTests, 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(default60s); - creates
USERif missing; - prints one reusable 24h key.
Extra flags pass through, e.g.
--ephemeralor--tags tag:ci. In a policy, refer to these users asUSER@, because they have no email. - waits for headscale for up to
-
hs-join KEY [tailscale up flags], on atestkit-peernode, waits for tailscaled, then joins the control node namedheadscalewith a 60stailscale up --timeout, so a broken control plane fails the test instead of hanging it. A trailing--login-serveroverrides the URL. -
Defaults you can override:
policy.mode = "database": nothing stored allows all, andheadscale policy set -f FILEapplies live. For a fixed policy, setsettings.policy = { mode = "file"; path = pkgs.writeText "policy.json" (builtins.toJSON { … }); }. A policy may name users beforehs-authkeycreates 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.
- Override the package with a plain assignment, e.g. to carry a patch:
Clients
- tailscaled: import
testkit-peerand usehs-join, or runtailscale up --login-server http://headscale --auth-key KEYyourself. Its log warns that it "could not establish an encrypted connection": that is the untrusted certificate on :443, and it is expected. - tsnet: set
ControlURLtohttp://headscaleand hand it the key, e.g.TS_AUTHKEYin anEnvironmentFilethat the test writes before it starts the unit. - tailscale-rs: build with its
ts_control/insecure-keyfetchandts_control/insecure-derpfeatures, which allow plain-HTTP/keyand honourInsecureForTests. See its examples for the control URL, auth key and hostname flags. - Proving relay: set
TS_DEBUG_ALWAYS_USE_DERP=1on a Go peer.- Between Go peers,
tailscale ping --until-direct=false PEERreportsvia 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.
- Between Go peers,
- Logs:
testkit-peerturns off tailscaled's log uploads. Inside the Nix build sandbox other clients' uploads just fail; setTS_NO_LOGS_NO_SUPPORT=1for 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: grantCAP_NET_ADMIN, or callnetns.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_ADDRto 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_urlis 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 inderp.paths, as.yaml,.jsonor.hujson.
Migrating a hand-rolled control node
Delete these and import the kit:
- the self-signed cert,
security.pki.certificateFilesand the nginx TLS proxy; - the embedded DERP block (
region_id = 999,urls = [ ]) and its firewall ports; ip_prefixes, which headscale no longer reads;/etc/hostspins 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.followsworks only if your nixpkgs has the Go versiongo.modrequires. Comparenix eval --raw nixpkgs#go_latest.versionagainst it. - The input also brings headscale's development inputs into your lock file. If
you already depend on them,
followsthem.
Upstream
The module in this repository may be newer than the nixpkgs version.