mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-06 14:50:07 +09:00
nix: add a NixOS test kit for Tailscale clients
nixosModules.testkit makes a node a control server every client joins without trust setup; testkit-peer joins it with hs-join.
This commit is contained in:
committed by
Kristoffer Dalby
parent
35a90e0018
commit
200c2c01e8
@@ -331,3 +331,9 @@ a broken one.
|
|||||||
- **Do not edit `gen/`** — it is regenerated from `proto/` by
|
- **Do not edit `gen/`** — it is regenerated from `proto/` by
|
||||||
`make generate`.
|
`make generate`.
|
||||||
- **Proto changes + code changes should be two commits**, not one.
|
- **Proto changes + code changes should be two commits**, not one.
|
||||||
|
- **The NixOS test kit is a public contract** consumed by other projects'
|
||||||
|
NixOS tests: the "Contract" in `nix/README.md`, covering
|
||||||
|
`nix/testkit.nix`, `nix/testkit-peer.nix`, the embedded DERP region
|
||||||
|
and `HEADSCALE_DEBUG_INSECURE_TLS_LISTEN_ADDR`, and the CLI commands
|
||||||
|
`hs-authkey` calls. Breaking it needs a "NixOS test kit" BREAKING entry
|
||||||
|
in `CHANGELOG.md`.
|
||||||
|
|||||||
@@ -60,6 +60,24 @@ removed on this schedule:
|
|||||||
|
|
||||||
[#3352](https://github.com/juanfont/headscale/pull/3352)
|
[#3352](https://github.com/juanfont/headscale/pull/3352)
|
||||||
|
|
||||||
|
### NixOS test kit
|
||||||
|
|
||||||
|
Projects built on Tailscale, such as tsnet services, tailscaled integrations or
|
||||||
|
Tailscale client implementations, can now test against a real control server
|
||||||
|
in their NixOS VM tests. Import `nixosModules.testkit` on a node named
|
||||||
|
`headscale`. Clients join `http://headscale` with no certificates or other
|
||||||
|
setup, and `hs-authkey USER` on that node mints their auth keys.
|
||||||
|
`nixosModules.testkit-peer` adds a tailscaled peer that joins with `hs-join KEY`:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
nodes.headscale.imports = [ inputs.headscale.nixosModules.testkit ];
|
||||||
|
nodes.peer.imports = [ inputs.headscale.nixosModules.testkit-peer ];
|
||||||
|
# testScript: peer.succeed(f"hs-join {headscale.succeed('hs-authkey alice').strip()}")
|
||||||
|
```
|
||||||
|
|
||||||
|
See `nix/README.md` for the full contract, recipes for tsnet and non-Go
|
||||||
|
clients, and how to run the same setup without Nix.
|
||||||
|
|
||||||
### BREAKING
|
### BREAKING
|
||||||
|
|
||||||
#### Database
|
#### Database
|
||||||
|
|||||||
@@ -25,8 +25,13 @@
|
|||||||
{
|
{
|
||||||
# NixOS module
|
# NixOS module
|
||||||
nixosModules = rec {
|
nixosModules = rec {
|
||||||
headscale = import ./nix/module.nix;
|
# A path, so importing it twice (directly and via testkit) dedupes.
|
||||||
|
headscale = ./nix/module.nix;
|
||||||
default = headscale;
|
default = headscale;
|
||||||
|
# Control node for NixOS VM tests of Tailscale clients, and a peer
|
||||||
|
# that joins it; nix/README.md.
|
||||||
|
testkit = import ./nix/testkit.nix self;
|
||||||
|
testkit-peer = ./nix/testkit-peer.nix;
|
||||||
};
|
};
|
||||||
|
|
||||||
overlays.default =
|
overlays.default =
|
||||||
|
|||||||
+160
@@ -14,6 +14,9 @@ aim to be upstreamed to nixpkgs.
|
|||||||
- **[`module.nix`](./module.nix)** - The NixOS module implementation
|
- **[`module.nix`](./module.nix)** - The NixOS module implementation
|
||||||
- **[`example-configuration.nix`](./example-configuration.nix)** - Example
|
- **[`example-configuration.nix`](./example-configuration.nix)** - Example
|
||||||
configuration demonstrating all major features
|
configuration demonstrating all major features
|
||||||
|
- **[`testkit.nix`](./testkit.nix)**,
|
||||||
|
**[`testkit-peer.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/`](./tests/)** - NixOS integration tests
|
- **[`tests/`](./tests/)** - NixOS integration tests
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@@ -33,6 +36,163 @@ imports = [ inputs.headscale.nixosModules.default ];
|
|||||||
See [`example-configuration.nix`](./example-configuration.nix) for configuration
|
See [`example-configuration.nix`](./example-configuration.nix) for configuration
|
||||||
options.
|
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.
|
||||||
|
|
||||||
|
```nix
|
||||||
|
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:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
headscale -c CONFIG users create NAME
|
||||||
|
headscale -c CONFIG preauthkeys create --user NAME --reusable --expiration 24h
|
||||||
|
```
|
||||||
|
|
||||||
|
A minimal config:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 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
|
## Upstream
|
||||||
|
|
||||||
- [nixpkgs module](https://github.com/NixOS/nixpkgs/blob/master/nixos/modules/services/networking/headscale.nix)
|
- [nixpkgs module](https://github.com/NixOS/nixpkgs/blob/master/nixos/modules/services/networking/headscale.nix)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# A tailscaled peer for nix/testkit.nix: `hs-join KEY` joins the kit's control
|
||||||
|
# node, which is named headscale. Contract: nix/README.md.
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
hs-join = pkgs.writeShellApplication {
|
||||||
|
name = "hs-join";
|
||||||
|
runtimeInputs = [ config.services.tailscale.package ];
|
||||||
|
text = ''
|
||||||
|
key=''${1:?usage: hs-join KEY [tailscale up flags]}
|
||||||
|
shift
|
||||||
|
# Returns once tailscaled is up, so joining straight after boot can't race it.
|
||||||
|
systemctl start tailscaled.service
|
||||||
|
# Broken control fails here instead of hanging the test to its global timeout.
|
||||||
|
exec tailscale up --timeout=60s --login-server http://headscale --auth-key "$key" "$@"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
key = "headscale-testkit-peer";
|
||||||
|
services.tailscale.enable = true;
|
||||||
|
# Interactive driver runs have a route out; keep their logs off Tailscale's servers.
|
||||||
|
services.tailscale.disableUpstreamLogging = lib.mkDefault true;
|
||||||
|
environment.systemPackages = [ hs-join ];
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Makes a NixOS test node a headscale control server that any Tailscale client
|
||||||
|
# joins without trust setup: plain-HTTP control, plus the same router on a
|
||||||
|
# self-signed TLS :443 whose DERP region is InsecureForTests. That is the one
|
||||||
|
# shape tailscaled, tsnet, webpki-only tailscale-rs and the Android app all
|
||||||
|
# accept. Contract: nix/README.md.
|
||||||
|
self:
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
hs-authkey = pkgs.writeShellApplication {
|
||||||
|
name = "hs-authkey";
|
||||||
|
runtimeInputs = [
|
||||||
|
config.services.headscale.package
|
||||||
|
pkgs.jq
|
||||||
|
];
|
||||||
|
text = ''
|
||||||
|
user=''${1:?usage: hs-authkey USER [headscale preauthkeys create flags]}
|
||||||
|
shift
|
||||||
|
# Safe straight after start_all(): the CLI retries the socket until this.
|
||||||
|
HEADSCALE_CLI_TIMEOUT=''${HEADSCALE_CLI_TIMEOUT:-60s} headscale health >/dev/null
|
||||||
|
headscale users list --name "$user" -o json | jq -e 'length > 0' >/dev/null ||
|
||||||
|
headscale users create "$user" >/dev/null
|
||||||
|
headscale preauthkeys create --user "$user" --reusable --expiration 24h "$@"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
key = "headscale-testkit";
|
||||||
|
_file = ./testkit.nix;
|
||||||
|
imports = [ self.nixosModules.headscale ];
|
||||||
|
|
||||||
|
services.headscale = {
|
||||||
|
enable = true;
|
||||||
|
package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.headscale;
|
||||||
|
address = "[::]";
|
||||||
|
port = 80;
|
||||||
|
settings = {
|
||||||
|
server_url = "http://${config.networking.hostName}";
|
||||||
|
dns.base_domain = lib.mkDefault "tailnet";
|
||||||
|
dns.override_local_dns = lib.mkDefault false;
|
||||||
|
# Nothing stored allows all, and `headscale policy set` works live.
|
||||||
|
policy.mode = lib.mkDefault "database";
|
||||||
|
derp = {
|
||||||
|
urls = [ ];
|
||||||
|
server = {
|
||||||
|
enabled = true;
|
||||||
|
region_id = 999;
|
||||||
|
region_code = "headscale";
|
||||||
|
region_name = "headscale test kit";
|
||||||
|
stun_listen_addr = "[::]:3478";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# DERP rides this listener, and so does noise: Go clients redial only :443
|
||||||
|
# for a hostname URL after a recent dial.
|
||||||
|
systemd.services.headscale = {
|
||||||
|
environment.HEADSCALE_DEBUG_INSECURE_TLS_LISTEN_ADDR = "[::]:443";
|
||||||
|
# The module grants this only for a privileged main port.
|
||||||
|
serviceConfig.AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
|
||||||
|
serviceConfig.CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
networking.firewall = {
|
||||||
|
allowedTCPPorts = [
|
||||||
|
80
|
||||||
|
443
|
||||||
|
];
|
||||||
|
allowedUDPPorts = [ 3478 ];
|
||||||
|
};
|
||||||
|
|
||||||
|
environment.systemPackages = [ hs-authkey ];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user