Created
May 22, 2026 19:48
-
-
Save tfc/ec97cb965845c96200c9ca3279f777e1 to your computer and use it in GitHub Desktop.
tailscale demo und test
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| let | |
| pkgs = import <nixpkgs> {}; | |
| in | |
| pkgs.testers.runNixOSTest ./test.nix |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { 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