Skip to content

Instantly share code, notes, and snippets.

@tfc
Created May 22, 2026 19:48
Show Gist options
  • Select an option

  • Save tfc/ec97cb965845c96200c9ca3279f777e1 to your computer and use it in GitHub Desktop.

Select an option

Save tfc/ec97cb965845c96200c9ca3279f777e1 to your computer and use it in GitHub Desktop.
tailscale demo und test
let
pkgs = import <nixpkgs> {};
in
pkgs.testers.runNixOSTest ./test.nix
{ pkgs, ... }:
let
tls-cert =
pkgs.runCommand "selfSignedCerts"
{
buildInputs = [ pkgs.openssl ];
}
''
openssl req \
-x509 -newkey rsa:2048 -sha256 -days 365 \
-nodes -out cert.pem -keyout key.pem \
-subj '/CN=server1' -addext "subjectAltName=DNS:server1"
mkdir -p $out
cp key.pem cert.pem $out
'';
authKeyPath = "/var/lib/tailscale/preauthkey";
# IPv4 prefix headscale allocates tailnet IPs from. Single source of truth
# for both the headscale allocator and the nginx allow-list.
tailnetCidrV4 = "100.64.0.0/10";
helloVhost =
{ config, ... }:
{
services.nginx = {
enable = true;
virtualHosts = {
hello = {
default = true;
extraConfig = ''
allow ${tailnetCidrV4};
deny all;
'';
locations."/".return = "200 'hello from ${config.networking.hostName}\\n'";
};
};
};
};
tailscaleJoin =
{ config, ... }:
{
services.tailscale = {
enable = true;
authKeyFile = authKeyPath;
extraUpFlags = [
"--login-server=https://server1"
"--hostname=${config.networking.hostName}"
];
};
environment.systemPackages = [
pkgs.tailscale
];
};
in
{
name = "headscale-tailnet-3node";
nodes = {
server1 =
{ config, ... }:
{
imports = [
helloVhost
tailscaleJoin
];
services.headscale = {
enable = true;
settings = {
server_url = "https://server1";
derp = {
urls = [ ];
server = {
enabled = true;
region_id = 999;
stun_listen_addr = "0.0.0.0:${toString config.services.tailscale.derper.stunPort}";
# Pin IPv4 so tailscaled's netcheck doesn't need to DNS-resolve
# the test-VLAN hostname.
ipv4 = config.networking.primaryIPAddress;
};
};
dns = {
base_domain = "acme.internal";
override_local_dns = false;
};
prefixes.v4 = tailnetCidrV4;
};
};
services.nginx = {
enable = true;
virtualHosts = {
# TLS reverse proxy for headscale's control API + DERP.
server1 = {
onlySSL = true;
sslCertificate = "${tls-cert}/cert.pem";
sslCertificateKey = "${tls-cert}/key.pem";
locations."/" = {
proxyPass = "http://127.0.0.1:${toString config.services.headscale.port}";
proxyWebsockets = true;
};
};
};
};
security.pki.certificateFiles = [ "${tls-cert}/cert.pem" ];
networking.firewall.allowedTCPPorts = [ 443 ];
networking.firewall.allowedUDPPorts = [ config.services.tailscale.derper.stunPort ];
networking.firewall.interfaces.tailscale0.allowedTCPPorts = [ 80 ];
environment.systemPackages = [
config.services.headscale.package
pkgs.jq
];
};
server2 = {
imports = [
helloVhost
tailscaleJoin
];
security.pki.certificateFiles = [ "${tls-cert}/cert.pem" ];
networking.firewall.interfaces.tailscale0.allowedTCPPorts = [ 80 ];
environment.systemPackages = [ pkgs.tailscale ];
};
client = {
services.tailscale.enable = true;
security.pki.certificateFiles = [ "${tls-cert}/cert.pem" ];
environment.systemPackages = [ pkgs.tailscale ];
};
};
testScript = ''
start_all()
server1.wait_for_unit("headscale.service")
server1.wait_for_open_port(443)
server1.wait_for_unit("nginx.service")
server1.wait_for_unit("tailscaled.service")
server2.wait_for_unit("nginx.service")
server2.wait_for_unit("tailscaled.service")
client.wait_for_unit("tailscaled.service")
# in a real life setup, we would deploy all servers from scratch on new
# VMs or whatever.
# this would result in setups where the tailscale services error out
# because the keys are not distributed, yet.
# so what needs to happen once per new deployment:
# 1.) admin runs the next two steps to extract the authkey:
server1.succeed("headscale users create infra")
auth_key = server1.succeed(
"headscale preauthkeys create --user 1 --reusable --expiration 24h "
"--output json | jq -r .key"
).strip()
# 2.) admin adds the key to the agenix setup and redeploys everything
# Plant the key on each infra server. tailscaled-autoconnect failed at
# boot (file didn't exist yet); restarting it now triggers the join.
for s in [server1, server2]:
s.succeed(
"install -m 600 -D /dev/null ${authKeyPath} && "
f"printf %s {auth_key} > ${authKeyPath}"
)
s.succeed("systemctl restart tailscaled-autoconnect.service")
# 3.) now, all the servers are in the tailscale network.
# Freelancers and employees join manually.
# This would happen via the browser in a more pleasant process than this
# here in the test:
client_key = server1.succeed(
"headscale preauthkeys create --user 1 --reusable --expiration 24h "
"--output json | jq -r .key"
).strip()
client.execute(
f"tailscale up --login-server 'https://server1' "
f"--auth-key {client_key} --hostname=client"
)
# Wait for an actual working tailnet path to each server.
client.wait_until_succeeds("tailscale ping --c 1 --timeout 2s server1")
client.wait_until_succeeds("tailscale ping --c 1 --timeout 2s server2")
# Reach both nginx instances via magic DNS over the tailnet.
out1 = client.succeed("curl --fail --max-time 5 http://server1.acme.internal/")
assert "hello from server1" in out1, f"unexpected: {out1!r}"
out2 = client.succeed("curl --fail --max-time 5 http://server2.acme.internal/")
assert "hello from server2" in out2, f"unexpected: {out2!r}"
# nginx must not be reachable over the unsecured test LAN!
client.fail("curl --fail --max-time 5 http://server1/")
client.fail("curl --fail --max-time 5 http://server2/")
'';
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment