diff --git a/.github/actions/headscale-up/action.yml b/.github/actions/headscale-up/action.yml new file mode 100644 index 000000000..06ff65e24 --- /dev/null +++ b/.github/actions/headscale-up/action.yml @@ -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 < /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" diff --git a/.github/workflows/tailscale-action-integration.yaml b/.github/workflows/tailscale-action-integration.yaml new file mode 100644 index 000000000..14df703eb --- /dev/null +++ b/.github/workflows/tailscale-action-integration.yaml @@ -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" diff --git a/docs/ref/api.md b/docs/ref/api.md index 8f492dfd3..484c91066 100644 --- a/docs/ref/api.md +++ b/docs/ref/api.md @@ -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--?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. diff --git a/hscontrol/api/v2/README.md b/hscontrol/api/v2/README.md index 90fad3638..28d1687fb 100644 --- a/hscontrol/api/v2/README.md +++ b/hscontrol/api/v2/README.md @@ -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--?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--?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 diff --git a/hscontrol/auth_test.go b/hscontrol/auth_test.go index ed294f3ca..cce2c9e8a 100644 --- a/hscontrol/auth_test.go +++ b/hscontrol/auth_test.go @@ -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 diff --git a/hscontrol/db/oauth.go b/hscontrol/db/oauth.go index 6ce757bc7..5b0729cad 100644 --- a/hscontrol/db/oauth.go +++ b/hscontrol/db/oauth.go @@ -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 } diff --git a/hscontrol/db/oauth_test.go b/hscontrol/db/oauth_test.go index 1bf8c72d4..8ad30b9b1 100644 --- a/hscontrol/db/oauth_test.go +++ b/hscontrol/db/oauth_test.go @@ -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" diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index ddb410eb7..8849c04f3 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -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. diff --git a/hscontrol/types/oauth.go b/hscontrol/types/oauth.go index 66c87e83b..e878a6ab6 100644 --- a/hscontrol/types/oauth.go +++ b/hscontrol/types/oauth.go @@ -11,6 +11,15 @@ const ( // hskey-client--. 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--. The v2 auth middleware dispatches a // scope-limited token from an all-access admin key on this prefix alone, so