Skip to content

Commit bf6741c

Browse files
committed
ci: matrix-test v2 API credentials and the Tailscale Action OAuth join
Stand up a nix-built headscale over self-signed TLS with embedded DERP, join a regular node via pre-auth, then fan out: the official tailscale action joins over OAuth (tskey-client-) and pre-auth with ping as the success gate, and the v2 API is driven under every credential, transport, and scope.
1 parent 0ef6748 commit bf6741c

2 files changed

Lines changed: 327 additions & 0 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
name: headscale-up
2+
description: >-
3+
Build headscale with nix, start it over self-signed TLS with embedded DERP, and
4+
join one regular node via a pre-auth key (so the tailnet has a node already in
5+
the network and a ping target). Outputs the URL and bootstrap credentials.
6+
7+
# TLS is not optional: the embedded DERP server requires it, and DERP is what
8+
# gives the nodes a data plane to ping over. The self-signed cert is trusted on
9+
# the runner so the tailscale client and OAuth exchange accept it.
10+
11+
outputs:
12+
url:
13+
description: headscale server URL (https)
14+
value: ${{ steps.bootstrap.outputs.url }}
15+
api_key:
16+
description: admin API key (hskey-api-)
17+
value: ${{ steps.bootstrap.outputs.api_key }}
18+
preauth:
19+
description: reusable pre-auth key for user 'ci'
20+
value: ${{ steps.bootstrap.outputs.preauth }}
21+
pre_ip:
22+
description: tailnet IPv4 of the pre-joined regular node
23+
value: ${{ steps.prenode.outputs.pre_ip }}
24+
25+
runs:
26+
using: composite
27+
steps:
28+
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
29+
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
30+
31+
- name: Build headscale
32+
shell: bash
33+
run: nix build --fallback
34+
35+
- name: Start headscale (TLS + embedded DERP)
36+
id: bootstrap
37+
shell: bash
38+
run: |
39+
set -euo pipefail
40+
mkdir -p /tmp/hs
41+
echo "127.0.0.1 headscale" | sudo tee -a /etc/hosts
42+
43+
# Self-signed cert for the control server, trusted system-wide so the
44+
# tailscale client and the OAuth token exchange accept it.
45+
openssl req -x509 -newkey rsa:4096 -sha256 -days 1 -nodes \
46+
-keyout /tmp/hs/tls.key -out /tmp/hs/tls.crt \
47+
-subj '/CN=headscale' -addext 'subjectAltName=DNS:headscale'
48+
sudo cp /tmp/hs/tls.crt /usr/local/share/ca-certificates/headscale.crt
49+
sudo update-ca-certificates
50+
51+
URL="https://headscale:8443"
52+
53+
cat > config.yaml <<EOF
54+
server_url: ${URL}
55+
listen_addr: 0.0.0.0:8443
56+
metrics_listen_addr: 127.0.0.1:9090
57+
grpc_listen_addr: 127.0.0.1:50443
58+
unix_socket: /tmp/hs/headscale.sock
59+
unix_socket_permission: "0770"
60+
private_key_path: /tmp/hs/private.key
61+
noise:
62+
private_key_path: /tmp/hs/noise.key
63+
prefixes:
64+
v4: 100.64.0.0/10
65+
v6: fd7a:115c:a1e0::/48
66+
allocation: sequential
67+
derp:
68+
server:
69+
enabled: true
70+
region_id: 999
71+
region_code: headscale
72+
region_name: Headscale Embedded DERP
73+
stun_listen_addr: 0.0.0.0:3478
74+
private_key_path: /tmp/hs/derp.key
75+
automatically_add_embedded_derp_region: true
76+
urls: []
77+
auto_update_enabled: false
78+
update_frequency: 1m
79+
database:
80+
type: sqlite
81+
sqlite:
82+
path: /tmp/hs/db.sqlite
83+
dns:
84+
base_domain: headscale.net
85+
magic_dns: true
86+
nameservers:
87+
global:
88+
- 1.1.1.1
89+
tls_cert_path: /tmp/hs/tls.crt
90+
tls_key_path: /tmp/hs/tls.key
91+
policy:
92+
mode: file
93+
path: /tmp/hs/policy.hujson
94+
EOF
95+
96+
cat > /tmp/hs/policy.hujson <<'EOF'
97+
{
98+
"tagOwners": { "tag:ci": [] },
99+
"acls": [ { "action": "accept", "src": ["*"], "dst": ["*:*"] } ]
100+
}
101+
EOF
102+
103+
./result/bin/headscale serve > /tmp/hs/serve.log 2>&1 &
104+
105+
# Wait for readiness via curl's own retry (no fixed sleeps).
106+
curl -fsS --retry 60 --retry-delay 1 --retry-all-errors --cacert /tmp/hs/tls.crt \
107+
"${URL}/health"
108+
109+
# Not UID: that is a readonly bash builtin.
110+
USERID=$(./result/bin/headscale users create ci -o json | jq -r .id)
111+
API_KEY=$(./result/bin/headscale apikeys create)
112+
PREAUTH=$(./result/bin/headscale preauthkeys create --user "$USERID" --reusable)
113+
114+
echo "::add-mask::$API_KEY"
115+
echo "::add-mask::$PREAUTH"
116+
{
117+
echo "url=${URL}"
118+
echo "api_key=${API_KEY}"
119+
echo "preauth=${PREAUTH}"
120+
} >> "$GITHUB_OUTPUT"
121+
122+
- name: Join a regular node (already in the network)
123+
id: prenode
124+
shell: bash
125+
env:
126+
URL: ${{ steps.bootstrap.outputs.url }}
127+
PREAUTH: ${{ steps.bootstrap.outputs.preauth }}
128+
run: |
129+
set -euo pipefail
130+
nix profile install nixpkgs#tailscale
131+
132+
# Userspace networking: no tun/root needed. Distinct port so it does not
133+
# collide with the action's own tailscaled later in the join jobs.
134+
tailscaled --tun=userspace-networking --socket=/tmp/pre.sock \
135+
--state=/tmp/pre.state --port=41642 > /tmp/pre-tailscaled.log 2>&1 &
136+
137+
timeout 30 bash -c 'until [ -S /tmp/pre.sock ]; do sleep 0.2; done'
138+
timeout 120 tailscale --socket=/tmp/pre.sock up \
139+
--authkey="$PREAUTH" --login-server="$URL" --hostname=pre-node
140+
141+
PRE_IP=$(tailscale --socket=/tmp/pre.sock ip -4)
142+
echo "pre-joined node IP: $PRE_IP"
143+
echo "pre_ip=${PRE_IP}" >> "$GITHUB_OUTPUT"

