name: headscale-up description: >- Build headscale with nix, start it over self-signed TLS with embedded DERP, and join one regular node via a pre-auth key (so the tailnet has a node already in the network and a ping target). Outputs the URL and bootstrap credentials. # TLS is not optional: the embedded DERP server requires it, and DERP is what # gives the nodes a data plane to ping over. The self-signed cert is trusted on # the runner so the tailscale client and OAuth exchange accept it. outputs: url: description: headscale server URL (https) value: ${{ steps.bootstrap.outputs.url }} preauth: description: reusable pre-auth key for user 'ci' value: ${{ steps.bootstrap.outputs.preauth }} pre_ip: description: tailnet IPv4 of the pre-joined regular node value: ${{ steps.prenode.outputs.pre_ip }} runs: using: composite steps: - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main - uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2.0.0 - name: Build headscale shell: bash run: nix build --fallback - name: Start headscale (TLS + embedded DERP) id: bootstrap shell: bash run: | set -euo pipefail mkdir -p /tmp/hs echo "127.0.0.1 headscale" | sudo tee -a /etc/hosts # Self-signed cert for the control server, trusted system-wide so the # tailscale client and the OAuth token exchange accept it. openssl req -x509 -newkey rsa:4096 -sha256 -days 1 -nodes \ -keyout /tmp/hs/tls.key -out /tmp/hs/tls.crt \ -subj '/CN=headscale' -addext 'subjectAltName=DNS:headscale' sudo cp /tmp/hs/tls.crt /usr/local/share/ca-certificates/headscale.crt sudo update-ca-certificates URL="https://headscale:8443" cat > config.yaml < /tmp/hs/policy.hujson <<'EOF' { "tagOwners": { "tag:ci": [] }, "acls": [ { "action": "accept", "src": ["*"], "dst": ["*:*"] } ] } EOF ./result/bin/headscale serve > /tmp/hs/serve.log 2>&1 & # Wait for readiness via curl's own retry (no fixed sleeps). curl -fsS --retry 60 --retry-delay 1 --retry-all-errors --cacert /tmp/hs/tls.crt \ "${URL}/health" || { cat /tmp/hs/serve.log; exit 1; } # Not UID: that is a readonly bash builtin. USERID=$(./result/bin/headscale users create ci -o json | jq -r .id) PREAUTH=$(./result/bin/headscale preauthkeys create --user "$USERID" --reusable) echo "::add-mask::$PREAUTH" { echo "url=${URL}" echo "preauth=${PREAUTH}" } >> "$GITHUB_OUTPUT" - name: Join a regular node (already in the network) id: prenode shell: bash env: URL: ${{ steps.bootstrap.outputs.url }} PREAUTH: ${{ steps.bootstrap.outputs.preauth }} run: | set -euo pipefail # Pinned via flake.lock and kept off PATH, so the action's own tailscale # stays the one `tailscale` resolves to in later steps. nix build --inputs-from . nixpkgs#tailscale -o /tmp/pre-ts # Userspace networking: no tun/root needed. Distinct port so it does not # collide with the action's own tailscaled later in the join jobs. /tmp/pre-ts/bin/tailscaled --tun=userspace-networking --socket=/tmp/pre.sock \ --state=/tmp/pre.state --port=41642 > /tmp/pre-tailscaled.log 2>&1 & timeout 30 bash -c 'until [ -S /tmp/pre.sock ]; do sleep 0.2; done' timeout 120 /tmp/pre-ts/bin/tailscale --socket=/tmp/pre.sock up \ --authkey="$PREAUTH" --login-server="$URL" --hostname=pre-node PRE_IP=$(/tmp/pre-ts/bin/tailscale --socket=/tmp/pre.sock ip -4) echo "pre-joined node IP: $PRE_IP" echo "pre_ip=${PRE_IP}" >> "$GITHUB_OUTPUT"