mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-26 02:04:53 +09:00
ci: integration-test the tailscale GitHub action against headscale
Stand up a nix-built headscale over self-signed TLS with embedded DERP and a regular node joined via pre-auth, then drive the official tailscale/github-action against it: connect over OAuth (tskey-client-) and an auth key with ping as the success gate, and exercise its hostname, version, tailscaled-args, statedir and args inputs.
This commit is contained in:
@@ -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 <<EOF
|
||||
server_url: ${URL}
|
||||
listen_addr: 0.0.0.0:8443
|
||||
metrics_listen_addr: 127.0.0.1:9090
|
||||
grpc_listen_addr: 127.0.0.1:50443
|
||||
unix_socket: /tmp/hs/headscale.sock
|
||||
unix_socket_permission: "0770"
|
||||
private_key_path: /tmp/hs/private.key
|
||||
noise:
|
||||
private_key_path: /tmp/hs/noise.key
|
||||
prefixes:
|
||||
v4: 100.64.0.0/10
|
||||
v6: fd7a:115c:a1e0::/48
|
||||
allocation: sequential
|
||||
derp:
|
||||
server:
|
||||
enabled: true
|
||||
region_id: 999
|
||||
region_code: headscale
|
||||
region_name: Headscale Embedded DERP
|
||||
stun_listen_addr: 0.0.0.0:3478
|
||||
private_key_path: /tmp/hs/derp.key
|
||||
automatically_add_embedded_derp_region: true
|
||||
urls: []
|
||||
auto_update_enabled: false
|
||||
update_frequency: 1m
|
||||
database:
|
||||
type: sqlite
|
||||
sqlite:
|
||||
path: /tmp/hs/db.sqlite
|
||||
dns:
|
||||
base_domain: headscale.net
|
||||
magic_dns: true
|
||||
nameservers:
|
||||
global:
|
||||
- 1.1.1.1
|
||||
tls_cert_path: /tmp/hs/tls.crt
|
||||
tls_key_path: /tmp/hs/tls.key
|
||||
policy:
|
||||
mode: file
|
||||
path: /tmp/hs/policy.hujson
|
||||
EOF
|
||||
|
||||
cat > /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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user