diff --git a/.github/actions/headscale-up/action.yml b/.github/actions/headscale-up/action.yml new file mode 100644 index 00000000..42541b2d --- /dev/null +++ b/.github/actions/headscale-up/action.yml @@ -0,0 +1,139 @@ +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" diff --git a/.github/workflows/tailscale-action-integration.yaml b/.github/workflows/tailscale-action-integration.yaml new file mode 100644 index 00000000..bf6202bb --- /dev/null +++ b/.github/workflows/tailscale-action-integration.yaml @@ -0,0 +1,155 @@ +name: tailscale-action integration + +# Integration tests for the official Tailscale GitHub Action +# (https://github.com/tailscale/github-action) against a self-hosted headscale. +# A nix-built headscale runs over self-signed TLS with embedded DERP and a +# regular node already joined; the action then connects a runner to that tailnet +# and its built-in ping to the existing node is the success gate. SQLite only. + +on: + workflow_dispatch: + push: + branches: [main] + pull_request: + paths: + - ".github/workflows/tailscale-action-integration.yaml" + - ".github/actions/headscale-up/**" + - "hscontrol/api/v2/**" + - "hscontrol/scope/**" + - "hscontrol/db/oauth*.go" + - "hscontrol/db/preauth_keys.go" + - "hscontrol/types/oauth.go" + - "hscontrol/state/**" + - "hscontrol/auth*.go" + - "hscontrol/noise.go" + - "cmd/headscale/cli/oauth_client.go" + - "flake.*" + - "go.mod" + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + # The action connects a runner to headscale and pings the already-joined node. + # Both of the action's auth modes are exercised: an OAuth client (delivered as a + # tskey-client- secret the client resolves against headscale) and a plain auth + # key. Ping is the pass/fail gate — real connectivity, not just registration. + connect: + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + auth: [oauth, authkey] + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - id: hs + uses: ./.github/actions/headscale-up + + - name: Prepare ${{ matrix.auth }} credential + id: prep + env: + URL: ${{ steps.hs.outputs.url }} + PREAUTH: ${{ steps.hs.outputs.preauth }} + run: | + set -euo pipefail + if [ "${{ matrix.auth }}" = "oauth" ]; then + FULL=$(./result/bin/headscale oauth-clients create -s auth_keys -t tag:ci -o json | jq -r .key) + # The upstream client only runs its OAuth exchange for tskey-client- + # secrets, and reads the control URL from a baseURL= attribute on the + # secret itself (--login-server governs registration, not the exchange). + TS="tskey-${FULL#hskey-}?baseURL=${URL}&ephemeral=true&preauthorized=true" + echo "::add-mask::$TS" + echo "authkey=$TS" >> "$GITHUB_OUTPUT" + echo "args=--login-server=${URL} --advertise-tags=tag:ci" >> "$GITHUB_OUTPUT" + else + echo "authkey=${PREAUTH}" >> "$GITHUB_OUTPUT" + echo "args=--login-server=${URL}" >> "$GITHUB_OUTPUT" + fi + + - name: Connect via the tailscale action + ping + uses: tailscale/github-action@d1b6cd204f8dceda5b3eaad7f1f767be390056cd # v4.2.0 + with: + authkey: ${{ steps.prep.outputs.authkey }} + args: ${{ steps.prep.outputs.args }} + ping: ${{ steps.hs.outputs.pre_ip }} + + - name: Assert node registered + run: | + set -euo pipefail + ./result/bin/headscale nodes list -o json > nodes.json + test "$(jq length nodes.json)" -ge 2 + if [ "${{ matrix.auth }}" = "oauth" ]; then + jq -e '[.[] | select((.tags // []) | index("tag:ci"))] | length >= 1' nodes.json + fi + + - name: Dump logs on failure + if: failure() + run: | + for f in /tmp/hs/serve.log /tmp/pre-tailscaled.log "$HOME/tailscaled.log"; do + echo "::group::$f" + sudo tail -n 300 "$f" || true + echo "::endgroup::" + done + + # The action's remaining inputs, each connecting via an auth key with ping as + # the connectivity gate plus a per-input assertion. Unused inputs are empty and + # fall back to the action's defaults. + inputs: + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - input: hostname + hostname: feature-host + assert: ./result/bin/headscale nodes list -o json | jq -e '[.[]|select(.givenName=="feature-host")]|length>=1' + - input: version + version: "1.98.4" + # The running daemon, not just the CLI binary, must be the pinned one. + assert: tailscale status --json | jq -e '.Version | startswith("1.98.4")' + - input: tailscaled-args + tailscaled_args: --verbose=1 + assert: pgrep -af 'tailscaled.*--verbose=1' + - input: statedir + statedir: /tmp/ts-state + assert: sudo ls -A /tmp/ts-state | grep -q . + - input: args + extra_args: --accept-dns=false + assert: sudo tailscale debug prefs | jq -e '.CorpDNS == false' + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - id: hs + uses: ./.github/actions/headscale-up + + - name: Connect with ${{ matrix.input }} + uses: tailscale/github-action@d1b6cd204f8dceda5b3eaad7f1f767be390056cd # v4.2.0 + with: + authkey: ${{ steps.hs.outputs.preauth }} + args: --login-server=${{ steps.hs.outputs.url }} ${{ matrix.extra_args }} + hostname: ${{ matrix.hostname }} + version: ${{ matrix.version }} + tailscaled-args: ${{ matrix.tailscaled_args }} + statedir: ${{ matrix.statedir }} + ping: ${{ steps.hs.outputs.pre_ip }} + + - name: Assert ${{ matrix.input }} + env: + ASSERT: ${{ matrix.assert }} + run: | + set -euo pipefail + bash -c "$ASSERT" + + - name: Dump logs on failure + if: failure() + run: | + for f in /tmp/hs/serve.log /tmp/pre-tailscaled.log "$HOME/tailscaled.log"; do + echo "::group::$f" + sudo tail -n 300 "$f" || true + echo "::endgroup::" + done