mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-21 02:21:43 +00:00
Merge 935483a52d into 048308511c
This commit is contained in:
commit
a108f096af
9 changed files with 452 additions and 3 deletions
143
.github/actions/headscale-up/action.yml
vendored
Normal file
143
.github/actions/headscale-up/action.yml
vendored
Normal 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"
|
||||
129
.github/workflows/tailscale-action-integration.yaml
vendored
Normal file
129
.github/workflows/tailscale-action-integration.yaml
vendored
Normal 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"
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue