This commit is contained in:
Kristoffer Dalby 2026-07-04 13:10:36 -04:00 committed by GitHub
commit a108f096af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 452 additions and 3 deletions

143
.github/actions/headscale-up/action.yml vendored Normal file
View file

@ -0,0 +1,143 @@
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 }}
api_key:
description: admin API key (hskey-api-)
value: ${{ steps.bootstrap.outputs.api_key }}
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/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
- 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"
# Not UID: that is a readonly bash builtin.
USERID=$(./result/bin/headscale users create ci -o json | jq -r .id)
API_KEY=$(./result/bin/headscale apikeys create)
PREAUTH=$(./result/bin/headscale preauthkeys create --user "$USERID" --reusable)
echo "::add-mask::$API_KEY"
echo "::add-mask::$PREAUTH"
{
echo "url=${URL}"
echo "api_key=${API_KEY}"
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
nix profile install nixpkgs#tailscale
# Userspace networking: no tun/root needed. Distinct port so it does not
# collide with the action's own tailscaled later in the join jobs.
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 tailscale --socket=/tmp/pre.sock up \
--authkey="$PREAUTH" --login-server="$URL" --hostname=pre-node
PRE_IP=$(tailscale --socket=/tmp/pre.sock ip -4)
echo "pre-joined node IP: $PRE_IP"
echo "pre_ip=${PRE_IP}" >> "$GITHUB_OUTPUT"

View file

@ -0,0 +1,129 @@
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:
pull_request:
paths:
- ".github/workflows/tailscale-action-integration.yaml"
- ".github/actions/headscale-up/**"
- "hscontrol/api/v2/**"
- "hscontrol/scope/**"
- "hscontrol/db/oauth*.go"
- "hscontrol/types/oauth.go"
- "cmd/headscale/cli/oauth_client.go"
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
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@v4
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
# 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
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"
# Assert the action's installed binary by tool-cache path: a bare
# `tailscale` resolves to the nixpkgs build the pre-node step put on
# PATH, not the version the action pinned.
assert: /opt/hostedtoolcache/tailscale/1.98.4/Linux-amd64/tailscale version | grep -q 1.98.4
- input: tailscaled-args
tailscaled_args: --verbose=1
assert: "true"
- input: statedir
statedir: /tmp/ts-state
assert: sudo ls -A /tmp/ts-state | grep -q .
- input: args
extra_args: --accept-dns=false
assert: "true"
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- id: hs
uses: ./.github/actions/headscale-up
- name: Connect with ${{ matrix.input }}
uses: tailscale/github-action@v4
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"

View file

@ -58,6 +58,39 @@ Headscale server at `/api/v1/docs` for details.
https://headscale.example.com/api/v1/auth/register
```
## OAuth clients and the Tailscale GitHub Action
Headscale also serves a subset of the Tailscale-compatible API at `/api/v2`, which
accepts **OAuth 2.0 client-credentials** in addition to API keys. This lets parts
of the Tailscale ecosystem drive Headscale, including the official
[`tailscale/github-action`](https://github.com/tailscale/github-action).
Create an OAuth client and note its secret (shown once):
```shell
headscale oauth-clients create --scope auth_keys --tag tag:ci
```
The official action can use the secret as an auth key, but the upstream Tailscale
client has two requirements when targeting a self-hosted control server:
- The secret must be prefixed `tskey-client-`. Headscale issues `hskey-client-…`
but accepts the `tskey-client-` alias, so swap the prefix.
- The OAuth exchange URL is read from a `baseURL` attribute on the secret itself
(not from `--login-server`). Append your Headscale URL.
```yaml
- uses: tailscale/github-action@v4
with:
# hskey-client-... with the prefix swapped to tskey-client- and baseURL appended
authkey: tskey-client-<id>-<secret>?baseURL=https://headscale.example.com&ephemeral=true&preauthorized=true
args: --login-server=https://headscale.example.com --advertise-tags=tag:ci
```
Use the action's `authkey` input, not `oauth-secret`: the latter appends its own
query parameters and would mangle `baseURL`. The client must advertise the tag(s)
the OAuth client owns.
## Remote control
The `headscale` binary can control a Headscale instance from a remote machine over the HTTP API.

View file

@ -75,6 +75,38 @@ operator is OAuth-only. Supporting OAuth lets all of them drive Headscale.
**Argon2id** hash of the secret (no JWT, no signing keys). `OAuthClient` and
`OAuthAccessToken` live in `types/oauth.go` and `db/oauth.go`.
## OAuth with the tailscale client and GitHub Action
The upstream `tailscale` client (and the official
[`tailscale/github-action`](https://github.com/tailscale/github-action)) can use
an OAuth client secret directly as an auth key: it runs the client-credentials
exchange itself (`feature/oauthkey`), mints an auth key, and registers. Two
Tailscale-specific quirks apply when pointing it at Headscale:
- It only attempts the exchange when the secret is prefixed **`tskey-client-`**.
Headscale issues `hskey-client-…` but `AuthenticateOAuthClient` accepts the
`tskey-client-` alias too, so swap the prefix: `tskey-${SECRET#hskey-}`.
- It exchanges against a **`baseURL`** read from the secret's query string
(default `https://api.tailscale.com`); `--login-server` only sets the
registration server, not the exchange. Append your Headscale URL.
- The exchange requires `--advertise-tags`; registration accepts advertised tags
that are a subset of the key's own tags.
So a working auth key is
`tskey-client-<id>-<secret>?baseURL=https://headscale.example.com&ephemeral=true&preauthorized=true`.
In the GitHub Action, pass it as the `authkey` input (the `oauth-secret` input is
unusable here: it appends its own query parameters, which collide with `baseURL`):
```yaml
- uses: tailscale/github-action@v4
with:
authkey: tskey-client-<id>-<secret>?baseURL=https://headscale.example.com&ephemeral=true&preauthorized=true
args: --login-server=https://headscale.example.com --advertise-tags=tag:ci
```
The OAuth client needs the `auth_keys` scope and the tag(s) it assigns. The
end-to-end flow is exercised in `.github/workflows/tailscale-action-integration.yaml`.
## Adding an endpoint
Worked example: the keys resource (`keys.go`) = Tailscale auth keys = Headscale

View file

@ -1025,6 +1025,51 @@ func TestAuthenticationFlows(t *testing.T) {
wantError: true, // RequestTags rejected for PreAuthKey registrations
},
// TEST: Tagged PreAuthKey accepts RequestTags that match the key's tags
// WHAT: The official tailscale client's OAuth flow re-advertises the key's
// own tags via --advertise-tags; a subset of the key's tags is tolerated.
// INPUT: Tagged PreAuthKey ([tag:authorized]) with RequestTags [tag:authorized]
// EXPECTED: Registration succeeds; node is tagged from the key.
// WHY: Redundant advertise-tags matching the key are not a tag escalation,
// and rejecting them breaks the tailscale client's OAuth authkey flow.
{
name: "tagged_preauth_key_accepts_matching_request_tags",
setupFunc: func(t *testing.T, app *Headscale) (string, error) { //nolint:thelper
t.Helper()
user := app.state.CreateUserForTest("tagged-pak-matchtags-user")
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, []string{"tag:authorized"})
if err != nil {
return "", err
}
return pak.Key, nil
},
request: func(authKey string) tailcfg.RegisterRequest {
return tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{
AuthKey: authKey,
},
NodeKey: nodeKey1.Public(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: "tagged-pak-matchtags-node",
RequestTags: []string{"tag:authorized"},
},
Expiry: time.Now().Add(24 * time.Hour),
}
},
machineKey: machineKey1.Public,
wantAuth: true,
validate: func(t *testing.T, _ *tailcfg.RegisterResponse, app *Headscale) {
t.Helper()
node, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(t, found)
assert.True(t, node.IsTagged(), "node should be tagged from the key")
},
},
// === RE-AUTHENTICATION SCENARIOS ===
// TEST: Existing node re-authenticates with new pre-auth key
// WHAT: Tests that existing node can re-authenticate using new pre-auth key