.github/workflows/test-v2-api.yaml

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
name: v2 API
2+
3+
# Exercises the Tailscale-compatible v2 API end to end: a nix-built headscale,
4+
# the official tailscale GitHub Action joining over OAuth and pre-auth, and the
5+
# v2 API driven under every credential and scope. SQLite only.
6+
7+
on:
8+
workflow_dispatch:
9+
pull_request:
10+
paths:
11+
- "hscontrol/api/v2/**"
12+
- "hscontrol/scope/**"
13+
- "hscontrol/db/oauth*.go"
14+
- "hscontrol/types/oauth.go"
15+
- "cmd/headscale/cli/oauth_client.go"
16+
- ".github/workflows/test-v2-api.yaml"
17+
- ".github/actions/headscale-up/**"
18+
19+
concurrency:
20+
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
21+
cancel-in-progress: true
22+
23+
permissions:
24+
contents: read
25+
26+
jobs:
27+
# Unit/contract tests for the OAuth credential model and scope enforcement.
28+
go-tests:
29+
runs-on: ubuntu-latest
30+
defaults:
31+
run:
32+
shell: nix develop --fallback --command bash -e {0}
33+
steps:
34+
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
35+
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
36+
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
37+
- name: go test
38+
env:
39+
CGO_ENABLED: "0"
40+
run: go test ./hscontrol/db/... ./hscontrol/api/v2/... ./hscontrol/scope/...
41+
42+
# Headline E2E: the official action joins a node and its built-in ping to the
43+
# pre-joined regular node is the pass/fail gate (real connectivity, not just
44+
# registration). One row joins via the OAuth client-credentials flow (the
45+
# tskey-client- secret the client resolves against headscale), one via pre-auth.
46+
join:
47+
runs-on: ubuntu-latest
48+
strategy:
49+
fail-fast: false
50+
matrix:
51+
join: [oauth-tskey, preauth]
52+
steps:
53+
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
54+
- id: hs
55+
uses: ./.github/actions/headscale-up
56+
57+
- name: Prepare auth (${{ matrix.join }})
58+
id: prep
59+
env:
60+
URL: ${{ steps.hs.outputs.url }}
61+
PREAUTH: ${{ steps.hs.outputs.preauth }}
62+
run: |
63+
set -euo pipefail
64+
if [ "${{ matrix.join }}" = "oauth-tskey" ]; then
65+
FULL=$(./result/bin/headscale oauth-clients create -s auth_keys -t tag:ci -o json | jq -r .key)
66+
# The upstream client only runs its OAuth exchange for tskey-client-
67+
# secrets, and reads the control URL from a baseURL= attribute on the
68+
# secret itself (--login-server governs registration, not the exchange).
69+
TS="tskey-${FULL#hskey-}?baseURL=${URL}&ephemeral=true&preauthorized=true"
70+
echo "::add-mask::$TS"
71+
echo "authkey=$TS" >> "$GITHUB_OUTPUT"
72+
echo "args=--login-server=${URL} --advertise-tags=tag:ci" >> "$GITHUB_OUTPUT"
73+
else
74+
echo "authkey=${PREAUTH}" >> "$GITHUB_OUTPUT"
75+
echo "args=--login-server=${URL}" >> "$GITHUB_OUTPUT"
76+
fi
77+
78+
- name: Join via official tailscale action + ping
79+
uses: tailscale/github-action@v4
80+
with:
81+
authkey: ${{ steps.prep.outputs.authkey }}
82+
args: ${{ steps.prep.outputs.args }}
83+
ping: ${{ steps.hs.outputs.pre_ip }}
84+
85+
- name: Assert node registered
86+
run: |
87+
set -euo pipefail
88+
./result/bin/headscale nodes list -o json > nodes.json
89+
test "$(jq length nodes.json)" -ge 2
90+
if [ "${{ matrix.join }}" = "oauth-tskey" ]; then
91+
jq -e '[.[] | select((.tags // []) | index("tag:ci"))] | length >= 1' nodes.json
92+
fi
93+
94+
# Auth model: an admin API key (all-access), a full OAuth token, and a
95+
# read-only OAuth token must all read devices, over both Bearer and Basic.
96+
credential:
97+
runs-on: ubuntu-latest
98+
strategy:
99+
fail-fast: false
100+
matrix:
101+
credential: [admin-apikey, oauth-full, oauth-readonly]
102+
transport: [bearer, basic]
103+
steps:
104+
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
105+
- id: hs
106+
uses: ./.github/actions/headscale-up
107+
108+
- name: Resolve credential + assert device read
109+
env:
110+
URL: ${{ steps.hs.outputs.url }}
111+
API_KEY: ${{ steps.hs.outputs.api_key }}
112+
run: |
113+
set -euo pipefail
114+
mint() { curl -fsS "${URL}/api/v2/oauth/token" -d "client_secret=$1" | jq -r .access_token; }
115+
116+
case "${{ matrix.credential }}" in
117+
admin-apikey) CRED="$API_KEY" ;;
118+
oauth-full)
119+
S=$(./result/bin/headscale oauth-clients create -s devices:core -t tag:ci -o json | jq -r .key)
120+
CRED=$(mint "$S") ;;
121+
oauth-readonly)
122+
S=$(./result/bin/headscale oauth-clients create -s devices:core:read -t tag:ci -o json | jq -r .key)
123+
CRED=$(mint "$S") ;;
124+
esac
125+
echo "::add-mask::$CRED"
126+
127+
if [ "${{ matrix.transport }}" = "bearer" ]; then
128+
AUTH=(-H "Authorization: Bearer ${CRED}")
129+
else
130+
AUTH=(-u "${CRED}:")
131+
fi
132+
133+
CODE=$(curl -s -o body.json -w '%{http_code}' "${AUTH[@]}" "${URL}/api/v2/tailnet/-/devices")
134+
echo "status=$CODE"; cat body.json
135+
test "$CODE" = "200"
136+
# The pre-joined node means the tailnet is not empty.
137+
jq -e '.devices | length >= 1' body.json
138+
# Scope enforcement: a token minted with exactly one scope may run that
139+
# scope's operation but is 403'd on an operation requiring another scope.
140+
scope:
141+
runs-on: ubuntu-latest
142+
strategy:
143+
fail-fast: false
144+
matrix:
145+
include:
146+
- { scope: "devices:core:read", allow: "GET /api/v2/tailnet/-/devices", deny: "DELETE /api/v2/device/1" }
147+
- { scope: "devices:core", allow: "GET /api/v2/tailnet/-/devices", deny: "POST /api/v2/tailnet/-/acl" }
148+
- { scope: "auth_keys:read", allow: "GET /api/v2/tailnet/-/keys", deny: "POST /api/v2/tailnet/-/acl" }
149+
- { scope: "auth_keys", allow: "GET /api/v2/tailnet/-/keys", deny: "POST /api/v2/tailnet/-/acl" }
150+
- { scope: "policy_file:read", allow: "GET /api/v2/tailnet/-/acl", deny: "DELETE /api/v2/device/1" }
151+
- { scope: "policy_file", allow: "GET /api/v2/tailnet/-/acl", deny: "DELETE /api/v2/device/1" }
152+
- { scope: "users:read", allow: "GET /api/v2/tailnet/-/users", deny: "DELETE /api/v2/device/1" }
153+
- { scope: "all:read", allow: "GET /api/v2/tailnet/-/devices", deny: "DELETE /api/v2/device/1" }
154+
- { scope: "all", allow: "GET /api/v2/tailnet/-/devices", deny: "" }
155+
steps:
156+
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
157+
- id: hs
158+
uses: ./.github/actions/headscale-up
159+
160+
- name: Assert scope ${{ matrix.scope }}
161+
env:
162+
URL: ${{ steps.hs.outputs.url }}
163+
run: |
164+
set -euo pipefail
165+
S=$(./result/bin/headscale oauth-clients create -s '${{ matrix.scope }}' -t tag:ci -o json | jq -r .key)
166+
TOKEN=$(curl -fsS "${URL}/api/v2/oauth/token" -d "client_secret=$S" | jq -r .access_token)
167+
echo "::add-mask::$TOKEN"
168+
169+
code() {
170+
curl -s -o /dev/null -w '%{http_code}' -X "$1" \
171+
-H "Authorization: Bearer ${TOKEN}" "${URL}${2}"
172+
}
173+
174+
read -r AM AP <<<"${{ matrix.allow }}"
175+
ALLOW=$(code "$AM" "$AP")
176+
echo "allow ${{ matrix.allow }} -> $ALLOW"
177+
case "$ALLOW" in 2*) ;; *) echo "expected 2xx"; exit 1 ;; esac
178+
179+
if [ -n "${{ matrix.deny }}" ]; then
180+
read -r DM DP <<<"${{ matrix.deny }}"
181+
DENY=$(code "$DM" "$DP")
182+
echo "deny ${{ matrix.deny }} -> $DENY"
183+
test "$DENY" = "403"
184+
fi

0 commit comments

Comments
 (0)