View file

@ -192,7 +192,14 @@ func (hsdb *HSDatabase) AuthenticateOAuthClient(secretStr string) (*types.OAuthC
// used directly as an auth key; strip them before parsing.
secretStr, _, _ = strings.Cut(secretStr, "?")
// Accept headscale's prefix or the upstream tailscale client's tskey-client-
// alias, so the official client and GitHub Action can authenticate the same
// stored client. The prefix is only a label: rest is identical either way.
_, rest, found := strings.Cut(secretStr, types.OAuthClientPrefix)
if !found {
_, rest, found = strings.Cut(secretStr, types.TailscaleOAuthClientPrefix)
}
if !found {
return nil, ErrOAuthClientFailedToParse
}

View file

@ -86,6 +86,44 @@ func TestOAuthClientCreateAndAuthenticate(t *testing.T) {
require.Error(t, err)
}
// TestOAuthClientAuthenticateTailscalePrefix asserts the same stored client
// authenticates when its secret carries Tailscale's tskey-client- prefix instead
// of headscale's hskey-client-. The official tailscale client only runs its
// OAuth client-credentials exchange for secrets prefixed tskey-client-
// (feature/oauthkey), so accepting that label lets the upstream client (and the
// official GitHub Action) mint auth keys against headscale. The prefix is a pure
// label sliced off before lookup; the hash is over the bare secret, so both
// labels resolve to the same client.
func TestOAuthClientAuthenticateTailscalePrefix(t *testing.T) {
db, err := newSQLiteTestDB()
require.NoError(t, err)
secret, client, err := db.CreateOAuthClient(
[]string{"auth_keys"},
[]string{"tag:ci"},
"",
nil,
)
require.NoError(t, err)
tsSecret := "tskey-client-" + strings.TrimPrefix(secret, "hskey-client-")
got, err := db.AuthenticateOAuthClient(tsSecret)
require.NoError(t, err)
assert.Equal(t, client.ClientID, got.ClientID)
// The tailscale client appends ?key=value attributes (e.g. baseURL,
// ephemeral) when the secret is used directly as an auth key; they are
// stripped before parsing.
got, err = db.AuthenticateOAuthClient(tsSecret + "?baseURL=http://127.0.0.1:8080&ephemeral=true")
require.NoError(t, err)
assert.Equal(t, client.ClientID, got.ClientID)
// A wrong secret under the tailscale prefix is still rejected.
_, err = db.AuthenticateOAuthClient("tskey-client-" + client.ClientID + "-" + strings.Repeat("0", 64))
require.Error(t, err)
}
func TestHashSecretRoundTrip(t *testing.T) {
const secret = "a-high-entropy-credential-secret"

View file

@ -1914,10 +1914,23 @@ func (s *State) createAndSaveNewNode(params newNodeParams) (types.NodeView, erro
nodeToRegister.Tags = nil
}
// Reject advertise-tags for PreAuthKey registrations early, before any resource allocation.
// PreAuthKey nodes get their tags from the key itself, not from client requests.
// PreAuthKey nodes get their tags from the key itself, not from client
// requests. The official tailscale client's OAuth authkey flow re-advertises
// the key's own tags via --advertise-tags, so tolerate RequestTags that are a
// subset of the key's tags (redundant, no escalation) and reject only tags the
// key does not carry. Checked early, before any resource allocation.
if params.PreAuthKey != nil && params.Hostinfo != nil && len(params.Hostinfo.RequestTags) > 0 {
return types.NodeView{}, fmt.Errorf("%w %v are invalid or not permitted", ErrRequestedTagsInvalidOrNotPermitted, params.Hostinfo.RequestTags)
var extraTags []string
for _, tag := range params.Hostinfo.RequestTags {
if !slices.Contains(params.PreAuthKey.Tags, tag) {
extraTags = append(extraTags, tag)
}
}
if len(extraTags) > 0 {
return types.NodeView{}, fmt.Errorf("%w %v are invalid or not permitted", ErrRequestedTagsInvalidOrNotPermitted, extraTags)
}
}
// Process RequestTags (from tailscale up --advertise-tags) ONLY for non-PreAuthKey registrations.

View file

@ -11,6 +11,15 @@ const (
// hskey-client-<clientID>-<secret>.
OAuthClientPrefix = "hskey-client-" //nolint:gosec // prefix, not a credential
// TailscaleOAuthClientPrefix is the prefix the upstream tailscale client uses
// for OAuth client secrets. The client only runs its OAuth client-credentials
// exchange (feature/oauthkey) when the secret starts with this, so accepting
// it as an alias for [OAuthClientPrefix] lets the official tailscale client
// and GitHub Action mint auth keys against headscale. The prefix is only a
// label, sliced off before lookup, so the same stored client authenticates
// under either.
TailscaleOAuthClientPrefix = "tskey-client-" //nolint:gosec // prefix, not a credential
// AccessTokenPrefix prefixes an OAuth access token:
// hskey-oauthtok-<prefix>-<secret>. The v2 auth middleware dispatches a
// scope-limited token from an all-access admin key on this prefix alone, so