mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-19 06:20:43 +09:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dea4e452f | |||
| 88327a071f | |||
| 048308511c | |||
| a84945f134 | |||
| fef80f3eb0 | |||
| 01ef350d5f | |||
| f4fba32dc6 | |||
| fc6f216b61 | |||
| de9db9c811 | |||
| 4946d1c88d | |||
| c497612c99 | |||
| 4e4512c4b7 | |||
| 08956d51a4 | |||
| d528686f14 | |||
| 6f317c7576 | |||
| 518b497be9 | |||
| 8d91e42a9f | |||
| 99fd62477d | |||
| c7b162509c | |||
| a4ec76605e | |||
| 1867213b69 | |||
| 622e08f5e6 | |||
| 79bf201ccb | |||
| af46fe8867 | |||
| b05f8875c3 | |||
| 84715e976c | |||
| ee95cf58d9 | |||
| 7b1776a87e | |||
| f6865fb40c | |||
| 6e0814a845 | |||
| 6e6ea3758d | |||
| 3846c3f148 | |||
| fe536dd799 |
@@ -78,6 +78,13 @@ jobs:
|
||||
echo '{"storage-driver":"overlay2"}' | sudo tee /etc/docker/daemon.json
|
||||
sudo systemctl restart docker
|
||||
docker version
|
||||
- name: Load br_netfilter for in-cluster service routing
|
||||
if: inputs.test == 'TestK8sOperator'
|
||||
# TestK8sOperator runs k3s in a container; without br_netfilter on the
|
||||
# host, bridged pod-to-pod traffic skips kube-proxy's ClusterIP DNAT and
|
||||
# in-cluster DNS (kube-dns) is unreachable. The module cannot be loaded
|
||||
# from inside the unprivileged-module rancher/k3s image, so load it here.
|
||||
run: sudo modprobe br_netfilter
|
||||
- name: Login to Docker Hub
|
||||
env:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_CI_USERNAME }}
|
||||
|
||||
@@ -275,6 +275,8 @@ jobs:
|
||||
- TestNodeTagCommand
|
||||
- TestNodeRouteCommands
|
||||
- TestNodeBackfillIPsCommand
|
||||
- TestOAuthClientCommand
|
||||
- TestOAuthClientCommandValidation
|
||||
- TestPolicyCheckCommand
|
||||
- TestSSHTestsRejectFailingPolicy
|
||||
- TestPolicyCommand
|
||||
@@ -309,6 +311,7 @@ jobs:
|
||||
- Test2118DeletingOnlineNodePanics
|
||||
- TestGrantCapRelay
|
||||
- TestGrantCapDrive
|
||||
- TestK8sOperator
|
||||
- TestEnablingRoutes
|
||||
- TestHASubnetRouterFailover
|
||||
- TestSubnetRouteACL
|
||||
@@ -376,6 +379,8 @@ jobs:
|
||||
- TestTagsAuthKeyWithoutUserInheritsTags
|
||||
- TestTagsAuthKeyWithoutUserRejectsAdvertisedTags
|
||||
- TestTagsAuthKeyConvertToUserViaCLIRegister
|
||||
- TestTS2021WebSocketGET
|
||||
- TestTS2021WASMClientUnderNode
|
||||
- TestTailscaleRustAxum
|
||||
uses: ./.github/workflows/integration-test-template.yml
|
||||
secrets: inherit
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# AI Policy
|
||||
|
||||
Using AI (i.e., LLMs) as tools for coding is welcome. A high bar is held for
|
||||
all contributions to this project. Moreover, the project maintainers remain
|
||||
responsible for any code that is published as part of a release. Contributors
|
||||
are expected to be responsible for any code they publish.
|
||||
|
||||
**AI should not be used to generate comments when communicating with
|
||||
maintainers**. Comments are expected to be written by humans. Comments that are
|
||||
believed to be written by AI may be hidden without notice.
|
||||
|
||||
If you are opening an issue, you should be able to describe the problem in your
|
||||
own words.
|
||||
|
||||
If you are opening a pull request, you are expected to be able to explain the
|
||||
proposed changes in your own words. This includes the pull request body and
|
||||
responses to questions. **Do not copy responses from the AI when replying to
|
||||
questions from maintainers.**
|
||||
|
||||
This project requires a human in the loop who understands the work produced by
|
||||
AI. **Autonomous agents are not allowed to be used for contributing to this
|
||||
project**. Pull requests that appear in violation of this will be closed,
|
||||
perhaps without notice.
|
||||
|
||||
If you wish to include context from an interaction with AI in your comments, it
|
||||
must be in a quote block (e.g., using `>`) and disclosed as such. It must be
|
||||
accompanied by human commentary explaining the relevance and implications of
|
||||
the context. Do not share long snippets.
|
||||
|
||||
AI is useful when communicating as a non-native English speaker. If you are
|
||||
using AI to edit your comments for this purpose, please take the time to ensure
|
||||
it reflects your own voice and ideas. If using AI for translation, we recommend
|
||||
writing in your native language and including the AI translation in a quote
|
||||
block.
|
||||
|
||||
This policy was adapted from [uv's AI policy].
|
||||
|
||||
[uv's AI policy]: https://github.com/astral-sh/.github/blob/c5187e200db51bfe11d56e13053d29bd3793fdd8/AI_POLICY.md
|
||||
@@ -14,6 +14,18 @@ HTTP API directly.
|
||||
|
||||
[#3324](https://github.com/juanfont/headscale/pull/3324)
|
||||
|
||||
### OAuth clients and scopes for the v2 API
|
||||
|
||||
The v2 API now authenticates with OAuth 2.0 client-credentials, the way the
|
||||
Tailscale ecosystem does. An OAuth client mints short-lived access tokens whose
|
||||
scopes limit which operations they may perform and whose tags limit the devices
|
||||
they may create, so a credential can be issued with only the access it needs.
|
||||
The `headscale oauth-clients` command manages them. This lets the Tailscale
|
||||
Terraform provider and Kubernetes operator drive Headscale unchanged; admin API
|
||||
keys remain all-access.
|
||||
|
||||
[#3334](https://github.com/juanfont/headscale/pull/3334)
|
||||
|
||||
### BREAKING
|
||||
|
||||
#### API
|
||||
@@ -31,6 +43,17 @@ HTTP API directly.
|
||||
### Changes
|
||||
|
||||
- Expiring or deleting a non-existent pre-auth key now returns an error instead of silently succeeding [#3324](https://github.com/juanfont/headscale/pull/3324)
|
||||
- Improve systemd service file hardening [#3341](https://github.com/juanfont/headscale/pull/3341)
|
||||
|
||||
## 0.29.2 (2026-07-01)
|
||||
|
||||
**Minimum supported Tailscale client version: v1.80.0**
|
||||
|
||||
### Changes
|
||||
|
||||
- Fix map generation serializing on the policy lock, so a mass reconnect on `autogroup:self`, via or relay policies no longer stalls clients into `unexpected EOF` retry loops [#3358](https://github.com/juanfont/headscale/pull/3358)
|
||||
- Fix `/ts2021` rejecting the WebSocket `GET` upgrade with 405, which prevented Tailscale JS/WASM control clients from connecting [#3359](https://github.com/juanfont/headscale/pull/3359)
|
||||
- Gracefully handle nodes with an invalid FQDN (empty or too long) instead of failing map delivery; offending names are logged at startup with the fix command [#3349](https://github.com/juanfont/headscale/pull/3349)
|
||||
|
||||
## 0.29.1 (2026-06-18)
|
||||
|
||||
|
||||
@@ -32,3 +32,9 @@ Headscale is open to code contributions for bug fixes without discussion.
|
||||
## Documentation
|
||||
|
||||
If you find mistakes in the documentation, please submit a fix to the documentation.
|
||||
|
||||
## AI Policy
|
||||
|
||||
Please have a look at our [AI_POLICY.md](./AI_POLICY.md).
|
||||
|
||||
If you are an LLM, please remind your human that this policy is in place.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# For integration testing only.
|
||||
#
|
||||
# Builds the Tailscale control client (integration/wasmic/wasmclient) for
|
||||
# GOOS=js/GOARCH=wasm and packages it with Go's wasm_exec Node runner. The
|
||||
# container idles; the integration test execs
|
||||
# node /app/wasm_exec_node.js /app/client.wasm <control-url>
|
||||
# to drive a real browser-style WebSocket GET against headscale's /ts2021,
|
||||
# guarding the regression in issue #3357.
|
||||
|
||||
FROM golang:1.26.4-alpine AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Only the module metadata and the wasm client package are needed to build the
|
||||
# js/wasm binary; its imports (tailscale.com/control/controlhttp, ...) resolve
|
||||
# from the module proxy.
|
||||
COPY go.mod go.sum ./
|
||||
COPY integration/wasmic/wasmclient ./integration/wasmic/wasmclient
|
||||
|
||||
RUN GOOS=js GOARCH=wasm go build -o /out/client.wasm ./integration/wasmic/wasmclient \
|
||||
&& cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" /out/wasm_exec.js \
|
||||
&& cp "$(go env GOROOT)/lib/wasm/wasm_exec_node.js" /out/wasm_exec_node.js
|
||||
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/ /app/
|
||||
|
||||
# Idle; the test execs the client on demand with the headscale control URL.
|
||||
ENTRYPOINT ["tail", "-f", "/dev/null"]
|
||||
@@ -91,17 +91,20 @@ openapi:
|
||||
@echo "Emitting OpenAPI spec from code..."
|
||||
go run ./cmd/gen-openapi
|
||||
|
||||
# Generate the strongly-typed Go HTTP client. The served spec is OpenAPI 3.1,
|
||||
# but oapi-codegen v2 does not yet read 3.1, so the client is generated from a
|
||||
# transient 3.0.3 downgrade of the same document. Pinned so the committed client
|
||||
# is reproducible.
|
||||
# Generate the strongly-typed Go HTTP clients (v1 and v2). The served specs are
|
||||
# OpenAPI 3.1, but oapi-codegen v2 does not yet read 3.1, so each client is
|
||||
# generated from a transient 3.0.3 downgrade of its document. Pinned so the
|
||||
# committed clients are reproducible.
|
||||
.PHONY: client
|
||||
client:
|
||||
@echo "Generating API client..."
|
||||
@echo "Generating API clients..."
|
||||
@tmp=$$(mktemp -t headscale-openapi-3.0.XXXXXX.yaml); \
|
||||
go run ./cmd/gen-openapi -downgrade "$$tmp" && \
|
||||
go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.7.1 \
|
||||
-generate types,client -package clientv1 -o gen/client/v1/client.gen.go "$$tmp"; \
|
||||
-generate types,client -package clientv1 -o gen/client/v1/client.gen.go "$$tmp" && \
|
||||
go run ./cmd/gen-openapi -api v2 -downgrade "$$tmp" && \
|
||||
go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.7.1 \
|
||||
-generate types,client -package clientv2 -o gen/client/v2/client.gen.go "$$tmp"; \
|
||||
status=$$?; rm -f "$$tmp"; exit $$status
|
||||
|
||||
# Clean targets
|
||||
|
||||
@@ -90,6 +90,8 @@ The maintainers work together on setting the direction for the project. The unde
|
||||
|
||||
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) file.
|
||||
|
||||
Have also a look at our [AI_POLICY.md](./AI_POLICY.md).
|
||||
|
||||
### Requirements
|
||||
|
||||
To contribute to headscale you would need the latest version of [Go](https://golang.org)
|
||||
|
||||
+36
-11
@@ -1,33 +1,58 @@
|
||||
// Command gen-openapi emits the Headscale v1 OpenAPI document from the
|
||||
// authoritative Huma definitions in hscontrol/api/v1. The server also serves the
|
||||
// spec live at /openapi.yaml; this tool emits it on demand, and with -downgrade
|
||||
// the 3.0.3 form used to generate the client. The output is not committed.
|
||||
// Command gen-openapi emits a Headscale OpenAPI document from the authoritative
|
||||
// Huma definitions in hscontrol/api/v1 and hscontrol/api/v2. The server also
|
||||
// serves each spec live (at /openapi.yaml and /api/v2/openapi); this tool emits
|
||||
// them on demand, and with -downgrade the 3.0.3 form used to generate the typed
|
||||
// client. The output is not committed.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/gen-openapi # write the 3.1 spec to openapi/v1/headscale.yaml
|
||||
// go run ./cmd/gen-openapi -downgrade <path> # write the 3.0.3 downgrade (for client gen)
|
||||
// go run ./cmd/gen-openapi # write the v1 3.1 spec to its default path
|
||||
// go run ./cmd/gen-openapi -api v2 # write the v2 3.1 spec to its default path
|
||||
// go run ./cmd/gen-openapi -downgrade <path> # write the v1 3.0.3 downgrade (for client gen)
|
||||
// go run ./cmd/gen-openapi -api v2 -downgrade <path> # the v2 3.0.3 downgrade
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
apiv1 "github.com/juanfont/headscale/hscontrol/api/v1"
|
||||
apiv2 "github.com/juanfont/headscale/hscontrol/api/v2"
|
||||
)
|
||||
|
||||
// outPath is relative to the repository root.
|
||||
const outPath = "openapi/v1/headscale.yaml"
|
||||
// spec bundles a version's full (3.1) and downgraded (3.0.3) generators with the
|
||||
// committed output path. outPath is relative to the repository root.
|
||||
type spec struct {
|
||||
full func() ([]byte, error)
|
||||
down func() ([]byte, error)
|
||||
outPath string
|
||||
}
|
||||
|
||||
// specs maps the -api value to its generators.
|
||||
var specs = map[string]spec{
|
||||
"v1": {apiv1.Spec, apiv1.Spec30, "openapi/v1/headscale.yaml"},
|
||||
"v2": {apiv2.Spec, apiv2.Spec30, "openapi/v2/headscale.yaml"},
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) == 3 && os.Args[1] == "-downgrade" {
|
||||
writeSpec(os.Args[2], apiv1.Spec30)
|
||||
api := flag.String("api", "v1", "which API spec to emit: v1 or v2")
|
||||
downgrade := flag.String("downgrade", "", "write the OpenAPI 3.0.3 downgrade to this path instead of the committed 3.1 spec")
|
||||
flag.Parse()
|
||||
|
||||
s, ok := specs[*api]
|
||||
if !ok {
|
||||
log.Fatalf("unknown -api %q (want v1 or v2)", *api)
|
||||
}
|
||||
|
||||
if *downgrade != "" {
|
||||
writeSpec(*downgrade, s.down)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
writeSpec(outPath, apiv1.Spec)
|
||||
writeSpec(s.outPath, s.full)
|
||||
}
|
||||
|
||||
func writeSpec(path string, gen func() ([]byte, error)) {
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
clientv2 "github.com/juanfont/headscale/gen/client/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// oauthTailnet is the single Headscale tailnet the v2 API addresses as "-".
|
||||
const oauthTailnet = "-"
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(oauthClientsCmd)
|
||||
|
||||
oauthClientsCmd.AddCommand(listOAuthClientsCmd)
|
||||
|
||||
createOAuthClientCmd.Flags().
|
||||
StringArrayP("scope", "s", nil, "Scope the client's tokens are granted (repeatable): auth_keys, oauth_keys, devices:core, devices:routes, policy_file, feature_settings (each with a :read variant), or all/all:read")
|
||||
createOAuthClientCmd.Flags().
|
||||
StringArrayP("tag", "t", nil, "Tag the client's tokens may assign to devices (repeatable), e.g. tag:k8s-operator")
|
||||
createOAuthClientCmd.Flags().StringP("description", "d", "", "Human-readable description")
|
||||
oauthClientsCmd.AddCommand(createOAuthClientCmd)
|
||||
|
||||
deleteOAuthClientCmd.Flags().StringP("id", "i", "", "OAuth client id")
|
||||
oauthClientsCmd.AddCommand(deleteOAuthClientCmd)
|
||||
}
|
||||
|
||||
var oauthClientsCmd = &cobra.Command{
|
||||
Use: "oauth-clients",
|
||||
Short: "Manage OAuth clients",
|
||||
Aliases: []string{"oauth-client", "oauthclients", "oauthclient", "oauth"},
|
||||
}
|
||||
|
||||
var createOAuthClientCmd = &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create an OAuth client",
|
||||
Long: `Create a general-purpose OAuth client. It authenticates with the OAuth 2.0
|
||||
client-credentials grant and mints short-lived, scope-limited access tokens.
|
||||
The wire format is compatible with Tailscale tooling (the Terraform provider,
|
||||
the Kubernetes operator, tscli, ...), so those can drive Headscale unchanged.
|
||||
|
||||
The client secret is shown ONCE on creation and cannot be retrieved again; if
|
||||
you lose it, delete the client and create a new one.
|
||||
|
||||
Scopes gate what the client's tokens may do; tags are the device tags those
|
||||
tokens may assign and are required when the scopes include devices:core or
|
||||
auth_keys.`,
|
||||
Aliases: []string{"c", cmdNew},
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
scopes, _ := cmd.Flags().GetStringArray("scope")
|
||||
tags, _ := cmd.Flags().GetStringArray("tag")
|
||||
description, _ := cmd.Flags().GetString("description")
|
||||
|
||||
if len(scopes) == 0 {
|
||||
return fmt.Errorf("at least one --scope is required: %w", errMissingParameter)
|
||||
}
|
||||
|
||||
ctx, client, cancel, err := newV2Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
keyType := "client"
|
||||
|
||||
resp, err := client.CreateKeyWithResponse(ctx, oauthTailnet, clientv2.CreateKeyRequest{
|
||||
KeyType: &keyType,
|
||||
Scopes: &scopes,
|
||||
Tags: &tags,
|
||||
Description: &description,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating oauth client: %w", err)
|
||||
}
|
||||
|
||||
err = v2Error(resp.HTTPResponse.StatusCode, resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
key := resp.JSON200
|
||||
|
||||
return printOutput(cmd, key,
|
||||
fmt.Sprintf("OAuth client %s created.\nSecret (shown once, store it now): %s", key.Id, ptrStr(key.Key)))
|
||||
},
|
||||
}
|
||||
|
||||
var listOAuthClientsCmd = &cobra.Command{
|
||||
Use: cmdList,
|
||||
Short: "List OAuth clients",
|
||||
Aliases: []string{"ls", cmdShow},
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
ctx, client, cancel, err := newV2Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
resp, err := client.ListKeysWithResponse(ctx, oauthTailnet, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing oauth clients: %w", err)
|
||||
}
|
||||
|
||||
err = v2Error(resp.HTTPResponse.StatusCode, resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The keys endpoint is multiplexed; keep only OAuth clients.
|
||||
clients := make([]clientv2.Key, 0, len(resp.JSON200.Keys))
|
||||
|
||||
for _, k := range resp.JSON200.Keys {
|
||||
if k.KeyType == "client" {
|
||||
clients = append(clients, k)
|
||||
}
|
||||
}
|
||||
|
||||
return printListOutput(cmd, clients, func() error {
|
||||
rows := make([][]string, 0, len(clients))
|
||||
for _, c := range clients {
|
||||
rows = append(rows, []string{
|
||||
c.Id,
|
||||
strings.Join(ptrStrs(c.Scopes), ","),
|
||||
strings.Join(ptrStrs(c.Tags), ","),
|
||||
ptrStr(c.Description),
|
||||
c.Created.Format(HeadscaleDateTimeFormat),
|
||||
})
|
||||
}
|
||||
|
||||
return renderTable([]string{"ID", "Scopes", "Tags", "Description", colCreated}, rows)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var deleteOAuthClientCmd = &cobra.Command{
|
||||
Use: cmdDelete,
|
||||
Short: "Delete an OAuth client",
|
||||
Aliases: []string{"remove", aliasDel},
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
id, _ := cmd.Flags().GetString("id")
|
||||
if id == "" {
|
||||
return fmt.Errorf("--id is required: %w", errMissingParameter)
|
||||
}
|
||||
|
||||
ctx, client, cancel, err := newV2Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
resp, err := client.DeleteKeyWithResponse(ctx, oauthTailnet, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting oauth client: %w", err)
|
||||
}
|
||||
|
||||
err = v2Error(resp.HTTPResponse.StatusCode, resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return printOutput(cmd, map[string]string{"id": id}, "OAuth client "+id+" deleted")
|
||||
},
|
||||
}
|
||||
|
||||
// newV2Client builds a generated v2 API client, selecting the transport the same
|
||||
// way the v1 client does: over the local unix socket it is unauthenticated
|
||||
// (local trust); a remote address injects the configured API key as a bearer
|
||||
// token.
|
||||
func newV2Client() (context.Context, *clientv2.ClientWithResponses, context.CancelFunc, error) {
|
||||
cfg, err := types.LoadCLIConfig()
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("loading configuration: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cfg.CLI.Timeout)
|
||||
|
||||
if cfg.CLI.Address == "" {
|
||||
socketPath := cfg.UnixSocket
|
||||
|
||||
httpClient := &http.Client{Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return dialHeadscaleSocket(ctx, socketPath)
|
||||
},
|
||||
}}
|
||||
|
||||
client, err := clientv2.NewClientWithResponses("http://local", clientv2.WithHTTPClient(httpClient))
|
||||
if err != nil {
|
||||
cancel()
|
||||
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
return ctx, client, cancel, nil
|
||||
}
|
||||
|
||||
if cfg.CLI.APIKey == "" {
|
||||
cancel()
|
||||
|
||||
return nil, nil, nil, errAPIKeyNotSet
|
||||
}
|
||||
|
||||
transport := &http.Transport{}
|
||||
if cfg.CLI.Insecure {
|
||||
//nolint:gosec // intentionally honouring the insecure flag
|
||||
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
}
|
||||
|
||||
apiKey := cfg.CLI.APIKey
|
||||
|
||||
client, err := clientv2.NewClientWithResponses(
|
||||
clientBaseURL(cfg.CLI.Address),
|
||||
clientv2.WithHTTPClient(&http.Client{Transport: transport}),
|
||||
clientv2.WithRequestEditorFn(func(_ context.Context, req *http.Request) error {
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
cancel()
|
||||
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
return ctx, client, cancel, nil
|
||||
}
|
||||
|
||||
// v2Error turns a non-2xx v2 response into an error. The v2 API emits the
|
||||
// Tailscale error body ({"message":...}) rather than RFC 7807, so it reads the
|
||||
// "message" field instead of the generated problem+json types.
|
||||
func v2Error(status int, body []byte) error {
|
||||
if status >= http.StatusOK && status < http.StatusMultipleChoices {
|
||||
return nil
|
||||
}
|
||||
|
||||
var e struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
if json.Unmarshal(body, &e) == nil && e.Message != "" {
|
||||
//nolint:err113 // surfacing the server's message
|
||||
return fmt.Errorf("api error (%d): %s", status, e.Message)
|
||||
}
|
||||
|
||||
//nolint:err113 // surfacing the server's body
|
||||
return fmt.Errorf("api error (%d): %s", status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
func ptrStr(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return *s
|
||||
}
|
||||
|
||||
func ptrStrs(s *[]string) []string {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return *s
|
||||
}
|
||||
+18
-3
@@ -187,14 +187,29 @@ func removeContainerWithRetry(ctx context.Context, cli *client.Client, container
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// testContainerNamePrefixes are the name prefixes used by containers that the
|
||||
// integration test harness creates (headscale, tailscale, DERP, and k3s).
|
||||
var testContainerNamePrefixes = []string{"hs-", "ts-", "derp-", "k3s-"}
|
||||
|
||||
// matchesTestContainerPrefix reports whether name belongs to an integration
|
||||
// test container, ignoring any leading "/" that Docker prefixes names with.
|
||||
func matchesTestContainerPrefix(name string) bool {
|
||||
name = strings.TrimPrefix(name, "/")
|
||||
for _, prefix := range testContainerNamePrefixes {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isTestContainerName reports whether any of the container names belong to an
|
||||
// integration test container.
|
||||
func isTestContainerName(names []string) bool {
|
||||
for _, name := range names {
|
||||
if strings.Contains(name, "headscale-test-suite") ||
|
||||
strings.Contains(name, "hs-") ||
|
||||
strings.Contains(name, "ts-") ||
|
||||
strings.Contains(name, "derp-") {
|
||||
matchesTestContainerPrefix(name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -735,7 +735,7 @@ func getCurrentTestContainers(containers []container.Summary, testContainerID st
|
||||
for _, cont := range containers {
|
||||
for _, name := range cont.Names {
|
||||
containerName := strings.TrimPrefix(name, "/")
|
||||
if strings.HasPrefix(containerName, "hs-") || strings.HasPrefix(containerName, "ts-") {
|
||||
if matchesTestContainerPrefix(containerName) {
|
||||
// Check if container has matching run ID label
|
||||
if cont.Labels != nil && cont.Labels["hi.run-id"] == runID {
|
||||
testRunContainers = append(testRunContainers, testContainer{
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/juanfont/headscale/integration/dockertestutil"
|
||||
"github.com/juanfont/headscale/integration/k3sic"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -21,6 +22,7 @@ const (
|
||||
nameDockerContext = "Docker Context"
|
||||
nameDockerSocket = "Docker Socket"
|
||||
nameGolangImage = "Golang Image"
|
||||
nameK3sImage = "K3s Image"
|
||||
nameGoInstall = "Go Installation"
|
||||
)
|
||||
|
||||
@@ -66,6 +68,7 @@ func runDoctorCheck(ctx context.Context) error {
|
||||
results = append(results, checkDockerSocket(ctx))
|
||||
results = append(results, checkDockerHubCredentials())
|
||||
results = append(results, checkGolangImage(ctx))
|
||||
results = append(results, checkK3sImage(ctx))
|
||||
}
|
||||
|
||||
// Check 3: Go installation
|
||||
@@ -242,6 +245,44 @@ func checkGolangImage(ctx context.Context) DoctorResult {
|
||||
return pass(nameGolangImage, fmt.Sprintf("Golang image %s is now available", imageName))
|
||||
}
|
||||
|
||||
// checkK3sImage verifies the ghcr k3s image used by TestK8sOperator is available
|
||||
// locally or can be pulled. The image is pinned (see [k3sic.K3sImage]).
|
||||
func checkK3sImage(ctx context.Context) DoctorResult {
|
||||
cli, err := createDockerClient(ctx)
|
||||
if err != nil {
|
||||
return fail(nameK3sImage, "Cannot create Docker client for image check")
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
imageName := k3sic.K3sImage
|
||||
|
||||
available, err := checkImageAvailableLocally(ctx, cli, imageName)
|
||||
if err != nil {
|
||||
return fail(
|
||||
nameK3sImage,
|
||||
fmt.Sprintf("Cannot check k3s image %s: %v", imageName, err),
|
||||
"Check Docker daemon status",
|
||||
"Try: docker images | grep k3s",
|
||||
)
|
||||
}
|
||||
|
||||
if available {
|
||||
return pass(nameK3sImage, fmt.Sprintf("K3s image %s is available locally", imageName))
|
||||
}
|
||||
|
||||
err = ensureImageAvailable(ctx, cli, imageName, false)
|
||||
if err != nil {
|
||||
return warn(
|
||||
nameK3sImage,
|
||||
fmt.Sprintf("K3s image %s not available locally and could not pull: %v", imageName, err),
|
||||
"Only TestK8sOperator needs this image; other tests are unaffected",
|
||||
"Try: docker pull "+imageName,
|
||||
)
|
||||
}
|
||||
|
||||
return pass(nameK3sImage, fmt.Sprintf("K3s image %s is now available", imageName))
|
||||
}
|
||||
|
||||
// checkGoInstallation verifies Go is installed and working.
|
||||
func checkGoInstallation(ctx context.Context) DoctorResult {
|
||||
_, err := exec.LookPath("go")
|
||||
|
||||
+9
-9
@@ -230,12 +230,12 @@ database:
|
||||
# ssl: false
|
||||
|
||||
### TLS configuration
|
||||
#
|
||||
## Let's encrypt / ACME
|
||||
#
|
||||
# headscale supports automatically requesting and setting up
|
||||
# See: https://headscale.net/stable/ref/tls/
|
||||
|
||||
## Let's Encrypt / ACME
|
||||
# Headscale supports automatically requesting and setting up
|
||||
# TLS for a domain with Let's Encrypt.
|
||||
#
|
||||
|
||||
# URL to ACME directory
|
||||
acme_url: https://acme-v02.api.letsencrypt.org/directory
|
||||
|
||||
@@ -245,15 +245,13 @@ acme_email: ""
|
||||
# Domain name to request a TLS certificate for:
|
||||
tls_letsencrypt_hostname: ""
|
||||
|
||||
# Path to store certificates and metadata needed by
|
||||
# letsencrypt
|
||||
# For production:
|
||||
# Path to store certificates and metadata needed by letsencrypt
|
||||
tls_letsencrypt_cache_dir: /var/lib/headscale/cache
|
||||
|
||||
# Type of ACME challenge to use, currently supported types:
|
||||
# HTTP-01 or TLS-ALPN-01
|
||||
# See: https://headscale.net/stable/ref/tls/
|
||||
tls_letsencrypt_challenge_type: HTTP-01
|
||||
|
||||
# When HTTP-01 challenge is chosen, letsencrypt must set up a
|
||||
# verification endpoint, and it will be listening on:
|
||||
# :http = port 80
|
||||
@@ -279,6 +277,7 @@ policy:
|
||||
# The mode can be "file" or "database" that defines
|
||||
# where the policies are stored and read from.
|
||||
mode: file
|
||||
|
||||
# If the mode is set to "file", the path to a HuJSON file containing policies.
|
||||
path: ""
|
||||
|
||||
@@ -460,6 +459,7 @@ taildrop:
|
||||
# choice.
|
||||
auto_update:
|
||||
enabled: false
|
||||
|
||||
# Advanced performance tuning parameters.
|
||||
# The defaults are carefully chosen and should rarely need adjustment.
|
||||
# Only modify these if you have identified a specific performance issue.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+48
-11
@@ -1,7 +1,7 @@
|
||||
# API v2 — Headscale's v2 API
|
||||
# API v2: Headscale's v2 API
|
||||
|
||||
This is Headscale's v2 HTTP API, served at `/api/v2`. Some of its endpoints are
|
||||
**ported from Tailscale's API** — reusing Tailscale's wire shapes — so the
|
||||
**ported from Tailscale's API**, reusing Tailscale's wire shapes, so the
|
||||
Tailscale ecosystem that cannot talk to Headscale today works: the
|
||||
[Terraform/OpenTofu provider], [tscli], and the official [Go client]
|
||||
(`tailscale.com/client/tailscale/v2`).
|
||||
@@ -23,12 +23,20 @@ headscale's own conventions. The headscale-native admin API stays at `/api/v1`
|
||||
- Errors use **Tailscale's** body (`{"message","data","status"}`), installed as
|
||||
a per-API transform (`tailscaleErrorTransformer` in `errors.go`). A future
|
||||
headscale-native v2 operation would keep Huma's RFC 9457 problem+json.
|
||||
- Auth accepts the API key as **HTTP Basic** (key as username — what the SDK
|
||||
sends) or **Bearer**. See `authMiddleware`.
|
||||
- Each operation declares the Tailscale scope it would require (`auth_keys`,
|
||||
`devices:core`, `devices:routes`, `policy_file`, `feature_settings`, each with
|
||||
a `:read` subset). Nothing is enforced yet — every key is all-access — pending
|
||||
OAuth tokens; see the `TODO(scopes)` in `api.go`.
|
||||
- Auth accepts a credential as **HTTP Basic** (key as username, what the SDK
|
||||
sends) or **Bearer**: an admin API key (`hskey-api-…`), or an OAuth access
|
||||
token (`hskey-oauthtok-…`). See `authMiddleware`.
|
||||
- Each operation declares the Tailscale scope it requires (`auth_keys`,
|
||||
`oauth_keys`, `devices:core`, `devices:routes`, `policy_file`,
|
||||
`feature_settings`, each with a `:read` subset, plus `all`/`all:read`).
|
||||
`requireScope` records it both for the middleware and in the generated
|
||||
OpenAPI, as an `x-required-scope` extension and a sentence in the operation
|
||||
description, so the scope shows up in the docs and spec. Enforcement: an
|
||||
**admin API key is all-access** (scope checks skipped); an **OAuth access
|
||||
token is scope-limited**, the middleware checks the operation's declared scope
|
||||
against the token's grant (`scope.Grants`, where a write scope subsumes its
|
||||
`:read` and `all`/`all:read` are super-scopes). The two are told apart by
|
||||
credential prefix.
|
||||
- Resolve one entity by id with a typed getter (`GetNodeByID`, `GetUserByID`,
|
||||
`GetAPIKeyByID`, `GetPreAuthKeyByID`); add one to state/db if it is missing
|
||||
rather than scanning a `List`. Build responses from the view accessors
|
||||
@@ -38,6 +46,35 @@ headscale's own conventions. The headscale-native admin API stays at `/api/v1`
|
||||
`ExpirySeconds *time.Duration` marshals as nanoseconds, which the spec and
|
||||
every client read as seconds.
|
||||
|
||||
## OAuth clients & scopes
|
||||
|
||||
Most of the Tailscale ecosystem (the Terraform provider, `tscli`, the Go client)
|
||||
accepts **either** an API key **or OAuth 2.0 client-credentials**; the Kubernetes
|
||||
operator is OAuth-only. Supporting OAuth lets all of them drive Headscale.
|
||||
|
||||
- **OAuth clients** are not a separate resource; they are `keyType:"client"` on
|
||||
the keys endpoint, exactly as Tailscale does it. Create
|
||||
(`POST /api/v2/tailnet/-/keys` with `{"keyType":"client","scopes":[…],"tags":[…]}`)
|
||||
returns a `Key` whose `id` is the client id and whose `key` is the secret,
|
||||
**shown once**; get/list never re-expose it. The secret is
|
||||
`hskey-client-<clientID>-<secret>`, embedding the client id so the token
|
||||
endpoint derives it from the secret (Tailscale's `get-authkey` trick). See
|
||||
`keys.go` (`createOAuthClient`) and `db/oauth.go`.
|
||||
- **Token endpoint** `POST /api/v2/oauth/token` (`oauth.go`) is a plain handler,
|
||||
not a Huma operation: it takes `application/x-www-form-urlencoded` and emits
|
||||
RFC 6749 OAuth2 error bodies (`{"error","error_description"}`). Credentials
|
||||
arrive in the body or HTTP Basic; optional space-delimited `scope`/`tags`
|
||||
narrow the token to a subset of the client's grant. It returns a 1-hour
|
||||
`Bearer` access token (`hskey-oauthtok-…`).
|
||||
- **Scope enforcement** is the one seam in `authMiddleware`. **Tag enforcement**:
|
||||
an auth key minted by a token may only carry tags the token holds, or tags
|
||||
owned-by them via the policy `tagOwners` (`State.TagOwnedByTags` →
|
||||
`policy/v2`), so e.g. an operator token tagged `tag:k8s-operator` may mint
|
||||
`tag:k8s` keys.
|
||||
- Credentials/tokens are stored like API keys: a public id/prefix plus an
|
||||
**Argon2id** hash of the secret (no JWT, no signing keys). `OAuthClient` and
|
||||
`OAuthAccessToken` live in `types/oauth.go` and `db/oauth.go`.
|
||||
|
||||
## Adding an endpoint
|
||||
|
||||
Worked example: the keys resource (`keys.go`) = Tailscale auth keys = Headscale
|
||||
@@ -55,7 +92,7 @@ pre-auth keys.
|
||||
|
||||
3. **Map to Headscale.** Write the field ↔ field ↔ `state` call mapping. Record
|
||||
gaps and the decision for each (e.g. Tailscale `preauthorized` has no
|
||||
Headscale equivalent — accepted, ignored, echoed back). _Acceptance: every
|
||||
Headscale equivalent: accepted, ignored, echoed back). _Acceptance: every
|
||||
request field is consumed or deliberately ignored; every response field has a
|
||||
source._
|
||||
|
||||
@@ -73,13 +110,13 @@ compat`; declare its `Errors`; enforce the tailnet and scope. Map state
|
||||
|
||||
6. **Roundtrip the real clients.** Add a `t.Run` subtest to `TestAPIv2`
|
||||
(`hscontrol/servertest/apiv2_test.go`) for each of the Go client, tscli, and
|
||||
OpenTofu — full create→read→list→delete against one shared server on a real
|
||||
OpenTofu, full create→read→list→delete against one shared server on a real
|
||||
loopback port (`servertest.WithRealListener`). tscli and tofu come from the
|
||||
nix dev shell; a missing binary fails the test. _Acceptance: `nix develop -c
|
||||
go test ./hscontrol/servertest/ -run TestAPIv2` is green._
|
||||
|
||||
7. **Update the CLI** only if the v2 operation fully replaces a v1 one. Tailscale
|
||||
has no separate key-expire verb — its `DELETE` _is_ the revoke — so v2 maps
|
||||
has no separate key-expire verb (its `DELETE` _is_ the revoke), so v2 maps
|
||||
`DELETE` to a soft revoke: the key stays retrievable with `invalid: true`
|
||||
until the collector reaps it (`preauth_keys.revoked_retention`), the
|
||||
equivalent of v1 `preauthkeys expire`. `headscale preauthkeys` still stays on
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/scope"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
)
|
||||
@@ -60,7 +61,7 @@ func registerACL(api huma.API, b Backend) {
|
||||
Tags: aclTags,
|
||||
Security: security,
|
||||
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusInternalServerError},
|
||||
}, ScopePolicyFileRead), func(ctx context.Context, in *getACLInput) (*huma.StreamResponse, error) {
|
||||
}, scope.PolicyFileRead), func(ctx context.Context, in *getACLInput) (*huma.StreamResponse, error) {
|
||||
err := requireDefaultTailnet(in.Tailnet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -89,7 +90,7 @@ func registerACL(api huma.API, b Backend) {
|
||||
http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden,
|
||||
http.StatusNotFound, http.StatusPreconditionFailed, http.StatusInternalServerError,
|
||||
},
|
||||
}, ScopePolicyFile), func(ctx context.Context, in *setACLInput) (*huma.StreamResponse, error) {
|
||||
}, scope.PolicyFile), func(ctx context.Context, in *setACLInput) (*huma.StreamResponse, error) {
|
||||
err := requireDefaultTailnet(in.Tailnet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
+104
-45
@@ -1,8 +1,8 @@
|
||||
// Package apiv2 is Headscale's v2 HTTP API, served at /api/v2.
|
||||
//
|
||||
// Where the v1 API (hscontrol/api/v1) is the headscale-native admin surface, v2
|
||||
// additionally ports selected endpoints from Tailscale's API — reusing
|
||||
// Tailscale's wire shapes (paths, request/response JSON, error body) — so the
|
||||
// additionally ports selected endpoints from Tailscale's API, reusing
|
||||
// Tailscale's wire shapes (paths, request/response JSON, error body), so the
|
||||
// existing Tailscale ecosystem (the Terraform/OpenTofu provider, tscli, and
|
||||
// tailscale.com/client/tailscale/v2) can drive Headscale unchanged. Ported
|
||||
// operations carry the "Tailscale compat" tag; a headscale-native v2 operation
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humachi"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/juanfont/headscale/hscontrol/scope"
|
||||
"github.com/juanfont/headscale/hscontrol/state"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/types/change"
|
||||
@@ -40,7 +41,7 @@ type Backend struct {
|
||||
}
|
||||
|
||||
// security is the requirement applied to authenticated operations: an API key
|
||||
// presented as HTTP Basic (the key as username — what the Tailscale SDK sends)
|
||||
// presented as HTTP Basic (the key as username, what the Tailscale SDK sends)
|
||||
// or as a Bearer token.
|
||||
var security = []map[string][]string{{"basicAuth": {}}, {"bearerAuth": {}}}
|
||||
|
||||
@@ -79,7 +80,7 @@ func Config() huma.Config {
|
||||
// Accept application/hujson request bodies (the Tailscale SDK sends the
|
||||
// policy file that way). The bytes are captured raw by the ACL handler, so
|
||||
// reusing the JSON format is only to satisfy huma's content-type check.
|
||||
// Clone first — config.Formats aliases huma's shared DefaultFormats map.
|
||||
// Clone first: config.Formats aliases huma's shared DefaultFormats map.
|
||||
formats := maps.Clone(config.Formats)
|
||||
formats["application/hujson"] = formats["application/json"]
|
||||
config.Formats = formats
|
||||
@@ -98,6 +99,10 @@ func NewAPI(router chi.Router, backend Backend) huma.API {
|
||||
|
||||
Register(api, backend)
|
||||
|
||||
// The OAuth token endpoint is a plain route, not a Huma operation (see
|
||||
// oauth.go); register it on the same router.
|
||||
registerOAuthToken(router, backend)
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
@@ -117,11 +122,21 @@ func Spec() ([]byte, error) {
|
||||
return api.OpenAPI().YAML()
|
||||
}
|
||||
|
||||
// Spec30 emits the document downgraded to OpenAPI 3.0.3, needed because
|
||||
// oapi-codegen cannot yet read 3.1; the typed client is generated from this.
|
||||
func Spec30() ([]byte, error) {
|
||||
api := NewAPI(chi.NewMux(), Backend{})
|
||||
|
||||
return api.OpenAPI().DowngradeYAML()
|
||||
}
|
||||
|
||||
type contextKey int
|
||||
|
||||
const (
|
||||
localTrustKey contextKey = iota
|
||||
ownerUserKey
|
||||
principalScopesKey
|
||||
principalTagsKey
|
||||
)
|
||||
|
||||
// WithLocalTrust marks a request as arriving over a locally-trusted transport
|
||||
@@ -136,8 +151,8 @@ func WithLocalTrust(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// authMiddleware authenticates the API key (HTTP Basic with the key as the
|
||||
// username — what the Tailscale SDK sends — or Bearer), records the key's
|
||||
// owning user for handlers, and enforces the operation's required scope.
|
||||
// username, what the Tailscale SDK sends, or Bearer), records the key's owning
|
||||
// user for handlers, and enforces the operation's required scope.
|
||||
func authMiddleware(api huma.API, b Backend) func(huma.Context, func(huma.Context)) {
|
||||
return func(ctx huma.Context, next func(huma.Context)) {
|
||||
if ctx.Context().Value(localTrustKey) != nil {
|
||||
@@ -159,6 +174,35 @@ func authMiddleware(api huma.API, b Backend) func(huma.Context, func(huma.Contex
|
||||
return
|
||||
}
|
||||
|
||||
// An OAuth access token is scope-limited; an admin API key is all-access.
|
||||
// They are told apart by prefix so a scoped token can never be mistaken
|
||||
// for an all-access key.
|
||||
if strings.HasPrefix(token, types.AccessTokenPrefix) {
|
||||
at, err := b.State.AuthenticateAccessToken(token)
|
||||
if err != nil {
|
||||
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if want, ok := requiredScope(ctx.Operation()); ok && !scope.Grants(scope.Parse(at.Scopes), want) {
|
||||
_ = huma.WriteErr(api, ctx, http.StatusForbidden,
|
||||
"token is missing the required scope "+string(want))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// The keys handler multiplexes on keyType, so its required scope and
|
||||
// permitted tags depend on the body; carry the token's scopes and tags
|
||||
// for it to finish the check the static middleware cannot.
|
||||
ctx = huma.WithValue(ctx, principalScopesKey, at.Scopes)
|
||||
ctx = huma.WithValue(ctx, principalTagsKey, at.Tags)
|
||||
|
||||
next(ctx)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
key, err := b.State.AuthenticateAPIKey(token)
|
||||
if err != nil {
|
||||
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized")
|
||||
@@ -166,13 +210,9 @@ func authMiddleware(api huma.API, b Backend) func(huma.Context, func(huma.Contex
|
||||
return
|
||||
}
|
||||
|
||||
// TODO(scopes): every valid key is all-access today. Operations still
|
||||
// declare their required scope (requireScope) so that once OAuth tokens
|
||||
// arrive, the granted set can be derived from the token and checked
|
||||
// against the operation's scope here. Until then, accept everything.
|
||||
|
||||
// Record the key's owning user (may be unset) so handlers can create
|
||||
// user-owned keys on its behalf.
|
||||
// An admin API key is all-access: its operations are not scope-checked.
|
||||
// Record its owning user (may be unset) so handlers can create user-owned
|
||||
// keys on its behalf.
|
||||
if key.UserID != nil {
|
||||
ctx = huma.WithValue(ctx, ownerUserKey, types.UserID(*key.UserID))
|
||||
}
|
||||
@@ -210,6 +250,24 @@ func ownerUser(ctx context.Context) (types.UserID, bool) {
|
||||
return uid, ok
|
||||
}
|
||||
|
||||
// principalScopes returns the scopes granted to the request's OAuth access
|
||||
// token, and whether the request authenticated with one. ok is false for an
|
||||
// admin API key, which is all-access and not scope-checked.
|
||||
func principalScopes(ctx context.Context) ([]string, bool) {
|
||||
scopes, ok := ctx.Value(principalScopesKey).([]string)
|
||||
|
||||
return scopes, ok
|
||||
}
|
||||
|
||||
// principalTags returns the tags granted to the request's OAuth access token,
|
||||
// and whether the request authenticated with one. An admin API key is not an
|
||||
// OAuth token, so ok is false and its key creation is unrestricted by tags.
|
||||
func principalTags(ctx context.Context) ([]string, bool) {
|
||||
tags, ok := ctx.Value(principalTagsKey).([]string)
|
||||
|
||||
return tags, ok
|
||||
}
|
||||
|
||||
// requireDefaultTailnet rejects any tailnet other than "-". Headscale is
|
||||
// single-tailnet; the Tailscale SDK sends "-" (its default tailnet). A non-"-"
|
||||
// value is "no such tailnet", a 404, which lets the SDK's IsNotFound behave.
|
||||
@@ -221,46 +279,47 @@ func requireDefaultTailnet(tailnet string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Scope is an OAuth capability an operation requires and a token grants. The
|
||||
// names mirror Tailscale's API scopes (see the OAuth scope descriptions in the
|
||||
// Tailscale OpenAPI spec); a ...Read scope is the read-only subset of its
|
||||
// write scope. Nothing is enforced yet — every key is all-access — but every
|
||||
// operation declares the scope it would require, so OAuth tokens can later be
|
||||
// checked against it without reworking the operations.
|
||||
type Scope string
|
||||
// The scope vocabulary and the grant predicate live in the hscontrol/scope
|
||||
// package; this file only wires a required scope onto each huma operation and
|
||||
// reads it back in the middleware.
|
||||
|
||||
const (
|
||||
ScopeAuthKeys Scope = "auth_keys"
|
||||
ScopeAuthKeysRead Scope = "auth_keys:read"
|
||||
|
||||
ScopeDevicesCore Scope = "devices:core"
|
||||
ScopeDevicesCoreRead Scope = "devices:core:read"
|
||||
|
||||
ScopeDevicesRoutes Scope = "devices:routes"
|
||||
ScopeDevicesRoutesRead Scope = "devices:routes:read"
|
||||
|
||||
ScopePolicyFile Scope = "policy_file"
|
||||
ScopePolicyFileRead Scope = "policy_file:read"
|
||||
|
||||
ScopeFeatureSettings Scope = "feature_settings"
|
||||
ScopeFeatureSettingsRead Scope = "feature_settings:read"
|
||||
|
||||
ScopeUsers Scope = "users"
|
||||
ScopeUsersRead Scope = "users:read"
|
||||
)
|
||||
|
||||
// scopeMetaKey keys the per-operation required Scope in huma.Operation.Metadata.
|
||||
// scopeMetaKey keys the per-operation required scope in huma.Operation.Metadata.
|
||||
const scopeMetaKey = "headscale.scope"
|
||||
|
||||
// requireScope records op's required scope in its Metadata, where the auth
|
||||
// middleware can read it back. It keeps the requirement next to the operation
|
||||
// definition.
|
||||
func requireScope(op huma.Operation, s Scope) huma.Operation {
|
||||
// requireScope records op's required scope, both in its Metadata (where the auth
|
||||
// middleware reads it back) and in the generated OpenAPI document: an
|
||||
// x-required-scope extension for machine consumers and a Description line so the
|
||||
// rendered docs state what each operation needs.
|
||||
func requireScope(op huma.Operation, s scope.Scope) huma.Operation {
|
||||
if op.Metadata == nil {
|
||||
op.Metadata = map[string]any{}
|
||||
}
|
||||
|
||||
op.Metadata[scopeMetaKey] = s
|
||||
|
||||
if op.Extensions == nil {
|
||||
op.Extensions = map[string]any{}
|
||||
}
|
||||
|
||||
op.Extensions["x-required-scope"] = string(s)
|
||||
|
||||
note := "Requires the `" + string(s) + "` OAuth scope (an admin API key is all-access)."
|
||||
if op.Description == "" {
|
||||
op.Description = note
|
||||
} else {
|
||||
op.Description += "\n\n" + note
|
||||
}
|
||||
|
||||
return op
|
||||
}
|
||||
|
||||
// requiredScope returns the scope an operation declared via requireScope, if any.
|
||||
func requiredScope(op *huma.Operation) (scope.Scope, bool) {
|
||||
if op == nil || op.Metadata == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
s, ok := op.Metadata[scopeMetaKey].(scope.Scope)
|
||||
|
||||
return s, ok
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/scope"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"tailscale.com/net/tsaddr"
|
||||
@@ -125,7 +126,7 @@ func registerDevices(api huma.API, b Backend) {
|
||||
Tags: deviceTags,
|
||||
Security: security,
|
||||
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
|
||||
}, ScopeDevicesCoreRead), func(ctx context.Context, in *deviceByIDInput) (*deviceOutput, error) {
|
||||
}, scope.DevicesCoreRead), func(ctx context.Context, in *deviceByIDInput) (*deviceOutput, error) {
|
||||
node, err := lookupNode(b, in.DeviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -142,7 +143,7 @@ func registerDevices(api huma.API, b Backend) {
|
||||
Tags: deviceTags,
|
||||
Security: security,
|
||||
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
|
||||
}, ScopeDevicesCoreRead), func(ctx context.Context, in *listDevicesInput) (*listDevicesOutput, error) {
|
||||
}, scope.DevicesCoreRead), func(ctx context.Context, in *listDevicesInput) (*listDevicesOutput, error) {
|
||||
err := requireDefaultTailnet(in.Tailnet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -170,7 +171,7 @@ func registerDevices(api huma.API, b Backend) {
|
||||
Security: security,
|
||||
DefaultStatus: http.StatusOK,
|
||||
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
|
||||
}, ScopeDevicesCore), func(ctx context.Context, in *deviceByIDInput) (*emptyOutput, error) {
|
||||
}, scope.DevicesCore), func(ctx context.Context, in *deviceByIDInput) (*emptyOutput, error) {
|
||||
node, err := lookupNode(b, in.DeviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -195,7 +196,7 @@ func registerDevices(api huma.API, b Backend) {
|
||||
Security: security,
|
||||
DefaultStatus: http.StatusOK,
|
||||
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
|
||||
}, ScopeDevicesCore), func(ctx context.Context, in *setAuthorizedInput) (*emptyOutput, error) {
|
||||
}, scope.DevicesCore), func(ctx context.Context, in *setAuthorizedInput) (*emptyOutput, error) {
|
||||
_, err := lookupNode(b, in.DeviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -222,7 +223,7 @@ func registerDevices(api huma.API, b Backend) {
|
||||
Security: security,
|
||||
DefaultStatus: http.StatusOK,
|
||||
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
|
||||
}, ScopeDevicesCore), func(ctx context.Context, in *setNameInput) (*emptyOutput, error) {
|
||||
}, scope.DevicesCore), func(ctx context.Context, in *setNameInput) (*emptyOutput, error) {
|
||||
node, err := lookupNode(b, in.DeviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -247,7 +248,7 @@ func registerDevices(api huma.API, b Backend) {
|
||||
Security: security,
|
||||
DefaultStatus: http.StatusOK,
|
||||
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
|
||||
}, ScopeDevicesCore), func(ctx context.Context, in *setTagsInput) (*emptyOutput, error) {
|
||||
}, scope.DevicesCore), func(ctx context.Context, in *setTagsInput) (*emptyOutput, error) {
|
||||
node, err := lookupNode(b, in.DeviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -261,6 +262,19 @@ func registerDevices(api huma.API, b Backend) {
|
||||
return &emptyOutput{}, nil
|
||||
}
|
||||
|
||||
// An OAuth token may only assign tags within its grant (held directly or
|
||||
// owned by a held tag per policy); an admin API key is unrestricted. The
|
||||
// devices:core scope alone must not let a token stamp an arbitrary policy
|
||||
// tag (e.g. tag:prod) onto any node. SetNodeTags still enforces that each
|
||||
// tag exists in policy.
|
||||
if tokenTags, isOAuth := principalTags(ctx); isOAuth {
|
||||
for _, tag := range in.Body.Tags {
|
||||
if !b.State.TagOwnedByTags(tag, tokenTags) {
|
||||
return nil, huma.Error403Forbidden("token may not assign tag " + tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, nodeChange, err := b.State.SetNodeTags(node.ID(), in.Body.Tags)
|
||||
if err != nil {
|
||||
return nil, mapError("setting device tags", err)
|
||||
@@ -280,7 +294,7 @@ func registerDevices(api huma.API, b Backend) {
|
||||
Security: security,
|
||||
DefaultStatus: http.StatusOK,
|
||||
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
|
||||
}, ScopeDevicesCore), func(ctx context.Context, in *setKeyInput) (*emptyOutput, error) {
|
||||
}, scope.DevicesCore), func(ctx context.Context, in *setKeyInput) (*emptyOutput, error) {
|
||||
node, err := lookupNode(b, in.DeviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -313,7 +327,7 @@ func registerDevices(api huma.API, b Backend) {
|
||||
Security: security,
|
||||
DefaultStatus: http.StatusOK,
|
||||
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
|
||||
}, ScopeDevicesRoutes), func(ctx context.Context, in *setSubnetRoutesInput) (*deviceRoutesOutput, error) {
|
||||
}, scope.DevicesRoutes), func(ctx context.Context, in *setSubnetRoutesInput) (*deviceRoutesOutput, error) {
|
||||
node, err := lookupNode(b, in.DeviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -342,7 +356,7 @@ func registerDevices(api huma.API, b Backend) {
|
||||
Tags: deviceTags,
|
||||
Security: security,
|
||||
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
|
||||
}, ScopeDevicesRoutesRead), func(ctx context.Context, in *deviceByIDInput) (*deviceRoutesOutput, error) {
|
||||
}, scope.DevicesRoutesRead), func(ctx context.Context, in *deviceByIDInput) (*deviceRoutesOutput, error) {
|
||||
node, err := lookupNode(b, in.DeviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -9,12 +9,12 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// apiError is the Tailscale API error body. The official Tailscale Go client —
|
||||
// and therefore the Terraform provider and tscli built on it — decode 4xx/5xx
|
||||
// apiError is the Tailscale API error body. The official Tailscale Go client
|
||||
// (and therefore the Terraform provider and tscli built on it) decodes 4xx/5xx
|
||||
// responses into this shape. Huma's default RFC 9457 problem+json would reach
|
||||
// them with an empty message, so every v2 error is rewritten into this shape by
|
||||
// tailscaleErrorTransformer. The HTTP status is read from the response code,
|
||||
// not from the body's status field.
|
||||
// them with an empty message, so tailscaleErrorTransformer rewrites every v2
|
||||
// error into this shape. The HTTP status is read from the response code, not
|
||||
// from the body's status field.
|
||||
type apiError struct {
|
||||
Message string `json:"message"`
|
||||
Data []apiErrorData `json:"data,omitempty"`
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package apiv2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/juanfont/headscale/hscontrol/scope"
|
||||
)
|
||||
|
||||
// selfEnforcedKeyOps are the authenticated operations that intentionally declare
|
||||
// NO static scope because they multiplex on keyType and authorize inside the
|
||||
// handler via requireKeyScope (see keys.go). Every other authenticated operation
|
||||
// must declare a scope.
|
||||
var selfEnforcedKeyOps = map[string]bool{
|
||||
"POST /api/v2/tailnet/{tailnet}/keys": true,
|
||||
"GET /api/v2/tailnet/{tailnet}/keys": true,
|
||||
"GET /api/v2/tailnet/{tailnet}/keys/{keyId}": true,
|
||||
"DELETE /api/v2/tailnet/{tailnet}/keys/{keyId}": true,
|
||||
}
|
||||
|
||||
// TestEveryAuthenticatedOperationDeclaresScope is the structural guarantee that no
|
||||
// v2 operation ships unprotected: any operation that requires authentication
|
||||
// (non-empty Security) must either declare a required scope via requireScope, or
|
||||
// be one of the keyType-multiplexed keys operations that self-enforce. A new
|
||||
// operation added without scope protection fails this test.
|
||||
func TestEveryAuthenticatedOperationDeclaresScope(t *testing.T) {
|
||||
api := NewAPI(chi.NewMux(), Backend{})
|
||||
|
||||
for path, item := range api.OpenAPI().Paths {
|
||||
for method, op := range humaOperations(item) {
|
||||
if op == nil || len(op.Security) == 0 {
|
||||
continue // unregistered method or a public operation
|
||||
}
|
||||
|
||||
key := method + " " + path
|
||||
if selfEnforcedKeyOps[key] {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := op.Metadata[scopeMetaKey].(scope.Scope); !ok {
|
||||
t.Errorf("operation %q is authenticated but declares no required scope; "+
|
||||
"wrap it in requireScope, or add it to selfEnforcedKeyOps if it "+
|
||||
"authorizes inside the handler", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func humaOperations(item *huma.PathItem) map[string]*huma.Operation {
|
||||
return map[string]*huma.Operation{
|
||||
"GET": item.Get,
|
||||
"POST": item.Post,
|
||||
"PUT": item.Put,
|
||||
"DELETE": item.Delete,
|
||||
"PATCH": item.Patch,
|
||||
}
|
||||
}
|
||||
+298
-72
@@ -3,10 +3,13 @@ package apiv2
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/scope"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -17,9 +20,13 @@ func init() {
|
||||
// 90 days, matching Tailscale and the Terraform provider default.
|
||||
const defaultExpiry = 90 * 24 * time.Hour
|
||||
|
||||
// keyTypeAuth is the only key kind Headscale issues; Tailscale's OAuth-client
|
||||
// and federated-identity kinds are out of scope.
|
||||
const keyTypeAuth = "auth"
|
||||
const (
|
||||
// keyTypeAuth is a machine auth key (Headscale pre-auth key); the default.
|
||||
keyTypeAuth = "auth"
|
||||
// keyTypeClient is an OAuth client (client-credentials). Multiplexed onto the
|
||||
// keys resource exactly as Tailscale does.
|
||||
keyTypeClient = "client"
|
||||
)
|
||||
|
||||
// KeyCapabilities maps a resource to the actions a key permits. Headscale
|
||||
// populates only devices.create (auth keys); the named types (vs Tailscale's
|
||||
@@ -42,16 +49,27 @@ type KeyDeviceCreateCapabilities struct {
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
// CreateKeyRequest is the POST body. expirySeconds is plain seconds (unlike the
|
||||
// response, see Key).
|
||||
// CreateKeyRequest is the POST body. It is multiplexed by keyType: "auth"
|
||||
// (default) creates a machine auth key from Capabilities; "client" creates an
|
||||
// OAuth client from the top-level Scopes and Tags. expirySeconds is plain
|
||||
// seconds (unlike the response, see Key).
|
||||
type CreateKeyRequest struct {
|
||||
Capabilities KeyCapabilities `json:"capabilities"`
|
||||
ExpirySeconds int64 `doc:"Lifetime in seconds; defaults to 90 days." json:"expirySeconds,omitempty"`
|
||||
Description string `json:"description,omitempty" maxLength:"50"`
|
||||
KeyType string `doc:"Key kind: \"auth\" (default) or \"client\" (OAuth client)." json:"keyType,omitempty"`
|
||||
// Capabilities is optional: an auth key carries device-create capabilities,
|
||||
// but an OAuth client (keyType:"client") has none and the Tailscale clients
|
||||
// omit the field entirely, so it must not be required.
|
||||
Capabilities *KeyCapabilities `json:"capabilities,omitempty"`
|
||||
ExpirySeconds int64 `doc:"Lifetime in seconds; defaults to 90 days. Auth keys only." json:"expirySeconds,omitempty"`
|
||||
Description string `json:"description,omitempty" maxLength:"50"`
|
||||
// Scopes and Tags are top-level and apply only to keyType "client" (an OAuth
|
||||
// client). Auth-key tags live under Capabilities.Devices.Create.Tags.
|
||||
Scopes []string `doc:"OAuth scopes granted to the client. keyType=client only." json:"scopes,omitempty"`
|
||||
Tags []string `doc:"Tags the client may assign. keyType=client only." json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// Key is the Tailscale auth-key response. expirySeconds is emitted in seconds
|
||||
// to match the Tailscale spec; the secret key is present only at creation.
|
||||
// Key is the Tailscale key response, shared by auth keys and OAuth clients.
|
||||
// expirySeconds is emitted in seconds to match the Tailscale spec; the secret
|
||||
// key is present only at creation.
|
||||
type Key struct {
|
||||
ID string `json:"id"`
|
||||
KeyType string `json:"keyType"`
|
||||
@@ -63,6 +81,7 @@ type Key struct {
|
||||
Revoked *time.Time `json:"revoked,omitempty"`
|
||||
Invalid bool `json:"invalid"`
|
||||
Capabilities KeyCapabilities `json:"capabilities"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
}
|
||||
@@ -98,14 +117,19 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
// The keys resource is multiplexed by keyType (auth key vs OAuth client), so the
|
||||
// scope an operation requires depends on the request rather than being fixed.
|
||||
// requireScope (which the middleware enforces statically) is therefore omitted
|
||||
// here; each handler authorizes via requireKeyScope once the kind is known.
|
||||
func registerKeys(api huma.API, b Backend) {
|
||||
keysTags := []string{"Keys", "Tailscale compat"}
|
||||
|
||||
huma.Register(api, requireScope(huma.Operation{
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "createKey",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v2/tailnet/{tailnet}/keys",
|
||||
Summary: "Create an auth key",
|
||||
Summary: "Create an auth key or OAuth client",
|
||||
Description: "Requires the `auth_keys` scope for an auth key, or `oauth_keys` for an OAuth client (an admin API key is all-access).",
|
||||
Tags: keysTags,
|
||||
Security: security,
|
||||
Errors: []int{
|
||||
@@ -114,63 +138,25 @@ func registerKeys(api huma.API, b Backend) {
|
||||
http.StatusForbidden,
|
||||
http.StatusNotFound,
|
||||
},
|
||||
}, ScopeAuthKeys), func(ctx context.Context, in *createKeyInput) (*keyOutput, error) {
|
||||
}, func(ctx context.Context, in *createKeyInput) (*keyOutput, error) {
|
||||
err := requireDefaultTailnet(in.Tailnet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
create := in.Body.Capabilities.Devices.Create
|
||||
|
||||
// Ownership: tags -> tagged key; no tags -> owned by the API key's user;
|
||||
// no tags and an ownerless (legacy/admin) key -> 400.
|
||||
var userID *types.UserID
|
||||
|
||||
if len(create.Tags) == 0 {
|
||||
uid, ok := ownerUser(ctx)
|
||||
if !ok {
|
||||
return nil, huma.Error400BadRequest(
|
||||
"an auth key without tags must be created with a user-owned API key",
|
||||
)
|
||||
}
|
||||
|
||||
userID = &uid
|
||||
if in.Body.KeyType == keyTypeClient {
|
||||
return createOAuthClient(ctx, b, in.Body)
|
||||
}
|
||||
|
||||
expiration := time.Now().Add(expiryDuration(in.Body.ExpirySeconds))
|
||||
|
||||
pak, err := b.State.CreatePreAuthKey(
|
||||
userID,
|
||||
create.Reusable,
|
||||
create.Ephemeral,
|
||||
&expiration,
|
||||
create.Tags,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapError("creating auth key", err)
|
||||
}
|
||||
|
||||
if in.Body.Description != "" {
|
||||
err := b.State.SetPreAuthKeyDescription(
|
||||
pak.ID,
|
||||
in.Body.Description,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError(
|
||||
"setting auth key description",
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return &keyOutput{Body: keyFromNew(pak, in.Body)}, nil
|
||||
return createAuthKey(ctx, b, in.Body)
|
||||
})
|
||||
|
||||
huma.Register(api, requireScope(huma.Operation{
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "listKeys",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/v2/tailnet/{tailnet}/keys",
|
||||
Summary: "List auth keys",
|
||||
Summary: "List auth keys and OAuth clients",
|
||||
Description: "A token sees the kinds it can read: `auth_keys:read` for auth keys, `oauth_keys:read` for OAuth clients (an admin API key sees all).",
|
||||
Tags: keysTags,
|
||||
Security: security,
|
||||
Errors: []int{
|
||||
@@ -178,32 +164,49 @@ func registerKeys(api huma.API, b Backend) {
|
||||
http.StatusForbidden,
|
||||
http.StatusNotFound,
|
||||
},
|
||||
}, ScopeAuthKeysRead), func(ctx context.Context, in *listKeysInput) (*listKeysOutput, error) {
|
||||
}, func(ctx context.Context, in *listKeysInput) (*listKeysOutput, error) {
|
||||
err := requireDefaultTailnet(in.Tailnet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keys, err := b.State.ListPreAuthKeys()
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("listing auth keys", err)
|
||||
}
|
||||
scopes, isOAuth := principalScopes(ctx)
|
||||
|
||||
out := &listKeysOutput{}
|
||||
out.Body.Keys = make([]Key, 0, len(keys))
|
||||
out.Body.Keys = []Key{}
|
||||
|
||||
for i := range keys {
|
||||
out.Body.Keys = append(out.Body.Keys, keyFromStored(&keys[i]))
|
||||
// A token sees the key kinds it has read scope for; an admin key sees all.
|
||||
if !isOAuth || scope.Grants(scope.Parse(scopes), scope.AuthKeysRead) {
|
||||
keys, err := b.State.ListPreAuthKeys()
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("listing auth keys", err)
|
||||
}
|
||||
|
||||
for i := range keys {
|
||||
out.Body.Keys = append(out.Body.Keys, keyFromStored(&keys[i]))
|
||||
}
|
||||
}
|
||||
|
||||
if !isOAuth || scope.Grants(scope.Parse(scopes), scope.OAuthKeysRead) {
|
||||
clients, err := b.State.ListOAuthClients()
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("listing oauth clients", err)
|
||||
}
|
||||
|
||||
for i := range clients {
|
||||
out.Body.Keys = append(out.Body.Keys, oauthClientToKey(&clients[i], ""))
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, requireScope(huma.Operation{
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getKey",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/v2/tailnet/{tailnet}/keys/{keyId}",
|
||||
Summary: "Get an auth key",
|
||||
Summary: "Get an auth key or OAuth client",
|
||||
Description: "Requires `auth_keys:read` for an auth key, or `oauth_keys:read` for an OAuth client (an admin API key is all-access).",
|
||||
Tags: keysTags,
|
||||
Security: security,
|
||||
Errors: []int{
|
||||
@@ -211,12 +214,29 @@ func registerKeys(api huma.API, b Backend) {
|
||||
http.StatusForbidden,
|
||||
http.StatusNotFound,
|
||||
},
|
||||
}, ScopeAuthKeysRead), func(ctx context.Context, in *keyByIDInput) (*keyOutput, error) {
|
||||
}, func(ctx context.Context, in *keyByIDInput) (*keyOutput, error) {
|
||||
err := requireDefaultTailnet(in.Tailnet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// An OAuth client id is a hex string distinct from a numeric auth-key id,
|
||||
// so a client lookup that hits is authoritative; otherwise fall through to
|
||||
// the auth-key path. The lookup is gated on the caller actually holding
|
||||
// oauth_keys:read so a token without it cannot tell a real client id (403)
|
||||
// from an unknown key (404) — i.e. no client-existence oracle.
|
||||
if requireKeyScope(ctx, scope.OAuthKeysRead) == nil {
|
||||
client, err := b.State.GetOAuthClientByClientID(in.KeyID)
|
||||
if err == nil {
|
||||
return &keyOutput{Body: oauthClientToKey(client, "")}, nil
|
||||
}
|
||||
}
|
||||
|
||||
err = requireKeyScope(ctx, scope.AuthKeysRead)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key, err := findKeyByID(b, in.KeyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -225,11 +245,12 @@ func registerKeys(api huma.API, b Backend) {
|
||||
return &keyOutput{Body: keyFromStored(key)}, nil
|
||||
})
|
||||
|
||||
huma.Register(api, requireScope(huma.Operation{
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "deleteKey",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/api/v2/tailnet/{tailnet}/keys/{keyId}",
|
||||
Summary: "Delete an auth key",
|
||||
Summary: "Delete an auth key or OAuth client",
|
||||
Description: "Requires the `auth_keys` scope for an auth key, or `oauth_keys` for an OAuth client (an admin API key is all-access).",
|
||||
Tags: keysTags,
|
||||
Security: security,
|
||||
DefaultStatus: http.StatusOK,
|
||||
@@ -238,12 +259,31 @@ func registerKeys(api huma.API, b Backend) {
|
||||
http.StatusForbidden,
|
||||
http.StatusNotFound,
|
||||
},
|
||||
}, ScopeAuthKeys), func(ctx context.Context, in *keyByIDInput) (*deleteKeyOutput, error) {
|
||||
}, func(ctx context.Context, in *keyByIDInput) (*deleteKeyOutput, error) {
|
||||
err := requireDefaultTailnet(in.Tailnet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Gated on oauth_keys (write) for the same no-existence-oracle reason as
|
||||
// getKey: a token without it must not learn that an id is an OAuth client.
|
||||
if requireKeyScope(ctx, scope.OAuthKeys) == nil {
|
||||
_, err = b.State.GetOAuthClientByClientID(in.KeyID)
|
||||
if err == nil {
|
||||
err = b.State.RevokeOAuthClient(in.KeyID)
|
||||
if err != nil {
|
||||
return nil, mapError("deleting oauth client", err)
|
||||
}
|
||||
|
||||
return &deleteKeyOutput{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
err = requireKeyScope(ctx, scope.AuthKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id, err := parseID(in.KeyID, "auth key")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -261,6 +301,163 @@ func registerKeys(api huma.API, b Backend) {
|
||||
})
|
||||
}
|
||||
|
||||
// requireKeyScope authorizes a keys operation for an OAuth access token. An admin
|
||||
// API key carries no OAuth scopes and is all-access, so it always passes.
|
||||
func requireKeyScope(ctx context.Context, need scope.Scope) error {
|
||||
scopes, isOAuth := principalScopes(ctx)
|
||||
if !isOAuth {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !scope.Grants(scope.Parse(scopes), need) {
|
||||
return huma.Error403Forbidden("token is missing the required scope " + string(need))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createAuthKey creates a machine auth key (pre-auth key). Ownership: tags -> a
|
||||
// tagged key; no tags -> owned by the API key's user. An OAuth access token must
|
||||
// mint tagged keys, and each tag must be within the token's grant.
|
||||
func createAuthKey(ctx context.Context, b Backend, body CreateKeyRequest) (*keyOutput, error) {
|
||||
err := requireKeyScope(ctx, scope.AuthKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var create KeyDeviceCreateCapabilities
|
||||
if body.Capabilities != nil {
|
||||
create = body.Capabilities.Devices.Create
|
||||
}
|
||||
|
||||
tokenTags, isOAuth := principalTags(ctx)
|
||||
|
||||
var userID *types.UserID
|
||||
|
||||
switch {
|
||||
case len(create.Tags) > 0:
|
||||
// A key minted by an OAuth token may only carry tags within the token's
|
||||
// grant (held directly, or owned by a held tag) and defined in policy,
|
||||
// matching SetNodeTags. An admin key keeps the historical behaviour of
|
||||
// validating only tag syntax (db.validateACLTags).
|
||||
if isOAuth {
|
||||
for _, tag := range create.Tags {
|
||||
if !b.State.TagExists(tag) {
|
||||
return nil, huma.Error400BadRequest("tag " + tag + " is not defined in policy")
|
||||
}
|
||||
|
||||
if !b.State.TagOwnedByTags(tag, tokenTags) {
|
||||
return nil, huma.Error403Forbidden(
|
||||
"token may not assign tag " + tag,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case isOAuth:
|
||||
// OAuth-minted keys are tailnet/tag-owned; an untagged (user-owned) key
|
||||
// cannot be created from a token.
|
||||
return nil, huma.Error403Forbidden("an OAuth client must create tagged auth keys")
|
||||
|
||||
default:
|
||||
uid, ok := ownerUser(ctx)
|
||||
if !ok {
|
||||
return nil, huma.Error400BadRequest(
|
||||
"an auth key without tags must be created with a user-owned API key",
|
||||
)
|
||||
}
|
||||
|
||||
userID = &uid
|
||||
}
|
||||
|
||||
expiration := time.Now().Add(expiryDuration(body.ExpirySeconds))
|
||||
|
||||
pak, err := b.State.CreatePreAuthKey(
|
||||
userID,
|
||||
create.Reusable,
|
||||
create.Ephemeral,
|
||||
&expiration,
|
||||
create.Tags,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapError("creating auth key", err)
|
||||
}
|
||||
|
||||
if body.Description != "" {
|
||||
err := b.State.SetPreAuthKeyDescription(pak.ID, body.Description)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("setting auth key description", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &keyOutput{Body: keyFromNew(pak, body)}, nil
|
||||
}
|
||||
|
||||
// createOAuthClient creates an OAuth client (keyType:"client"). The client secret
|
||||
// is returned once, here.
|
||||
func createOAuthClient(ctx context.Context, b Backend, body CreateKeyRequest) (*keyOutput, error) {
|
||||
err := requireKeyScope(ctx, scope.OAuthKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(body.Scopes) == 0 {
|
||||
return nil, huma.Error400BadRequest("an OAuth client must declare at least one scope")
|
||||
}
|
||||
|
||||
// Tailscale: tags are mandatory when the scopes include devices:core or
|
||||
// auth_keys, because such a client mints tagged, tailnet-owned credentials.
|
||||
if scope.RequiresTags(scope.Parse(body.Scopes)) && len(body.Tags) == 0 {
|
||||
return nil, huma.Error400BadRequest(
|
||||
"tags are required when scopes include devices:core or auth_keys",
|
||||
)
|
||||
}
|
||||
|
||||
// A client created by an OAuth token may not be granted authority the token
|
||||
// lacks: its scopes must each be within the token's grant, and its tags within
|
||||
// the token's tags and defined in policy (matching SetNodeTags). Otherwise an
|
||||
// oauth_keys token could mint an all-access client and escalate. An admin API
|
||||
// key (not an OAuth token) is unrestricted and keeps the historical tag
|
||||
// behaviour (syntax-only validation).
|
||||
if tokenScopes, isOAuth := principalScopes(ctx); isOAuth {
|
||||
for _, s := range body.Scopes {
|
||||
if !scope.Grants(scope.Parse(tokenScopes), scope.Scope(s)) {
|
||||
return nil, huma.Error403Forbidden(
|
||||
"client may not be granted scope " + s + " beyond the creating token",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tokenTags, _ := principalTags(ctx)
|
||||
|
||||
for _, tag := range body.Tags {
|
||||
if !b.State.TagExists(tag) {
|
||||
return nil, huma.Error400BadRequest("tag " + tag + " is not defined in policy")
|
||||
}
|
||||
|
||||
if !b.State.TagOwnedByTags(tag, tokenTags) {
|
||||
return nil, huma.Error403Forbidden(
|
||||
"client may not be granted tag " + tag + " beyond the creating token",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var creator *uint
|
||||
|
||||
if uid, ok := ownerUser(ctx); ok {
|
||||
u := uint(uid)
|
||||
creator = &u
|
||||
}
|
||||
|
||||
secret, client, err := b.State.CreateOAuthClient(body.Scopes, body.Tags, body.Description, creator)
|
||||
if err != nil {
|
||||
return nil, mapError("creating oauth client", err)
|
||||
}
|
||||
|
||||
return &keyOutput{Body: oauthClientToKey(client, secret)}, nil
|
||||
}
|
||||
|
||||
// findKeyByID looks up a stored pre-auth key by its (stringified) id with a
|
||||
// direct by-id query; an unknown id surfaces as gorm.ErrRecordNotFound, which
|
||||
// mapError turns into a 404.
|
||||
@@ -283,7 +480,10 @@ func findKeyByID(b Backend, rawID string) (*types.PreAuthKey, error) {
|
||||
// match keyFromStored: Headscale always authorizes pre-auth-key nodes, so the
|
||||
// create and read paths must agree or the Terraform provider sees a diff.
|
||||
func keyFromNew(pak *types.PreAuthKeyNew, req CreateKeyRequest) Key {
|
||||
create := req.Capabilities.Devices.Create
|
||||
var create KeyDeviceCreateCapabilities
|
||||
if req.Capabilities != nil {
|
||||
create = req.Capabilities.Devices.Create
|
||||
}
|
||||
|
||||
key := Key{
|
||||
ID: pak.StringID(),
|
||||
@@ -344,6 +544,32 @@ func keyFromStored(pak *types.PreAuthKey) Key {
|
||||
return key
|
||||
}
|
||||
|
||||
// oauthClientToKey builds the keys response for an OAuth client. secret is the
|
||||
// plaintext client secret, set only on the create response and empty on get/list
|
||||
// (the secret is never re-exposed).
|
||||
func oauthClientToKey(client *types.OAuthClient, secret string) Key {
|
||||
key := Key{
|
||||
ID: client.ClientID,
|
||||
KeyType: keyTypeClient,
|
||||
Key: secret,
|
||||
Description: client.Description,
|
||||
Created: timeOrZero(client.CreatedAt),
|
||||
Scopes: emptyIfNil(client.Scopes),
|
||||
Tags: emptyIfNil(client.Tags),
|
||||
}
|
||||
|
||||
if client.Revoked != nil {
|
||||
key.Revoked = client.Revoked
|
||||
key.Invalid = true
|
||||
}
|
||||
|
||||
if client.UserID != nil {
|
||||
key.UserID = strconv.FormatUint(uint64(*client.UserID), util.Base10)
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
func capabilities(
|
||||
reusable, ephemeral, preauthorized bool,
|
||||
tags []string,
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package apiv2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/juanfont/headscale/hscontrol/scope"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
)
|
||||
|
||||
// accessTokenTTL is the fixed lifetime of a minted access token, matching
|
||||
// Tailscale's non-configurable one hour.
|
||||
const accessTokenTTL = time.Hour
|
||||
|
||||
// registerOAuthToken mounts POST /api/v2/oauth/token on the router. It is a plain
|
||||
// handler, not a Huma operation: it consumes application/x-www-form-urlencoded
|
||||
// and emits RFC 6749 OAuth2 error bodies ({"error","error_description"}), neither
|
||||
// of which fits Huma's JSON-in / Tailscale-error-out machinery.
|
||||
// ponytail: one bespoke OAuth endpoint isn't worth bending Huma around.
|
||||
func registerOAuthToken(router chi.Router, b Backend) {
|
||||
router.Post("/api/v2/oauth/token", oauthTokenHandler(b))
|
||||
}
|
||||
|
||||
// tokenResponse is the OAuth 2.0 client-credentials success body.
|
||||
type tokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
// oauthTokenHandler implements the client-credentials grant (RFC 6749 §4.4):
|
||||
// authenticate the client, optionally narrow the granted scopes/tags, and mint a
|
||||
// short-lived bearer token.
|
||||
func oauthTokenHandler(b Backend) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "could not parse request body")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// grant_type defaults to client_credentials: Tailscale's documented curl
|
||||
// omits it, and the x/oauth2 client always sends it.
|
||||
if gt := r.PostForm.Get("grant_type"); gt != "" && gt != "client_credentials" {
|
||||
writeOAuthError(w, http.StatusBadRequest, "unsupported_grant_type",
|
||||
"only the client_credentials grant is supported")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Credentials may arrive in the body or as HTTP Basic (the x/oauth2
|
||||
// auto-detect probes Basic first). The secret embeds the client id, so a
|
||||
// separate client_id is not required.
|
||||
secret := r.PostForm.Get("client_secret")
|
||||
if secret == "" {
|
||||
if _, pass, ok := r.BasicAuth(); ok {
|
||||
secret = pass
|
||||
}
|
||||
}
|
||||
|
||||
if secret == "" {
|
||||
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "missing client credentials")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
client, err := b.State.AuthenticateOAuthClient(secret)
|
||||
if err != nil {
|
||||
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "invalid client credentials")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Optional space-delimited scope/tags narrow the token to a subset of the
|
||||
// client's grant.
|
||||
scopes, badScope, ok := narrowScopes(client.Scopes, strings.Fields(r.PostForm.Get("scope")))
|
||||
if !ok {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_scope",
|
||||
"scope "+badScope+" is not granted to this client")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
tags, badTag, ok := narrowTags(client, strings.Fields(r.PostForm.Get("tags")))
|
||||
if !ok {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_target",
|
||||
"tag "+badTag+" is not granted to this client")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
expiry := time.Now().Add(accessTokenTTL)
|
||||
|
||||
tokenStr, _, err := b.State.MintAccessToken(client.ClientID, scopes, tags, &expiry)
|
||||
if err != nil {
|
||||
writeOAuthError(w, http.StatusInternalServerError, "server_error", "could not mint access token")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, tokenResponse{
|
||||
AccessToken: tokenStr,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(accessTokenTTL.Seconds()),
|
||||
Scope: strings.Join(scopes, " "),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// narrowScopes returns the requested scopes if each is granted by the client (an
|
||||
// empty request means "the client's full grant"), otherwise the offending scope
|
||||
// and false. A client holding a broad scope (e.g. "all") may mint a token limited
|
||||
// to a narrower one.
|
||||
func narrowScopes(granted, requested []string) ([]string, string, bool) {
|
||||
if len(requested) == 0 {
|
||||
return granted, "", true
|
||||
}
|
||||
|
||||
for _, req := range requested {
|
||||
if !scope.Grants(scope.Parse(granted), scope.Scope(req)) {
|
||||
return nil, req, false
|
||||
}
|
||||
}
|
||||
|
||||
return requested, "", true
|
||||
}
|
||||
|
||||
// narrowTags returns the requested tags if each is within the client's grant (an
|
||||
// empty request means "the client's full tag set"), otherwise the offending tag
|
||||
// and false. A client with the "all" scope may assign any tag, matching Tailscale.
|
||||
func narrowTags(client *types.OAuthClient, requested []string) ([]string, string, bool) {
|
||||
if len(requested) == 0 {
|
||||
return client.Tags, "", true
|
||||
}
|
||||
|
||||
allScope := slices.Contains(client.Scopes, string(scope.All))
|
||||
|
||||
for _, req := range requested {
|
||||
// Reject malformed tags at the trust boundary. A client's own tags are
|
||||
// validated at creation, so this only matters for an "all"-scope client,
|
||||
// whose tags would otherwise skip the membership check below and flow
|
||||
// unvalidated into auth-key creation.
|
||||
if !strings.HasPrefix(req, "tag:") {
|
||||
return nil, req, false
|
||||
}
|
||||
|
||||
if !allScope && !slices.Contains(client.Tags, req) {
|
||||
return nil, req, false
|
||||
}
|
||||
}
|
||||
|
||||
return requested, "", true
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// Token responses carry bearer credentials; RFC 6749 §5.1 forbids caching
|
||||
// them. writeJSON serves only the token endpoint, so set it unconditionally.
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v) //nolint:errchkjson // best-effort response write of a known-safe value
|
||||
}
|
||||
|
||||
// writeOAuthError emits an RFC 6749 §5.2 error body, which the x/oauth2 client
|
||||
// parses into its RetrieveError.
|
||||
func writeOAuthError(w http.ResponseWriter, status int, code, desc string) {
|
||||
writeJSON(w, status, map[string]string{
|
||||
"error": code,
|
||||
"error_description": desc,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package apiv2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestNarrowTagsRejectsMalformed asserts the token endpoint validates tag
|
||||
// format at the trust boundary. An "all"-scope client may assign any tag, but a
|
||||
// malformed tag (missing the "tag:" prefix) must still be rejected rather than
|
||||
// flowing unvalidated into auth-key creation.
|
||||
func TestNarrowTagsRejectsMalformed(t *testing.T) {
|
||||
all := &types.OAuthClient{Scopes: []string{"all"}}
|
||||
|
||||
_, bad, ok := narrowTags(all, []string{"not-a-tag"})
|
||||
assert.False(t, ok, "malformed tag must be rejected even with all scope")
|
||||
assert.Equal(t, "not-a-tag", bad)
|
||||
|
||||
got, _, ok := narrowTags(all, []string{"tag:anything"})
|
||||
assert.True(t, ok, "all scope may assign any well-formed tag")
|
||||
assert.Equal(t, []string{"tag:anything"}, got)
|
||||
|
||||
// A scoped client may only assign its own well-formed tags.
|
||||
scoped := &types.OAuthClient{Scopes: []string{"auth_keys"}, Tags: []string{"tag:ci"}}
|
||||
|
||||
_, bad, ok = narrowTags(scoped, []string{"tag:other"})
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, "tag:other", bad)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/scope"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
)
|
||||
|
||||
@@ -59,7 +60,7 @@ func registerSettings(api huma.API, b Backend) {
|
||||
Tags: settingsTags,
|
||||
Security: security,
|
||||
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
|
||||
}, ScopeFeatureSettingsRead), func(ctx context.Context, in *getSettingsInput) (*settingsOutput, error) {
|
||||
}, scope.FeatureSettingsRead), func(ctx context.Context, in *getSettingsInput) (*settingsOutput, error) {
|
||||
err := requireDefaultTailnet(in.Tailnet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -86,7 +87,7 @@ func registerSettings(api huma.API, b Backend) {
|
||||
// The body is accepted but ignored; skip validation.
|
||||
SkipValidateBody: true,
|
||||
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusNotImplemented},
|
||||
}, ScopeFeatureSettings), func(ctx context.Context, in *patchSettingsInput) (*settingsOutput, error) {
|
||||
}, scope.FeatureSettings), func(ctx context.Context, in *patchSettingsInput) (*settingsOutput, error) {
|
||||
err := requireDefaultTailnet(in.Tailnet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/scope"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
)
|
||||
|
||||
@@ -73,7 +74,7 @@ func registerUsers(api huma.API, b Backend) {
|
||||
Tags: usersTags,
|
||||
Security: security,
|
||||
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
|
||||
}, ScopeUsersRead), func(ctx context.Context, in *userByIDInput) (*userOutput, error) {
|
||||
}, scope.UsersRead), func(ctx context.Context, in *userByIDInput) (*userOutput, error) {
|
||||
view, err := lookupUser(b, in.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -90,7 +91,7 @@ func registerUsers(api huma.API, b Backend) {
|
||||
Tags: usersTags,
|
||||
Security: security,
|
||||
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
|
||||
}, ScopeUsersRead), func(ctx context.Context, in *listUsersInput) (*listUsersOutput, error) {
|
||||
}, scope.UsersRead), func(ctx context.Context, in *listUsersInput) (*listUsersOutput, error) {
|
||||
err := requireDefaultTailnet(in.Tailnet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -17,8 +17,8 @@ import (
|
||||
)
|
||||
|
||||
// taggedCaps builds capabilities for a tagged auth key.
|
||||
func taggedCaps(tags ...string) apiv2.KeyCapabilities {
|
||||
return apiv2.KeyCapabilities{
|
||||
func taggedCaps(tags ...string) *apiv2.KeyCapabilities {
|
||||
return &apiv2.KeyCapabilities{
|
||||
Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{
|
||||
Reusable: true,
|
||||
Preauthorized: true,
|
||||
@@ -138,7 +138,7 @@ func TestAPIv2Key_Create_Tagged(t *testing.T) {
|
||||
KeyType: "auth",
|
||||
Description: "dev access",
|
||||
ExpirySeconds: 86400,
|
||||
Capabilities: taggedCaps("tag:test"),
|
||||
Capabilities: *taggedCaps("tag:test"),
|
||||
Tags: []string{"tag:test"},
|
||||
}
|
||||
if diff := cmp.Diff(want, created, cmpopts.IgnoreFields(apiv2.Key{}, "ID", "Key", "Created", "Expires")); diff != "" {
|
||||
@@ -172,7 +172,7 @@ func TestAPIv2Key_Create_Permutations(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "single-use",
|
||||
req: apiv2.CreateKeyRequest{Capabilities: apiv2.KeyCapabilities{
|
||||
req: apiv2.CreateKeyRequest{Capabilities: &apiv2.KeyCapabilities{
|
||||
Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{Tags: []string{"tag:test"}}},
|
||||
}},
|
||||
wantSeconds: 7776000,
|
||||
@@ -185,7 +185,7 @@ func TestAPIv2Key_Create_Permutations(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "ephemeral",
|
||||
req: apiv2.CreateKeyRequest{Capabilities: apiv2.KeyCapabilities{
|
||||
req: apiv2.CreateKeyRequest{Capabilities: &apiv2.KeyCapabilities{
|
||||
Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{
|
||||
Ephemeral: true,
|
||||
Tags: []string{"tag:test"},
|
||||
@@ -246,7 +246,7 @@ func TestAPIv2Key_Get(t *testing.T) {
|
||||
KeyType: "auth",
|
||||
Description: "dev access",
|
||||
ExpirySeconds: 86400,
|
||||
Capabilities: taggedCaps("tag:test"),
|
||||
Capabilities: *taggedCaps("tag:test"),
|
||||
Tags: []string{"tag:test"},
|
||||
}
|
||||
if diff := cmp.Diff(want, got, cmpopts.IgnoreFields(apiv2.Key{}, "Created", "Expires")); diff != "" {
|
||||
@@ -313,7 +313,7 @@ func TestAPIv2Key_Create_NoTags_NoOwner_400(t *testing.T) {
|
||||
|
||||
before := keyCount(t, app)
|
||||
|
||||
resp := api.Post("/api/v2/tailnet/-/keys", apiv2.CreateKeyRequest{Capabilities: apiv2.KeyCapabilities{}})
|
||||
resp := api.Post("/api/v2/tailnet/-/keys", apiv2.CreateKeyRequest{Capabilities: &apiv2.KeyCapabilities{}})
|
||||
assert.Equal(t, http.StatusBadRequest, resp.Code)
|
||||
assert.Contains(t, resp.Body.String(), `"message"`)
|
||||
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
package hscontrol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
apiv2 "github.com/juanfont/headscale/hscontrol/api/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/scope"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// apiReq issues an arbitrary-method request with an optional bearer token and JSON
|
||||
// body, returning the status and raw body. It owns the response body.
|
||||
func apiReq(t *testing.T, method, target, bearer string, body any) (int, []byte) {
|
||||
t.Helper()
|
||||
|
||||
var r io.Reader
|
||||
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
r = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), method, target, r)
|
||||
require.NoError(t, err)
|
||||
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
|
||||
return resp.StatusCode, data
|
||||
}
|
||||
|
||||
// scopeDenied reports whether a response is a scope-enforcement denial. The scope
|
||||
// gate runs in the middleware before the handler, so a denial is exactly a 403
|
||||
// carrying this message; any other status (200, or a 400/404 from the handler on
|
||||
// minimal input) means the request passed the scope gate.
|
||||
func scopeDenied(status int, body []byte) bool {
|
||||
return status == http.StatusForbidden &&
|
||||
strings.Contains(string(body), "missing the required scope")
|
||||
}
|
||||
|
||||
// mintScopedToken creates an OAuth client holding exactly scope s (tagged tag:ci so
|
||||
// tag-requiring scopes are valid) and returns an access token for it.
|
||||
func mintScopedToken(t *testing.T, baseURL, admin string, s scope.Scope) string {
|
||||
t.Helper()
|
||||
|
||||
_, secret := createClient(t, baseURL, admin, []string{string(s)}, []string{"tag:ci"})
|
||||
|
||||
return tokenFor(t, baseURL, secret)
|
||||
}
|
||||
|
||||
// createAdminAuthKey creates a tagged auth key with the admin key and returns its id.
|
||||
func createAdminAuthKey(t *testing.T, baseURL, admin string) string {
|
||||
t.Helper()
|
||||
|
||||
status, body := apiReq(t, http.MethodPost, baseURL+"/api/v2/tailnet/-/keys", admin, apiv2.CreateKeyRequest{
|
||||
Capabilities: &apiv2.KeyCapabilities{Devices: apiv2.KeyDeviceCapabilities{
|
||||
Create: apiv2.KeyDeviceCreateCapabilities{Reusable: true, Tags: []string{"tag:ci"}},
|
||||
}},
|
||||
})
|
||||
require.Equalf(t, http.StatusOK, status, "create admin auth key: %s", body)
|
||||
|
||||
var key apiv2.Key
|
||||
require.NoError(t, json.Unmarshal(body, &key))
|
||||
|
||||
return key.ID
|
||||
}
|
||||
|
||||
type matrixOp struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
need scope.Scope
|
||||
body any
|
||||
// multiplexed marks the keyType-multiplexed keys ops. They self-enforce in
|
||||
// the handler and, to avoid an existence oracle, deny an unauthorized token
|
||||
// with a uniform not-found rather than the middleware's scope-403. So denial
|
||||
// is "resource not returned" (status != 200), not a specific 403.
|
||||
multiplexed bool
|
||||
}
|
||||
|
||||
// TestAPIv2OAuthMatrix_Enforcement is the exhaustive operation×scope cross-product:
|
||||
// for every scope-gated operation and every scope in the vocabulary, a token
|
||||
// holding exactly that scope is allowed iff scope.Grants permits it (P3) and denied
|
||||
// otherwise (P2).
|
||||
func TestAPIv2OAuthMatrix_Enforcement(t *testing.T) {
|
||||
app, baseURL, admin := newOAuthTestServer(t)
|
||||
|
||||
// Stable ids for the keyType-multiplexed get-by-id operations.
|
||||
clientID, _ := createClient(t, baseURL, admin, []string{"oauth_keys:read"}, nil)
|
||||
authKeyID := createAdminAuthKey(t, baseURL, admin)
|
||||
_ = app
|
||||
|
||||
ops := []matrixOp{
|
||||
{"getDevice", http.MethodGet, "/api/v2/device/1", scope.DevicesCoreRead, nil, false},
|
||||
{"listDevices", http.MethodGet, "/api/v2/tailnet/-/devices", scope.DevicesCoreRead, nil, false},
|
||||
{"deleteDevice", http.MethodDelete, "/api/v2/device/1", scope.DevicesCore, nil, false},
|
||||
{"authorizeDevice", http.MethodPost, "/api/v2/device/1/authorized", scope.DevicesCore, map[string]any{}, false},
|
||||
{"setDeviceName", http.MethodPost, "/api/v2/device/1/name", scope.DevicesCore, map[string]any{}, false},
|
||||
{"setDeviceTags", http.MethodPost, "/api/v2/device/1/tags", scope.DevicesCore, map[string]any{}, false},
|
||||
{"setDeviceKey", http.MethodPost, "/api/v2/device/1/key", scope.DevicesCore, map[string]any{}, false},
|
||||
{"setDeviceRoutes", http.MethodPost, "/api/v2/device/1/routes", scope.DevicesRoutes, map[string]any{}, false},
|
||||
{"getDeviceRoutes", http.MethodGet, "/api/v2/device/1/routes", scope.DevicesRoutesRead, nil, false},
|
||||
{"getACL", http.MethodGet, "/api/v2/tailnet/-/acl", scope.PolicyFileRead, nil, false},
|
||||
{"setACL", http.MethodPost, "/api/v2/tailnet/-/acl", scope.PolicyFile, map[string]any{}, false},
|
||||
{"getSettings", http.MethodGet, "/api/v2/tailnet/-/settings", scope.FeatureSettingsRead, nil, false},
|
||||
{"updateSettings", http.MethodPatch, "/api/v2/tailnet/-/settings", scope.FeatureSettings, map[string]any{}, false},
|
||||
{"getKeyClient", http.MethodGet, "/api/v2/tailnet/-/keys/" + clientID, scope.OAuthKeysRead, nil, true},
|
||||
{"getKeyAuth", http.MethodGet, "/api/v2/tailnet/-/keys/" + authKeyID, scope.AuthKeysRead, nil, true},
|
||||
}
|
||||
|
||||
// One token per scope, reused across every operation.
|
||||
tokens := make(map[scope.Scope]string, len(scope.Known()))
|
||||
for _, s := range scope.Known() {
|
||||
tokens[s] = mintScopedToken(t, baseURL, admin, s)
|
||||
}
|
||||
|
||||
for _, op := range ops {
|
||||
for _, s := range scope.Known() {
|
||||
status, body := apiReq(t, op.method, baseURL+op.path, tokens[s], op.body)
|
||||
|
||||
wantDenied := !scope.Grants([]scope.Scope{s}, op.need)
|
||||
|
||||
// A multiplexed keys op denies via uniform not-found (no existence
|
||||
// oracle), so denial is "resource not returned"; every other op
|
||||
// denies with the middleware's scope-403.
|
||||
denied := scopeDenied(status, body)
|
||||
if op.multiplexed {
|
||||
denied = status != http.StatusOK
|
||||
}
|
||||
|
||||
assert.Equalf(t, wantDenied, denied,
|
||||
"op %s with scope %q (needs %q): status=%d body=%s",
|
||||
op.name, s, op.need, status, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIv2OAuthMatrix_ScopeNarrowing proves P1 for token minting: a client may
|
||||
// only mint a token for scopes within its own grant. For every held scope X and
|
||||
// requested scope Y, the mint succeeds iff scope.Grants([X], Y).
|
||||
func TestAPIv2OAuthMatrix_ScopeNarrowing(t *testing.T) {
|
||||
_, baseURL, admin := newOAuthTestServer(t)
|
||||
|
||||
for _, held := range scope.Known() {
|
||||
_, secret := createClient(t, baseURL, admin, []string{string(held)}, []string{"tag:ci"})
|
||||
|
||||
for _, want := range scope.Known() {
|
||||
status, m := mintToken(t, baseURL, "", secret, string(want), false)
|
||||
|
||||
wantOK := scope.Grants([]scope.Scope{held}, want)
|
||||
if wantOK {
|
||||
assert.Equalf(t, http.StatusOK, status, "held %q narrow to %q: %v", held, want, m)
|
||||
} else {
|
||||
assert.Equalf(t, http.StatusBadRequest, status,
|
||||
"held %q must not mint %q: %v", held, want, m)
|
||||
assert.Equal(t, "invalid_scope", m["error"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIv2OAuthMatrix_TagNarrowing proves P1 for tags at mint time: a client may
|
||||
// only mint a token for tags within its grant (closing the /oauth/token tags-param
|
||||
// path). A client with the "all" scope may request any tag.
|
||||
func TestAPIv2OAuthMatrix_TagNarrowing(t *testing.T) {
|
||||
_, baseURL, admin := newOAuthTestServer(t)
|
||||
|
||||
_, secret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:a", "tag:b"})
|
||||
|
||||
// In-grant tags mint; an out-of-grant tag is rejected.
|
||||
status, m := mintToken(t, baseURL, "", secret, "", false)
|
||||
require.Equalf(t, http.StatusOK, status, "%v", m)
|
||||
|
||||
for _, tag := range []string{"tag:a", "tag:b"} {
|
||||
st, mm := mintTokenWithTags(t, baseURL, secret, tag)
|
||||
assert.Equalf(t, http.StatusOK, st, "in-grant tag %q: %v", tag, mm)
|
||||
}
|
||||
|
||||
st, mm := mintTokenWithTags(t, baseURL, secret, "tag:c")
|
||||
assert.Equal(t, http.StatusBadRequest, st, mm)
|
||||
assert.Equal(t, "invalid_target", mm["error"])
|
||||
|
||||
// An all-scope client may request any tag.
|
||||
_, allSecret := createClient(t, baseURL, admin, []string{"all"}, []string{"tag:a"})
|
||||
stAll, mAll := mintTokenWithTags(t, baseURL, allSecret, "tag:anything")
|
||||
assert.Equalf(t, http.StatusOK, stAll, "all-scope client may assign any tag: %v", mAll)
|
||||
}
|
||||
|
||||
// TestAPIv2OAuthMatrix_Lifecycle proves expired and revoked credentials are denied
|
||||
// at the HTTP layer, and that an admin API key bypasses scope checks.
|
||||
func TestAPIv2OAuthMatrix_Lifecycle(t *testing.T) {
|
||||
app, baseURL, admin := newOAuthTestServer(t)
|
||||
|
||||
devicesPath := baseURL + "/api/v2/tailnet/-/devices"
|
||||
|
||||
// Expired token → 401.
|
||||
_, client, err := app.state.CreateOAuthClient([]string{"devices:core:read"}, []string{"tag:ci"}, "expired", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
past := time.Now().Add(-time.Hour)
|
||||
expiredTok, _, err := app.state.MintAccessToken(client.ClientID, []string{"devices:core:read"}, nil, &past)
|
||||
require.NoError(t, err)
|
||||
|
||||
status, _ := apiReq(t, http.MethodGet, devicesPath, expiredTok, nil)
|
||||
assert.Equal(t, http.StatusUnauthorized, status, "expired token must be rejected")
|
||||
|
||||
// Revoked client → its token is denied.
|
||||
_, revClient, err := app.state.CreateOAuthClient([]string{"devices:core:read"}, []string{"tag:ci"}, "revoked", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
future := time.Now().Add(time.Hour)
|
||||
revTok, _, err := app.state.MintAccessToken(revClient.ClientID, []string{"devices:core:read"}, nil, &future)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Works before revocation.
|
||||
okStatus, okBody := apiReq(t, http.MethodGet, devicesPath, revTok, nil)
|
||||
assert.Falsef(t, scopeDenied(okStatus, okBody), "token should pass the scope gate before revoke: %d", okStatus)
|
||||
|
||||
require.NoError(t, app.state.RevokeOAuthClient(revClient.ClientID))
|
||||
|
||||
revStatus, _ := apiReq(t, http.MethodGet, devicesPath, revTok, nil)
|
||||
assert.Equal(t, http.StatusUnauthorized, revStatus, "token of a revoked client must be rejected")
|
||||
|
||||
// Admin API key bypasses scope checks: it reaches a devices op that a minimal
|
||||
// (feature_settings:read) token cannot.
|
||||
minimalTok := mintScopedToken(t, baseURL, admin, scope.FeatureSettingsRead)
|
||||
minStatus, minBody := apiReq(t, http.MethodGet, devicesPath, minimalTok, nil)
|
||||
assert.True(t, scopeDenied(minStatus, minBody), "minimal token must be scope-denied on devices")
|
||||
|
||||
adminStatus, adminBody := apiReq(t, http.MethodGet, devicesPath, admin, nil)
|
||||
assert.Falsef(t, scopeDenied(adminStatus, adminBody),
|
||||
"admin key is all-access and must not be scope-denied: %d %s", adminStatus, adminBody)
|
||||
}
|
||||
|
||||
// mintTokenWithTags mints with a tags narrowing parameter.
|
||||
func mintTokenWithTags(t *testing.T, baseURL, secret, tags string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
|
||||
form := fmt.Sprintf("grant_type=client_credentials&client_secret=%s&tags=%s", secret, tags)
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
|
||||
baseURL+"/api/v2/oauth/token", strings.NewReader(form))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var m map[string]any
|
||||
|
||||
_ = json.Unmarshal(data, &m)
|
||||
|
||||
return resp.StatusCode, m
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
package hscontrol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apiv2 "github.com/juanfont/headscale/hscontrol/api/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newOAuthTestServer builds the full v2 API (the auth+scope middleware and the
|
||||
// OAuth token endpoint, which the humatest harness in apiv2_keys_test.go does
|
||||
// not mount) over a real httptest server, returning the app, its base URL, and
|
||||
// an all-access admin API key.
|
||||
func newOAuthTestServer(t *testing.T) (*Headscale, string, string) {
|
||||
t.Helper()
|
||||
|
||||
app := createTestApp(t)
|
||||
|
||||
// Tag creation now requires the tag to exist in policy (matching
|
||||
// SetNodeTags), so define the tags these tests assign. Tests needing
|
||||
// specific tag ownership (e.g. delegation) override this policy.
|
||||
const policy = `{"tagOwners":{"tag:a":[],"tag:b":[],"tag:c":[],"tag:ci":[],"tag:k8s":[],"tag:k8s-operator":[],"tag:other":[],"tag:anything":[]},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`
|
||||
|
||||
_, err := app.state.SetPolicy([]byte(policy))
|
||||
require.NoError(t, err)
|
||||
_, err = app.state.SetPolicyInDB(policy)
|
||||
require.NoError(t, err)
|
||||
_, err = app.state.ReloadPolicy()
|
||||
require.NoError(t, err)
|
||||
|
||||
mux, _ := apiv2.Handler(apiv2.Backend{State: app.state, Change: app.Change, Cfg: app.cfg})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
adminKey, _, err := app.state.CreateAPIKey(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
return app, srv.URL, adminKey
|
||||
}
|
||||
|
||||
// apiPost POSTs a JSON body with an optional bearer token, returning the status
|
||||
// and raw body. It owns the response body so callers never leak it.
|
||||
func apiPost(t *testing.T, target, bearer string, body any) (int, []byte) {
|
||||
t.Helper()
|
||||
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, target, bytes.NewReader(b))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
|
||||
return resp.StatusCode, data
|
||||
}
|
||||
|
||||
// apiGet GETs a target with an optional bearer token, returning the status. It
|
||||
// drains and closes the response body so callers never leak it.
|
||||
func apiGet(t *testing.T, target, bearer string) int {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
// createClient creates an OAuth client through the v2 keys API as the bearer
|
||||
// credential, returning the client id and the once-shown secret.
|
||||
func createClient(t *testing.T, baseURL, bearer string, scopes, tags []string) (string, string) {
|
||||
t.Helper()
|
||||
|
||||
status, body := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", bearer, apiv2.CreateKeyRequest{
|
||||
KeyType: "client",
|
||||
Scopes: scopes,
|
||||
Tags: tags,
|
||||
})
|
||||
require.Equalf(t, http.StatusOK, status, "create client: %s", body)
|
||||
|
||||
var key apiv2.Key
|
||||
require.NoError(t, json.Unmarshal(body, &key))
|
||||
assert.Equal(t, "client", key.KeyType)
|
||||
require.NotEmpty(t, key.ID, "client id returned")
|
||||
require.NotEmpty(t, key.Key, "secret returned once on create")
|
||||
|
||||
return key.ID, key.Key
|
||||
}
|
||||
|
||||
// mintToken runs the client-credentials grant. scope is an optional space-delimited
|
||||
// narrowing of the client's scopes. credsInBasic sends the secret via HTTP Basic
|
||||
// instead of the body. Returns the status and decoded JSON body.
|
||||
func mintToken(t *testing.T, baseURL, clientID, secret, scope string, credsInBasic bool) (int, map[string]any) {
|
||||
t.Helper()
|
||||
|
||||
form := url.Values{"grant_type": {"client_credentials"}}
|
||||
if scope != "" {
|
||||
form.Set("scope", scope)
|
||||
}
|
||||
|
||||
if !credsInBasic {
|
||||
form.Set("client_secret", secret)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
|
||||
baseURL+"/api/v2/oauth/token", strings.NewReader(form.Encode()))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
if credsInBasic {
|
||||
req.SetBasicAuth(clientID, secret)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var m map[string]any
|
||||
|
||||
_ = json.Unmarshal(data, &m)
|
||||
|
||||
return resp.StatusCode, m
|
||||
}
|
||||
|
||||
// tokenFor mints and returns a bearer access token for the client.
|
||||
func tokenFor(t *testing.T, baseURL, secret string) string {
|
||||
t.Helper()
|
||||
|
||||
status, m := mintToken(t, baseURL, "", secret, "", false)
|
||||
require.Equalf(t, http.StatusOK, status, "mint token: %v", m)
|
||||
|
||||
tok, _ := m["access_token"].(string)
|
||||
require.NotEmpty(t, tok)
|
||||
|
||||
return tok
|
||||
}
|
||||
|
||||
// createTaggedKey attempts to create a tagged auth key and returns the HTTP
|
||||
// status, so allow/deny is asserted by the caller.
|
||||
func createTaggedKey(t *testing.T, baseURL, bearer string, tags []string) int {
|
||||
t.Helper()
|
||||
|
||||
status, _ := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", bearer, apiv2.CreateKeyRequest{
|
||||
Capabilities: &apiv2.KeyCapabilities{Devices: apiv2.KeyDeviceCapabilities{
|
||||
Create: apiv2.KeyDeviceCreateCapabilities{Reusable: true, Tags: tags},
|
||||
}},
|
||||
})
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
func TestAPIv2OAuth_TokenEndpoint(t *testing.T) {
|
||||
_, baseURL, admin := newOAuthTestServer(t)
|
||||
|
||||
clientID, secret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:ci"})
|
||||
|
||||
// The secret embeds the client id (Tailscale's derive-from-secret trick).
|
||||
assert.Contains(t, secret, clientID)
|
||||
|
||||
// Mint via the request body.
|
||||
status, m := mintToken(t, baseURL, clientID, secret, "", false)
|
||||
require.Equalf(t, http.StatusOK, status, "%v", m)
|
||||
assert.Equal(t, "Bearer", m["token_type"])
|
||||
assert.InDelta(t, 3600, m["expires_in"], 0, "fixed 1h, in seconds")
|
||||
tok, _ := m["access_token"].(string)
|
||||
assert.Contains(t, tok, "hskey-oauthtok-", "distinct prefix from admin keys")
|
||||
|
||||
// Mint via HTTP Basic (x/oauth2 auto-detect probes Basic first).
|
||||
statusBasic, mBasic := mintToken(t, baseURL, clientID, secret, "", true)
|
||||
require.Equalf(t, http.StatusOK, statusBasic, "%v", mBasic)
|
||||
assert.NotEmpty(t, mBasic["access_token"])
|
||||
|
||||
// Bad credentials → RFC 6749 invalid_client.
|
||||
statusBad, mBad := mintToken(t, baseURL, clientID, "hskey-client-deadbeefdead-"+stringOf("0", 64), "", false)
|
||||
assert.Equal(t, http.StatusUnauthorized, statusBad)
|
||||
assert.Equal(t, "invalid_client", mBad["error"])
|
||||
|
||||
// The token response carries a bearer credential and must not be cached
|
||||
// (RFC 6749 §5.1). mintToken consumes the body, so probe the header raw.
|
||||
form := url.Values{"grant_type": {"client_credentials"}, "client_secret": {secret}}
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
|
||||
baseURL+"/api/v2/oauth/token", strings.NewReader(form.Encode()))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
assert.Equal(t, "no-store", resp.Header.Get("Cache-Control"))
|
||||
}
|
||||
|
||||
func TestAPIv2OAuth_ScopeEnforcement(t *testing.T) {
|
||||
_, baseURL, admin := newOAuthTestServer(t)
|
||||
|
||||
// A write-scoped token may create an auth key within its tag grant...
|
||||
_, writeSecret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:ci"})
|
||||
writeTok := tokenFor(t, baseURL, writeSecret)
|
||||
assert.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, writeTok, []string{"tag:ci"}),
|
||||
"write scope + in-grant tag allowed")
|
||||
|
||||
// ...but not with a tag outside its grant.
|
||||
assert.Equal(t, http.StatusForbidden, createTaggedKey(t, baseURL, writeTok, []string{"tag:other"}),
|
||||
"tag outside grant denied")
|
||||
|
||||
// A read-only token is denied the write.
|
||||
_, readSecret := createClient(t, baseURL, admin, []string{"auth_keys:read"}, []string{"tag:ci"})
|
||||
readTok := tokenFor(t, baseURL, readSecret)
|
||||
assert.Equal(t, http.StatusForbidden, createTaggedKey(t, baseURL, readTok, []string{"tag:ci"}),
|
||||
"read scope denied write")
|
||||
|
||||
// The wrong write scope (devices:core) cannot create auth keys.
|
||||
_, devSecret := createClient(t, baseURL, admin, []string{"devices:core"}, []string{"tag:ci"})
|
||||
devTok := tokenFor(t, baseURL, devSecret)
|
||||
assert.Equal(t, http.StatusForbidden, createTaggedKey(t, baseURL, devTok, []string{"tag:ci"}),
|
||||
"wrong scope denied")
|
||||
|
||||
// The "all" super-scope grants auth_keys.
|
||||
_, allSecret := createClient(t, baseURL, admin, []string{"all"}, []string{"tag:ci"})
|
||||
allTok := tokenFor(t, baseURL, allSecret)
|
||||
assert.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, allTok, []string{"tag:ci"}),
|
||||
"all grants auth_keys")
|
||||
|
||||
// A token minted from the all-powerful client but narrowed to read-only is
|
||||
// itself denied writes: narrowing is enforced on the minted token.
|
||||
statusNarrow, mNarrow := mintToken(t, baseURL, "", allSecret, "auth_keys:read", false)
|
||||
require.Equalf(t, http.StatusOK, statusNarrow, "narrow mint: %v", mNarrow)
|
||||
narrowed, _ := mNarrow["access_token"].(string)
|
||||
assert.Equal(t, http.StatusForbidden, createTaggedKey(t, baseURL, narrowed, []string{"tag:ci"}),
|
||||
"token narrowed to :read denied write")
|
||||
}
|
||||
|
||||
func TestAPIv2OAuth_ClientManagementScopes(t *testing.T) {
|
||||
_, baseURL, admin := newOAuthTestServer(t)
|
||||
|
||||
// An oauth_keys token may create a client within its own grant...
|
||||
_, okSecret := createClient(t, baseURL, admin, []string{"oauth_keys"}, []string{"tag:ci"})
|
||||
okTok := tokenFor(t, baseURL, okSecret)
|
||||
status, body := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", okTok, apiv2.CreateKeyRequest{
|
||||
KeyType: "client", Scopes: []string{"oauth_keys:read"},
|
||||
})
|
||||
assert.Equalf(t, http.StatusOK, status, "oauth_keys creates an in-grant client: %s", body)
|
||||
|
||||
// ...but may NOT escalate by granting the new client a scope the token lacks.
|
||||
statusEsc, _ := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", okTok, apiv2.CreateKeyRequest{
|
||||
KeyType: "client", Scopes: []string{"auth_keys"}, Tags: []string{"tag:ci"},
|
||||
})
|
||||
assert.Equal(t, http.StatusForbidden, statusEsc, "oauth_keys token cannot mint a broader client")
|
||||
|
||||
// A token without oauth_keys cannot manage clients at all.
|
||||
_, akSecret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:ci"})
|
||||
akTok := tokenFor(t, baseURL, akSecret)
|
||||
statusDeny, _ := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", akTok, apiv2.CreateKeyRequest{
|
||||
KeyType: "client", Scopes: []string{"auth_keys"}, Tags: []string{"tag:ci"},
|
||||
})
|
||||
assert.Equal(t, http.StatusForbidden, statusDeny, "auth_keys (no oauth_keys) cannot create a client")
|
||||
}
|
||||
|
||||
func TestAPIv2OAuth_TagOwnedBy(t *testing.T) {
|
||||
app, baseURL, admin := newOAuthTestServer(t)
|
||||
|
||||
// tag:k8s is owned by tag:k8s-operator: the operator's tag delegation.
|
||||
// tag:other exists but is owned by no one, so it tests grant denial (403)
|
||||
// rather than tag-not-in-policy (400).
|
||||
const policy = `{"tagOwners":{"tag:k8s-operator":[],"tag:k8s":["tag:k8s-operator"],"tag:other":[]},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`
|
||||
|
||||
_, err := app.state.SetPolicy([]byte(policy))
|
||||
require.NoError(t, err)
|
||||
_, err = app.state.SetPolicyInDB(policy)
|
||||
require.NoError(t, err)
|
||||
_, err = app.state.ReloadPolicy()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, secret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:k8s-operator"})
|
||||
tok := tokenFor(t, baseURL, secret)
|
||||
|
||||
// Exact-match tag: allowed.
|
||||
assert.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, tok, []string{"tag:k8s-operator"}),
|
||||
"a token may use its own tag")
|
||||
|
||||
// Owned-by tag: allowed (tag:k8s is owned by tag:k8s-operator).
|
||||
assert.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, tok, []string{"tag:k8s"}),
|
||||
"a token may use a tag owned by its tag")
|
||||
|
||||
// Unrelated tag: denied.
|
||||
assert.Equal(t, http.StatusForbidden, createTaggedKey(t, baseURL, tok, []string{"tag:other"}),
|
||||
"a token may not use an unowned tag")
|
||||
}
|
||||
|
||||
// TestAPIv2OAuth_NoClientExistenceOracle asserts a token that cannot read OAuth
|
||||
// clients gets the same response for a real client id as for a missing key, so
|
||||
// it cannot enumerate which ids are OAuth clients.
|
||||
func TestAPIv2OAuth_NoClientExistenceOracle(t *testing.T) {
|
||||
_, baseURL, admin := newOAuthTestServer(t)
|
||||
|
||||
clientID, _ := createClient(t, baseURL, admin, []string{"oauth_keys"}, []string{"tag:ci"})
|
||||
|
||||
// A token holding only auth_keys:read (no oauth_keys:read).
|
||||
_, akSecret := createClient(t, baseURL, admin, []string{"auth_keys:read"}, []string{"tag:ci"})
|
||||
akTok := tokenFor(t, baseURL, akSecret)
|
||||
|
||||
statusReal := apiGet(t, baseURL+"/api/v2/tailnet/-/keys/"+clientID, akTok)
|
||||
statusMissing := apiGet(t, baseURL+"/api/v2/tailnet/-/keys/deadbeefdead", akTok)
|
||||
|
||||
assert.Equal(t, statusMissing, statusReal,
|
||||
"a token without oauth_keys:read must not distinguish a real client id from a missing key")
|
||||
assert.NotEqual(t, http.StatusOK, statusReal, "the client must not be readable")
|
||||
|
||||
// A token that does hold oauth_keys:read can read the client.
|
||||
_, okSecret := createClient(t, baseURL, admin, []string{"oauth_keys:read"}, []string{"tag:ci"})
|
||||
okTok := tokenFor(t, baseURL, okSecret)
|
||||
|
||||
statusOK := apiGet(t, baseURL+"/api/v2/tailnet/-/keys/"+clientID, okTok)
|
||||
assert.Equal(t, http.StatusOK, statusOK, "oauth_keys:read may read the client")
|
||||
}
|
||||
|
||||
// TestAPIv2OAuth_SetDeviceTagsGrant asserts a devices:core token may only set
|
||||
// tags within its grant on a device. Without the grant check, the scope alone
|
||||
// would let a token stamp any existing policy tag (e.g. tag:other) onto any node.
|
||||
func TestAPIv2OAuth_SetDeviceTagsGrant(t *testing.T) {
|
||||
app, baseURL, admin := newOAuthTestServer(t)
|
||||
|
||||
// A registered, user-owned node to retag.
|
||||
user := app.state.CreateUserForTest("dut")
|
||||
node := app.state.CreateRegisteredNodeForTest(user, "dut")
|
||||
node.User = user
|
||||
view := app.state.PutNodeInStoreForTest(*node)
|
||||
devURL := baseURL + "/api/v2/device/" + view.StringID() + "/tags"
|
||||
|
||||
// A devices:core token granted only tag:ci.
|
||||
_, secret := createClient(t, baseURL, admin, []string{"devices:core"}, []string{"tag:ci"})
|
||||
tok := tokenFor(t, baseURL, secret)
|
||||
|
||||
// In-grant tag is allowed.
|
||||
st, body := apiPost(t, devURL, tok, map[string]any{"tags": []string{"tag:ci"}})
|
||||
assert.Equalf(t, http.StatusOK, st, "in-grant tag: %s", body)
|
||||
|
||||
// An existing policy tag outside the token's grant is denied, not silently set.
|
||||
st, _ = apiPost(t, devURL, tok, map[string]any{"tags": []string{"tag:other"}})
|
||||
assert.Equal(t, http.StatusForbidden, st, "out-of-grant tag must be denied")
|
||||
|
||||
// An admin API key is unrestricted.
|
||||
st, body = apiPost(t, devURL, admin, map[string]any{"tags": []string{"tag:other"}})
|
||||
assert.Equalf(t, http.StatusOK, st, "admin may set any policy tag: %s", body)
|
||||
}
|
||||
|
||||
// TestAPIv2OAuth_UndefinedTagRejected asserts an OAuth token cannot create a
|
||||
// client or auth key carrying a tag absent from policy (matching SetNodeTags),
|
||||
// while an admin key retains the historical syntax-only validation.
|
||||
func TestAPIv2OAuth_UndefinedTagRejected(t *testing.T) {
|
||||
_, baseURL, admin := newOAuthTestServer(t)
|
||||
|
||||
// A token that may create clients and auth keys, holding tag:ci (in policy).
|
||||
_, secret := createClient(t, baseURL, admin, []string{"oauth_keys", "auth_keys"}, []string{"tag:ci"})
|
||||
tok := tokenFor(t, baseURL, secret)
|
||||
|
||||
// A token-created auth key with a tag not in policy is rejected.
|
||||
assert.Equal(t, http.StatusBadRequest, createTaggedKey(t, baseURL, tok, []string{"tag:undefined"}),
|
||||
"token may not create an auth key with an undefined tag")
|
||||
|
||||
// A token-created client with a tag not in policy is rejected.
|
||||
st, _ := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", tok, apiv2.CreateKeyRequest{
|
||||
KeyType: "client", Scopes: []string{"auth_keys"}, Tags: []string{"tag:undefined"},
|
||||
})
|
||||
assert.Equal(t, http.StatusBadRequest, st, "token may not create a client with an undefined tag")
|
||||
|
||||
// An admin key keeps the historical behaviour: a syntactically valid but
|
||||
// undefined tag is accepted (consistent with the v1/CLI pre-auth-key path).
|
||||
assert.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, admin, []string{"tag:adminhistorical"}),
|
||||
"admin retains syntax-only tag validation")
|
||||
}
|
||||
|
||||
// TestAPIv2OAuth_DenialBranches covers the token-endpoint and bearer-dispatch
|
||||
// failure paths: each must fail closed with the right status and no session.
|
||||
func TestAPIv2OAuth_DenialBranches(t *testing.T) {
|
||||
_, baseURL, _ := newOAuthTestServer(t)
|
||||
tokenURL := baseURL + "/api/v2/oauth/token"
|
||||
keysURL := baseURL + "/api/v2/tailnet/-/keys"
|
||||
|
||||
post := func(form string) (int, map[string]any) {
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, tokenURL, strings.NewReader(form))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var m map[string]any
|
||||
|
||||
_ = json.Unmarshal(data, &m)
|
||||
|
||||
return resp.StatusCode, m
|
||||
}
|
||||
|
||||
// No credentials → invalid_client.
|
||||
st, m := post("grant_type=client_credentials")
|
||||
assert.Equal(t, http.StatusUnauthorized, st)
|
||||
assert.Equal(t, "invalid_client", m["error"])
|
||||
|
||||
// Wrong grant_type → unsupported_grant_type.
|
||||
st, m = post("grant_type=authorization_code&client_secret=x")
|
||||
assert.Equal(t, http.StatusBadRequest, st)
|
||||
assert.Equal(t, "unsupported_grant_type", m["error"])
|
||||
|
||||
// Unknown secret → invalid_client.
|
||||
st, m = post("grant_type=client_credentials&client_secret=not-a-real-secret")
|
||||
assert.Equal(t, http.StatusUnauthorized, st)
|
||||
assert.Equal(t, "invalid_client", m["error"])
|
||||
|
||||
// Bearer dispatch: every malformed/unknown bearer is unauthorized.
|
||||
for _, bearer := range []string{
|
||||
"hskey-oauthtok-deadbeefdead-" + stringOf("0", 64), // well-formed prefix, unknown token
|
||||
"hskey-oauthtok-garbage", // malformed OAuth token
|
||||
"hskey-client-deadbeefdead-" + stringOf("0", 64), // client secret presented as API bearer
|
||||
"totally-bogus",
|
||||
} {
|
||||
assert.Equalf(t, http.StatusUnauthorized, apiGet(t, keysURL, bearer),
|
||||
"bearer %q must be unauthorized", bearer)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIv2OAuth_KeysMultiplexIsolation asserts the multiplexed keys endpoint
|
||||
// keeps the two kinds isolated by scope: a token cannot list or delete a kind it
|
||||
// lacks the scope for, and an admin key sees both.
|
||||
func TestAPIv2OAuth_KeysMultiplexIsolation(t *testing.T) {
|
||||
_, baseURL, admin := newOAuthTestServer(t)
|
||||
keysURL := baseURL + "/api/v2/tailnet/-/keys"
|
||||
|
||||
clientID, _ := createClient(t, baseURL, admin, []string{"oauth_keys"}, []string{"tag:ci"})
|
||||
require.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, admin, []string{"tag:ci"}))
|
||||
|
||||
listKinds := func(bearer string) map[string]int {
|
||||
st, body := apiReq(t, http.MethodGet, keysURL, bearer, nil)
|
||||
require.Equalf(t, http.StatusOK, st, "%s", body)
|
||||
|
||||
var out struct {
|
||||
Keys []apiv2.Key `json:"keys"`
|
||||
}
|
||||
|
||||
require.NoError(t, json.Unmarshal(body, &out))
|
||||
|
||||
kinds := map[string]int{}
|
||||
for _, k := range out.Keys {
|
||||
kinds[k.KeyType]++
|
||||
}
|
||||
|
||||
return kinds
|
||||
}
|
||||
|
||||
_, akReadSecret := createClient(t, baseURL, admin, []string{"auth_keys:read"}, []string{"tag:ci"})
|
||||
akKinds := listKinds(tokenFor(t, baseURL, akReadSecret))
|
||||
assert.Zero(t, akKinds["client"], "auth_keys:read must not see OAuth clients")
|
||||
assert.Positive(t, akKinds["auth"], "auth_keys:read sees auth keys")
|
||||
|
||||
_, okReadSecret := createClient(t, baseURL, admin, []string{"oauth_keys:read"}, []string{"tag:ci"})
|
||||
okKinds := listKinds(tokenFor(t, baseURL, okReadSecret))
|
||||
assert.Zero(t, okKinds["auth"], "oauth_keys:read must not see auth keys")
|
||||
assert.Positive(t, okKinds["client"], "oauth_keys:read sees OAuth clients")
|
||||
|
||||
adminKinds := listKinds(admin)
|
||||
assert.Positive(t, adminKinds["auth"])
|
||||
assert.Positive(t, adminKinds["client"])
|
||||
|
||||
// An auth_keys (write) token cannot delete an OAuth client; it survives.
|
||||
_, akWriteSecret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:ci"})
|
||||
stDel, _ := apiReq(t, http.MethodDelete, keysURL+"/"+clientID, tokenFor(t, baseURL, akWriteSecret), nil)
|
||||
assert.NotEqual(t, http.StatusOK, stDel, "auth_keys token must not delete an OAuth client")
|
||||
assert.Positive(t, listKinds(admin)["client"], "client survives the denied delete")
|
||||
|
||||
// An oauth_keys (write) token deletes the client.
|
||||
_, okWriteSecret := createClient(t, baseURL, admin, []string{"oauth_keys"}, []string{"tag:ci"})
|
||||
stDel2, body2 := apiReq(t, http.MethodDelete, keysURL+"/"+clientID, tokenFor(t, baseURL, okWriteSecret), nil)
|
||||
assert.Equalf(t, http.StatusOK, stDel2, "oauth_keys deletes the client: %s", body2)
|
||||
}
|
||||
|
||||
func stringOf(s string, n int) string {
|
||||
return strings.Repeat(s, n)
|
||||
}
|
||||
+31
-7
@@ -319,6 +319,11 @@ func (h *Headscale) scheduledTasks(ctx context.Context) {
|
||||
revokedKeyGCChan = revokedKeyTicker.C
|
||||
}
|
||||
|
||||
// OAuth access tokens are short-lived (1h) and re-minted on demand; reap
|
||||
// expired rows hourly so the table stays bounded.
|
||||
accessTokenTicker := time.NewTicker(time.Hour)
|
||||
defer accessTokenTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -335,6 +340,14 @@ func (h *Headscale) scheduledTasks(ctx context.Context) {
|
||||
log.Info().Int("count", reaped).Msg("reaped revoked pre-auth keys")
|
||||
}
|
||||
|
||||
case <-accessTokenTicker.C:
|
||||
reaped, err := h.state.DeleteExpiredAccessTokens(time.Now())
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("reaping expired oauth access tokens")
|
||||
} else if reaped > 0 {
|
||||
log.Debug().Int64("count", reaped).Msg("reaped expired oauth access tokens")
|
||||
}
|
||||
|
||||
case <-expireTicker.C:
|
||||
var (
|
||||
expiredNodeChanges []change.Change
|
||||
@@ -447,6 +460,11 @@ func (h *Headscale) createRouter(apiV1Mux, apiV2Mux http.Handler) *chi.Mux {
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(securityHeaders)
|
||||
|
||||
// TS2021 accepts both the native client's HTTP POST upgrade and the
|
||||
// browser/WASM client's WebSocket GET upgrade; NoiseUpgradeHandler
|
||||
// dispatches on the Upgrade header, not the method. Registering GET as
|
||||
// well keeps the router from rejecting the WebSocket handshake with 405.
|
||||
r.Get(ts2021UpgradePath, h.NoiseUpgradeHandler)
|
||||
r.Post(ts2021UpgradePath, h.NoiseUpgradeHandler)
|
||||
|
||||
r.Get("/robots.txt", h.RobotsHandler)
|
||||
@@ -627,20 +645,26 @@ func (h *Headscale) Serve() error {
|
||||
Cfg: h.cfg,
|
||||
})
|
||||
|
||||
// The Headscale v2 API. It is served only over the remote
|
||||
// listener (Basic/Bearer auth); no unix-socket mount yet, as nothing local
|
||||
// calls it — the CLI still speaks v1.
|
||||
// The Headscale v2 API. Served behind Basic/Bearer auth on the remote
|
||||
// listener, and over the local unix socket (local trust) so the CLI can
|
||||
// manage OAuth clients through the same v2 keys handler the Tailscale
|
||||
// ecosystem uses.
|
||||
humaV2Mux, _ := apiv2.Handler(apiv2.Backend{
|
||||
State: h.state,
|
||||
Change: h.Change,
|
||||
Cfg: h.cfg,
|
||||
})
|
||||
|
||||
// Serve the Huma API over the unix socket without TLS or auth: socket access
|
||||
// implies trust. WithLocalTrust marks these requests so the security
|
||||
// middleware skips the API-key check.
|
||||
// Serve both Huma APIs over the unix socket without TLS or auth: socket
|
||||
// access implies trust. WithLocalTrust marks these requests so each API's
|
||||
// security middleware skips the credential check. v2 paths route to the v2
|
||||
// mux; everything else (the v1 paths) to v1.
|
||||
socketHandler := http.NewServeMux()
|
||||
socketHandler.Handle("/api/v2/", apiv2.WithLocalTrust(humaV2Mux))
|
||||
socketHandler.Handle("/", apiv1.WithLocalTrust(humaMux))
|
||||
|
||||
socketServer := &http.Server{
|
||||
Handler: apiv1.WithLocalTrust(humaMux),
|
||||
Handler: socketHandler,
|
||||
ReadTimeout: types.HTTPTimeout,
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ package capver
|
||||
import (
|
||||
"maps"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/cmpver"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
@@ -74,7 +74,9 @@ func TailscaleLatestMajorMinor(n int, stripV bool) []string {
|
||||
}
|
||||
|
||||
majorSl := majors.Slice()
|
||||
sort.Strings(majorSl)
|
||||
// cmpver orders versions numerically, so v1.100 sorts after v1.98 rather than
|
||||
// lexically before it.
|
||||
slices.SortFunc(majorSl, cmpver.Compare)
|
||||
|
||||
if n > len(majorSl) {
|
||||
return majorSl
|
||||
|
||||
@@ -831,6 +831,75 @@ WHERE user_id IS NULL
|
||||
},
|
||||
Rollback: func(db *gorm.DB) error { return nil },
|
||||
},
|
||||
{
|
||||
// Add the OAuth client + access token tables backing the v2 API's
|
||||
// OAuth client-credentials flow. They mirror the api_keys /
|
||||
// pre_auth_keys security model: a public id/prefix plus an Argon2id
|
||||
// hash of the secret.
|
||||
//
|
||||
// SQLite uses explicit DDL that matches schema.sql byte-for-byte
|
||||
// (the squibble digest is the SQLite source of truth). Postgres,
|
||||
// which has no digest and rejects SQLite-isms like AUTOINCREMENT,
|
||||
// uses dialect-aware AutoMigrate, mirroring InitSchema's fresh-DB
|
||||
// table creation so an existing Postgres deployment can upgrade.
|
||||
ID: "202606211200-oauth-clients-and-tokens",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
if tx.Migrator().HasTable(&types.OAuthClient{}) &&
|
||||
tx.Migrator().HasTable(&types.OAuthAccessToken{}) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if tx.Name() != "sqlite" {
|
||||
return tx.AutoMigrate(&types.OAuthClient{}, &types.OAuthAccessToken{})
|
||||
}
|
||||
|
||||
if !tx.Migrator().HasTable(&types.OAuthClient{}) {
|
||||
err := tx.Exec(`CREATE TABLE oauth_clients(
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
client_id text,
|
||||
secret_hash blob,
|
||||
scopes text,
|
||||
tags text,
|
||||
description text,
|
||||
user_id integer,
|
||||
created_at datetime,
|
||||
revoked datetime
|
||||
)`).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating oauth_clients table: %w", err)
|
||||
}
|
||||
|
||||
err = tx.Exec(`CREATE UNIQUE INDEX idx_oauth_clients_client_id ON oauth_clients(client_id)`).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating oauth_clients index: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !tx.Migrator().HasTable(&types.OAuthAccessToken{}) {
|
||||
err := tx.Exec(`CREATE TABLE oauth_access_tokens(
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
prefix text,
|
||||
hash blob,
|
||||
client_id text,
|
||||
scopes text,
|
||||
tags text,
|
||||
expiration datetime,
|
||||
created_at datetime
|
||||
)`).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating oauth_access_tokens table: %w", err)
|
||||
}
|
||||
|
||||
err = tx.Exec(`CREATE UNIQUE INDEX idx_oauth_access_tokens_prefix ON oauth_access_tokens(prefix)`).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating oauth_access_tokens index: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Rollback: func(db *gorm.DB) error { return nil },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -842,6 +911,8 @@ WHERE user_id IS NULL
|
||||
&types.APIKey{},
|
||||
&types.Node{},
|
||||
&types.Policy{},
|
||||
&types.OAuthClient{},
|
||||
&types.OAuthAccessToken{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -857,6 +928,8 @@ WHERE user_id IS NULL
|
||||
`DROP INDEX IF EXISTS "idx_name_provider_identifier"`,
|
||||
`DROP INDEX IF EXISTS "idx_name_no_provider_identifier"`,
|
||||
`DROP INDEX IF EXISTS "idx_pre_auth_keys_prefix"`,
|
||||
`DROP INDEX IF EXISTS "idx_oauth_clients_client_id"`,
|
||||
`DROP INDEX IF EXISTS "idx_oauth_access_tokens_prefix"`,
|
||||
}
|
||||
|
||||
for _, dropSQL := range dropIndexes {
|
||||
@@ -875,6 +948,8 @@ WHERE user_id IS NULL
|
||||
`CREATE UNIQUE INDEX idx_name_provider_identifier ON users(name, provider_identifier)`,
|
||||
`CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users(name) WHERE provider_identifier IS NULL`,
|
||||
`CREATE UNIQUE INDEX idx_pre_auth_keys_prefix ON pre_auth_keys(prefix) WHERE prefix IS NOT NULL AND prefix != ''`,
|
||||
`CREATE UNIQUE INDEX idx_oauth_clients_client_id ON oauth_clients(client_id)`,
|
||||
`CREATE UNIQUE INDEX idx_oauth_access_tokens_prefix ON oauth_access_tokens(prefix)`,
|
||||
}
|
||||
|
||||
for _, indexSQL := range indexes {
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"golang.org/x/crypto/argon2"
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/util/rands"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
const (
|
||||
// OAuth client secret: hskey-client-<clientID(12)>-<secret(64)>. The clientID
|
||||
// is the public, indexed lookup key (the analogue of an API key's prefix) and
|
||||
// is embedded in the secret so the token endpoint can derive it. The prefix
|
||||
// itself lives in the types package ([types.OAuthClientPrefix]).
|
||||
oauthClientIDLength = 12
|
||||
oauthClientSecretLength = 64
|
||||
|
||||
// OAuth access token: hskey-oauthtok-<prefix(12)>-<secret(64)>. The distinct
|
||||
// prefix (vs hskey-api- admin keys, [types.AccessTokenPrefix]) lets the auth
|
||||
// middleware dispatch a scoped token from an all-access admin key alone.
|
||||
accessTokenPrefixLength = 12
|
||||
accessTokenSecretLength = 64
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOAuthClientNotFound = fmt.Errorf("oauth client not found: %w", gorm.ErrRecordNotFound)
|
||||
ErrOAuthClientFailedToParse = errors.New("failed to parse oauth client secret")
|
||||
ErrOAuthClientRevoked = errors.New("oauth client revoked")
|
||||
|
||||
ErrAccessTokenNotFound = fmt.Errorf("oauth access token not found: %w", gorm.ErrRecordNotFound)
|
||||
ErrAccessTokenFailedToParse = errors.New("failed to parse oauth access token")
|
||||
ErrAccessTokenExpired = errors.New("oauth access token expired")
|
||||
ErrAccessTokenClientRevoked = errors.New("oauth access token issuing client revoked or deleted")
|
||||
|
||||
errSecretHashMalformed = errors.New("malformed secret hash")
|
||||
errSecretMismatch = errors.New("secret does not match hash")
|
||||
)
|
||||
|
||||
// Argon2id parameters, OWASP's minimum recommendation (19 MiB, 2 iterations, 1
|
||||
// lane). They are encoded into every stored hash, so raising them later still
|
||||
// verifies credentials stored under the old cost.
|
||||
const (
|
||||
argon2Time = 2
|
||||
argon2Memory = 19 * 1024
|
||||
argon2Threads = 1
|
||||
argon2KeyLen = 32
|
||||
argon2SaltLen = 16
|
||||
)
|
||||
|
||||
// argon2Limiter bounds concurrent Argon2id computations. Each costs ~19 MiB and
|
||||
// the unauthenticated OAuth token endpoint runs one per attempt, so an unbounded
|
||||
// flood could exhaust memory. ponytail: a global semaphore sized to GOMAXPROCS;
|
||||
// revisit only if credential hashing ever becomes a throughput bottleneck.
|
||||
var argon2Limiter = make(chan struct{}, max(2, runtime.GOMAXPROCS(0)))
|
||||
|
||||
// hashSecret hashes a credential secret with Argon2id, encoded in PHC string
|
||||
// form so the parameters travel with the hash. Argon2id is the current OWASP
|
||||
// recommendation, replacing bcrypt for new credential storage.
|
||||
func hashSecret(secret string) ([]byte, error) {
|
||||
salt := make([]byte, argon2SaltLen)
|
||||
|
||||
_, err := rand.Read(salt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating salt: %w", err)
|
||||
}
|
||||
|
||||
hash := argon2.IDKey([]byte(secret), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen)
|
||||
|
||||
encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version, argon2Memory, argon2Time, argon2Threads,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(hash),
|
||||
)
|
||||
|
||||
return []byte(encoded), nil
|
||||
}
|
||||
|
||||
// verifySecret reports whether secret matches a hashSecret-encoded hash. It
|
||||
// reads the cost parameters from the stored hash and compares in constant time
|
||||
// so a mismatch leaks no timing signal.
|
||||
func verifySecret(encoded []byte, secret string) error {
|
||||
parts := strings.Split(string(encoded), "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return errSecretHashMalformed
|
||||
}
|
||||
|
||||
var version int
|
||||
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version { //nolint:noinlineerr
|
||||
return errSecretHashMalformed
|
||||
}
|
||||
|
||||
var (
|
||||
memory, time uint32
|
||||
threads uint8
|
||||
)
|
||||
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { //nolint:noinlineerr
|
||||
return errSecretHashMalformed
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return errSecretHashMalformed
|
||||
}
|
||||
|
||||
want, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return errSecretHashMalformed
|
||||
}
|
||||
|
||||
argon2Limiter <- struct{}{}
|
||||
//nolint:gosec // want is a 32-byte hash read back from storage, no overflow
|
||||
got := argon2.IDKey([]byte(secret), salt, time, memory, threads, uint32(len(want)))
|
||||
|
||||
<-argon2Limiter
|
||||
|
||||
if subtle.ConstantTimeCompare(got, want) != 1 {
|
||||
return errSecretMismatch
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateOAuthClient creates a new [types.OAuthClient] and returns the plaintext
|
||||
// secret (shown ONCE) alongside the stored client. creatorUserID is the user who
|
||||
// created it (informational), or nil.
|
||||
func (hsdb *HSDatabase) CreateOAuthClient(
|
||||
scopes, tags []string,
|
||||
description string,
|
||||
creatorUserID *uint,
|
||||
) (string, *types.OAuthClient, error) {
|
||||
tags, err := validateACLTags(tags)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
scopes = set.SetOf(scopes).Slice()
|
||||
slices.Sort(scopes)
|
||||
|
||||
clientID := rands.HexString(oauthClientIDLength)
|
||||
secret := rands.HexString(oauthClientSecretLength)
|
||||
secretStr := types.OAuthClientPrefix + clientID + "-" + secret
|
||||
|
||||
hash, err := hashSecret(secret)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
client := types.OAuthClient{
|
||||
ClientID: clientID,
|
||||
SecretHash: hash,
|
||||
Scopes: scopes,
|
||||
Tags: tags,
|
||||
Description: description,
|
||||
UserID: creatorUserID,
|
||||
CreatedAt: &now,
|
||||
}
|
||||
|
||||
err = hsdb.Write(func(tx *gorm.DB) error {
|
||||
return tx.Save(&client).Error
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("saving oauth client: %w", err)
|
||||
}
|
||||
|
||||
return secretStr, &client, nil
|
||||
}
|
||||
|
||||
// AuthenticateOAuthClient validates a presented client secret and returns the
|
||||
// matching, unrevoked [types.OAuthClient]. The client id is derived from the
|
||||
// secret (its middle segment), so any separately-supplied client_id is
|
||||
// redundant, matching Tailscale, where get-authkey passes a dummy id and the
|
||||
// server derives the real one from the secret.
|
||||
func (hsdb *HSDatabase) AuthenticateOAuthClient(secretStr string) (*types.OAuthClient, error) {
|
||||
if secretStr == "" {
|
||||
return nil, ErrOAuthClientFailedToParse
|
||||
}
|
||||
|
||||
// Tailscale allows the secret to carry optional ?key=value attributes when
|
||||
// used directly as an auth key; strip them before parsing.
|
||||
secretStr, _, _ = strings.Cut(secretStr, "?")
|
||||
|
||||
_, rest, found := strings.Cut(secretStr, types.OAuthClientPrefix)
|
||||
if !found {
|
||||
return nil, ErrOAuthClientFailedToParse
|
||||
}
|
||||
|
||||
clientID, secret, err := parsePrefixedKey(
|
||||
rest,
|
||||
oauthClientIDLength,
|
||||
oauthClientSecretLength,
|
||||
ErrOAuthClientFailedToParse,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var client types.OAuthClient
|
||||
if err := hsdb.DB.First(&client, "client_id = ?", clientID).Error; err != nil { //nolint:noinlineerr
|
||||
return nil, ErrOAuthClientNotFound
|
||||
}
|
||||
|
||||
if err := verifySecret(client.SecretHash, secret); err != nil { //nolint:noinlineerr
|
||||
return nil, fmt.Errorf("invalid oauth client secret: %w", err)
|
||||
}
|
||||
|
||||
if client.Revoked != nil {
|
||||
return nil, ErrOAuthClientRevoked
|
||||
}
|
||||
|
||||
return &client, nil
|
||||
}
|
||||
|
||||
// GetOAuthClientByClientID returns a [types.OAuthClient] by its public client id.
|
||||
func (hsdb *HSDatabase) GetOAuthClientByClientID(clientID string) (*types.OAuthClient, error) {
|
||||
var client types.OAuthClient
|
||||
if result := hsdb.DB.First(&client, "client_id = ?", clientID); result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
return &client, nil
|
||||
}
|
||||
|
||||
// ListOAuthClients returns every [types.OAuthClient].
|
||||
func (hsdb *HSDatabase) ListOAuthClients() ([]types.OAuthClient, error) {
|
||||
clients := []types.OAuthClient{}
|
||||
|
||||
err := hsdb.DB.Find(&clients).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return clients, nil
|
||||
}
|
||||
|
||||
// RevokeOAuthClient deletes a client and all access tokens it issued. An unknown
|
||||
// client id returns [ErrOAuthClientNotFound], so a repeated DELETE is a clean
|
||||
// 404. Unlike pre-auth keys (which soft-revoke for node-registration history), an
|
||||
// OAuth client has no such history and is removed outright, matching Tailscale.
|
||||
func (hsdb *HSDatabase) RevokeOAuthClient(clientID string) error {
|
||||
return hsdb.Write(func(tx *gorm.DB) error {
|
||||
err := tx.Where("client_id = ?", clientID).
|
||||
Delete(&types.OAuthAccessToken{}).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting oauth access tokens: %w", err)
|
||||
}
|
||||
|
||||
res := tx.Where("client_id = ?", clientID).Delete(&types.OAuthClient{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrOAuthClientNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// MintAccessToken stores a new [types.OAuthAccessToken] for clientID with the
|
||||
// given (already narrowed) scopes/tags and expiration, returning the plaintext
|
||||
// token (shown ONCE).
|
||||
func (hsdb *HSDatabase) MintAccessToken(
|
||||
clientID string,
|
||||
scopes, tags []string,
|
||||
expiration *time.Time,
|
||||
) (string, *types.OAuthAccessToken, error) {
|
||||
prefix := rands.HexString(accessTokenPrefixLength)
|
||||
secret := rands.HexString(accessTokenSecretLength)
|
||||
tokenStr := types.AccessTokenPrefix + prefix + "-" + secret
|
||||
|
||||
hash, err := hashSecret(secret)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
token := types.OAuthAccessToken{
|
||||
Prefix: prefix,
|
||||
Hash: hash,
|
||||
ClientID: clientID,
|
||||
Scopes: scopes,
|
||||
Tags: tags,
|
||||
Expiration: expiration,
|
||||
CreatedAt: &now,
|
||||
}
|
||||
|
||||
// Mint inside a transaction that re-checks the client still exists and is
|
||||
// not revoked, so a mint cannot complete against a client being deleted.
|
||||
err = hsdb.Write(func(tx *gorm.DB) error {
|
||||
var client types.OAuthClient
|
||||
|
||||
err := tx.First(&client, "client_id = ?", clientID).Error
|
||||
if err != nil {
|
||||
return ErrOAuthClientNotFound
|
||||
}
|
||||
|
||||
if client.Revoked != nil {
|
||||
return ErrOAuthClientRevoked
|
||||
}
|
||||
|
||||
return tx.Save(&token).Error
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("saving oauth access token: %w", err)
|
||||
}
|
||||
|
||||
return tokenStr, &token, nil
|
||||
}
|
||||
|
||||
// AuthenticateAccessToken validates a presented bearer token and returns the
|
||||
// matching, unexpired [types.OAuthAccessToken] (carrying its granted scopes and
|
||||
// tags). A non-nil error means the token is missing, malformed, or expired.
|
||||
func (hsdb *HSDatabase) AuthenticateAccessToken(tokenStr string) (*types.OAuthAccessToken, error) {
|
||||
if tokenStr == "" {
|
||||
return nil, ErrAccessTokenFailedToParse
|
||||
}
|
||||
|
||||
_, rest, found := strings.Cut(tokenStr, types.AccessTokenPrefix)
|
||||
if !found {
|
||||
return nil, ErrAccessTokenFailedToParse
|
||||
}
|
||||
|
||||
prefix, secret, err := parsePrefixedKey(
|
||||
rest,
|
||||
accessTokenPrefixLength,
|
||||
accessTokenSecretLength,
|
||||
ErrAccessTokenFailedToParse,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var token types.OAuthAccessToken
|
||||
if err := hsdb.DB.First(&token, "prefix = ?", prefix).Error; err != nil { //nolint:noinlineerr
|
||||
return nil, ErrAccessTokenNotFound
|
||||
}
|
||||
|
||||
if err := verifySecret(token.Hash, secret); err != nil { //nolint:noinlineerr
|
||||
return nil, fmt.Errorf("invalid oauth access token: %w", err)
|
||||
}
|
||||
|
||||
if token.Expiration != nil && token.Expiration.Before(time.Now()) {
|
||||
return nil, ErrAccessTokenExpired
|
||||
}
|
||||
|
||||
// Bind validity to the issuing client: a token whose client has been
|
||||
// revoked or deleted is rejected. This closes a mint/revoke race (where a
|
||||
// token could be inserted after the client's tokens were purged) and any
|
||||
// orphan left by manual deletion or a future soft-revoke path.
|
||||
var client types.OAuthClient
|
||||
if err := hsdb.DB.First(&client, "client_id = ?", token.ClientID).Error; err != nil { //nolint:noinlineerr
|
||||
return nil, ErrAccessTokenClientRevoked
|
||||
}
|
||||
|
||||
if client.Revoked != nil {
|
||||
return nil, ErrAccessTokenClientRevoked
|
||||
}
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// DeleteExpiredAccessTokens hard-deletes every access token that expired before
|
||||
// cutoff, returning how many were removed. Auth-time checks already reject
|
||||
// expired tokens; the hourly reaper (see app.go) calls this only to keep the
|
||||
// table from growing unbounded.
|
||||
func (hsdb *HSDatabase) DeleteExpiredAccessTokens(cutoff time.Time) (int64, error) {
|
||||
res := hsdb.DB.Where("expiration IS NOT NULL AND expiration < ?", cutoff).
|
||||
Delete(&types.OAuthAccessToken{})
|
||||
|
||||
return res.RowsAffected, res.Error
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestVerifySecretConcurrent runs more concurrent verifications than the Argon2
|
||||
// concurrency semaphore admits, asserting the limiter releases correctly (no
|
||||
// deadlock) and stays correct under contention. Run with -race.
|
||||
func TestVerifySecretConcurrent(t *testing.T) {
|
||||
hash, err := hashSecret("s3cr3t")
|
||||
require.NoError(t, err)
|
||||
|
||||
const n = 64
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
errs := make([]error, n)
|
||||
|
||||
for i := range n {
|
||||
wg.Add(1)
|
||||
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
|
||||
if i%2 == 0 {
|
||||
errs[i] = verifySecret(hash, "s3cr3t")
|
||||
} else {
|
||||
errs[i] = verifySecret(hash, "wrong")
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
for i, e := range errs {
|
||||
if i%2 == 0 {
|
||||
assert.NoError(t, e, "correct secret must verify")
|
||||
} else {
|
||||
assert.Error(t, e, "wrong secret must fail")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthClientCreateAndAuthenticate(t *testing.T) {
|
||||
db, err := newSQLiteTestDB()
|
||||
require.NoError(t, err)
|
||||
|
||||
secret, client, err := db.CreateOAuthClient(
|
||||
[]string{"auth_keys", "devices:core"},
|
||||
[]string{"tag:ci"},
|
||||
"my client",
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, client)
|
||||
|
||||
// Secret carries the public client id as its middle segment, so it can be
|
||||
// derived from the secret alone (the Tailscale get-authkey trick).
|
||||
assert.True(t, strings.HasPrefix(secret, "hskey-client-"+client.ClientID+"-"))
|
||||
// Scopes/tags are deduplicated and sorted for stable storage.
|
||||
assert.Equal(t, []string{"auth_keys", "devices:core"}, client.Scopes)
|
||||
assert.Equal(t, []string{"tag:ci"}, client.Tags)
|
||||
// Only the Argon2id hash is stored, never the plaintext.
|
||||
assert.NotEmpty(t, client.SecretHash)
|
||||
assert.True(t, strings.HasPrefix(string(client.SecretHash), "$argon2id$"))
|
||||
|
||||
// The secret authenticates, deriving the client id from the secret itself.
|
||||
got, err := db.AuthenticateOAuthClient(secret)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, client.ClientID, got.ClientID)
|
||||
|
||||
// A truncated/garbage secret does not.
|
||||
_, err = db.AuthenticateOAuthClient("hskey-client-deadbeef-nope")
|
||||
require.Error(t, err)
|
||||
|
||||
// Wrong secret for a real client id is rejected by the constant-time compare.
|
||||
_, err = db.AuthenticateOAuthClient("hskey-client-" + client.ClientID + "-" + strings.Repeat("0", 64))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestHashSecretRoundTrip(t *testing.T) {
|
||||
const secret = "a-high-entropy-credential-secret"
|
||||
|
||||
encoded, err := hashSecret(secret)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, strings.HasPrefix(string(encoded), "$argon2id$v="))
|
||||
|
||||
// The same secret hashes to a different value each time (random salt) yet
|
||||
// still verifies.
|
||||
encoded2, err := hashSecret(secret)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, encoded, encoded2)
|
||||
|
||||
require.NoError(t, verifySecret(encoded, secret))
|
||||
require.ErrorIs(t, verifySecret(encoded, "wrong-secret"), errSecretMismatch)
|
||||
require.ErrorIs(t, verifySecret([]byte("not-a-phc-string"), secret), errSecretHashMalformed)
|
||||
}
|
||||
|
||||
func TestOAuthClientRevoke(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)
|
||||
|
||||
// A token minted by the client survives only until the client is revoked.
|
||||
_, _, err = db.MintAccessToken(client.ClientID, client.Scopes, client.Tags, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, db.RevokeOAuthClient(client.ClientID))
|
||||
|
||||
// The client no longer authenticates and a repeated revoke is a clean 404.
|
||||
_, err = db.AuthenticateOAuthClient(secret)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, db.RevokeOAuthClient(client.ClientID), ErrOAuthClientNotFound)
|
||||
}
|
||||
|
||||
func TestOAuthAccessTokenMintAuthenticateExpire(t *testing.T) {
|
||||
db, err := newSQLiteTestDB()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, client, err := db.CreateOAuthClient([]string{"auth_keys"}, []string{"tag:ci"}, "", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
future := time.Now().Add(time.Hour)
|
||||
tokenStr, token, err := db.MintAccessToken(
|
||||
client.ClientID,
|
||||
[]string{"auth_keys"},
|
||||
[]string{"tag:ci"},
|
||||
&future,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, strings.HasPrefix(tokenStr, "hskey-oauthtok-"))
|
||||
|
||||
got, err := db.AuthenticateAccessToken(tokenStr)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, client.ClientID, got.ClientID)
|
||||
assert.Equal(t, []string{"auth_keys"}, got.Scopes)
|
||||
assert.Equal(t, []string{"tag:ci"}, got.Tags)
|
||||
|
||||
// An expired token is rejected even though the row still exists.
|
||||
past := time.Now().Add(-time.Hour)
|
||||
expiredStr, _, err := db.MintAccessToken(client.ClientID, nil, nil, &past)
|
||||
require.NoError(t, err)
|
||||
_, err = db.AuthenticateAccessToken(expiredStr)
|
||||
require.ErrorIs(t, err, ErrAccessTokenExpired)
|
||||
|
||||
// The reaper deletes the expired row; the live token is untouched.
|
||||
n, err := db.DeleteExpiredAccessTokens(time.Now())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), n)
|
||||
|
||||
_ = token
|
||||
|
||||
_, err = db.AuthenticateAccessToken(tokenStr)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestAccessTokenRejectedWhenClientGone asserts a token whose issuing client no
|
||||
// longer exists (orphaned by a delete/revoke race) is rejected, even though the
|
||||
// token row itself is valid and unexpired.
|
||||
func TestAccessTokenRejectedWhenClientGone(t *testing.T) {
|
||||
db, err := newSQLiteTestDB()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, client, err := db.CreateOAuthClient([]string{"auth_keys"}, []string{"tag:ci"}, "", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
future := time.Now().Add(time.Hour)
|
||||
tokenStr, _, err := db.MintAccessToken(client.ClientID, []string{"auth_keys"}, []string{"tag:ci"}, &future)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.AuthenticateAccessToken(tokenStr)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Delete only the client row, leaving the token orphaned (the state a
|
||||
// mint/revoke race or manual deletion would produce).
|
||||
require.NoError(t, db.DB.Where("client_id = ?", client.ClientID).Delete(&types.OAuthClient{}).Error)
|
||||
|
||||
_, err = db.AuthenticateAccessToken(tokenStr)
|
||||
require.ErrorIs(t, err, ErrAccessTokenClientRevoked)
|
||||
|
||||
// A soft-revoked client (row present, Revoked set) is likewise rejected.
|
||||
_, client2, err := db.CreateOAuthClient([]string{"auth_keys"}, []string{"tag:ci"}, "", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
tokenStr2, _, err := db.MintAccessToken(client2.ClientID, []string{"auth_keys"}, []string{"tag:ci"}, &future)
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now()
|
||||
require.NoError(t, db.DB.Model(&types.OAuthClient{}).
|
||||
Where("client_id = ?", client2.ClientID).Update("revoked", now).Error)
|
||||
|
||||
_, err = db.AuthenticateAccessToken(tokenStr2)
|
||||
require.ErrorIs(t, err, ErrAccessTokenClientRevoked)
|
||||
}
|
||||
@@ -25,6 +25,26 @@ var (
|
||||
ErrPreAuthKeyACLTagInvalid = errors.New("auth-key tag is invalid")
|
||||
)
|
||||
|
||||
// validateACLTags deduplicates, sorts, and checks that every tag carries the
|
||||
// "tag:" prefix. Shared by the pre-auth-key and OAuth credential paths so both
|
||||
// enforce the same tag shape.
|
||||
func validateACLTags(tags []string) ([]string, error) {
|
||||
tags = set.SetOf(tags).Slice()
|
||||
slices.Sort(tags)
|
||||
|
||||
for _, tag := range tags {
|
||||
if !strings.HasPrefix(tag, "tag:") {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: '%s' did not begin with 'tag:'",
|
||||
ErrPreAuthKeyACLTagInvalid,
|
||||
tag,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (hsdb *HSDatabase) CreatePreAuthKey(
|
||||
uid *types.UserID,
|
||||
reusable bool,
|
||||
@@ -76,20 +96,9 @@ func CreatePreAuthKey(
|
||||
userID = &user.ID
|
||||
}
|
||||
|
||||
// Remove duplicates and sort for consistency
|
||||
aclTags = set.SetOf(aclTags).Slice()
|
||||
slices.Sort(aclTags)
|
||||
|
||||
// TODO(kradalby): factor out and create a reusable tag validation,
|
||||
// check if there is one in Tailscale's lib.
|
||||
for _, tag := range aclTags {
|
||||
if !strings.HasPrefix(tag, "tag:") {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: '%s' did not begin with 'tag:'",
|
||||
ErrPreAuthKeyACLTagInvalid,
|
||||
tag,
|
||||
)
|
||||
}
|
||||
aclTags, err := validateACLTags(aclTags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
@@ -228,6 +237,7 @@ func findAuthKey(tx *gorm.DB, keyStr string) (*types.PreAuthKey, error) {
|
||||
// separator-based to handle dashes in base64 URL-safe characters.
|
||||
func parsePrefixedKey(
|
||||
prefixAndSecret string,
|
||||
//nolint:unparam // kept explicit though every credential kind uses a 12-char prefix and 64-char secret today
|
||||
prefixLen, secretLen int,
|
||||
parseErr error,
|
||||
) (string, string, error) {
|
||||
|
||||
@@ -70,6 +70,36 @@ CREATE TABLE api_keys(
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_api_keys_prefix ON api_keys(prefix);
|
||||
|
||||
-- OAuth 2.0 client-credentials clients for the v2 API. client_id is public and
|
||||
-- embedded in the secret (hskey-client-<client_id>-<secret>); only the bcrypt
|
||||
-- hash of the secret is stored. Mirrors the api_keys security model.
|
||||
CREATE TABLE oauth_clients(
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
client_id text,
|
||||
secret_hash blob,
|
||||
scopes text,
|
||||
tags text,
|
||||
description text,
|
||||
user_id integer,
|
||||
created_at datetime,
|
||||
revoked datetime
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_oauth_clients_client_id ON oauth_clients(client_id);
|
||||
|
||||
-- Short-lived bearer access tokens minted by an oauth_client. Stored as a bcrypt
|
||||
-- hash of the secret, looked up by prefix.
|
||||
CREATE TABLE oauth_access_tokens(
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
prefix text,
|
||||
hash blob,
|
||||
client_id text,
|
||||
scopes text,
|
||||
tags text,
|
||||
expiration datetime,
|
||||
created_at datetime
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_oauth_access_tokens_prefix ON oauth_access_tokens(prefix);
|
||||
|
||||
CREATE TABLE nodes(
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
machine_key text,
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"github.com/juanfont/headscale/hscontrol/policy"
|
||||
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
|
||||
"github.com/rs/zerolog/log"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/multierr"
|
||||
@@ -152,7 +154,15 @@ func (b *MapResponseBuilder) WithSSHPolicy() *MapResponseBuilder {
|
||||
|
||||
sshPolicy, err := b.mapper.state.SSHPolicy(node)
|
||||
if err != nil {
|
||||
b.addError(err)
|
||||
// SSH policy is optional for a node to function. Rather than fail the
|
||||
// whole map (leaving the node unable to connect), log and continue
|
||||
// without it; the node still receives a usable netmap.
|
||||
log.Warn().Caller().
|
||||
Err(err).
|
||||
Uint64(zf.NodeID, node.ID().Uint64()).
|
||||
Str(zf.NodeHostname, node.Hostname()).
|
||||
Msg("building map response: skipping SSH policy for node; node will receive a map without SSH rules")
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -284,7 +294,19 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) (
|
||||
return b.mapper.state.RoutesForPeer(node, peer, matchers)
|
||||
}, b.mapper.cfg, allCapMaps[peer.ID()])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// One peer with invalid data (e.g. an empty or over-long
|
||||
// GivenName that fails GetFQDN) must not blank out the map for
|
||||
// every node that can see it. Drop the offending peer, log it
|
||||
// with the identity an operator needs to fix it, and keep
|
||||
// building from the remaining valid peers.
|
||||
log.Warn().Caller().
|
||||
Err(err).
|
||||
Uint64(zf.NodeID, peer.ID().Uint64()).
|
||||
Str(zf.NodeHostname, peer.Hostname()).
|
||||
Uint64("map.viewer.node.id", b.nodeID.Uint64()).
|
||||
Msgf("dropping peer %d from map response: invalid node data; fix with `headscale nodes rename %d <name>`", peer.ID(), peer.ID())
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// [tailcfg.Node.CapMap] on a peer carries the small set of
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
//go:build !race
|
||||
|
||||
// This is a timing-sensitive performance regression test; the race detector's
|
||||
// ~10x slowdown makes its wall-clock assertion meaningless, so it is excluded
|
||||
// from -race builds. The concurrency correctness of the policy lock change it
|
||||
// guards is covered under -race by TestPolicyManagerConcurrentReads in
|
||||
// hscontrol/policy/v2.
|
||||
|
||||
package mapper
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/db"
|
||||
"github.com/juanfont/headscale/hscontrol/derp"
|
||||
"github.com/juanfont/headscale/hscontrol/state"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// setupStormBatcher builds a real state+batcher with production-default
|
||||
// NodeStore batching so the reconnect-storm contention is realistic. It mirrors
|
||||
// setupBatcherWithTestData but lets the test control BatcherWorkers and the
|
||||
// policy.
|
||||
func setupStormBatcher(tb testing.TB, nodeCount, workers int, policy string) (*TestData, func()) {
|
||||
tb.Helper()
|
||||
|
||||
tmpDir := tb.TempDir()
|
||||
prefixV4 := netip.MustParsePrefix("100.64.0.0/10")
|
||||
prefixV6 := netip.MustParsePrefix("fd7a:115c:a1e0::/48")
|
||||
|
||||
cfg := &types.Config{
|
||||
Database: types.DatabaseConfig{
|
||||
Type: types.DatabaseSqlite,
|
||||
Sqlite: types.SqliteConfig{Path: tmpDir + "/headscale_test.db"},
|
||||
},
|
||||
PrefixV4: &prefixV4,
|
||||
PrefixV6: &prefixV6,
|
||||
IPAllocation: types.IPAllocationStrategySequential,
|
||||
BaseDomain: "headscale.test",
|
||||
Policy: types.PolicyConfig{Mode: types.PolicyModeDB},
|
||||
DERP: types.DERPConfig{
|
||||
ServerEnabled: false,
|
||||
DERPMap: &tailcfg.DERPMap{
|
||||
Regions: map[int]*tailcfg.DERPRegion{999: {RegionID: 999}},
|
||||
},
|
||||
},
|
||||
Tuning: types.Tuning{
|
||||
BatchChangeDelay: 10 * time.Millisecond,
|
||||
BatcherWorkers: workers,
|
||||
// Production defaults: coalesce writes so the storm is not
|
||||
// exaggerated by an unrealistically small NodeStore batch.
|
||||
NodeStoreBatchSize: 100,
|
||||
NodeStoreBatchTimeout: 500 * time.Millisecond,
|
||||
},
|
||||
}
|
||||
|
||||
database, err := db.NewHeadscaleDatabase(cfg)
|
||||
require.NoError(tb, err)
|
||||
|
||||
users := database.CreateUsersForTest(1, "testuser")
|
||||
dbNodes := database.CreateRegisteredNodesForTest(users[0], nodeCount, "node")
|
||||
|
||||
allNodes := make([]node, 0, nodeCount)
|
||||
for i := range dbNodes {
|
||||
allNodes = append(allNodes, node{
|
||||
n: dbNodes[i],
|
||||
ch: make(chan *tailcfg.MapResponse, normalBufferSize),
|
||||
})
|
||||
}
|
||||
|
||||
st, err := state.NewState(cfg)
|
||||
require.NoError(tb, err)
|
||||
|
||||
derpMap, err := derp.GetDERPMap(cfg.DERP)
|
||||
require.NoError(tb, err)
|
||||
st.SetDERPMap(derpMap)
|
||||
|
||||
_, err = st.SetPolicy([]byte(policy))
|
||||
require.NoError(tb, err)
|
||||
|
||||
batcher := wrapBatcherForTest(NewBatcherAndMapper(cfg, st), st)
|
||||
batcher.Start()
|
||||
|
||||
td := &TestData{
|
||||
Database: database,
|
||||
Users: users,
|
||||
Nodes: allNodes,
|
||||
State: st,
|
||||
Config: cfg,
|
||||
Batcher: batcher,
|
||||
}
|
||||
|
||||
return td, func() {
|
||||
batcher.Close()
|
||||
st.Close()
|
||||
database.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// TestInitialMapNotStarvedByReconnectStorm reproduces juanfont/headscale#3346.
|
||||
//
|
||||
// When every node redials at once (e.g. after a server upgrade restart), each
|
||||
// connection writes the NodeStore (UpdateNodeFromMapRequest + Connect) and the
|
||||
// batcher generates its initial map. All of that reads the policy through the
|
||||
// PolicyManager. Before the fix the PolicyManager guarded every read with a
|
||||
// single exclusive mutex, so the NodeStore writer's O(n^2) BuildPeerMap and
|
||||
// every node's FilterForNode serialised against each other. On a per-node
|
||||
// filter policy (autogroup:self, via, relay grants) each hold is expensive, so
|
||||
// under the storm time-to-initial-map grew without bound.
|
||||
//
|
||||
// On the production server in #3346 this drove the batcher's per-node
|
||||
// total.duration from ~4s to ~76s; tailscale clients aborted the map POST
|
||||
// first and reported
|
||||
//
|
||||
// PollNetMap: Post ".../machine/map": unexpected EOF
|
||||
//
|
||||
// then redialled, feeding the storm so it never converged. An allow-all policy
|
||||
// does NOT reproduce this — BuildPeerMap is cheap there; the per-node filter
|
||||
// path is what makes it expensive, matching a real deployment's ACLs.
|
||||
//
|
||||
// The fix makes PolicyManager reads take a shared RLock so map generation runs
|
||||
// concurrently. AddNode blocks until the initial map is generated and handed to
|
||||
// the node channel, so its wall-clock duration is the time-to-initial-map the
|
||||
// client experiences. Without the fix this test's slowest node takes ~10s+ at
|
||||
// this scale (lock-bound, and more workers do not help); with it, generation
|
||||
// parallelises across workers and stays well within a client's patience.
|
||||
func TestInitialMapNotStarvedByReconnectStorm(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("timing-sensitive storm regression; skipped in -short")
|
||||
}
|
||||
|
||||
const (
|
||||
nodeCount = 300
|
||||
|
||||
// A per-node-filter policy: forces BuildPeerMap and FilterForNode onto
|
||||
// the slow path that recompiles filter rules per node, the same shape
|
||||
// as a real ACL using autogroup:self / via / relay grants.
|
||||
perNodeFilterPolicy = `{"acls":[{"action":"accept","src":["autogroup:member"],"dst":["autogroup:self:*"]}]}`
|
||||
|
||||
// Deliberately roomy so it passes on CI's few-core runners, where the
|
||||
// single-writer BuildPeerMap sets the floor (~10s) whatever the reads
|
||||
// do. It still trips on a hang or a return to the ~76s serialised
|
||||
// behaviour; the fine-grained concurrency is verified separately by
|
||||
// TestPolicyManagerConcurrentReads under -race.
|
||||
maxAcceptableLatency = 30 * time.Second
|
||||
)
|
||||
|
||||
// Use the real available parallelism, as production does.
|
||||
workers := runtime.NumCPU()
|
||||
|
||||
td, cleanup := setupStormBatcher(t, nodeCount, workers, perNodeFilterPolicy)
|
||||
defer cleanup()
|
||||
|
||||
latencies := make([]time.Duration, nodeCount)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := range td.Nodes {
|
||||
wg.Go(func() {
|
||||
n := &td.Nodes[i]
|
||||
|
||||
start := time.Now()
|
||||
err := td.Batcher.AddNode(n.n.ID, n.ch, tailcfg.CapabilityVersion(100), nil)
|
||||
latencies[i] = time.Since(start)
|
||||
|
||||
assert.NoError(t, err) //nolint:testifylint // assert (not require) is correct off the test goroutine
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
slices.Sort(latencies)
|
||||
p50 := latencies[len(latencies)/2]
|
||||
p95 := latencies[len(latencies)*95/100]
|
||||
maxLatency := latencies[len(latencies)-1]
|
||||
t.Logf("initial-map latency over %d nodes (workers=%d): p50=%s p95=%s max=%s",
|
||||
nodeCount, workers, p50, p95, maxLatency)
|
||||
|
||||
require.Less(t, maxLatency, maxAcceptableLatency,
|
||||
"slowest initial map took %s: policy reads are serialising instead of running concurrently (issue #3346)", maxLatency)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package mapper
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
@@ -536,6 +537,94 @@ func TestBuildFromChangeVisibilityMatchesFullMap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFullMapResponseSurvivesPeerWithInvalidName proves a single node with an
|
||||
// FQDN-invalid GivenName must not break map generation for its peers.
|
||||
//
|
||||
// A node whose stored GivenName is empty (ErrNodeHasNoGivenName) or yields an
|
||||
// FQDN longer than MaxHostnameLength (ErrHostnameTooLong) makes GetFQDN, and
|
||||
// therefore TailNode, return an error. buildTailPeers used to abort the entire
|
||||
// peer list on the first such error, so MapResponseBuilder.Build() failed for
|
||||
// every node that could see the bad peer; on the initial-connection path that
|
||||
// surfaced as "PollNetMap: ... unexpected EOF" and the "Unable to connect to
|
||||
// the Tailscale coordination server" health warning. A legacy DB row loads
|
||||
// verbatim (NewNodeStore reads db.ListNodes() without re-sanitising names), so
|
||||
// the bad peer persists across restart. The build for an unaffected viewer
|
||||
// must succeed: the bad peer is dropped, valid peers and self survive.
|
||||
func TestFullMapResponseSurvivesPeerWithInvalidName(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
badName string
|
||||
}{
|
||||
{"empty given name", ""},
|
||||
{"over-long fqdn", strings.Repeat("a", types.MaxHostnameLength+1)},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
p4 := netip.MustParsePrefix("100.64.0.0/10")
|
||||
p6 := netip.MustParsePrefix("fd7a:115c:a1e0::/48")
|
||||
cfg := &types.Config{
|
||||
Database: types.DatabaseConfig{
|
||||
Type: types.DatabaseSqlite,
|
||||
Sqlite: types.SqliteConfig{Path: tmp + "/h.db"},
|
||||
},
|
||||
PrefixV4: &p4,
|
||||
PrefixV6: &p6,
|
||||
IPAllocation: types.IPAllocationStrategySequential,
|
||||
BaseDomain: "headscale.test",
|
||||
Policy: types.PolicyConfig{Mode: types.PolicyModeDB},
|
||||
DERP: types.DERPConfig{
|
||||
DERPMap: &tailcfg.DERPMap{
|
||||
Regions: map[int]*tailcfg.DERPRegion{999: {RegionID: 999}},
|
||||
},
|
||||
},
|
||||
Tuning: types.Tuning{
|
||||
NodeStoreBatchSize: state.TestBatchSize,
|
||||
NodeStoreBatchTimeout: state.TestBatchTimeout,
|
||||
},
|
||||
}
|
||||
|
||||
database, err := db.NewHeadscaleDatabase(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
user := database.CreateUserForTest("u1")
|
||||
n1 := database.CreateRegisteredNodeForTest(user, "n1") // viewer, valid
|
||||
bad := database.CreateRegisteredNodeForTest(user, "bad") // peer, name corrupted below
|
||||
good := database.CreateRegisteredNodeForTest(user, "good") // peer, valid control
|
||||
|
||||
// Simulate a legacy/corrupt row that v29 loads verbatim.
|
||||
require.NoError(t, database.DB.
|
||||
Model(&types.Node{}).
|
||||
Where("id = ?", bad.ID).
|
||||
Update("given_name", tt.badName).Error)
|
||||
require.NoError(t, database.Close())
|
||||
|
||||
s, err := state.NewState(cfg)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
// Allow-all so n1 sees both peers; the bad one must still be dropped.
|
||||
_, err = s.SetPolicy([]byte(`{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
m := &mapper{state: s, cfg: cfg}
|
||||
capVer := tailcfg.CurrentCapabilityVersion
|
||||
|
||||
resp, err := m.fullMapResponse(n1.ID, capVer)
|
||||
require.NoError(t, err, "n1's map must build despite a peer with an invalid name")
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, resp.Node, "n1 must receive its own self node")
|
||||
|
||||
peers := map[tailcfg.NodeID]bool{}
|
||||
for _, p := range resp.Peers {
|
||||
peers[p.ID] = true
|
||||
}
|
||||
|
||||
assert.False(t, peers[bad.ID.NodeID()], "the peer with an invalid name must be dropped")
|
||||
assert.True(t, peers[good.ID.NodeID()], "valid peers must remain in the map")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateDNSConfigNilHostinfoNoPanic proves generateDNSConfig does not
|
||||
// panic when a node's Hostinfo is nil (e.g. a legacy DB row with a NULL
|
||||
// host_info column). addNextDNSMetadata dereferenced node.Hostinfo().OS()
|
||||
|
||||
@@ -459,6 +459,71 @@ func TestSSHActionHandler_RejectsMissingSessionWithoutCheck(t *testing.T) {
|
||||
"a bogus auth_id with no active check must be rejected, body=%s", rec.Body.String())
|
||||
}
|
||||
|
||||
// TestTS2021Route_AcceptsGETAndPOST reproduces a regression where the
|
||||
// browser/WASM control client could not connect. Tailscale's JS/WASM control
|
||||
// client opens /ts2021 as a WebSocket, which is an HTTP GET upgrade; the native
|
||||
// Go client uses an HTTP POST upgrade. The gorilla->chi router migration
|
||||
// registered /ts2021 for POST only, so the GET WebSocket handshake was rejected
|
||||
// with 405 Method Not Allowed by the router before it could reach
|
||||
// NoiseUpgradeHandler. Both methods must route to the handler.
|
||||
//
|
||||
// NoiseUpgradeHandler dispatches on the Upgrade header, not the HTTP method, so
|
||||
// once the route is reachable the handler handles both upgrade styles. The
|
||||
// httptest recorder is not an http.Hijacker, so the upgrade itself fails past
|
||||
// the router (501 for the WebSocket path, 400 for the native path) — the point
|
||||
// is only that neither is 405, i.e. the router no longer rejects GET early.
|
||||
func TestTS2021Route_AcceptsGETAndPOST(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := createTestApp(t).HTTPHandler()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
headers map[string]string
|
||||
}{
|
||||
{
|
||||
name: "websocket_get_from_wasm_client",
|
||||
method: http.MethodGet,
|
||||
headers: map[string]string{
|
||||
"Connection": "Upgrade",
|
||||
"Upgrade": "websocket",
|
||||
"Sec-WebSocket-Version": "13",
|
||||
"Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==",
|
||||
"Sec-WebSocket-Protocol": "tailscale-control-protocol",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "native_post_upgrade",
|
||||
method: http.MethodPost,
|
||||
headers: map[string]string{
|
||||
"Connection": "upgrade",
|
||||
"Upgrade": "tailscale-control-protocol",
|
||||
"X-Tailscale-Handshake": "AAAA",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequestWithContext(context.Background(), tt.method,
|
||||
"/ts2021?X-Tailscale-Handshake=AAAA", nil)
|
||||
for k, v := range tt.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
assert.NotEqual(t, http.StatusMethodNotAllowed, rec.Code,
|
||||
"%s /ts2021 must reach NoiseUpgradeHandler, not be rejected by the router with 405",
|
||||
tt.method)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// newSSHActionFollowUpRequest is like newSSHActionRequest but carries the
|
||||
// auth_id query parameter that marks a follow-up poll.
|
||||
func newSSHActionFollowUpRequest(t *testing.T, src, dst types.NodeID, authID types.AuthID) *http.Request {
|
||||
|
||||
+42
-12
@@ -85,8 +85,8 @@ func NewAuthProviderOIDC(
|
||||
serverURL string,
|
||||
cfg *types.OIDCConfig,
|
||||
) (*AuthProviderOIDC, error) {
|
||||
var err error
|
||||
// grab oidc config if it hasn't been already
|
||||
// Use the caller's context (bounded, see app.go) so a slow or unreachable
|
||||
// issuer fails discovery within the timeout instead of hanging startup.
|
||||
oidcProvider, err := oidc.NewProvider(ctx, cfg.Issuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating OIDC provider from issuer config: %w", err)
|
||||
@@ -117,6 +117,15 @@ func NewAuthProviderOIDC(
|
||||
}, nil
|
||||
}
|
||||
|
||||
// cookiesSecure reports whether the OIDC cookies should carry the Secure flag.
|
||||
// It keys off the configured server_url scheme, not req.TLS, so cookies stay
|
||||
// Secure behind a TLS-terminating reverse proxy (where the proxy→Headscale hop
|
||||
// is plain HTTP and req.TLS is nil). Deriving it from config avoids trusting a
|
||||
// spoofable X-Forwarded-Proto header.
|
||||
func (a *AuthProviderOIDC) cookiesSecure() bool {
|
||||
return strings.HasPrefix(a.serverURL, "https://")
|
||||
}
|
||||
|
||||
func (a *AuthProviderOIDC) AuthURL(authID types.AuthID) string {
|
||||
return authPathURL(a.serverURL, "auth", authID)
|
||||
}
|
||||
@@ -156,10 +165,10 @@ func (a *AuthProviderOIDC) authHandler(
|
||||
}
|
||||
|
||||
// Set the state and nonce cookies to protect against CSRF attacks
|
||||
state := setCSRFCookie(writer, req, "state")
|
||||
state := setCSRFCookie(writer, req, "state", a.cookiesSecure())
|
||||
|
||||
// Set the state and nonce cookies to protect against CSRF attacks
|
||||
nonce := setCSRFCookie(writer, req, "nonce")
|
||||
nonce := setCSRFCookie(writer, req, "nonce", a.cookiesSecure())
|
||||
|
||||
registrationInfo := AuthInfo{
|
||||
AuthID: authID,
|
||||
@@ -263,6 +272,11 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
return
|
||||
}
|
||||
|
||||
// The state/nonce cookies have served their CSRF purpose; clear them so a
|
||||
// single-use pair does not linger in the browser until MaxAge.
|
||||
clearOIDCCallbackCookie(writer, stateCookieName)
|
||||
clearOIDCCallbackCookie(writer, nonceCookieName)
|
||||
|
||||
nodeExpiry := a.determineNodeExpiry(idToken.Expiry)
|
||||
|
||||
var claims types.OIDCClaims
|
||||
@@ -589,13 +603,17 @@ func doOIDCAuthorization(
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAuthInfoFromState retrieves the registration ID from the state.
|
||||
// getAuthInfoFromState retrieves and consumes the auth info for a state. The
|
||||
// entry is removed on read so a state is single-use: a replayed callback cannot
|
||||
// resolve the same auth session twice, even within the cache TTL.
|
||||
func (a *AuthProviderOIDC) getAuthInfoFromState(state string) *AuthInfo {
|
||||
authInfo, ok := a.authCache.Get(state)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
a.authCache.Remove(state)
|
||||
|
||||
return &authInfo
|
||||
}
|
||||
|
||||
@@ -658,14 +676,15 @@ func setRegisterConfirmCookie(
|
||||
authID types.AuthID,
|
||||
value string,
|
||||
maxAge int,
|
||||
secure bool,
|
||||
) {
|
||||
//nolint:gosec // G124: Secure set conditionally via req.TLS; HttpOnly + SameSite already set
|
||||
//nolint:gosec // G124: Secure from server_url scheme or req.TLS; HttpOnly + SameSite already set
|
||||
http.SetCookie(writer, &http.Cookie{
|
||||
Name: registerConfirmCSRFCookie,
|
||||
Value: value,
|
||||
Path: "/register/confirm/" + authID.String(),
|
||||
MaxAge: maxAge,
|
||||
Secure: req.TLS != nil,
|
||||
Secure: secure || req.TLS != nil,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
@@ -707,7 +726,7 @@ func (a *AuthProviderOIDC) renderRegistrationConfirmInterstitial(
|
||||
CSRF: csrf,
|
||||
})
|
||||
|
||||
setRegisterConfirmCookie(writer, req, authID, csrf, int(authCacheExpiration.Seconds()))
|
||||
setRegisterConfirmCookie(writer, req, authID, csrf, int(authCacheExpiration.Seconds()), a.cookiesSecure())
|
||||
|
||||
regData := authReq.RegistrationData()
|
||||
|
||||
@@ -824,7 +843,7 @@ func (a *AuthProviderOIDC) RegisterConfirmHandler(
|
||||
}
|
||||
|
||||
// Clear the CSRF cookie now that the registration is final.
|
||||
setRegisterConfirmCookie(writer, req, authID, "", -1)
|
||||
setRegisterConfirmCookie(writer, req, authID, "", -1, a.cookiesSecure())
|
||||
|
||||
content := renderRegistrationSuccessTemplate(user, newNode)
|
||||
|
||||
@@ -920,16 +939,27 @@ func getCookieName(baseName, value string) string {
|
||||
return fmt.Sprintf("%s_%s", baseName, value[:n])
|
||||
}
|
||||
|
||||
func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string) string {
|
||||
// clearOIDCCallbackCookie expires a /oidc/callback cookie by name. Matching the
|
||||
// path the cookie was set with is required for the browser to drop it.
|
||||
func clearOIDCCallbackCookie(w http.ResponseWriter, name string) {
|
||||
//nolint:gosec // G124: a deletion cookie (empty value, MaxAge<0); security attributes are moot
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Path: "/oidc/callback",
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string, secure bool) string {
|
||||
val := rands.HexString(64)
|
||||
|
||||
//nolint:gosec // G124: Secure set conditionally via r.TLS; HttpOnly + SameSite set below
|
||||
//nolint:gosec // G124: Secure from server_url scheme or req.TLS; HttpOnly + SameSite set below
|
||||
c := &http.Cookie{
|
||||
Path: "/oidc/callback",
|
||||
Name: getCookieName(name, val),
|
||||
Value: val,
|
||||
MaxAge: int(time.Hour.Seconds()),
|
||||
Secure: r.TLS != nil,
|
||||
Secure: secure || r.TLS != nil,
|
||||
HttpOnly: true,
|
||||
// Lax, not Strict: the OIDC callback is a cross-site top-level GET
|
||||
// redirect from the IdP that must still carry this cookie. Strict
|
||||
|
||||
+69
-1
@@ -4,7 +4,9 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/golang-lru/v2/expirable"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -186,10 +188,76 @@ func TestSetCSRFCookieSameSite(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/abcdef0123456789", nil)
|
||||
|
||||
setCSRFCookie(w, r, "state")
|
||||
setCSRFCookie(w, r, "state", false)
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
assert.Equal(t, http.SameSiteLaxMode, cookies[0].SameSite,
|
||||
"OIDC CSRF cookie must explicitly set SameSite=Lax")
|
||||
}
|
||||
|
||||
// TestExtractCodeAndStateParam covers the callback's first trust-boundary
|
||||
// checks: both params required, and a too-short state is rejected before
|
||||
// getCookieName can slice out of range.
|
||||
func TestExtractCodeAndStateParam(t *testing.T) {
|
||||
_, _, err := extractCodeAndStateParamFromRequest(
|
||||
httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/oidc/callback", nil))
|
||||
require.Error(t, err)
|
||||
|
||||
_, _, err = extractCodeAndStateParamFromRequest(
|
||||
httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/oidc/callback?code=c&state=abc", nil))
|
||||
require.ErrorIs(t, err, errOIDCStateTooShort)
|
||||
|
||||
code, state, err := extractCodeAndStateParamFromRequest(
|
||||
httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/oidc/callback?code=c&state=abcdef0123", nil))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "c", code)
|
||||
assert.Equal(t, "abcdef0123", state)
|
||||
}
|
||||
|
||||
// TestGetAuthInfoFromStateSingleUse asserts a consumed OIDC state cannot be
|
||||
// resolved twice, so a replayed callback cannot re-bind the same session.
|
||||
func TestGetAuthInfoFromStateSingleUse(t *testing.T) {
|
||||
a := &AuthProviderOIDC{
|
||||
authCache: expirable.NewLRU[string, AuthInfo](16, nil, time.Minute),
|
||||
}
|
||||
a.authCache.Add("state-x", AuthInfo{Registration: true})
|
||||
|
||||
got := a.getAuthInfoFromState("state-x")
|
||||
require.NotNil(t, got)
|
||||
assert.True(t, got.Registration)
|
||||
|
||||
assert.Nil(t, a.getAuthInfoFromState("state-x"), "a consumed state must not resolve again")
|
||||
}
|
||||
|
||||
// TestClearOIDCCallbackCookie asserts the cookie is expired (negative MaxAge) on
|
||||
// the same path it was set with, so the browser drops it.
|
||||
func TestClearOIDCCallbackCookie(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
clearOIDCCallbackCookie(w, "state_abcdef")
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
assert.Equal(t, "state_abcdef", cookies[0].Name)
|
||||
assert.Negative(t, cookies[0].MaxAge, "deletion cookie must have negative MaxAge")
|
||||
}
|
||||
|
||||
// TestSetCSRFCookieSecure verifies the Secure flag is driven by the secure
|
||||
// argument (derived from the configured https server_url), not only req.TLS, so
|
||||
// cookies stay Secure behind a TLS-terminating reverse proxy where req.TLS is
|
||||
// nil.
|
||||
func TestSetCSRFCookieSecure(t *testing.T) {
|
||||
r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/abcdef0123456789", nil)
|
||||
|
||||
secureRec := httptest.NewRecorder()
|
||||
setCSRFCookie(secureRec, r, "state", true)
|
||||
require.Len(t, secureRec.Result().Cookies(), 1)
|
||||
assert.True(t, secureRec.Result().Cookies()[0].Secure,
|
||||
"https server_url must set Secure even when req.TLS is nil (proxy case)")
|
||||
|
||||
plainRec := httptest.NewRecorder()
|
||||
setCSRFCookie(plainRec, r, "state", false)
|
||||
require.Len(t, plainRec.Result().Cookies(), 1)
|
||||
assert.False(t, plainRec.Result().Cookies()[0].Secure,
|
||||
"plain-http server_url without req.TLS must not set Secure")
|
||||
}
|
||||
|
||||
@@ -33,6 +33,12 @@ type PolicyManager interface {
|
||||
// TagExists reports whether the given tag is defined in the policy.
|
||||
TagExists(tag string) bool
|
||||
|
||||
// TagOwnedByTags reports whether a credential holding ownerTags may apply
|
||||
// tag: true if tag is one of ownerTags, or tag's tag-to-tag ownership chain
|
||||
// transitively includes one of ownerTags. Authorises the tags an OAuth
|
||||
// access token may set on the auth keys it mints.
|
||||
TagOwnedByTags(tag string, ownerTags []string) bool
|
||||
|
||||
// NodeCanApproveRoute reports whether the given node can approve the given route.
|
||||
NodeCanApproveRoute(node types.NodeView, route netip.Prefix) bool
|
||||
|
||||
|
||||
+134
-64
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/juanfont/headscale/hscontrol/policy/matcher"
|
||||
"github.com/juanfont/headscale/hscontrol/policy/policyutil"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/puzpuzpuz/xsync/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
"go4.org/netipx"
|
||||
"tailscale.com/net/tsaddr"
|
||||
@@ -28,7 +29,10 @@ import (
|
||||
var ErrInvalidTagOwner = errors.New("tag owner is not an Alias")
|
||||
|
||||
type PolicyManager struct {
|
||||
mu sync.Mutex
|
||||
// RWMutex, not Mutex, so concurrent map generation does not serialise on
|
||||
// reads. The per-node caches are xsync.Maps so a read can fill them without
|
||||
// taking the write lock.
|
||||
mu sync.RWMutex
|
||||
pol *Policy
|
||||
users []types.User
|
||||
nodes views.Slice[types.NodeView]
|
||||
@@ -55,7 +59,7 @@ type PolicyManager struct {
|
||||
viaTargetTags map[Tag]struct{}
|
||||
|
||||
// Lazy map of SSH policies
|
||||
sshPolicyMap map[types.NodeID]*tailcfg.SSHPolicy
|
||||
sshPolicyMap *xsync.Map[types.NodeID, *tailcfg.SSHPolicy]
|
||||
|
||||
// compiledGrants are the grants with sources pre-resolved.
|
||||
// The single source of truth for filter compilation. Both
|
||||
@@ -64,12 +68,12 @@ type PolicyManager struct {
|
||||
userNodeIdx userNodeIndex
|
||||
|
||||
// Lazy map of per-node filter rules (reduced, for packet filters)
|
||||
filterRulesMap map[types.NodeID][]tailcfg.FilterRule
|
||||
filterRulesMap *xsync.Map[types.NodeID, []tailcfg.FilterRule]
|
||||
|
||||
// Lazy map of per-node matchers derived from UNREDUCED filter
|
||||
// rules. Only populated on the slow path when needsPerNodeFilter
|
||||
// is true; the fast path returns pm.matchers directly.
|
||||
matchersForNodeMap map[types.NodeID][]matcher.Match
|
||||
matchersForNodeMap *xsync.Map[types.NodeID, []matcher.Match]
|
||||
|
||||
// needsPerNodeFilter is true when any compiled grant requires
|
||||
// per-node work (autogroup:self or via grants).
|
||||
@@ -197,9 +201,9 @@ func NewPolicyManager(b []byte, users []types.User, nodes views.Slice[types.Node
|
||||
pol: policy,
|
||||
users: users,
|
||||
nodes: nodes,
|
||||
sshPolicyMap: make(map[types.NodeID]*tailcfg.SSHPolicy, nodes.Len()),
|
||||
filterRulesMap: make(map[types.NodeID][]tailcfg.FilterRule, nodes.Len()),
|
||||
matchersForNodeMap: make(map[types.NodeID][]matcher.Match, nodes.Len()),
|
||||
sshPolicyMap: xsync.NewMap[types.NodeID, *tailcfg.SSHPolicy](),
|
||||
filterRulesMap: xsync.NewMap[types.NodeID, []tailcfg.FilterRule](),
|
||||
matchersForNodeMap: xsync.NewMap[types.NodeID, []matcher.Match](),
|
||||
}
|
||||
|
||||
_, err = pm.updateLocked()
|
||||
@@ -354,9 +358,9 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
|
||||
// TODO(kradalby): This could potentially be optimized by only clearing the
|
||||
// policies for nodes that have changed. Particularly if the only difference is
|
||||
// that nodes has been added or removed.
|
||||
clear(pm.sshPolicyMap)
|
||||
clear(pm.filterRulesMap)
|
||||
clear(pm.matchersForNodeMap)
|
||||
pm.sshPolicyMap.Clear()
|
||||
pm.filterRulesMap.Clear()
|
||||
pm.matchersForNodeMap.Clear()
|
||||
}
|
||||
|
||||
// If nothing changed, no need to update nodes
|
||||
@@ -400,8 +404,8 @@ func (pm *PolicyManager) NodeNeedsPeerRecompute(node types.NodeView) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
|
||||
if pm.relayTargetIPs != nil && node.InIPSet(pm.relayTargetIPs) {
|
||||
return true
|
||||
@@ -422,10 +426,10 @@ func (pm *PolicyManager) NodeNeedsPeerRecompute(node types.NodeView) bool {
|
||||
// /machine/ssh/action/{src}/to/{dst}?local_user={local_user} per the
|
||||
// SaaS wire format. Cache is invalidated on policy reload.
|
||||
func (pm *PolicyManager) SSHPolicy(baseURL string, node types.NodeView) (*tailcfg.SSHPolicy, error) {
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
|
||||
if sshPol, ok := pm.sshPolicyMap[node.ID()]; ok {
|
||||
if sshPol, ok := pm.sshPolicyMap.Load(node.ID()); ok {
|
||||
return sshPol, nil
|
||||
}
|
||||
|
||||
@@ -434,7 +438,7 @@ func (pm *PolicyManager) SSHPolicy(baseURL string, node types.NodeView) (*tailcf
|
||||
return nil, fmt.Errorf("compiling SSH policy: %w", err)
|
||||
}
|
||||
|
||||
pm.sshPolicyMap[node.ID()] = sshPol
|
||||
pm.sshPolicyMap.Store(node.ID(), sshPol)
|
||||
|
||||
return sshPol, nil
|
||||
}
|
||||
@@ -450,8 +454,8 @@ func (pm *PolicyManager) SSHPolicy(baseURL string, node types.NodeView) (*tailcf
|
||||
func (pm *PolicyManager) SSHCheckParams(
|
||||
srcNodeID, dstNodeID types.NodeID,
|
||||
) (time.Duration, bool) {
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
|
||||
if pm.pol == nil || len(pm.pol.SSHs) == 0 {
|
||||
return 0, false
|
||||
@@ -584,8 +588,8 @@ func (pm *PolicyManager) Filter() ([]tailcfg.FilterRule, []matcher.Match) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
|
||||
return pm.filter, pm.matchers
|
||||
}
|
||||
@@ -604,8 +608,8 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ
|
||||
return nil
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
|
||||
// Precompute each node's subnet routes and exit-node status once; the
|
||||
// O(n^2) pair scans below would otherwise recompute them for every pair.
|
||||
@@ -726,7 +730,7 @@ func (pm *PolicyManager) filterForNodeLocked(
|
||||
return nil
|
||||
}
|
||||
|
||||
if rules, ok := pm.filterRulesMap[node.ID()]; ok {
|
||||
if rules, ok := pm.filterRulesMap.Load(node.ID()); ok {
|
||||
return rules
|
||||
}
|
||||
|
||||
@@ -738,7 +742,7 @@ func (pm *PolicyManager) filterForNodeLocked(
|
||||
}
|
||||
|
||||
reduced := policyutil.ReduceFilterRules(node, unreduced)
|
||||
pm.filterRulesMap[node.ID()] = reduced
|
||||
pm.filterRulesMap.Store(node.ID(), reduced)
|
||||
|
||||
return reduced
|
||||
}
|
||||
@@ -755,8 +759,8 @@ func (pm *PolicyManager) FilterForNode(node types.NodeView) ([]tailcfg.FilterRul
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
|
||||
return pm.filterForNodeLocked(node), nil
|
||||
}
|
||||
@@ -776,8 +780,8 @@ func (pm *PolicyManager) MatchersForNode(node types.NodeView) ([]matcher.Match,
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
|
||||
// For global policies, return the shared global matchers.
|
||||
// Via grants require per-node matchers because the global matchers
|
||||
@@ -786,7 +790,7 @@ func (pm *PolicyManager) MatchersForNode(node types.NodeView) ([]matcher.Match,
|
||||
return pm.matchers, nil
|
||||
}
|
||||
|
||||
if cached, ok := pm.matchersForNodeMap[node.ID()]; ok {
|
||||
if cached, ok := pm.matchersForNodeMap.Load(node.ID()); ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
@@ -794,7 +798,7 @@ func (pm *PolicyManager) MatchersForNode(node types.NodeView) ([]matcher.Match,
|
||||
// the stored compiled grants for this specific node.
|
||||
unreduced := pm.filterRulesForNodeLocked(node)
|
||||
matchers := matcher.MatchesFromFilterRules(unreduced)
|
||||
pm.matchersForNodeMap[node.ID()] = matchers
|
||||
pm.matchersForNodeMap.Store(node.ID(), matchers)
|
||||
|
||||
return matchers, nil
|
||||
}
|
||||
@@ -813,7 +817,7 @@ func (pm *PolicyManager) SetUsers(users []types.User) (bool, error) {
|
||||
// Clear SSH policy map when users change to force SSH policy recomputation
|
||||
// This ensures that if SSH policy compilation previously failed due to missing users,
|
||||
// it will be retried with the new user list
|
||||
clear(pm.sshPolicyMap)
|
||||
pm.sshPolicyMap.Clear()
|
||||
|
||||
changed, err := pm.updateLocked()
|
||||
if err != nil {
|
||||
@@ -866,9 +870,9 @@ func (pm *PolicyManager) SetNodes(nodes views.Slice[types.NodeView]) (bool, erro
|
||||
|
||||
if !needsUpdate {
|
||||
// This ensures fresh filter rules are generated for all nodes
|
||||
clear(pm.sshPolicyMap)
|
||||
clear(pm.filterRulesMap)
|
||||
clear(pm.matchersForNodeMap)
|
||||
pm.sshPolicyMap.Clear()
|
||||
pm.filterRulesMap.Clear()
|
||||
pm.matchersForNodeMap.Clear()
|
||||
}
|
||||
// Always return true when nodes changed, even if filter hash didn't change
|
||||
// (can happen with autogroup:self or when nodes are added but don't affect rules)
|
||||
@@ -929,8 +933,8 @@ func (pm *PolicyManager) NodeCanHaveTag(node types.NodeView, tag string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
|
||||
// pm.pol is written by SetPolicy under pm.mu; reading it before the
|
||||
// lock races with concurrent policy reloads.
|
||||
@@ -968,6 +972,66 @@ func (pm *PolicyManager) NodeCanHaveTag(node types.NodeView, tag string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// TagOwnedByTags reports whether a credential holding ownerTags is authorised to
|
||||
// apply tag. It is true when tag is one of ownerTags, or when tag's tagOwners
|
||||
// chain (tag-to-tag ownership) transitively includes one of ownerTags. This is
|
||||
// the tag-level check used when an OAuth access token mints an auth key: the
|
||||
// requested tags must each be owned by the token's tags, so an operator token
|
||||
// tagged tag:k8s-operator may mint tag:k8s keys when the policy declares
|
||||
// "tag:k8s": ["tag:k8s-operator"]. It is purely tag-relational and does not
|
||||
// consult node IPs.
|
||||
func (pm *PolicyManager) TagOwnedByTags(tag string, ownerTags []string) bool {
|
||||
if pm == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
owns := make(map[string]bool, len(ownerTags))
|
||||
for _, t := range ownerTags {
|
||||
owns[t] = true
|
||||
}
|
||||
|
||||
// A credential may always apply a tag it directly holds; this needs no policy.
|
||||
if owns[tag] {
|
||||
return true
|
||||
}
|
||||
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
|
||||
// Owned-by delegation requires the policy's tagOwners.
|
||||
if pm.pol == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Walk tag-to-tag ownership transitively, guarding against cycles.
|
||||
visited := make(map[Tag]bool)
|
||||
|
||||
var walk func(t Tag) bool
|
||||
|
||||
walk = func(t Tag) bool {
|
||||
if visited[t] {
|
||||
return false
|
||||
}
|
||||
|
||||
visited[t] = true
|
||||
|
||||
for _, owner := range pm.pol.TagOwners[t] {
|
||||
ot, ok := owner.(*Tag)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if owns[string(*ot)] || walk(*ot) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return walk(Tag(tag))
|
||||
}
|
||||
|
||||
// userMatchesOwner checks if a user matches a tag owner entry.
|
||||
// This is used as a fallback when the node's IP is not in the [PolicyManager.tagOwnerMap].
|
||||
func (pm *PolicyManager) userMatchesOwner(user types.UserView, owner Owner) bool {
|
||||
@@ -1018,8 +1082,8 @@ func (pm *PolicyManager) TagExists(tag string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
|
||||
// pm.pol is written by SetPolicy under pm.mu; reading it before the
|
||||
// lock races with concurrent policy reloads.
|
||||
@@ -1037,8 +1101,8 @@ func (pm *PolicyManager) NodeCanApproveRoute(node types.NodeView, route netip.Pr
|
||||
return false
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
|
||||
// If the route to-be-approved is an exit route, then we need to check
|
||||
// if the node is in allowed to approve it. This is treated differently
|
||||
@@ -1100,8 +1164,8 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via
|
||||
return result
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
|
||||
// pm.pol is written by SetPolicy under pm.mu; reading it before the
|
||||
// lock races with concurrent policy reloads.
|
||||
@@ -1319,8 +1383,8 @@ func (pm *PolicyManager) DebugString() string {
|
||||
|
||||
// pm.pol, filter, matchers, and the derived maps are all written
|
||||
// under pm.mu by SetPolicy/SetUsers/SetNodes.
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
@@ -1465,7 +1529,7 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S
|
||||
// Clear cache entries for affected users only.
|
||||
// For autogroup:self, we need to clear all nodes belonging to affected users
|
||||
// because autogroup:self rules depend on the entire user's device set.
|
||||
for nodeID := range pm.filterRulesMap {
|
||||
pm.filterRulesMap.Range(func(nodeID types.NodeID, _ []tailcfg.FilterRule) bool {
|
||||
// Find the user for this cached node using the already-built indexes.
|
||||
node, ok := newNodeMap[nodeID]
|
||||
if !ok {
|
||||
@@ -1474,10 +1538,10 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S
|
||||
|
||||
// Node not found in either old or new list, clear it.
|
||||
if !ok {
|
||||
delete(pm.filterRulesMap, nodeID)
|
||||
delete(pm.matchersForNodeMap, nodeID)
|
||||
pm.filterRulesMap.Delete(nodeID)
|
||||
pm.matchersForNodeMap.Delete(nodeID)
|
||||
|
||||
continue
|
||||
return true
|
||||
}
|
||||
|
||||
// Tagged nodes don't participate in autogroup:self, so their cache
|
||||
@@ -1489,15 +1553,17 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S
|
||||
|
||||
// If the owning user is affected, clear this cache entry.
|
||||
if _, affected := affectedUsers[nodeUserID]; affected {
|
||||
delete(pm.filterRulesMap, nodeID)
|
||||
delete(pm.matchersForNodeMap, nodeID)
|
||||
pm.filterRulesMap.Delete(nodeID)
|
||||
pm.matchersForNodeMap.Delete(nodeID)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if len(affectedUsers) > 0 {
|
||||
log.Debug().
|
||||
Int("affected_users", len(affectedUsers)).
|
||||
Int("remaining_cache_entries", len(pm.filterRulesMap)).
|
||||
Int("remaining_cache_entries", pm.filterRulesMap.Size()).
|
||||
Msg("Selectively cleared autogroup:self cache for affected users")
|
||||
}
|
||||
}
|
||||
@@ -1531,23 +1597,27 @@ func (pm *PolicyManager) invalidateGlobalPolicyCache(newNodes views.Slice[types.
|
||||
}
|
||||
|
||||
if newNode.HasNetworkChanges(oldNode) {
|
||||
delete(pm.filterRulesMap, nodeID)
|
||||
delete(pm.matchersForNodeMap, nodeID)
|
||||
pm.filterRulesMap.Delete(nodeID)
|
||||
pm.matchersForNodeMap.Delete(nodeID)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove deleted nodes from cache
|
||||
for nodeID := range pm.filterRulesMap {
|
||||
pm.filterRulesMap.Range(func(nodeID types.NodeID, _ []tailcfg.FilterRule) bool {
|
||||
if _, exists := newNodeMap[nodeID]; !exists {
|
||||
delete(pm.filterRulesMap, nodeID)
|
||||
pm.filterRulesMap.Delete(nodeID)
|
||||
}
|
||||
}
|
||||
|
||||
for nodeID := range pm.matchersForNodeMap {
|
||||
return true
|
||||
})
|
||||
|
||||
pm.matchersForNodeMap.Range(func(nodeID types.NodeID, _ []matcher.Match) bool {
|
||||
if _, exists := newNodeMap[nodeID]; !exists {
|
||||
delete(pm.matchersForNodeMap, nodeID)
|
||||
pm.matchersForNodeMap.Delete(nodeID)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// flattenTags resolves nested tag-owner references. Cycles
|
||||
@@ -1729,8 +1799,8 @@ func (pm *PolicyManager) NodeCapMap(id types.NodeID) tailcfg.NodeCapMap {
|
||||
return nil
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
|
||||
src := pm.nodeAttrsMap[id]
|
||||
if len(src) == 0 {
|
||||
@@ -1753,8 +1823,8 @@ func (pm *PolicyManager) NodeCapMaps() map[types.NodeID]tailcfg.NodeCapMap {
|
||||
return nil
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
|
||||
out := make(map[types.NodeID]tailcfg.NodeCapMap, len(pm.nodeAttrsMap))
|
||||
maps.Copy(out, pm.nodeAttrsMap)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestPolicyManagerConcurrentReads is the correctness guard for the #3346 fix:
|
||||
// PolicyManager read methods take a shared RLock and populate their per-node
|
||||
// caches (filterRulesMap, matchersForNodeMap) concurrently. This test hammers
|
||||
// those reads from many goroutines while a writer mutates the node set, so the
|
||||
// race detector catches any unsafe access to the shared caches or policy state.
|
||||
//
|
||||
// It uses an autogroup:self policy so reads take the per-node filter slow path
|
||||
// — the same path that made #3346's reconnect storm expensive — which is where
|
||||
// the lazy caches are written.
|
||||
func TestPolicyManagerConcurrentReads(t *testing.T) {
|
||||
users := types.Users{
|
||||
{Model: gorm.Model{ID: 1}, Name: "user1", Email: "user1@headscale.net"},
|
||||
{Model: gorm.Model{ID: 2}, Name: "user2", Email: "user2@headscale.net"},
|
||||
{Model: gorm.Model{ID: 3}, Name: "user3", Email: "user3@headscale.net"},
|
||||
}
|
||||
|
||||
policy := `{
|
||||
"acls": [
|
||||
{
|
||||
"action": "accept",
|
||||
"src": ["autogroup:member"],
|
||||
"dst": ["autogroup:self:*"]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
const nodeCount = 60
|
||||
|
||||
nodes := make(types.Nodes, 0, nodeCount)
|
||||
for i := range nodeCount {
|
||||
n := node(
|
||||
fmt.Sprintf("node%d", i),
|
||||
fmt.Sprintf("100.64.0.%d", i+1),
|
||||
fmt.Sprintf("fd7a:115c:a1e0::%d", i+1),
|
||||
users[i%len(users)],
|
||||
)
|
||||
n.ID = types.NodeID(i + 1) //nolint:gosec // safe in test
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
|
||||
pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice())
|
||||
require.NoError(t, err)
|
||||
|
||||
const (
|
||||
readers = 16
|
||||
iterations = 60
|
||||
mutatorReloads = 30
|
||||
)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Concurrent readers exercise every converted RLock read path, including
|
||||
// the two lazily populated per-node caches. Assertions inside the
|
||||
// goroutines use assert (not require) so a failure does not call
|
||||
// t.FailNow from a non-test goroutine.
|
||||
for r := range readers {
|
||||
wg.Go(func() {
|
||||
for i := range iterations {
|
||||
nv := nodes[(r+i)%len(nodes)].View()
|
||||
|
||||
rules, err := pm.FilterForNode(nv)
|
||||
assert.NoError(t, err) //nolint:testifylint // assert (not require) is correct off the test goroutine
|
||||
assert.NotNil(t, rules)
|
||||
|
||||
_, err = pm.MatchersForNode(nv)
|
||||
assert.NoError(t, err) //nolint:testifylint // assert (not require) is correct off the test goroutine
|
||||
|
||||
pm.Filter()
|
||||
pm.NodeCapMap(nv.ID())
|
||||
|
||||
// BuildPeerMap is the O(n^2) writer-side read; exercise it
|
||||
// under RLock too, but not every iteration.
|
||||
if i%8 == 0 {
|
||||
assert.NotNil(t, pm.BuildPeerMap(nodes.ViewSlice()))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A writer repeatedly re-sets the node set, invalidating and racing the
|
||||
// caches the readers are populating.
|
||||
wg.Go(func() {
|
||||
for range mutatorReloads {
|
||||
_, err := pm.SetNodes(nodes.ViewSlice())
|
||||
assert.NoError(t, err) //nolint:testifylint // assert (not require) is correct off the test goroutine
|
||||
}
|
||||
})
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/juanfont/headscale/hscontrol/policy/matcher"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/puzpuzpuz/xsync/v4"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/net/tsaddr"
|
||||
@@ -122,7 +123,7 @@ func TestInvalidateAutogroupSelfCache(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
require.Len(t, pm.filterRulesMap, len(initialNodes))
|
||||
require.Equal(t, len(initialNodes), pm.filterRulesMap.Size())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -207,19 +208,20 @@ func TestInvalidateAutogroupSelfCache(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
pm.filterRulesMap = make(map[types.NodeID][]tailcfg.FilterRule)
|
||||
pm.filterRulesMap.Clear()
|
||||
|
||||
for _, n := range initialNodes {
|
||||
_, err := pm.FilterForNode(n.View())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
initialCacheSize := len(pm.filterRulesMap)
|
||||
initialCacheSize := pm.filterRulesMap.Size()
|
||||
require.Equal(t, len(initialNodes), initialCacheSize)
|
||||
|
||||
pm.invalidateAutogroupSelfCache(initialNodes.ViewSlice(), tt.newNodes.ViewSlice())
|
||||
|
||||
// Verify the expected number of cache entries were cleared
|
||||
finalCacheSize := len(pm.filterRulesMap)
|
||||
finalCacheSize := pm.filterRulesMap.Size()
|
||||
clearedEntries := initialCacheSize - finalCacheSize
|
||||
require.Equal(t, tt.expectedCleared, clearedEntries, tt.description)
|
||||
})
|
||||
@@ -498,15 +500,19 @@ func TestInvalidateGlobalPolicyCache(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pm := &PolicyManager{
|
||||
nodes: tt.oldNodes.ViewSlice(),
|
||||
filterRulesMap: tt.initialCache,
|
||||
nodes: tt.oldNodes.ViewSlice(),
|
||||
filterRulesMap: xsync.NewMap[types.NodeID, []tailcfg.FilterRule](),
|
||||
matchersForNodeMap: xsync.NewMap[types.NodeID, []matcher.Match](),
|
||||
}
|
||||
for id, rules := range tt.initialCache {
|
||||
pm.filterRulesMap.Store(id, rules)
|
||||
}
|
||||
|
||||
pm.invalidateGlobalPolicyCache(tt.newNodes.ViewSlice())
|
||||
|
||||
// Verify cache state
|
||||
for nodeID, shouldExist := range tt.expectedCacheAfter {
|
||||
_, exists := pm.filterRulesMap[nodeID]
|
||||
_, exists := pm.filterRulesMap.Load(nodeID)
|
||||
require.Equal(t, shouldExist, exists, "node %d cache existence mismatch", nodeID)
|
||||
}
|
||||
})
|
||||
@@ -2470,3 +2476,75 @@ func TestPeerRelayGrantMakesRelayVisible(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagOwnedByTags(t *testing.T) {
|
||||
// tag:leaf is owned by tag:mid, which is owned by tag:root: a tag-to-tag
|
||||
// delegation chain, the shape an operator token uses to mint narrower keys.
|
||||
const policy = `{
|
||||
"tagOwners": {
|
||||
"tag:root": [],
|
||||
"tag:mid": ["tag:root"],
|
||||
"tag:leaf": ["tag:mid"],
|
||||
"tag:lone": []
|
||||
},
|
||||
"acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}]
|
||||
}`
|
||||
|
||||
pm, err := NewPolicyManager([]byte(policy), nil, types.Nodes{}.ViewSlice())
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tag string
|
||||
ownerTags []string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "directly held tag needs no policy",
|
||||
tag: "tag:lone",
|
||||
ownerTags: []string{"tag:lone"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "one-hop owned-by",
|
||||
tag: "tag:mid",
|
||||
ownerTags: []string{"tag:root"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "transitive chain root owns leaf",
|
||||
tag: "tag:leaf",
|
||||
ownerTags: []string{"tag:root"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "owning one link does not grant a sibling",
|
||||
tag: "tag:lone",
|
||||
ownerTags: []string{"tag:root"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unowned tag denied",
|
||||
tag: "tag:leaf",
|
||||
ownerTags: []string{"tag:unrelated"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty owners deny a delegated tag",
|
||||
tag: "tag:leaf",
|
||||
ownerTags: nil,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, pm.TagOwnedByTags(tt.tag, tt.ownerTags))
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("nil policy manager denies", func(t *testing.T) {
|
||||
var nilPM *PolicyManager
|
||||
require.False(t, nilPM.TagOwnedByTags("tag:leaf", []string{"tag:root"}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -122,8 +122,8 @@ func (pm *PolicyManager) RunSSHTests() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
|
||||
cache := make(map[types.NodeID]*tailcfg.SSHPolicy)
|
||||
results := runSSHPolicyTests(pm.pol, pm.users, pm.nodes, cache)
|
||||
|
||||
@@ -215,8 +215,8 @@ func (pm *PolicyManager) RunTests() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
|
||||
results := runPolicyTests(pm.pol, pm.filter, pm.users, pm.nodes)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// TestParseDestinationAndPort tests the splitDestinationAndPort function using table-driven tests.
|
||||
// TestSplitDestinationAndPort tests the splitDestinationAndPort function using table-driven tests.
|
||||
func TestSplitDestinationAndPort(t *testing.T) {
|
||||
testCases := []struct {
|
||||
input string
|
||||
|
||||
@@ -230,6 +230,11 @@ func (m *mapSession) serveLongPoll() {
|
||||
mapReqChange, err := m.h.state.UpdateNodeFromMapRequest(m.node.ID, m.req)
|
||||
if err != nil {
|
||||
m.log.Error().Caller().Err(err).Msg("failed to update node from initial MapRequest")
|
||||
// Write an explicit error rather than returning silently: a bare
|
||||
// return leaves net/http to send an empty 200, which the client
|
||||
// reads as "unexpected EOF" and retries forever (issue #3346).
|
||||
httpError(m.w, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -252,6 +257,11 @@ func (m *mapSession) serveLongPoll() {
|
||||
// time between the node connecting and the batcher being ready.
|
||||
if err := m.h.mapBatcher.AddNode(m.node.ID, m.ch, m.capVer, m.stopFromBatcher); err != nil { //nolint:noinlineerr
|
||||
m.log.Error().Caller().Err(err).Msg("failed to add node to batcher")
|
||||
// Write an explicit error rather than returning silently: a bare
|
||||
// return leaves net/http to send an empty 200, which the client
|
||||
// reads as "unexpected EOF" and retries forever (issue #3346).
|
||||
httpError(m.w, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/db"
|
||||
"github.com/juanfont/headscale/hscontrol/mapper"
|
||||
"github.com/juanfont/headscale/hscontrol/state"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/types/change"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -89,6 +91,131 @@ func (w *delayedSuccessResponseWriter) WriteCount() int {
|
||||
return w.writeCount
|
||||
}
|
||||
|
||||
// recordingResponseWriter records the status code and whether anything was
|
||||
// written, so a test can tell an explicit error response apart from a handler
|
||||
// that returned without writing (which net/http turns into an empty 200 the
|
||||
// client reads as "unexpected EOF").
|
||||
type recordingResponseWriter struct {
|
||||
mu sync.Mutex
|
||||
header http.Header
|
||||
status int
|
||||
writes int
|
||||
}
|
||||
|
||||
func (w *recordingResponseWriter) Header() http.Header {
|
||||
if w.header == nil {
|
||||
w.header = make(http.Header)
|
||||
}
|
||||
|
||||
return w.header
|
||||
}
|
||||
|
||||
func (w *recordingResponseWriter) WriteHeader(code int) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
if w.status == 0 {
|
||||
w.status = code
|
||||
}
|
||||
}
|
||||
|
||||
func (w *recordingResponseWriter) Write(data []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
if w.status == 0 {
|
||||
w.status = http.StatusOK
|
||||
}
|
||||
|
||||
w.writes++
|
||||
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
func (w *recordingResponseWriter) Flush() {}
|
||||
|
||||
func (w *recordingResponseWriter) statusCode() int {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
return w.status
|
||||
}
|
||||
|
||||
// TestServeLongPollWritesErrorWhenInitialMapFails proves that when the initial
|
||||
// map cannot be generated (here: the node's own GivenName is invalid, so
|
||||
// WithSelfNode fails and AddNode errors), serveLongPoll writes an explicit HTTP
|
||||
// error instead of returning with no body. Returning empty leaves net/http to
|
||||
// send an empty 200, which the Tailscale client reports as
|
||||
// "PollNetMap: ... unexpected EOF" and retries forever (issue #3346).
|
||||
func TestServeLongPollWritesErrorWhenInitialMapFails(t *testing.T) {
|
||||
app := createTestApp(t)
|
||||
user := app.state.CreateUserForTest("self-bad-name-user")
|
||||
createdNode := app.state.CreateRegisteredNodeForTest(user, "self-bad-name-node")
|
||||
|
||||
// Corrupt the node's stored name to empty so GetFQDN fails for itself,
|
||||
// then reload state so the bad row enters the NodeStore verbatim.
|
||||
app.mapBatcher.Close()
|
||||
require.NoError(t, app.state.Close())
|
||||
|
||||
database, err := db.NewHeadscaleDatabase(app.cfg)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, database.DB.
|
||||
Model(&types.Node{}).
|
||||
Where("id = ?", createdNode.ID).
|
||||
Update("given_name", "").Error)
|
||||
require.NoError(t, database.Close())
|
||||
|
||||
app.state, err = state.NewState(app.cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
app.mapBatcher = mapper.NewBatcherAndMapper(app.cfg, app.state)
|
||||
app.mapBatcher.Start()
|
||||
|
||||
t.Cleanup(func() {
|
||||
app.mapBatcher.Close()
|
||||
require.NoError(t, app.state.Close())
|
||||
})
|
||||
|
||||
nodeView, ok := app.state.GetNodeByID(createdNode.ID)
|
||||
require.True(t, ok)
|
||||
|
||||
node := nodeView.AsStruct()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
writer := &recordingResponseWriter{}
|
||||
session := app.newMapSession(ctx, tailcfg.MapRequest{
|
||||
Stream: true,
|
||||
Version: tailcfg.CapabilityVersion(100),
|
||||
}, writer, node)
|
||||
|
||||
serveDone := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
session.serveLongPoll()
|
||||
close(serveDone)
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
// Break the post-disconnect reconnect wait so the goroutine exits.
|
||||
dummyCh := make(chan *tailcfg.MapResponse, 1)
|
||||
_ = app.mapBatcher.AddNode(node.ID, dummyCh, tailcfg.CapabilityVersion(100), nil)
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-serveDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
|
||||
_ = app.mapBatcher.RemoveNode(node.ID, dummyCh)
|
||||
})
|
||||
|
||||
assert.Eventually(t, func() bool {
|
||||
return writer.statusCode() >= http.StatusInternalServerError
|
||||
}, 2*time.Second, 10*time.Millisecond,
|
||||
"serveLongPoll must write an HTTP error response when the initial map cannot be built, not an empty 200")
|
||||
}
|
||||
|
||||
// TestGitHubIssue3129_TransientlyBlockedWriteDoesNotLeaveLiveStaleSession
|
||||
// tests the scenario reported in
|
||||
// https://github.com/juanfont/headscale/issues/3129.
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// Package scope models the OAuth capability scopes the Headscale v2 API enforces
|
||||
// and the rule for whether a granted set of scopes satisfies a required one.
|
||||
//
|
||||
// The vocabulary is taken from Tailscale's OpenAPI spec (the same scope names
|
||||
// the Terraform provider and Kubernetes operator request), so a client written
|
||||
// against Tailscale's scopes works unchanged against Headscale. The grant
|
||||
// predicate is kept here, separate from the HTTP/huma layer in
|
||||
// hscontrol/api/v2, so it can be tested exhaustively on its own.
|
||||
package scope
|
||||
|
||||
import "strings"
|
||||
|
||||
// Scope is an OAuth capability an operation requires and a token grants. The names
|
||||
// mirror Tailscale's API scopes; a "...:read" scope is the read-only subset of its
|
||||
// write scope.
|
||||
type Scope string
|
||||
|
||||
const (
|
||||
// All and AllRead are Tailscale's forward-compatible super-scopes: "all"
|
||||
// grants every other scope, "all:read" grants every :read subset.
|
||||
All Scope = "all"
|
||||
AllRead Scope = "all:read"
|
||||
|
||||
AuthKeys Scope = "auth_keys"
|
||||
AuthKeysRead Scope = "auth_keys:read"
|
||||
|
||||
// OAuthKeys gates managing OAuth clients (keyType:"client" on the keys
|
||||
// resource).
|
||||
OAuthKeys Scope = "oauth_keys"
|
||||
OAuthKeysRead Scope = "oauth_keys:read"
|
||||
|
||||
DevicesCore Scope = "devices:core"
|
||||
DevicesCoreRead Scope = "devices:core:read"
|
||||
|
||||
DevicesRoutes Scope = "devices:routes"
|
||||
DevicesRoutesRead Scope = "devices:routes:read"
|
||||
|
||||
PolicyFile Scope = "policy_file"
|
||||
PolicyFileRead Scope = "policy_file:read"
|
||||
|
||||
FeatureSettings Scope = "feature_settings"
|
||||
FeatureSettingsRead Scope = "feature_settings:read"
|
||||
|
||||
Users Scope = "users"
|
||||
UsersRead Scope = "users:read"
|
||||
)
|
||||
|
||||
const readSuffix = ":read"
|
||||
|
||||
// Known returns every scope in the vocabulary, in a stable order. Useful for
|
||||
// exhaustive iteration in tests and documentation.
|
||||
func Known() []Scope {
|
||||
return []Scope{
|
||||
All, AllRead,
|
||||
AuthKeys, AuthKeysRead,
|
||||
OAuthKeys, OAuthKeysRead,
|
||||
DevicesCore, DevicesCoreRead,
|
||||
DevicesRoutes, DevicesRoutesRead,
|
||||
PolicyFile, PolicyFileRead,
|
||||
FeatureSettings, FeatureSettingsRead,
|
||||
Users, UsersRead,
|
||||
}
|
||||
}
|
||||
|
||||
// IsRead reports whether s is a read-only scope (its name ends with ":read").
|
||||
func (s Scope) IsRead() bool {
|
||||
return strings.HasSuffix(string(s), readSuffix)
|
||||
}
|
||||
|
||||
// IsWrite reports whether s is a non-empty write scope.
|
||||
func (s Scope) IsWrite() bool {
|
||||
return s != "" && !s.IsRead()
|
||||
}
|
||||
|
||||
// Parse converts scope strings (as stored on a token or client) into Scope values.
|
||||
// Unknown strings are kept as-is; they simply never satisfy any required scope.
|
||||
func Parse(ss []string) []Scope {
|
||||
out := make([]Scope, len(ss))
|
||||
for i, s := range ss {
|
||||
out[i] = Scope(s)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// Grants reports whether the granted scopes satisfy the required want scope.
|
||||
func Grants(granted []Scope, want Scope) bool {
|
||||
for _, g := range granted {
|
||||
if satisfies(g, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// satisfies reports whether a single held scope satisfies want: exact match; a
|
||||
// write scope grants its own :read subset; "all" grants everything; "all:read"
|
||||
// grants any :read scope.
|
||||
func satisfies(have, want Scope) bool {
|
||||
if have == want || have == All {
|
||||
return true
|
||||
}
|
||||
|
||||
if have == AllRead {
|
||||
return want.IsRead()
|
||||
}
|
||||
|
||||
// A write scope grants its own read subset, e.g. auth_keys ⊇ auth_keys:read.
|
||||
return string(want) == string(have)+readSuffix
|
||||
}
|
||||
|
||||
// RequiresTags reports whether any scope obliges a credential to carry tags:
|
||||
// devices:core and auth_keys mint tagged, tailnet-owned credentials.
|
||||
func RequiresTags(scopes []Scope) bool {
|
||||
for _, s := range scopes {
|
||||
if s == DevicesCore || s == AuthKeys {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package scope
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"pgregory.net/rapid"
|
||||
)
|
||||
|
||||
// scopeGen draws a scope: mostly from the real vocabulary, sometimes adversarial
|
||||
// junk (including read-like junk such as "foo:read") so the rules are exercised
|
||||
// against unknown input too.
|
||||
func scopeGen() *rapid.Generator[Scope] {
|
||||
known := Known()
|
||||
|
||||
return rapid.Custom(func(t *rapid.T) Scope {
|
||||
if rapid.Float64().Draw(t, "junkP") < 0.2 {
|
||||
return Scope(rapid.StringMatching(`[a-z_]{1,12}(:read)?`).Draw(t, "junk"))
|
||||
}
|
||||
|
||||
return rapid.SampledFrom(known).Draw(t, "vocab")
|
||||
})
|
||||
}
|
||||
|
||||
// TestGrantsMatchesOracle fuzzes Grants against the independent oracle over random
|
||||
// granted-sets and want-scopes (vocabulary + junk).
|
||||
func TestGrantsMatchesOracle(t *testing.T) {
|
||||
rapid.Check(t, func(rt *rapid.T) {
|
||||
granted := rapid.SliceOfN(scopeGen(), 0, 6).Draw(rt, "granted")
|
||||
want := scopeGen().Draw(rt, "want")
|
||||
|
||||
if got, exp := Grants(granted, want), oracleGrants(granted, want); got != exp {
|
||||
rt.Fatalf("Grants(%v, %q) = %v, oracle = %v", granted, want, got, exp)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestGrantsInvariants asserts the algebraic properties of the grant relation hold
|
||||
// for arbitrary inputs.
|
||||
func TestGrantsInvariants(t *testing.T) {
|
||||
rapid.Check(t, func(rt *rapid.T) {
|
||||
granted := rapid.SliceOfN(scopeGen(), 0, 6).Draw(rt, "granted")
|
||||
want := scopeGen().Draw(rt, "want")
|
||||
|
||||
// Reflexivity: a scope always grants itself.
|
||||
if !Grants([]Scope{want}, want) {
|
||||
rt.Fatalf("reflexivity: %q does not grant itself", want)
|
||||
}
|
||||
|
||||
// The empty set grants nothing.
|
||||
if Grants(nil, want) {
|
||||
rt.Fatalf("empty grant satisfied %q", want)
|
||||
}
|
||||
|
||||
// OR-semantics: a set grants iff some member does.
|
||||
anyMember := slices.ContainsFunc(granted, func(g Scope) bool {
|
||||
return Grants([]Scope{g}, want)
|
||||
})
|
||||
if Grants(granted, want) != anyMember {
|
||||
rt.Fatalf("OR-semantics broken for %v / %q", granted, want)
|
||||
}
|
||||
|
||||
// Monotonicity: adding a scope never withdraws a grant.
|
||||
before := Grants(granted, want)
|
||||
extra := scopeGen().Draw(rt, "extra")
|
||||
after := Grants(append(slices.Clone(granted), extra), want)
|
||||
|
||||
if before && !after {
|
||||
rt.Fatalf("monotonicity broken: adding %q withdrew the grant of %q", extra, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSuperScopeProperties fuzzes the super-scope rules.
|
||||
func TestSuperScopeProperties(t *testing.T) {
|
||||
rapid.Check(t, func(rt *rapid.T) {
|
||||
want := scopeGen().Draw(rt, "want")
|
||||
|
||||
// all grants everything.
|
||||
if !Grants([]Scope{All}, want) {
|
||||
rt.Fatalf("all did not grant %q", want)
|
||||
}
|
||||
|
||||
// all:read grants exactly the read scopes.
|
||||
if Grants([]Scope{AllRead}, want) != want.IsRead() {
|
||||
rt.Fatalf("all:read grant of %q = %v, want IsRead = %v",
|
||||
want, Grants([]Scope{AllRead}, want), want.IsRead())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package scope
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// classify decomposes a scope into (resource, super, read) WITHOUT reusing any of
|
||||
// the production logic, so the oracle below is an independent second
|
||||
// implementation of the grant rule: a divergence between it and Grants is a real
|
||||
// bug in one of them, not a tautology.
|
||||
type classified struct {
|
||||
resource string // "" for super-scopes; otherwise the write-scope base (e.g. "auth_keys")
|
||||
super bool // all / all:read
|
||||
read bool // the :read variant
|
||||
}
|
||||
|
||||
func classify(s Scope) classified {
|
||||
str := string(s)
|
||||
read := strings.HasSuffix(str, ":read")
|
||||
base := strings.TrimSuffix(str, ":read")
|
||||
|
||||
if base == "all" {
|
||||
return classified{super: true, read: read}
|
||||
}
|
||||
|
||||
return classified{resource: base, read: read}
|
||||
}
|
||||
|
||||
// oracle re-derives "does have satisfy want" from the classification, independent
|
||||
// of satisfies/Grants.
|
||||
func oracle(have, want Scope) bool {
|
||||
h, w := classify(have), classify(want)
|
||||
|
||||
if h.super {
|
||||
// "all" grants everything; "all:read" grants only reads.
|
||||
return !h.read || w.read
|
||||
}
|
||||
|
||||
if h.resource != w.resource {
|
||||
return false
|
||||
}
|
||||
|
||||
// Same resource: a write scope grants both read and write; a read scope grants
|
||||
// only read.
|
||||
return !h.read || w.read
|
||||
}
|
||||
|
||||
func oracleGrants(granted []Scope, want Scope) bool {
|
||||
for _, g := range granted {
|
||||
if oracle(g, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// TestGrantsHandPicked pins specific (granted, want) outcomes with literal
|
||||
// expected values, independent of any oracle: the anchor for the rules.
|
||||
func TestGrantsHandPicked(t *testing.T) {
|
||||
tests := []struct {
|
||||
granted []Scope
|
||||
want Scope
|
||||
ok bool
|
||||
}{
|
||||
{granted: []Scope{AuthKeys}, want: AuthKeys, ok: true},
|
||||
{granted: []Scope{AuthKeys}, want: AuthKeysRead, ok: true},
|
||||
{granted: []Scope{AuthKeysRead}, want: AuthKeys, ok: false},
|
||||
{granted: []Scope{AuthKeysRead}, want: AuthKeysRead, ok: true},
|
||||
{granted: []Scope{DevicesCore}, want: AuthKeys, ok: false},
|
||||
{granted: []Scope{DevicesCoreRead}, want: AuthKeysRead, ok: false},
|
||||
{granted: []Scope{All}, want: AuthKeys, ok: true},
|
||||
{granted: []Scope{All}, want: FeatureSettingsRead, ok: true},
|
||||
{granted: []Scope{AllRead}, want: PolicyFileRead, ok: true},
|
||||
{granted: []Scope{AllRead}, want: PolicyFile, ok: false},
|
||||
{granted: []Scope{AllRead}, want: All, ok: false},
|
||||
{granted: []Scope{DevicesCore, OAuthKeys}, want: OAuthKeys, ok: true},
|
||||
{granted: nil, want: AuthKeysRead, ok: false},
|
||||
{granted: []Scope{"garbage"}, want: AuthKeys, ok: false},
|
||||
{granted: []Scope{"garbage"}, want: "garbage", ok: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%v_%s", tt.granted, tt.want), func(t *testing.T) {
|
||||
if got := Grants(tt.granted, tt.want); got != tt.ok {
|
||||
t.Errorf("Grants(%v, %q) = %v, want %v", tt.granted, tt.want, got, tt.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantsExhaustive checks every single-grant pair in the vocabulary against
|
||||
// the independent oracle, plus representative multi-grant cases.
|
||||
func TestGrantsExhaustive(t *testing.T) {
|
||||
known := Known()
|
||||
|
||||
for _, g := range known {
|
||||
for _, w := range known {
|
||||
got := Grants([]Scope{g}, w)
|
||||
exp := oracle(g, w)
|
||||
|
||||
if got != exp {
|
||||
t.Errorf("Grants([%q], %q) = %v, oracle = %v", g, w, got, exp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
multi := [][]Scope{
|
||||
{All, AuthKeysRead},
|
||||
{AllRead, AuthKeys},
|
||||
{AuthKeys, OAuthKeysRead},
|
||||
{DevicesCore, DevicesRoutes, PolicyFile},
|
||||
{AuthKeys, AuthKeys}, // duplicates
|
||||
}
|
||||
|
||||
for _, granted := range multi {
|
||||
for _, w := range known {
|
||||
got := Grants(granted, w)
|
||||
exp := oracleGrants(granted, w)
|
||||
|
||||
if got != exp {
|
||||
t.Errorf("Grants(%v, %q) = %v, oracle = %v", granted, w, got, exp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteGrantsItsRead and friends assert the structural rules over the whole
|
||||
// vocabulary, deterministically.
|
||||
func TestWriteGrantsItsRead(t *testing.T) {
|
||||
for _, s := range Known() {
|
||||
if !s.IsWrite() {
|
||||
continue
|
||||
}
|
||||
|
||||
read := Scope(string(s) + ":read")
|
||||
if !Grants([]Scope{s}, read) {
|
||||
t.Errorf("write scope %q does not grant its read subset %q", s, read)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadNeverGrantsWrite(t *testing.T) {
|
||||
for _, s := range Known() {
|
||||
if !s.IsRead() {
|
||||
continue
|
||||
}
|
||||
|
||||
write := Scope(strings.TrimSuffix(string(s), ":read"))
|
||||
if Grants([]Scope{s}, write) {
|
||||
t.Errorf("read scope %q must not grant write scope %q", s, write)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllGrantsEverything(t *testing.T) {
|
||||
for _, w := range Known() {
|
||||
if !Grants([]Scope{All}, w) {
|
||||
t.Errorf("all should grant %q", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllReadGrantsReadsOnly(t *testing.T) {
|
||||
for _, w := range Known() {
|
||||
got := Grants([]Scope{AllRead}, w)
|
||||
if got != w.IsRead() {
|
||||
t.Errorf("all:read grants %q = %v, want %v (IsRead)", w, got, w.IsRead())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResourceIsolation: a non-super scope never grants a scope of a different
|
||||
// resource.
|
||||
func TestResourceIsolation(t *testing.T) {
|
||||
for _, a := range Known() {
|
||||
if a == All || a == AllRead {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, b := range Known() {
|
||||
if classify(a).resource == classify(b).resource {
|
||||
continue
|
||||
}
|
||||
|
||||
if Grants([]Scope{a}, b) {
|
||||
t.Errorf("scope %q (resource %q) must not grant %q (resource %q)",
|
||||
a, classify(a).resource, b, classify(b).resource)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiresTags(t *testing.T) {
|
||||
tests := []struct {
|
||||
scopes []Scope
|
||||
requires bool
|
||||
}{
|
||||
{scopes: []Scope{DevicesCore}, requires: true},
|
||||
{scopes: []Scope{AuthKeys}, requires: true},
|
||||
{scopes: []Scope{OAuthKeys}, requires: false},
|
||||
{scopes: []Scope{PolicyFile, AuthKeys}, requires: true},
|
||||
{scopes: []Scope{DevicesCoreRead}, requires: false},
|
||||
{scopes: nil, requires: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%v", tt.scopes), func(t *testing.T) {
|
||||
if got := RequiresTags(tt.scopes); got != tt.requires {
|
||||
t.Errorf("RequiresTags(%v) = %v, want %v", tt.scopes, got, tt.requires)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnownIsComplete(t *testing.T) {
|
||||
known := Known()
|
||||
|
||||
seen := make(map[Scope]bool, len(known))
|
||||
for _, s := range known {
|
||||
if seen[s] {
|
||||
t.Errorf("Known() contains duplicate %q", s)
|
||||
}
|
||||
|
||||
seen[s] = true
|
||||
}
|
||||
|
||||
// 7 resources × 2 (write+read) + 2 super-scopes = 16.
|
||||
if len(known) != 16 {
|
||||
t.Errorf("Known() has %d scopes, want 16", len(known))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
package servertest_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/servertest"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestAPIv2OAuthScopes proves the v2 OAuth scope and tag enforcement through the
|
||||
// REAL Tailscale Terraform provider's client-credentials flow: the provider mints
|
||||
// a scoped access token at /api/v2/oauth/token, then runs one operation whose
|
||||
// allow/deny outcome must match the token's scope and tag grant.
|
||||
//
|
||||
// Each client is created with exactly the scopes/tags under test, and the
|
||||
// provider requests no scope narrowing, so the minted token carries the client's
|
||||
// full grant. The matrix covers right-scope-allowed, wrong-scope-denied, read vs
|
||||
// write, the "all" super-scope, and policy tag ownership (owned-by delegation).
|
||||
//
|
||||
// The oauth_keys rows drive the real provider's tailscale_oauth_client resource,
|
||||
// which omits the "capabilities" body field, so the request schema must make it
|
||||
// optional (it is). Drift is not asserted on the auth-key rows: the provider
|
||||
// defaults preauthorized=true on create but the server reads tagged keys back as
|
||||
// false, so a converged plan is never empty. That is orthogonal to scope
|
||||
// enforcement, so a clean apply is the allow proof.
|
||||
//
|
||||
// tofu is required, not optional: it ships in the nix dev shell, so a missing
|
||||
// binary means a broken environment and the test fails rather than skipping.
|
||||
func TestAPIv2OAuthScopes(t *testing.T) {
|
||||
srv := servertest.NewServer(t, servertest.WithRealListener())
|
||||
owner := srv.CreateUser(t, "apiv2-oauth")
|
||||
creator := owner.ID
|
||||
|
||||
// scopeMatrixPolicy declares every tag the matrix touches: tag:ci for the
|
||||
// auth-key rows, and the tag:k8s/tag:k8s-operator delegation for the
|
||||
// owned-by rows.
|
||||
setScopeMatrixPolicy(t, srv)
|
||||
|
||||
// A registered node is the target for the device-scope rows. Its decimal id
|
||||
// is the device id the v2 API and the provider's device resources address.
|
||||
// A devices:core write token also grants devices:core:read, so whatever
|
||||
// get/post sequence the provider runs within the core family is satisfied;
|
||||
// the read-scope row then fails on the write, proving read vs write end to
|
||||
// end. devices:routes and feature_settings are proven exhaustively by the Go
|
||||
// matrix (apiv2_oauth_matrix_test.go), not here: the provider's routes
|
||||
// resource issues a cross-family device read a routes-only token would lack,
|
||||
// and the server's settings endpoint is read-only.
|
||||
deviceID := srv.CreateRegisteredNode(t, owner).StringID()
|
||||
deviceAuthorizeConfig := `
|
||||
resource "tailscale_device_authorization" "d" {
|
||||
device_id = "` + deviceID + `"
|
||||
authorized = true
|
||||
}
|
||||
`
|
||||
|
||||
// The configs each exercise exactly one operation so a row's allow/deny
|
||||
// outcome is unambiguous.
|
||||
const (
|
||||
authKeyConfig = `
|
||||
resource "tailscale_tailnet_key" "k" {
|
||||
reusable = true
|
||||
ephemeral = false
|
||||
expiry = 3600
|
||||
description = "oauth-scope-matrix"
|
||||
tags = ["tag:ci"]
|
||||
}
|
||||
`
|
||||
aclConfig = `
|
||||
resource "tailscale_acl" "policy" {
|
||||
acl = jsonencode({
|
||||
tagOwners = {
|
||||
"tag:ci" = ["apiv2-oauth@"]
|
||||
"tag:k8s-operator" = []
|
||||
"tag:k8s" = ["tag:k8s-operator"]
|
||||
"tag:other" = []
|
||||
}
|
||||
acls = [{ action = "accept", src = ["*"], dst = ["*:*"] }]
|
||||
})
|
||||
overwrite_existing_content = true
|
||||
}
|
||||
`
|
||||
devicesReadConfig = `
|
||||
data "tailscale_devices" "all" {}
|
||||
output "device_count" { value = length(data.tailscale_devices.all.devices) }
|
||||
`
|
||||
// oauthClientConfig creates an OAuth client whose scope (oauth_keys:read)
|
||||
// is within an oauth_keys grant, so the only variable under test is whether
|
||||
// the caller may manage clients at all.
|
||||
oauthClientConfig = `
|
||||
resource "tailscale_oauth_client" "c" {
|
||||
description = "oauth-scope-matrix-client"
|
||||
scopes = ["oauth_keys:read"]
|
||||
}
|
||||
`
|
||||
// oauthClientEscalateConfig requests a scope (devices:core:read) that an
|
||||
// oauth_keys-only caller does not itself hold, so the escalation guard must
|
||||
// reject minting a broader client.
|
||||
oauthClientEscalateConfig = `
|
||||
resource "tailscale_oauth_client" "c" {
|
||||
description = "oauth-scope-matrix-escalate"
|
||||
scopes = ["devices:core:read"]
|
||||
}
|
||||
`
|
||||
)
|
||||
|
||||
// k8sAuthKeyConfig templates the auth-key tag for the owned-by rows.
|
||||
k8sAuthKeyConfig := func(tag string) string {
|
||||
return `
|
||||
resource "tailscale_tailnet_key" "k" {
|
||||
reusable = true
|
||||
ephemeral = false
|
||||
expiry = 3600
|
||||
description = "oauth-scope-matrix"
|
||||
tags = ["` + tag + `"]
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
// scopes/tags the OAuth client (and thus its minted token) holds.
|
||||
scopes []string
|
||||
tags []string
|
||||
// config is the single-operation HCL applied through the OAuth provider.
|
||||
config string
|
||||
// deny asserts apply fails; denyContains lists substrings, any of which
|
||||
// the failure output must contain. allow asserts a clean apply.
|
||||
deny bool
|
||||
denyContains []string
|
||||
}{
|
||||
// auth_keys: write scope mints an auth key; read/wrong/all-read do not.
|
||||
{
|
||||
name: "auth_keys allows tailnet_key",
|
||||
scopes: []string{"auth_keys"},
|
||||
tags: []string{"tag:ci"},
|
||||
config: authKeyConfig,
|
||||
},
|
||||
{
|
||||
name: "auth_keys:read denies tailnet_key",
|
||||
scopes: []string{"auth_keys:read"},
|
||||
tags: []string{"tag:ci"},
|
||||
config: authKeyConfig,
|
||||
deny: true,
|
||||
denyContains: []string{"403", "Forbidden", "missing the required scope"},
|
||||
},
|
||||
{
|
||||
name: "devices:core denies tailnet_key",
|
||||
scopes: []string{"devices:core"},
|
||||
tags: []string{"tag:ci"},
|
||||
config: authKeyConfig,
|
||||
deny: true,
|
||||
denyContains: []string{"403", "Forbidden", "missing the required scope"},
|
||||
},
|
||||
{
|
||||
name: "all allows tailnet_key",
|
||||
scopes: []string{"all"},
|
||||
tags: []string{"tag:ci"},
|
||||
config: authKeyConfig,
|
||||
},
|
||||
{
|
||||
name: "all:read denies tailnet_key",
|
||||
scopes: []string{"all:read"},
|
||||
tags: []string{"tag:ci"},
|
||||
config: authKeyConfig,
|
||||
deny: true,
|
||||
denyContains: []string{"403", "Forbidden", "missing the required scope"},
|
||||
},
|
||||
|
||||
// oauth_keys: managing OAuth clients. Read and wrong scopes are denied, and
|
||||
// a caller cannot mint a client carrying authority it lacks (escalation).
|
||||
{
|
||||
name: "oauth_keys allows oauth_client",
|
||||
scopes: []string{"oauth_keys"},
|
||||
config: oauthClientConfig,
|
||||
},
|
||||
{
|
||||
name: "oauth_keys:read denies oauth_client",
|
||||
scopes: []string{"oauth_keys:read"},
|
||||
config: oauthClientConfig,
|
||||
deny: true,
|
||||
denyContains: []string{"403", "Forbidden", "missing the required scope"},
|
||||
},
|
||||
{
|
||||
name: "auth_keys denies oauth_client",
|
||||
scopes: []string{"auth_keys"},
|
||||
tags: []string{"tag:ci"},
|
||||
config: oauthClientConfig,
|
||||
deny: true,
|
||||
denyContains: []string{"403", "Forbidden", "missing the required scope"},
|
||||
},
|
||||
{
|
||||
name: "oauth_keys cannot escalate client scope",
|
||||
scopes: []string{"oauth_keys"},
|
||||
config: oauthClientEscalateConfig,
|
||||
deny: true,
|
||||
denyContains: []string{"403", "Forbidden", "beyond the creating token"},
|
||||
},
|
||||
|
||||
// policy_file: write scope sets the ACL; read does not.
|
||||
{
|
||||
name: "policy_file allows acl",
|
||||
scopes: []string{"policy_file"},
|
||||
config: aclConfig,
|
||||
},
|
||||
{
|
||||
name: "policy_file:read denies acl",
|
||||
scopes: []string{"policy_file:read"},
|
||||
config: aclConfig,
|
||||
deny: true,
|
||||
denyContains: []string{"403", "Forbidden", "missing the required scope"},
|
||||
},
|
||||
|
||||
// devices:core:read reads the devices data source.
|
||||
{
|
||||
name: "devices:core:read allows devices read",
|
||||
scopes: []string{"devices:core:read"},
|
||||
config: devicesReadConfig,
|
||||
},
|
||||
{
|
||||
name: "policy_file:read denies devices read",
|
||||
scopes: []string{"policy_file:read"},
|
||||
config: devicesReadConfig,
|
||||
deny: true,
|
||||
denyContains: []string{"403", "Forbidden", "missing the required scope"},
|
||||
},
|
||||
|
||||
// devices:core: a write-scoped token authorizes a device; the read scope
|
||||
// and a wrong-family scope are denied (read vs write through the real
|
||||
// provider's tailscale_device_authorization resource).
|
||||
{
|
||||
name: "devices:core allows device authorize",
|
||||
scopes: []string{"devices:core"},
|
||||
tags: []string{"tag:ci"},
|
||||
config: deviceAuthorizeConfig,
|
||||
},
|
||||
{
|
||||
name: "devices:core:read denies device authorize",
|
||||
scopes: []string{"devices:core:read"},
|
||||
config: deviceAuthorizeConfig,
|
||||
deny: true,
|
||||
denyContains: []string{"403", "Forbidden", "missing the required scope"},
|
||||
},
|
||||
{
|
||||
name: "policy_file denies device authorize",
|
||||
scopes: []string{"policy_file"},
|
||||
config: deviceAuthorizeConfig,
|
||||
deny: true,
|
||||
denyContains: []string{"403", "Forbidden", "missing the required scope"},
|
||||
},
|
||||
|
||||
// Tag owned-by: a tag:k8s-operator client may use its own tag and the
|
||||
// tag:k8s it owns, but not an unowned tag.
|
||||
{
|
||||
name: "owned-by exact tag allowed",
|
||||
scopes: []string{"auth_keys"},
|
||||
tags: []string{"tag:k8s-operator"},
|
||||
config: k8sAuthKeyConfig("tag:k8s-operator"),
|
||||
},
|
||||
{
|
||||
name: "owned-by delegated tag allowed",
|
||||
scopes: []string{"auth_keys"},
|
||||
tags: []string{"tag:k8s-operator"},
|
||||
config: k8sAuthKeyConfig("tag:k8s"),
|
||||
},
|
||||
{
|
||||
name: "owned-by unowned tag denied",
|
||||
scopes: []string{"auth_keys"},
|
||||
tags: []string{"tag:k8s-operator"},
|
||||
config: k8sAuthKeyConfig("tag:other"),
|
||||
deny: true,
|
||||
denyContains: []string{"403", "Forbidden", "is not owned"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
secret, client, err := srv.State().CreateOAuthClient(tt.scopes, tt.tags, "scope-matrix", &creator)
|
||||
require.NoError(t, err)
|
||||
|
||||
tf := newTofuOAuth(t, srv.URL, client.ClientID, secret, oauthHCL(tt.config))
|
||||
tf.run("init", "-no-color", "-input=false")
|
||||
|
||||
if tt.deny {
|
||||
tf.runExpectError(t, tt.denyContains...)
|
||||
return
|
||||
}
|
||||
|
||||
tf.run("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// setScopeMatrixPolicy installs the policy the scope matrix relies on: tag:ci for
|
||||
// the auth-key rows, and the tag:k8s-operator → tag:k8s delegation for the
|
||||
// owned-by rows.
|
||||
func setScopeMatrixPolicy(t *testing.T, srv *servertest.TestServer) {
|
||||
t.Helper()
|
||||
|
||||
// tag:other exists but is owned by no one, so the owned-by denial row tests a
|
||||
// grant denial (403) rather than a tag-not-in-policy rejection (400).
|
||||
const policy = `{"tagOwners":{"tag:ci":["apiv2-oauth@"],"tag:k8s-operator":[],"tag:k8s":["tag:k8s-operator"],"tag:other":[]},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`
|
||||
|
||||
st := srv.State()
|
||||
|
||||
_, err := st.SetPolicy([]byte(policy))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = st.SetPolicyInDB(policy)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = st.ReloadPolicy()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// oauthHCL wraps a single-operation body with the provider block. The provider
|
||||
// authenticates via the OAuth env vars newTofuOAuth sets, so the block is empty.
|
||||
func oauthHCL(body string) string {
|
||||
return `
|
||||
terraform {
|
||||
required_providers {
|
||||
tailscale = {
|
||||
source = "tailscale/tailscale"
|
||||
version = "~> 0.21"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "tailscale" {}
|
||||
` + body
|
||||
}
|
||||
|
||||
// newTofuOAuth is a newTofu variant whose provider authenticates with OAuth
|
||||
// client credentials instead of an API key: it sets TAILSCALE_OAUTH_CLIENT_ID /
|
||||
// TAILSCALE_OAUTH_CLIENT_SECRET (the env vars the tailscale/tailscale provider
|
||||
// honors), so the real provider runs the client-credentials grant against
|
||||
// baseURL/api/v2/oauth/token. The client id is embedded in the secret, so the
|
||||
// secret embeds the client id (the provider sends both; the server derives the
|
||||
// client from the secret alone).
|
||||
func newTofuOAuth(t *testing.T, baseURL, clientID, clientSecret, config string) *tofu {
|
||||
t.Helper()
|
||||
|
||||
bin, err := exec.LookPath("tofu")
|
||||
require.NoErrorf(t, err, "tofu is required for TestAPIv2OAuthScopes (provided by the nix dev shell)")
|
||||
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "main.tf"), []byte(config), 0o600))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "plugin-cache"), 0o755))
|
||||
|
||||
env := append(
|
||||
os.Environ(),
|
||||
"TAILSCALE_BASE_URL="+baseURL,
|
||||
"TAILSCALE_OAUTH_CLIENT_ID="+clientID,
|
||||
"TAILSCALE_OAUTH_CLIENT_SECRET="+clientSecret,
|
||||
"TAILSCALE_TAILNET=-",
|
||||
// Keep provider plugins inside the temp dir so the run is self-contained.
|
||||
"TF_PLUGIN_CACHE_DIR="+filepath.Join(dir, "plugin-cache"),
|
||||
)
|
||||
|
||||
cmd := func(args ...string) *exec.Cmd {
|
||||
c := exec.CommandContext(t.Context(), bin, args...)
|
||||
c.Dir = dir
|
||||
c.Env = env
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
return &tofu{t: t, cmd: cmd}
|
||||
}
|
||||
|
||||
// runExpectError runs apply expecting FAILURE, asserting the combined output
|
||||
// contains at least one of mustContain. This is the deny half of every matrix
|
||||
// row: a scoped token attempting an operation it lacks the scope (or tag) for.
|
||||
func (tf *tofu) runExpectError(t *testing.T, mustContain ...string) {
|
||||
t.Helper()
|
||||
|
||||
out, err := tf.cmd("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1").CombinedOutput()
|
||||
require.Errorf(t, err, "expected apply to fail, but it succeeded:\n%s", out)
|
||||
|
||||
combined := string(out)
|
||||
for _, want := range mustContain {
|
||||
if strings.Contains(combined, want) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("apply failed but output contained none of %v:\n%s", mustContain, combined)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// nodeHealthCheck names a class of stored-node-data defect that breaks normal
|
||||
// operation and explains how to fix it. ok == true means the node passes the
|
||||
// check. This is the extension point for node-data validation: add a check
|
||||
// here as new corrupt-data classes surface (nil hostinfo, invalid IPs,
|
||||
// tags-XOR-user violations, ...) and both the boot scan and any future caller
|
||||
// run the whole set.
|
||||
type nodeHealthCheck struct {
|
||||
name string
|
||||
check func(nv types.NodeView, cfg *types.Config) (problem, fixHint string, ok bool)
|
||||
}
|
||||
|
||||
// nodeHealthChecks is the registry of node-data health checks. Today it carries
|
||||
// the one issue #3346 needs; append to it rather than reshaping callers.
|
||||
var nodeHealthChecks = []nodeHealthCheck{givenNameMapsToValidFQDN}
|
||||
|
||||
// givenNameMapsToValidFQDN flags a node whose stored GivenName cannot produce a
|
||||
// valid FQDN (empty, or longer than MaxHostnameLength once base_domain is
|
||||
// applied). Such a node cannot be rendered into a netmap — neither its own nor
|
||||
// any peer's — so it must be renamed to recover.
|
||||
var givenNameMapsToValidFQDN = nodeHealthCheck{
|
||||
name: "given-name-maps-to-valid-fqdn",
|
||||
check: func(nv types.NodeView, cfg *types.Config) (string, string, bool) {
|
||||
err := types.ValidateGivenName(nv.GivenName(), cfg.BaseDomain)
|
||||
if err != nil {
|
||||
return err.Error(), fmt.Sprintf("headscale nodes rename %d <name>", nv.ID()), false
|
||||
}
|
||||
|
||||
return "", "", true
|
||||
},
|
||||
}
|
||||
|
||||
// nodeHealthFinding is a single failed check for a single node.
|
||||
type nodeHealthFinding struct {
|
||||
nodeID types.NodeID
|
||||
hostname string
|
||||
check string
|
||||
problem string
|
||||
fixHint string
|
||||
}
|
||||
|
||||
// scanNodeHealth runs every registered check against every node in the store
|
||||
// and returns one finding per failure. It only reports — it never mutates a
|
||||
// node — so an operator can repair the underlying data without the server
|
||||
// silently rewriting a user-visible name.
|
||||
func (s *State) scanNodeHealth() []nodeHealthFinding {
|
||||
var findings []nodeHealthFinding
|
||||
|
||||
for _, nv := range s.nodeStore.ListNodes().All() {
|
||||
for _, c := range nodeHealthChecks {
|
||||
problem, fixHint, ok := c.check(nv, s.cfg)
|
||||
if ok {
|
||||
continue
|
||||
}
|
||||
|
||||
findings = append(findings, nodeHealthFinding{
|
||||
nodeID: nv.ID(),
|
||||
hostname: nv.Hostname(),
|
||||
check: c.name,
|
||||
problem: problem,
|
||||
fixHint: fixHint,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
// logNodeHealth scans the store once and logs an actionable warning per
|
||||
// finding. Called at startup so an operator learns — by node id and fix
|
||||
// command — about stored data that will break map generation, without the
|
||||
// server changing anything itself.
|
||||
func (s *State) logNodeHealth() {
|
||||
for _, f := range s.scanNodeHealth() {
|
||||
log.Warn().
|
||||
Uint64(zf.NodeID, f.nodeID.Uint64()).
|
||||
Str(zf.NodeHostname, f.hostname).
|
||||
Str("check", f.check).
|
||||
Str("problem", f.problem).
|
||||
Str("fix", f.fixHint).
|
||||
Msg("node has invalid data that breaks map generation; rename it to restore connectivity")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/db"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGivenNameMapsToValidFQDNCheck(t *testing.T) {
|
||||
cfg := &types.Config{BaseDomain: "example.com"}
|
||||
|
||||
_, _, ok := givenNameMapsToValidFQDN.check((&types.Node{ID: 1, GivenName: "valid"}).View(), cfg)
|
||||
require.True(t, ok, "a valid given name must pass the check")
|
||||
|
||||
problem, fixHint, ok := givenNameMapsToValidFQDN.check((&types.Node{ID: 7, GivenName: ""}).View(), cfg)
|
||||
require.False(t, ok, "an empty given name must fail the check")
|
||||
require.NotEmpty(t, problem)
|
||||
require.Contains(t, fixHint, "rename 7", "fix hint must name the offending node")
|
||||
}
|
||||
|
||||
// TestScanNodeHealthReportsInvalidNameWithoutMutating proves the boot scan
|
||||
// reports a node whose stored name would break map generation (issue #3346)
|
||||
// with an actionable fix, and that it never rewrites the stored name — the
|
||||
// maintainer's decision is log-only, no silent mutation.
|
||||
func TestScanNodeHealthReportsInvalidNameWithoutMutating(t *testing.T) {
|
||||
dbPath := t.TempDir() + "/headscale.db"
|
||||
cfg := persistTestConfig(dbPath)
|
||||
|
||||
database, err := db.NewHeadscaleDatabase(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
user := database.CreateUserForTest("scan-user")
|
||||
bad := database.CreateRegisteredNodeForTest(user, "scan-bad")
|
||||
good := database.CreateRegisteredNodeForTest(user, "scan-good")
|
||||
|
||||
require.NoError(t, database.DB.
|
||||
Model(&types.Node{}).
|
||||
Where("id = ?", bad.ID).
|
||||
Update("given_name", "").Error)
|
||||
require.NoError(t, database.Close())
|
||||
|
||||
s, err := NewState(cfg)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
findings := s.scanNodeHealth()
|
||||
|
||||
var badFinding *nodeHealthFinding
|
||||
|
||||
for i := range findings {
|
||||
require.NotEqual(t, good.ID, findings[i].nodeID, "a valid node must not be reported")
|
||||
|
||||
if findings[i].nodeID == bad.ID {
|
||||
badFinding = &findings[i]
|
||||
}
|
||||
}
|
||||
|
||||
require.NotNil(t, badFinding, "a node with an invalid name must be reported")
|
||||
require.Contains(t, badFinding.fixHint, "rename", "finding must carry an actionable fix")
|
||||
|
||||
// Log-only: neither the scan nor boot may rewrite the stored name.
|
||||
nv, ok := s.GetNodeByID(bad.ID)
|
||||
require.True(t, ok)
|
||||
require.Empty(t, nv.GivenName(), "boot scan must not mutate the stored name")
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/db"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestRenameNodeRejectsNameExceedingFQDNLimit proves RenameNode rejects a name
|
||||
// that is a valid DNS label but whose FQDN, under the configured base_domain,
|
||||
// exceeds MaxHostnameLength. Without the FQDN-length gate such a name persists
|
||||
// and then breaks map generation for the node and its peers (issue #3346):
|
||||
// admin-facing writes must not be able to introduce an unmappable name.
|
||||
func TestRenameNodeRejectsNameExceedingFQDNLimit(t *testing.T) {
|
||||
dbPath := t.TempDir() + "/headscale.db"
|
||||
cfg := persistTestConfig(dbPath)
|
||||
// A long base domain so a 63-char label overflows the 255-char FQDN bound.
|
||||
cfg.BaseDomain = strings.Repeat("b", 200) + ".example.com"
|
||||
|
||||
database, err := db.NewHeadscaleDatabase(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
user := database.CreateUserForTest("rename-user")
|
||||
node := database.CreateRegisteredNodeForTest(user, "rename-node")
|
||||
require.NoError(t, database.Close())
|
||||
|
||||
s, err := NewState(cfg)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
// Valid 63-char DNS label, but the resulting FQDN exceeds 255 chars.
|
||||
_, _, err = s.RenameNode(node.ID, strings.Repeat("a", 63))
|
||||
require.Error(t, err, "rename to a name whose FQDN exceeds the limit must be rejected")
|
||||
|
||||
// A short, valid name is still accepted.
|
||||
_, _, err = s.RenameNode(node.ID, "short")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -277,7 +277,7 @@ func NewState(cfg *types.Config) (*State, error) {
|
||||
)
|
||||
nodeStore.Start()
|
||||
|
||||
return &State{
|
||||
s := &State{
|
||||
cfg: cfg,
|
||||
|
||||
db: db,
|
||||
@@ -289,7 +289,14 @@ func NewState(cfg *types.Config) (*State, error) {
|
||||
|
||||
sshCheckAuth: make(map[sshCheckPair]time.Time),
|
||||
registerLocks: xsync.NewMap[key.MachinePublic, *sync.Mutex](),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Surface nodes whose stored data would break map generation (e.g. an
|
||||
// invalid given name from a legacy row) so an operator can fix them. This
|
||||
// only logs; it never mutates a node's stored name at boot.
|
||||
s.logNodeHealth()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Close gracefully shuts down the [State] instance and releases all resources.
|
||||
@@ -1032,7 +1039,10 @@ func (s *State) SetApprovedRoutes(nodeID types.NodeID, routes []netip.Prefix) (t
|
||||
// auto-sanitisation) and collisions error out rather than silently
|
||||
// bumping a user-facing label. See HOSTNAME.md for the CLI contract.
|
||||
func (s *State) RenameNode(nodeID types.NodeID, newName string) (types.NodeView, change.Change, error) {
|
||||
err := dnsname.ValidLabel(newName)
|
||||
// Validate the label AND that the resulting FQDN fits MaxHostnameLength:
|
||||
// a valid 63-char label can still overflow under a long base_domain, and
|
||||
// an unmappable name would break this node and its peers (issue #3346).
|
||||
err := types.ValidateGivenName(newName, s.cfg.BaseDomain)
|
||||
if err != nil {
|
||||
return types.NodeView{}, change.Change{}, fmt.Errorf("%w: %w", ErrGivenNameInvalid, err)
|
||||
}
|
||||
@@ -1511,6 +1521,63 @@ func (s *State) DeletePreAuthKey(id uint64) error {
|
||||
return s.db.DeletePreAuthKey(id)
|
||||
}
|
||||
|
||||
// CreateOAuthClient creates a new OAuth client-credentials client, returning the
|
||||
// plaintext secret (shown once) and the stored client.
|
||||
func (s *State) CreateOAuthClient(scopes, tags []string, description string, creatorUserID *uint) (string, *types.OAuthClient, error) {
|
||||
return s.db.CreateOAuthClient(scopes, tags, description, creatorUserID)
|
||||
}
|
||||
|
||||
// AuthenticateOAuthClient validates a client secret and returns the client.
|
||||
func (s *State) AuthenticateOAuthClient(secret string) (*types.OAuthClient, error) {
|
||||
return s.db.AuthenticateOAuthClient(secret)
|
||||
}
|
||||
|
||||
// GetOAuthClientByClientID returns an OAuth client by its public client id.
|
||||
func (s *State) GetOAuthClientByClientID(clientID string) (*types.OAuthClient, error) {
|
||||
return s.db.GetOAuthClientByClientID(clientID)
|
||||
}
|
||||
|
||||
// ListOAuthClients returns every OAuth client.
|
||||
func (s *State) ListOAuthClients() ([]types.OAuthClient, error) {
|
||||
return s.db.ListOAuthClients()
|
||||
}
|
||||
|
||||
// RevokeOAuthClient deletes a client and the access tokens it issued.
|
||||
func (s *State) RevokeOAuthClient(clientID string) error {
|
||||
return s.db.RevokeOAuthClient(clientID)
|
||||
}
|
||||
|
||||
// MintAccessToken stores a new scoped access token for an OAuth client.
|
||||
func (s *State) MintAccessToken(clientID string, scopes, tags []string, expiration *time.Time) (string, *types.OAuthAccessToken, error) {
|
||||
return s.db.MintAccessToken(clientID, scopes, tags, expiration)
|
||||
}
|
||||
|
||||
// AuthenticateAccessToken validates a bearer access token and returns it with
|
||||
// its granted scopes and tags.
|
||||
func (s *State) AuthenticateAccessToken(token string) (*types.OAuthAccessToken, error) {
|
||||
return s.db.AuthenticateAccessToken(token)
|
||||
}
|
||||
|
||||
// TagOwnedByTags reports whether a credential holding ownerTags may apply tag,
|
||||
// per the policy's tag-to-tag ownership. Used to authorise the tags an OAuth
|
||||
// access token sets on the auth keys it mints.
|
||||
func (s *State) TagOwnedByTags(tag string, ownerTags []string) bool {
|
||||
return s.polMan.TagOwnedByTags(tag, ownerTags)
|
||||
}
|
||||
|
||||
// TagExists reports whether tag is defined in the policy's tagOwners. Used to
|
||||
// reject OAuth clients and auth keys carrying tags that no policy authorises,
|
||||
// matching SetNodeTags.
|
||||
func (s *State) TagExists(tag string) bool {
|
||||
return s.polMan.TagExists(tag)
|
||||
}
|
||||
|
||||
// DeleteExpiredAccessTokens hard-deletes OAuth access tokens that expired before
|
||||
// cutoff, returning how many were removed.
|
||||
func (s *State) DeleteExpiredAccessTokens(cutoff time.Time) (int64, error) {
|
||||
return s.db.DeleteExpiredAccessTokens(cutoff)
|
||||
}
|
||||
|
||||
// GetAuthCacheEntry retrieves a pending auth request from the cache.
|
||||
func (s *State) GetAuthCacheEntry(id types.AuthID) (*types.AuthRequest, bool) {
|
||||
return s.authCache.Get(id)
|
||||
|
||||
@@ -33,6 +33,9 @@ const (
|
||||
|
||||
var (
|
||||
errOidcMutuallyExclusive = errors.New("oidc_client_secret and oidc_client_secret_path are mutually exclusive")
|
||||
errOIDCIssuerInvalid = errors.New("oidc.issuer must be a valid http(s) URL")
|
||||
errOIDCClientIDRequired = errors.New("oidc.client_id is required when oidc.issuer is set")
|
||||
errOIDCClientSecretRequired = errors.New("oidc.client_secret or oidc.client_secret_path is required when oidc.issuer is set")
|
||||
errServerURLSuffix = errors.New("server_url cannot be part of base_domain in a way that could make the DERP and headscale server unreachable")
|
||||
errServerURLSame = errors.New("server_url cannot use the same domain as base_domain in a way that could make the DERP and headscale server unreachable")
|
||||
errInvalidPKCEMethod = errors.New("pkce.method must be either 'plain' or 'S256'")
|
||||
@@ -363,6 +366,35 @@ func validatePKCEMethod(method string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateOIDCConfig validates the OIDC settings, called when oidc.issuer is
|
||||
// set. It fails fast on a setup that cannot work: an invalid PKCE method, a
|
||||
// malformed issuer URL (which would otherwise surface as an opaque discovery
|
||||
// error or, worse, resolve to an unintended provider), or a missing client
|
||||
// id/secret.
|
||||
func validateOIDCConfig() error {
|
||||
err := validatePKCEMethod(viper.GetString("oidc.pkce.method"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
issuer := viper.GetString("oidc.issuer")
|
||||
|
||||
u, err := url.Parse(issuer)
|
||||
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
|
||||
return fmt.Errorf("%w: got %q", errOIDCIssuerInvalid, issuer)
|
||||
}
|
||||
|
||||
if viper.GetString("oidc.client_id") == "" {
|
||||
return errOIDCClientIDRequired
|
||||
}
|
||||
|
||||
if viper.GetString("oidc.client_secret") == "" && viper.GetString("oidc.client_secret_path") == "" {
|
||||
return errOIDCClientSecretRequired
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Domain returns the hostname/domain part of the [Config.ServerURL].
|
||||
// If the [Config.ServerURL] is not a valid URL, it returns the [Config.BaseDomain].
|
||||
func (c *Config) Domain() string {
|
||||
@@ -564,11 +596,10 @@ func validateServerConfig() error {
|
||||
depr.fatalIfSet("oidc.expiry", "node.expiry")
|
||||
|
||||
// OIDC is activated by setting oidc.issuer (see app.go), not by a
|
||||
// dedicated oidc.enabled key. Gate PKCE method validation on the real
|
||||
// activation condition so an invalid method fails at startup instead of
|
||||
// silently disabling PKCE at runtime.
|
||||
// dedicated oidc.enabled key. Gate validation on the real activation
|
||||
// condition so a misconfiguration fails at startup.
|
||||
if viper.GetString("oidc.issuer") != "" {
|
||||
err := validatePKCEMethod(viper.GetString("oidc.pkce.method"))
|
||||
err := validateOIDCConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -444,6 +444,73 @@ oidc:
|
||||
assert.Contains(t, err.Error(), errInvalidPKCEMethod.Error())
|
||||
}
|
||||
|
||||
// TestOIDCConfigValidation covers the issuer-URL and required-field checks that
|
||||
// fail an unworkable OIDC setup fast at config load.
|
||||
func TestOIDCConfigValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
oidcBlock string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "non-http issuer",
|
||||
oidcBlock: `
|
||||
issuer: ftp://idp.example.com
|
||||
client_id: headscale
|
||||
client_secret: sekret`,
|
||||
wantErr: "valid http(s) URL",
|
||||
},
|
||||
{
|
||||
name: "missing client_id",
|
||||
oidcBlock: `
|
||||
issuer: https://idp.example.com
|
||||
client_secret: sekret`,
|
||||
wantErr: "client_id is required",
|
||||
},
|
||||
{
|
||||
name: "missing client_secret",
|
||||
oidcBlock: `
|
||||
issuer: https://idp.example.com
|
||||
client_id: headscale`,
|
||||
wantErr: "client_secret",
|
||||
},
|
||||
{
|
||||
name: "valid",
|
||||
oidcBlock: `
|
||||
issuer: https://idp.example.com
|
||||
client_id: headscale
|
||||
client_secret: sekret`,
|
||||
wantErr: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configYaml := []byte(`---
|
||||
noise:
|
||||
private_key_path: noise_private.key
|
||||
server_url: http://127.0.0.1:8080
|
||||
dns:
|
||||
override_local_dns: false
|
||||
oidc:` + tt.oidcBlock + "\n")
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.yaml"), configYaml, 0o600))
|
||||
require.NoError(t, LoadConfig(tmpDir, false))
|
||||
|
||||
err := validateServerConfig()
|
||||
if tt.wantErr == "" {
|
||||
require.NoError(t, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// OK
|
||||
// server_url: headscale.com, base: clients.headscale.com
|
||||
// server_url: headscale.com, base: headscale.net
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/dnsname"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -517,6 +518,28 @@ func (node *Node) GetFQDN(baseDomain string) (string, error) {
|
||||
return hostname, nil
|
||||
}
|
||||
|
||||
// ValidateGivenName reports whether givenName is usable as a node's DNS label:
|
||||
// a valid DNS label that, combined with baseDomain, yields an FQDN within
|
||||
// MaxHostnameLength. Admin-facing write paths (e.g. node rename) reject names
|
||||
// that fail this, since the mapper cannot build a map for a node — or any of
|
||||
// its peers — whose GetFQDN fails. Derived paths sanitise/coerce instead.
|
||||
func ValidateGivenName(givenName, baseDomain string) error {
|
||||
err := dnsname.ValidLabel(givenName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%q is not a valid DNS label: %w", givenName, err)
|
||||
}
|
||||
|
||||
// Reuse GetFQDN so the length bound stays identical to what the mapper
|
||||
// enforces; a valid 63-char label can still overflow under a long
|
||||
// base_domain.
|
||||
_, err = (&Node{GivenName: givenName}).GetFQDN(baseDomain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AnnouncedRoutes returns the list of routes the node announces, as
|
||||
// reported by the client in [tailcfg.Hostinfo.RoutableIPs]. Announcement alone
|
||||
// does not grant visibility — see [Node.SubnetRoutes] for approval-gated
|
||||
|
||||
@@ -417,6 +417,33 @@ func TestNodeFQDN(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGivenName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
givenName string
|
||||
baseDomain string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid", "test", "example.com", false},
|
||||
{"empty", "", "example.com", true},
|
||||
{"invalid label chars", "not valid", "example.com", true},
|
||||
{"label too long", strings.Repeat("a", 64), "example.com", true},
|
||||
// A valid 63-char label whose FQDN overflows only because the base
|
||||
// domain is long: ValidLabel passes, the FQDN-length bound rejects it.
|
||||
{"fqdn too long under long base domain", strings.Repeat("a", 63), strings.Repeat("b", 200) + ".example.com", true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidateGivenName(tc.givenName, tc.baseDomain)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("ValidateGivenName(%q, %q) error = %v, wantErr %v",
|
||||
tc.givenName, tc.baseDomain, err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerChangeFromMapRequest(t *testing.T) {
|
||||
nKeys := []key.NodePublic{
|
||||
key.NewNode().Public(),
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
const (
|
||||
// OAuthClientPrefix prefixes an OAuth client secret:
|
||||
// hskey-client-<clientID>-<secret>.
|
||||
OAuthClientPrefix = "hskey-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
|
||||
// it is one canonical constant shared by the db and api layers.
|
||||
AccessTokenPrefix = "hskey-oauthtok-" //nolint:gosec // prefix, not a credential
|
||||
)
|
||||
|
||||
// OAuthClient is a long-lived OAuth 2.0 client-credentials principal. It mints
|
||||
// short-lived [OAuthAccessToken]s limited to its Scopes and Tags. The secret is
|
||||
// stored only as an Argon2id hash. ClientID is public and embedded in the secret
|
||||
// string (hskey-client-<ClientID>-<secret>) so the token endpoint can derive it
|
||||
// from the secret alone, matching Tailscale, where the client id is a substring
|
||||
// of the client secret.
|
||||
//
|
||||
// An OAuth client is always tag/tailnet-scoped, never user-owned: the access
|
||||
// tokens it mints, and the auth keys those tokens create, produce tagged nodes.
|
||||
// UserID only records who created the client (informational), mirroring
|
||||
// [APIKey].
|
||||
type OAuthClient struct {
|
||||
ID uint64 `gorm:"primary_key"`
|
||||
ClientID string `gorm:"uniqueIndex"`
|
||||
SecretHash []byte
|
||||
|
||||
// Scopes the client may grant. Tags the client may assign to access tokens
|
||||
// (and, transitively, to the auth keys and nodes those tokens create).
|
||||
Scopes []string `gorm:"serializer:json"`
|
||||
Tags []string `gorm:"serializer:json"`
|
||||
|
||||
Description string
|
||||
|
||||
// UserID records who created the client. Kept as a plain column with no
|
||||
// foreign key so an upgraded database matches a freshly-migrated one.
|
||||
UserID *uint
|
||||
|
||||
CreatedAt *time.Time
|
||||
Revoked *time.Time
|
||||
}
|
||||
|
||||
// TableName pins the table name. GORM's naming strategy would otherwise render
|
||||
// OAuthClient as "o_auth_clients" (it breaks the OAuth initialism), diverging
|
||||
// from the hand-written migration DDL and schema.sql.
|
||||
func (*OAuthClient) TableName() string { return "oauth_clients" }
|
||||
|
||||
// OAuthAccessToken is a short-lived bearer token minted by an [OAuthClient] via
|
||||
// the client-credentials grant. It carries the scope/tag set granted at mint
|
||||
// time (a subset of the issuing client's), is stored as an Argon2id hash of its
|
||||
// secret, and authenticates v2 API requests as Authorization: Bearer.
|
||||
type OAuthAccessToken struct {
|
||||
ID uint64 `gorm:"primary_key"`
|
||||
Prefix string `gorm:"uniqueIndex"`
|
||||
Hash []byte
|
||||
|
||||
// ClientID links back to the issuing [OAuthClient].
|
||||
ClientID string
|
||||
|
||||
Scopes []string `gorm:"serializer:json"`
|
||||
Tags []string `gorm:"serializer:json"`
|
||||
|
||||
Expiration *time.Time
|
||||
CreatedAt *time.Time
|
||||
}
|
||||
|
||||
// TableName pins the table name (see [OAuthClient.TableName]).
|
||||
func (*OAuthAccessToken) TableName() string { return "oauth_access_tokens" }
|
||||
|
||||
// maskedClientID returns the client id in masked form for safe logging.
|
||||
// SECURITY: never log the secret or its hash.
|
||||
func (c *OAuthClient) maskedClientID() string {
|
||||
if c.ClientID != "" {
|
||||
return OAuthClientPrefix + c.ClientID + "-***"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// MarshalZerologObject implements [zerolog.LogObjectMarshaler] for safe logging.
|
||||
// SECURITY: intentionally does NOT log the secret or hash.
|
||||
func (c *OAuthClient) MarshalZerologObject(e *zerolog.Event) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
|
||||
e.Uint64("oauth_client_id", c.ID)
|
||||
|
||||
if masked := c.maskedClientID(); masked != "" {
|
||||
e.Str("oauth_client", masked)
|
||||
}
|
||||
|
||||
if len(c.Scopes) > 0 {
|
||||
e.Strs("oauth_client_scopes", c.Scopes)
|
||||
}
|
||||
|
||||
if len(c.Tags) > 0 {
|
||||
e.Strs("oauth_client_tags", c.Tags)
|
||||
}
|
||||
|
||||
if c.Revoked != nil {
|
||||
e.Time("oauth_client_revoked", *c.Revoked)
|
||||
}
|
||||
}
|
||||
|
||||
// maskedPrefix returns the token prefix in masked form for safe logging.
|
||||
// SECURITY: never log the secret or its hash.
|
||||
func (t *OAuthAccessToken) maskedPrefix() string {
|
||||
if t.Prefix != "" {
|
||||
return AccessTokenPrefix + t.Prefix + "-***"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// MarshalZerologObject implements [zerolog.LogObjectMarshaler] for safe logging.
|
||||
// SECURITY: intentionally does NOT log the secret or hash.
|
||||
func (t *OAuthAccessToken) MarshalZerologObject(e *zerolog.Event) {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
|
||||
e.Uint64("oauth_token_id", t.ID)
|
||||
|
||||
if masked := t.maskedPrefix(); masked != "" {
|
||||
e.Str("oauth_token", masked)
|
||||
}
|
||||
|
||||
if t.ClientID != "" {
|
||||
e.Str("oauth_token_client", t.ClientID)
|
||||
}
|
||||
|
||||
if len(t.Scopes) > 0 {
|
||||
e.Strs("oauth_token_scopes", t.Scopes)
|
||||
}
|
||||
|
||||
if len(t.Tags) > 0 {
|
||||
e.Strs("oauth_token_tags", t.Tags)
|
||||
}
|
||||
|
||||
if t.Expiration != nil {
|
||||
e.Time("oauth_token_expiration", *t.Expiration)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/integration/hsic"
|
||||
"github.com/juanfont/headscale/integration/integrationutil"
|
||||
"github.com/juanfont/headscale/integration/tsic"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// cliOAuthClient is the JSON the `oauth-clients` commands emit (the v2 Key shape,
|
||||
// only the fields the test asserts on).
|
||||
type cliOAuthClient struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
KeyType string `json:"keyType"`
|
||||
Scopes []string `json:"scopes"`
|
||||
Tags []string `json:"tags"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// TestOAuthClientCommand exercises the `headscale oauth-clients` CLI end to end:
|
||||
// create (with scopes and tags) -> list (secret hidden) -> delete. The CLI talks
|
||||
// to the v2 keys handler over the local unix socket, so this also proves the v2
|
||||
// API is reachable over the socket with local trust.
|
||||
func TestOAuthClientCommand(t *testing.T) {
|
||||
IntegrationSkip(t)
|
||||
|
||||
scenario, err := NewScenario(ScenarioSpec{Users: []string{}})
|
||||
require.NoError(t, err)
|
||||
|
||||
defer scenario.ShutdownAssertNoPanics(t)
|
||||
|
||||
err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("cli-oauthclient"))
|
||||
require.NoError(t, err)
|
||||
|
||||
headscale, err := scenario.Headscale()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create an OAuth client with scopes and a tag. devices:core/auth_keys
|
||||
// require a tag, which is supplied.
|
||||
createOut, err := headscale.Execute([]string{
|
||||
"headscale", "oauth-clients", "create",
|
||||
"--scope", "auth_keys",
|
||||
"--scope", "devices:core",
|
||||
"--tag", "tag:k8s-operator",
|
||||
"--description", "operator",
|
||||
"--output", "json",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var created cliOAuthClient
|
||||
require.NoError(t, json.Unmarshal([]byte(createOut), &created))
|
||||
assert.Equal(t, "client", created.KeyType)
|
||||
assert.NotEmpty(t, created.ID, "client id returned")
|
||||
assert.NotEmpty(t, created.Key, "secret returned once on create")
|
||||
assert.ElementsMatch(t, []string{"auth_keys", "devices:core"}, created.Scopes)
|
||||
assert.Equal(t, []string{"tag:k8s-operator"}, created.Tags)
|
||||
|
||||
// List shows the client without its secret.
|
||||
var listed []cliOAuthClient
|
||||
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
err := executeAndUnmarshal(headscale,
|
||||
[]string{"headscale", "oauth-clients", "list", "--output", "json"}, &listed)
|
||||
assert.NoError(c, err)
|
||||
assert.Len(c, listed, 1)
|
||||
}, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "waiting for oauth client list")
|
||||
|
||||
assert.Equal(t, created.ID, listed[0].ID)
|
||||
assert.Empty(t, listed[0].Key, "secret is never exposed on list")
|
||||
assert.ElementsMatch(t, []string{"auth_keys", "devices:core"}, listed[0].Scopes)
|
||||
assert.Equal(t, "operator", listed[0].Description)
|
||||
|
||||
// Delete it.
|
||||
_, err = headscale.Execute([]string{"headscale", "oauth-clients", "delete", "--id", created.ID})
|
||||
require.NoError(t, err)
|
||||
|
||||
var afterDelete []cliOAuthClient
|
||||
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
err := executeAndUnmarshal(headscale,
|
||||
[]string{"headscale", "oauth-clients", "list", "--output", "json"}, &afterDelete)
|
||||
assert.NoError(c, err)
|
||||
assert.Empty(c, afterDelete)
|
||||
}, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "waiting for oauth client list after delete")
|
||||
}
|
||||
|
||||
// TestOAuthClientCommandValidation covers the CLI's input validation and the
|
||||
// server's 404 on deleting an unknown client.
|
||||
func TestOAuthClientCommandValidation(t *testing.T) {
|
||||
IntegrationSkip(t)
|
||||
|
||||
scenario, err := NewScenario(ScenarioSpec{Users: []string{}})
|
||||
require.NoError(t, err)
|
||||
|
||||
defer scenario.ShutdownAssertNoPanics(t)
|
||||
|
||||
err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("cli-oauthclientval"))
|
||||
require.NoError(t, err)
|
||||
|
||||
headscale, err := scenario.Headscale()
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "no scope", args: []string{"oauth-clients", "create"}, wantErr: "at least one --scope is required"},
|
||||
{name: "devices:core needs tag", args: []string{"oauth-clients", "create", "--scope", "devices:core"}, wantErr: "tags are required"},
|
||||
{name: "delete no id", args: []string{"oauth-clients", "delete"}, wantErr: "--id is required"},
|
||||
{name: "delete nonexistent id", args: []string{"oauth-clients", "delete", "--id", "doesnotexist"}, wantErr: "404"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := headscale.Execute(append([]string{"headscale"}, tt.args...))
|
||||
require.ErrorContains(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
|
||||
clientv1 "github.com/juanfont/headscale/gen/client/v1"
|
||||
@@ -22,6 +23,8 @@ type ControlServer interface {
|
||||
ConnectToNetwork(network *dockertest.Network) error
|
||||
GetHealthEndpoint() string
|
||||
GetEndpoint() string
|
||||
GetIPEndpoint() string
|
||||
CreateOAuthClient(ctx context.Context, scopes, tags []string) (string, string, error)
|
||||
WaitForRunning() error
|
||||
Restart() error
|
||||
CreateUser(user string) (*clientv1.User, error)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -25,6 +26,7 @@ import (
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
clientv1 "github.com/juanfont/headscale/gen/client/v1"
|
||||
clientv2 "github.com/juanfont/headscale/gen/client/v2"
|
||||
"github.com/juanfont/headscale/hscontrol"
|
||||
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
@@ -1023,6 +1025,90 @@ func (t *HeadscaleInContainer) GetEndpoint() string {
|
||||
return t.getEndpoint(false)
|
||||
}
|
||||
|
||||
var errOAuthSecretMissing = errors.New(`OAuth client response missing secret in "key" field`)
|
||||
|
||||
// CreateOAuthClient mints an admin API key and uses it to create an OAuth client
|
||||
// via the v2 keys HTTP API (POST /api/v2/tailnet/-/keys, keyType=client),
|
||||
// returning the client id and secret. The secret is only returned once, in the
|
||||
// "key" field. It is a reusable building block for tests that need OAuth client
|
||||
// credentials (such as the Kubernetes operator).
|
||||
func (t *HeadscaleInContainer) CreateOAuthClient(
|
||||
ctx context.Context,
|
||||
scopes, tags []string,
|
||||
) (string, string, error) {
|
||||
apiKey, err := t.Execute([]string{"headscale", "apikeys", "create", "--expiration", "24h"})
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("creating admin api key: %w", err)
|
||||
}
|
||||
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
|
||||
client, err := clientv2.NewClientWithResponses(
|
||||
t.GetEndpoint(),
|
||||
clientv2.WithHTTPClient(t.httpClient()),
|
||||
clientv2.WithRequestEditorFn(func(_ context.Context, req *http.Request) error {
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("building v2 API client: %w", err)
|
||||
}
|
||||
|
||||
keyType := "client"
|
||||
|
||||
resp, err := client.CreateKeyWithResponse(ctx, "-", clientv2.CreateKeyRequest{
|
||||
KeyType: &keyType,
|
||||
Scopes: &scopes,
|
||||
Tags: &tags,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("creating OAuth client: %w", err)
|
||||
}
|
||||
|
||||
if resp.JSON200 == nil {
|
||||
return "", "", fmt.Errorf( //nolint:err113
|
||||
"creating OAuth client: status %s: %s", resp.Status(), strings.TrimSpace(string(resp.Body)))
|
||||
}
|
||||
|
||||
if resp.JSON200.Key == nil || *resp.JSON200.Key == "" {
|
||||
return "", "", errOAuthSecretMissing
|
||||
}
|
||||
|
||||
// The operator expects clientId and clientSecret as separate values. When the
|
||||
// server returns a single opaque credential, the client-credentials grant
|
||||
// splits it on "-" (id-secret), matching the Tailscale SaaS shape. Fall back
|
||||
// to the whole key as the secret when no id is given.
|
||||
clientID, clientSecret := resp.JSON200.Id, *resp.JSON200.Key
|
||||
|
||||
if clientID == "" {
|
||||
if id, secret, ok := strings.Cut(*resp.JSON200.Key, "-"); ok {
|
||||
clientID, clientSecret = id, secret
|
||||
}
|
||||
}
|
||||
|
||||
return clientID, clientSecret, nil
|
||||
}
|
||||
|
||||
// httpClient returns an HTTP client that trusts this Headscale's TLS CA when TLS
|
||||
// is enabled, or a default client when it serves plain HTTP.
|
||||
func (t *HeadscaleInContainer) httpClient() *http.Client {
|
||||
if !t.hasTLS() {
|
||||
return &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
|
||||
pool := x509.NewCertPool()
|
||||
pool.AppendCertsFromPEM(t.tlsCACert)
|
||||
|
||||
return &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetIPEndpoint returns the Headscale endpoint using IP address instead of hostname.
|
||||
func (t *HeadscaleInContainer) GetIPEndpoint() string {
|
||||
return t.getEndpoint(true)
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
// Package k3sic wraps a single-container k3s cluster (server + agent) as a
|
||||
// privileged sibling container on the host docker daemon, like the
|
||||
// DERP-in-container wrapper in the dsic package.
|
||||
//
|
||||
// It exists so that integration tests can install the real Tailscale
|
||||
// Kubernetes operator (via its Helm chart) into a real Kubernetes cluster and
|
||||
// point it at an in-test Headscale. k3s bundles kubectl and we run helm inside
|
||||
// the container through Execute, so the host dev shell needs no kube tooling.
|
||||
//
|
||||
// The operator is pointed at Headscale over plain HTTP (see
|
||||
// hsic.WithoutTLS), so the operator and proxy pods need no CA: there is no
|
||||
// image baking and no CoreDNS hostname mapping. To run instead against a TLS
|
||||
// Headscale with a private CA, see tls-ca-baking.md.
|
||||
//
|
||||
// The harness runs as sibling containers on the host docker daemon (the
|
||||
// test-suite container has the host docker socket bind-mounted); it is NOT
|
||||
// docker-in-docker. We therefore run the purpose-built single-container k3s image
|
||||
// as one more privileged sibling joined to the scenario networks, rather than using
|
||||
// k3d/kind which shell out to the docker daemon and fight the sibling model.
|
||||
// Privileged is the harness-wide norm here, not a k3s-specific escalation: the
|
||||
// tsic (client) and dsic (DERP) containers run privileged too (see
|
||||
// dockertestutil.DockerAllowNetworkAdministration).
|
||||
package k3sic
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/capver"
|
||||
"github.com/juanfont/headscale/integration/dockertestutil"
|
||||
"github.com/juanfont/headscale/integration/integrationutil"
|
||||
"github.com/ory/dockertest/v3"
|
||||
"github.com/ory/dockertest/v3/docker"
|
||||
"tailscale.com/util/rands"
|
||||
)
|
||||
|
||||
const (
|
||||
k3sicHashLength = 6
|
||||
|
||||
// K3sImage is the single-container k3s server+agent image, pinned on ghcr (no
|
||||
// anonymous Docker Hub rate limit). Pinned rather than resolved from the k3s
|
||||
// stable channel because that channel floats the k8s minor unpredictably and
|
||||
// would outrun the cgroup-v2 and br_netfilter workarounds below. Bump by hand.
|
||||
K3sImage = "ghcr.io/k3s-io/k3s:v1.35.5-k3s1"
|
||||
|
||||
// operatorImageRepo and proxyImageRepo are the ghcr-hosted operator and
|
||||
// proxy images. ghcr is used instead of Docker Hub to avoid anonymous pull
|
||||
// rate limits; the pods pull these directly.
|
||||
operatorImageRepo = "ghcr.io/tailscale/k8s-operator"
|
||||
proxyImageRepo = "ghcr.io/tailscale/tailscale"
|
||||
|
||||
dockerExecuteTimeout = 300 * time.Second
|
||||
|
||||
// helmVersionFallback is used when the latest helm release cannot be
|
||||
// resolved at runtime (see resolveHelmVersion). The image ships no helm and
|
||||
// cannot fetch it itself, so we inject a binary that matches the container
|
||||
// arch.
|
||||
helmVersionFallback = "v3.19.1"
|
||||
|
||||
// kubeconfigPath is where k3s writes the kubeconfig (see RunOptions.Env);
|
||||
// helm needs it pointed explicitly, kubectl finds it by default.
|
||||
kubeconfigPath = "/etc/rancher/k3s/k3s.yaml"
|
||||
|
||||
// kubectlBin is the in-container kubectl the k3s image ships on PATH.
|
||||
kubectlBin = "kubectl"
|
||||
|
||||
// shellBin is the in-container shell used for compound commands.
|
||||
shellBin = "/bin/sh"
|
||||
|
||||
// tailscaleNamespace is where the operator and its proxies are installed.
|
||||
tailscaleNamespace = "tailscale"
|
||||
|
||||
// kubeSystemNamespace holds CoreDNS and the rest of the k3s system addons.
|
||||
kubeSystemNamespace = "kube-system"
|
||||
)
|
||||
|
||||
var (
|
||||
errHelmDownload = errors.New("helm download failed")
|
||||
errHelmNotInTarball = errors.New("helm binary not found in release tarball")
|
||||
|
||||
errNoKubeDNSEndpoints = errors.New("kube-dns Service has no ready endpoints yet")
|
||||
)
|
||||
|
||||
// OperatorImageTag is the image tag the operator and proxy images use, derived
|
||||
// from the Tailscale minor Headscale tracks via capver (e.g. "v1.98"). The
|
||||
// operator shares the Tailscale release train, so this keeps the images and the
|
||||
// Helm chart in lockstep with the client versions Headscale is tested against,
|
||||
// without a hand-pinned constant. The tag is a rolling tag within the minor.
|
||||
func OperatorImageTag() string {
|
||||
return capver.TailscaleLatestMajorMinor(1, false)[0]
|
||||
}
|
||||
|
||||
// OperatorImage and ProxyImage are the ghcr operator/proxy image references the
|
||||
// test wires into the Helm chart.
|
||||
func OperatorImage() string { return operatorImageRepo + ":" + OperatorImageTag() }
|
||||
func ProxyImage() string { return proxyImageRepo + ":" + OperatorImageTag() }
|
||||
|
||||
// OperatorChartVersion is the Helm chart version constraint matching the derived
|
||||
// minor; helm resolves the latest patch in that line. The top-level loginServer
|
||||
// value the operator needs to target Headscale instead of the Tailscale SaaS
|
||||
// first shipped in chart 1.98.4.
|
||||
func OperatorChartVersion() string {
|
||||
return strings.TrimPrefix(OperatorImageTag(), "v") + ".*"
|
||||
}
|
||||
|
||||
// K3sInContainer represents a k3s cluster running in a single privileged
|
||||
// container (K3sInContainer, hence k3sic).
|
||||
type K3sInContainer struct {
|
||||
hostname string
|
||||
|
||||
pool *dockertest.Pool
|
||||
container *dockertest.Resource
|
||||
networks []*dockertest.Network
|
||||
}
|
||||
|
||||
// New starts a new [K3sInContainer] joined to the given networks.
|
||||
func New(
|
||||
pool *dockertest.Pool,
|
||||
networks []*dockertest.Network,
|
||||
) (*K3sInContainer, error) {
|
||||
hash := rands.HexString(k3sicHashLength)
|
||||
|
||||
// Include the run ID in the hostname for easier identification of which
|
||||
// test run owns this container, matching the dsic/tsic convention.
|
||||
runID := dockertestutil.GetIntegrationRunID()
|
||||
|
||||
var hostname string
|
||||
|
||||
if runID != "" {
|
||||
runIDShort := runID[len(runID)-6:]
|
||||
hostname = fmt.Sprintf("k3s-%s-%s", runIDShort, hash)
|
||||
} else {
|
||||
hostname = "k3s-" + hash
|
||||
}
|
||||
|
||||
k := &K3sInContainer{
|
||||
hostname: hostname,
|
||||
pool: pool,
|
||||
networks: networks,
|
||||
}
|
||||
|
||||
// Pull the k3s image (ghcr, not built) via PullWithAuth, as hsic does for
|
||||
// prebuilt/pulled images.
|
||||
err := dockertestutil.PullWithAuth(pool, K3sImage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pulling %s: %w", K3sImage, err)
|
||||
}
|
||||
|
||||
repo, tag, ok := strings.Cut(K3sImage, ":")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid k3s image reference %q", K3sImage) //nolint:err113
|
||||
}
|
||||
|
||||
runOptions := &dockertest.RunOptions{
|
||||
Name: hostname,
|
||||
Repository: repo,
|
||||
Tag: tag,
|
||||
Networks: networks,
|
||||
// "server" runs both the control plane and a built-in agent in one
|
||||
// container. --disable traefik/servicelb/metrics-server keeps the
|
||||
// cluster lean: the test only needs the API server and the ability to
|
||||
// schedule the operator pods. --tls-san pins the hostname into the
|
||||
// apiserver cert (not strictly needed since we exec kubectl in-container,
|
||||
// but harmless and future-proof).
|
||||
Cmd: []string{
|
||||
"server",
|
||||
"--disable", "traefik",
|
||||
"--disable", "servicelb",
|
||||
"--disable", "metrics-server",
|
||||
"--disable-network-policy",
|
||||
"--snapshotter", "native",
|
||||
"--tls-san", hostname,
|
||||
},
|
||||
Env: []string{
|
||||
"K3S_KUBECONFIG_OUTPUT=" + kubeconfigPath,
|
||||
"K3S_KUBECONFIG_MODE=0644",
|
||||
},
|
||||
}
|
||||
|
||||
// Stamp the run-id label or the reaper leaks the container.
|
||||
dockertestutil.DockerAddIntegrationLabels(runOptions, "k3s")
|
||||
|
||||
// dockertest does not handle pre-existing containers well; make sure a
|
||||
// stale one with this name is gone first.
|
||||
err = pool.RemoveContainerByName(hostname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
container, err := pool.RunWithOptions(
|
||||
runOptions,
|
||||
dockertestutil.DockerRestartPolicy,
|
||||
// Privileged + NET_ADMIN: k3s manages iptables/ipvs, mounts cgroups and
|
||||
// runs containerd. This is the same knob dsic uses for the DERP server.
|
||||
dockertestutil.DockerAllowNetworkAdministration,
|
||||
withK3sHostConfig,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s starting k3s container: %w", hostname, err)
|
||||
}
|
||||
|
||||
log.Printf("Created %s container\n", hostname)
|
||||
|
||||
k.container = container
|
||||
|
||||
// Make ClusterIP DNAT work before k3s programs kube-proxy rules; without it
|
||||
// in-cluster DNS times out on hosts where br_netfilter is not preloaded.
|
||||
k.ensureBridgeNetfilter()
|
||||
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// withK3sHostConfig sets the HostConfig knobs k3s needs beyond
|
||||
// privileged/NET_ADMIN: a tmpfs on /run and /var/run, which the k3s image
|
||||
// expects.
|
||||
//
|
||||
// It deliberately does NOT bind-mount the host /sys/fs/cgroup. On a cgroup-v2
|
||||
// host, bind-mounting the host cgroup tree into a container that keeps its own
|
||||
// (private) cgroup namespace makes the cgroup root visible to the container
|
||||
// disagree with the namespace runc places workload pods under. The kubelet can
|
||||
// then start (the apiserver and node go Ready), but every workload pod fails to
|
||||
// create its sandbox with "failed to apply cgroup configuration: ...
|
||||
// cgroup.procs: no such file or directory", so nothing the operator schedules
|
||||
// ever runs. Leaving the bind-mount off lets the privileged k3s entrypoint set
|
||||
// up cgroup-v2 delegation within its own namespace, which it is
|
||||
// designed to do, and workload pods schedule normally.
|
||||
func withK3sHostConfig(config *docker.HostConfig) {
|
||||
config.Tmpfs = map[string]string{
|
||||
"/run": "",
|
||||
"/var/run": "",
|
||||
}
|
||||
|
||||
// Bind the host kernel modules read-only so the container can load
|
||||
// br_netfilter (see ensureBridgeNetfilter). Without bridge netfilter,
|
||||
// kube-proxy's ClusterIP DNAT rules do not apply to bridged pod-to-pod
|
||||
// traffic, so kube-dns (and every other Service) is unreachable from pods —
|
||||
// the in-cluster DNS timeout seen on the arm64 CI runner. The container
|
||||
// shares the host kernel, so the modules match.
|
||||
config.Binds = append(config.Binds, "/lib/modules:/lib/modules:ro")
|
||||
}
|
||||
|
||||
// ensureBridgeNetfilter loads br_netfilter and enables the sysctls that make
|
||||
// kube-proxy's ClusterIP DNAT apply to bridged pod-to-pod traffic. On a host
|
||||
// where the module is already loaded (e.g. the amd64 dev box, where Docker
|
||||
// loads it for bridge networks) these are no-ops; on the arm64 CI runner the
|
||||
// module is absent and pods cannot reach any Service IP — kube-dns included —
|
||||
// so in-cluster DNS times out. Best-effort: k3s also loads the module, and a
|
||||
// genuinely missing module surfaces in DumpDiagnostics rather than here.
|
||||
func (k *K3sInContainer) ensureBridgeNetfilter() {
|
||||
for _, cmd := range []string{
|
||||
"modprobe br_netfilter || true",
|
||||
"sysctl -w net.bridge.bridge-nf-call-iptables=1 || true",
|
||||
"sysctl -w net.bridge.bridge-nf-call-ip6tables=1 || true",
|
||||
"sysctl -w net.ipv4.ip_forward=1 || true",
|
||||
} {
|
||||
out, stderr, err := k.Execute([]string{shellBin, "-c", cmd})
|
||||
if err != nil {
|
||||
log.Printf("[k3s] %q failed: %v (stdout: %s, stderr: %s)", cmd, err, out, stderr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hostname returns the hostname of the [K3sInContainer].
|
||||
func (k *K3sInContainer) Hostname() string {
|
||||
return k.hostname
|
||||
}
|
||||
|
||||
// ID returns the docker container ID of the [K3sInContainer].
|
||||
func (k *K3sInContainer) ID() string {
|
||||
return k.container.Container.ID
|
||||
}
|
||||
|
||||
// ConnectToNetwork connects the cluster container to an additional network.
|
||||
func (k *K3sInContainer) ConnectToNetwork(network *dockertest.Network) error {
|
||||
return k.container.ConnectToNetwork(network)
|
||||
}
|
||||
|
||||
// Execute runs a command inside the k3s container and returns its stdout.
|
||||
// kubectl and the k3s-bundled tools (and helm, once installed via
|
||||
// [K3sInContainer.InstallHelm]) are on PATH. KUBECONFIG is exported so helm,
|
||||
// which (unlike the image's kubectl) does not default to the k3s config, can
|
||||
// reach the cluster.
|
||||
func (k *K3sInContainer) Execute(command []string) (string, string, error) {
|
||||
return dockertestutil.ExecuteCommand(
|
||||
k.container,
|
||||
command,
|
||||
[]string{"KUBECONFIG=" + kubeconfigPath},
|
||||
dockertestutil.ExecuteCommandTimeout(dockerExecuteTimeout),
|
||||
)
|
||||
}
|
||||
|
||||
// WriteFile saves a file inside the container.
|
||||
func (k *K3sInContainer) WriteFile(path string, data []byte) error {
|
||||
return integrationutil.WriteFileToContainer(k.pool, k.container, path, data)
|
||||
}
|
||||
|
||||
// WaitForRunning blocks until the cluster is ready to schedule DNS-dependent
|
||||
// workloads: the kube-apiserver is serving, the single node reports Ready, and
|
||||
// in-cluster DNS is servable (CoreDNS rolled out with a backed kube-dns
|
||||
// Service). Gating on DNS here pins a missing-DNS failure at its source rather
|
||||
// than letting the operator crashloop on an opaque lookup timeout.
|
||||
func (k *K3sInContainer) WaitForRunning() error {
|
||||
log.Printf("waiting for k3s API server in %s to be ready", k.hostname)
|
||||
|
||||
err := k.pool.Retry(func() error {
|
||||
// `kubectl get --raw=/readyz` returns "ok" once the apiserver is up.
|
||||
out, _, err := k.Execute([]string{
|
||||
kubectlBin, "get", "--raw=/readyz",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("k3s apiserver not ready: %w", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(out, "ok") {
|
||||
return fmt.Errorf("k3s apiserver readyz returned %q", strings.TrimSpace(out)) //nolint:err113
|
||||
}
|
||||
|
||||
// Wait for the node object to exist and be Ready before returning so
|
||||
// pods can actually be scheduled.
|
||||
nodeOut, _, err := k.Execute([]string{
|
||||
kubectlBin, "get", "nodes", "--no-headers",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("k3s node not ready: %w", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(nodeOut, " Ready") {
|
||||
return fmt.Errorf("k3s node not Ready yet: %q", strings.TrimSpace(nodeOut)) //nolint:err113
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return k.waitForClusterDNS()
|
||||
}
|
||||
|
||||
// InstallHelm installs the helm binary into the container so the operator can be
|
||||
// installed. The k3s image ships kubectl but not helm, has no curl, and its
|
||||
// busybox wget cannot do HTTPS, so we download helm in the test process (which
|
||||
// has network egress) and inject the binary. helm's own HTTPS client then
|
||||
// fetches the operator chart from inside the container.
|
||||
func (k *K3sInContainer) InstallHelm() error {
|
||||
bin, err := fetchHelmBinary(resolveHelmVersion(), runtime.GOARCH)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching helm: %w", err)
|
||||
}
|
||||
|
||||
err = k.WriteFile("/usr/local/bin/helm", bin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing helm binary: %w", err)
|
||||
}
|
||||
|
||||
// WriteFile uploads with mode 0; make it readable+executable.
|
||||
_, stderr, err := k.Execute([]string{"chmod", "0755", "/usr/local/bin/helm"})
|
||||
if err != nil {
|
||||
return fmt.Errorf("chmod helm (stderr: %s): %w", stderr, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForClusterDNS blocks until CoreDNS is rolled out and the kube-dns Service
|
||||
// has at least one ready endpoint — i.e. in-cluster name resolution is actually
|
||||
// servable, which every workload (starting with the operator) depends on. k3s
|
||||
// deploys CoreDNS via its addon manager shortly after the node reports Ready, so
|
||||
// the deployment may not exist yet; retry until it does before checking rollout.
|
||||
func (k *K3sInContainer) waitForClusterDNS() error {
|
||||
err := k.pool.Retry(func() error {
|
||||
_, stderr, err := k.Execute([]string{
|
||||
kubectlBin, "-n", kubeSystemNamespace, "get", "deployment", "coredns",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("coredns deployment not present yet (stderr: %s): %w", stderr, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("waiting for coredns deployment to appear: %w", err)
|
||||
}
|
||||
|
||||
_, stderr, err := k.Execute([]string{
|
||||
kubectlBin, "-n", kubeSystemNamespace, "rollout", "status",
|
||||
"deployment/coredns", "--timeout=150s",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("coredns did not become available (stderr: %s): %w", stderr, err)
|
||||
}
|
||||
|
||||
return k.pool.Retry(func() error {
|
||||
// A populated endpoint set means a CoreDNS pod is serving :53 and
|
||||
// kube-proxy has a backend to DNAT the kube-dns ClusterIP to; empty means
|
||||
// in-cluster lookups will time out no matter how long a client waits.
|
||||
out, stderr, err := k.Execute([]string{
|
||||
kubectlBin, "-n", kubeSystemNamespace, "get", "endpoints", "kube-dns",
|
||||
"-o", "jsonpath={.subsets[*].addresses[*].ip}",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading kube-dns endpoints (stderr: %s): %w", stderr, err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(out) == "" {
|
||||
return errNoKubeDNSEndpoints
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ConfigureCoreDNSHost makes in-cluster pods resolve hostname to ip via CoreDNS.
|
||||
// The operator targets Headscale's control plane by IP, but the embedded DERP map
|
||||
// references Headscale by hostname; without this, the proxy pods cannot resolve
|
||||
// the DERP server, never connect to it, and — since they only advertise
|
||||
// unreachable pod-network endpoints — get no data path to nodes outside the
|
||||
// cluster. It installs a coredns-custom ConfigMap (a k3s-native extension point:
|
||||
// keys ending in .server become additional server blocks), which CoreDNS's reload
|
||||
// plugin picks up without a restart.
|
||||
func (k *K3sInContainer) ConfigureCoreDNSHost(hostname, ip string) error {
|
||||
manifest := fmt.Sprintf(`apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: coredns-custom
|
||||
namespace: kube-system
|
||||
data:
|
||||
headscale.server: |
|
||||
%s {
|
||||
hosts {
|
||||
%s %s
|
||||
fallthrough
|
||||
}
|
||||
}
|
||||
`, hostname, ip, hostname)
|
||||
|
||||
return k.ApplyManifest("coredns-custom", manifest)
|
||||
}
|
||||
|
||||
// DumpDiagnostics logs cluster state useful for debugging a failed operator
|
||||
// install: pod status across namespaces and the operator's own logs and events.
|
||||
// Best-effort — every command's failure is logged, not returned.
|
||||
func (k *K3sInContainer) DumpDiagnostics() {
|
||||
for _, c := range [][]string{
|
||||
{kubectlBin, "get", "pods", "-A", "-o", "wide"},
|
||||
{kubectlBin, "-n", tailscaleNamespace, "get", "events", "--sort-by=.lastTimestamp"},
|
||||
{kubectlBin, "-n", tailscaleNamespace, "describe", "pods"},
|
||||
{kubectlBin, "-n", tailscaleNamespace, "logs", "deployment/operator", "--tail=200"},
|
||||
// A crashlooping operator's fatal error is in the previous container.
|
||||
{kubectlBin, "-n", tailscaleNamespace, "logs", "deployment/operator", "--previous", "--tail=200"},
|
||||
{kubectlBin, "-n", tailscaleNamespace, "get", "statefulsets,pods", "-o", "wide"},
|
||||
// Proxy pods' tailscaled logs: DERP-connection and registration failures
|
||||
// (the data-path culprits) surface here, not in the operator log.
|
||||
{
|
||||
shellBin, "-c",
|
||||
"for p in $(kubectl -n " + tailscaleNamespace + " get pods -o name | grep /ts-); do " +
|
||||
"echo \"== $p ==\"; kubectl -n " + tailscaleNamespace +
|
||||
" logs $p -c tailscale --tail=80 2>&1; done",
|
||||
},
|
||||
// In-cluster DNS: the operator's first dependency. A "lookup
|
||||
// kubernetes.default.svc ... i/o timeout" crash means CoreDNS is not
|
||||
// serving, so capture its pod state, logs (Corefile parse errors land
|
||||
// here), and the Service endpoints.
|
||||
{kubectlBin, "-n", kubeSystemNamespace, "get", "pods", "-l", "k8s-app=kube-dns", "-o", "wide"},
|
||||
{kubectlBin, "-n", kubeSystemNamespace, "logs", "-l", "k8s-app=kube-dns", "--tail=100"},
|
||||
{kubectlBin, "-n", kubeSystemNamespace, "get", "endpoints", "kube-dns", "-o", "wide"},
|
||||
// Host network state behind a ClusterIP-unreachable DNS timeout: whether
|
||||
// br_netfilter is loaded and the call-iptables/forward sysctls are on, and
|
||||
// whether kube-proxy actually programmed the kube-dns DNAT rule. If CoreDNS
|
||||
// is healthy (above) but these are missing, the fault is the Service DNAT
|
||||
// path, not DNS.
|
||||
{shellBin, "-c", "lsmod | grep -E 'br_netfilter|nf_conntrack' || echo 'br_netfilter NOT loaded'"},
|
||||
{shellBin, "-c", "sysctl net.bridge.bridge-nf-call-iptables net.ipv4.ip_forward 2>&1 || true"},
|
||||
{shellBin, "-c", "iptables-save -t nat 2>/dev/null | grep -iE 'KUBE-SERVICES|kube-dns|10.43.0.10' | head -40 || echo 'no kube-dns nat rules'"},
|
||||
} {
|
||||
out, stderr, err := k.Execute(c)
|
||||
label := strings.Join(c, " ")
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[k3s diag] %s failed: %v (stderr: %s)", label, err, stderr)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("[k3s diag] %s:\n%s", label, out)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveHelmVersion returns the latest published helm release tag (e.g.
|
||||
// "v3.19.1"), falling back to [helmVersionFallback] if it cannot be resolved.
|
||||
// get.helm.sh is not Docker Hub and has no anonymous rate limit, so a "rolling"
|
||||
// latest is cheap; the fallback keeps a broken release from breaking CI.
|
||||
func resolveHelmVersion() string {
|
||||
req, err := http.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "https://get.helm.sh/helm-latest-version", nil)
|
||||
if err != nil {
|
||||
return helmVersionFallback
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return helmVersionFallback
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return helmVersionFallback
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 32))
|
||||
if err != nil {
|
||||
return helmVersionFallback
|
||||
}
|
||||
|
||||
version := strings.TrimSpace(string(body))
|
||||
if !strings.HasPrefix(version, "v") {
|
||||
return helmVersionFallback
|
||||
}
|
||||
|
||||
return version
|
||||
}
|
||||
|
||||
// fetchHelmBinary downloads the helm release tarball for version and goarch and
|
||||
// returns the helm binary bytes.
|
||||
func fetchHelmBinary(version, goarch string) ([]byte, error) {
|
||||
url := fmt.Sprintf("https://get.helm.sh/helm-%s-linux-%s.tar.gz", version, goarch)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%w: %s returned status %d", errHelmDownload, url, resp.StatusCode)
|
||||
}
|
||||
|
||||
gz, err := gzip.NewReader(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
want := "linux-" + goarch + "/helm"
|
||||
tr := tar.NewReader(gz)
|
||||
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if hdr.Name == want {
|
||||
return io.ReadAll(tr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("%w: %s", errHelmNotInTarball, want)
|
||||
}
|
||||
|
||||
// Shutdown saves the container log and then runs k3s-killall in-container so
|
||||
// k3s's own child processes/containers (containerd-shims, pods) do not leak,
|
||||
// before purging the container itself.
|
||||
func (k *K3sInContainer) Shutdown() error {
|
||||
err := k.SaveLog("/tmp/control")
|
||||
if err != nil {
|
||||
log.Printf("saving log from %s: %s", k.hostname, err)
|
||||
}
|
||||
|
||||
// k3s spawns containerd and a tree of child processes inside this
|
||||
// container; the bundled k3s-killall.sh tears them down. Best-effort: the
|
||||
// Purge below removes the container regardless.
|
||||
_, _, err = k.Execute([]string{shellBin, "-c", "k3s-killall.sh || true"})
|
||||
if err != nil {
|
||||
log.Printf("running k3s-killall in %s: %s", k.hostname, err)
|
||||
}
|
||||
|
||||
return k.pool.Purge(k.container)
|
||||
}
|
||||
|
||||
// SaveLog saves the container stdout/stderr logs to a path on the host.
|
||||
func (k *K3sInContainer) SaveLog(path string) error {
|
||||
_, _, err := dockertestutil.SaveLog(k.pool, k.container, path)
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package k3sic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// This file holds the reusable building blocks for driving the Tailscale
|
||||
// Kubernetes operator inside the cluster: installing it and applying the CRs and
|
||||
// workloads a test needs. Tests compose these methods rather than embedding
|
||||
// kubectl/helm invocations, so adding a new operator test is a few method calls.
|
||||
|
||||
// ApplyManifest writes manifest into the container as /tmp/<name>.yaml and
|
||||
// kubectl-applies it. It is the building block the helpers below use, and is
|
||||
// exported so tests can apply ad-hoc manifests without a bespoke method.
|
||||
func (k *K3sInContainer) ApplyManifest(name, manifest string) error {
|
||||
path := "/tmp/" + name + ".yaml"
|
||||
|
||||
err := k.WriteFile(path, []byte(manifest))
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing manifest %s: %w", name, err)
|
||||
}
|
||||
|
||||
_, stderr, err := k.Execute([]string{kubectlBin, "apply", "-f", path})
|
||||
if err != nil {
|
||||
return fmt.Errorf("applying manifest %s (stderr: %s): %w", name, stderr, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// InstallOperator installs the Tailscale Kubernetes operator via Helm into the
|
||||
// tailscale namespace, pointed at loginServer with the given OAuth client
|
||||
// credentials. loginServer is used by the operator for both the control plane
|
||||
// and the management API; for an in-test Headscale pass its HTTP endpoint by IP
|
||||
// (hsic.HeadscaleInContainer.GetIPEndpoint) so the pods need no DNS or CA. The
|
||||
// operator and proxy images come from ghcr at the capver-derived tag. Blocks
|
||||
// (helm --wait) until the operator deployment is available.
|
||||
func (k *K3sInContainer) InstallOperator(loginServer, clientID, clientSecret string) error {
|
||||
repoAdd := "helm repo add tailscale https://pkgs.tailscale.com/helmcharts && helm repo update"
|
||||
|
||||
_, stderr, err := k.Execute([]string{shellBin, "-c", repoAdd})
|
||||
if err != nil {
|
||||
return fmt.Errorf("helm repo add/update (stderr: %s): %w", stderr, err)
|
||||
}
|
||||
|
||||
_, stderr, err = k.Execute([]string{
|
||||
kubectlBin, "create", "namespace", tailscaleNamespace,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating %s namespace (stderr: %s): %w", tailscaleNamespace, stderr, err)
|
||||
}
|
||||
|
||||
// Precreate the operator-oauth Secret with --from-literal instead of the
|
||||
// chart's oauth.clientId/clientSecret: the chart interpolates those unquoted,
|
||||
// so an all-digit credential renders as a YAML number and the apiserver
|
||||
// rejects it. --from-literal always stores strings. The chart uses a Secret
|
||||
// named operator-oauth when oauth.clientId is unset.
|
||||
_, stderr, err = k.Execute([]string{
|
||||
kubectlBin, "-n", tailscaleNamespace, "create", "secret", "generic", "operator-oauth",
|
||||
"--from-literal=client_id=" + clientID,
|
||||
"--from-literal=client_secret=" + clientSecret,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating operator-oauth secret (stderr: %s): %w", stderr, err)
|
||||
}
|
||||
|
||||
opRepo, opTag, _ := strings.Cut(OperatorImage(), ":")
|
||||
proxyRepo, proxyTag, _ := strings.Cut(ProxyImage(), ":")
|
||||
|
||||
const set = "--set-string"
|
||||
|
||||
install := []string{
|
||||
"helm", "upgrade", "--install", "tailscale-operator",
|
||||
"tailscale/tailscale-operator",
|
||||
"--version", OperatorChartVersion(),
|
||||
"--namespace", tailscaleNamespace,
|
||||
set, "loginServer=" + loginServer,
|
||||
set, "operatorConfig.image.repository=" + opRepo,
|
||||
set, "operatorConfig.image.tag=" + opTag,
|
||||
set, "proxyConfig.image.repository=" + proxyRepo,
|
||||
set, "proxyConfig.image.tag=" + proxyTag,
|
||||
"--wait", "--timeout", "5m",
|
||||
}
|
||||
|
||||
_, stderr, err = k.Execute(install)
|
||||
if err != nil {
|
||||
k.DumpDiagnostics()
|
||||
return fmt.Errorf("helm install operator (stderr: %s): %w", stderr, err)
|
||||
}
|
||||
|
||||
// hsic serves the embedded DERP without TLS, but a proxy dials DERP over HTTPS
|
||||
// and so cannot relay through it. Pods on the k3s pod network can only reach
|
||||
// off-cluster nodes via DERP (their only endpoint is an unreachable pod IP),
|
||||
// so without this the ingress/egress proxies get no data path. The ProxyClass
|
||||
// injects TS_DEBUG_DERP_WS_CLIENT + TS_DEBUG_USE_DERP_HTTP, switching proxies to
|
||||
// plain-HTTP websocket DERP. The proxy-creating helpers below reference it.
|
||||
return k.applyDERPWebsocketProxyClass()
|
||||
}
|
||||
|
||||
// DERPWebsocketProxyClass is the ProxyClass [InstallOperator] creates to make
|
||||
// operator proxies reach the embedded (non-TLS) DERP over websocket. Proxy
|
||||
// resources reference it via spec.proxyClass / the tailscale.com/proxy-class
|
||||
// annotation.
|
||||
const DERPWebsocketProxyClass = "headscale-derp-ws" //nolint:gosec // G101 false positive: a ProxyClass name, not a credential
|
||||
|
||||
func (k *K3sInContainer) applyDERPWebsocketProxyClass() error {
|
||||
manifest := fmt.Sprintf(`apiVersion: tailscale.com/v1alpha1
|
||||
kind: ProxyClass
|
||||
metadata:
|
||||
name: %s
|
||||
spec:
|
||||
statefulSet:
|
||||
pod:
|
||||
tailscaleContainer:
|
||||
env:
|
||||
- name: TS_DEBUG_DERP_WS_CLIENT
|
||||
value: "true"
|
||||
- name: TS_DEBUG_USE_DERP_HTTP
|
||||
value: "true"
|
||||
`, DERPWebsocketProxyClass)
|
||||
|
||||
return k.ApplyManifest("proxyclass-"+DERPWebsocketProxyClass, manifest)
|
||||
}
|
||||
|
||||
// DeployConnector applies a Connector CR advertising an egress subnet router for
|
||||
// advertiseRoutes, tagged with tags. The operator provisions a proxy and
|
||||
// registers it as a node in Headscale.
|
||||
func (k *K3sInContainer) DeployConnector(name string, tags, advertiseRoutes []string) error {
|
||||
manifest := fmt.Sprintf(`apiVersion: tailscale.com/v1alpha1
|
||||
kind: Connector
|
||||
metadata:
|
||||
name: %s
|
||||
spec:
|
||||
proxyClass: %s
|
||||
tags:
|
||||
%s
|
||||
subnetRouter:
|
||||
advertiseRoutes:
|
||||
%s
|
||||
`, name, DERPWebsocketProxyClass, yamlList(tags, 4), yamlList(advertiseRoutes, 6))
|
||||
|
||||
return k.ApplyManifest("connector-"+name, manifest)
|
||||
}
|
||||
|
||||
// DeployProxyGroup applies a ProxyGroup CR of the given type ("ingress" or
|
||||
// "egress") with replicas proxies tagged with tags. ProxyGroups are the current
|
||||
// way to run a pool of operator proxies for HA ingress/egress.
|
||||
func (k *K3sInContainer) DeployProxyGroup(name, proxyType string, replicas int, tags []string) error {
|
||||
manifest := fmt.Sprintf(`apiVersion: tailscale.com/v1alpha1
|
||||
kind: ProxyGroup
|
||||
metadata:
|
||||
name: %s
|
||||
spec:
|
||||
type: %s
|
||||
replicas: %d
|
||||
proxyClass: %s
|
||||
tags:
|
||||
%s
|
||||
`, name, proxyType, replicas, DERPWebsocketProxyClass, yamlList(tags, 4))
|
||||
|
||||
return k.ApplyManifest("proxygroup-"+name, manifest)
|
||||
}
|
||||
|
||||
// DeployEchoServer deploys a minimal HTTP server (agnhost, served from
|
||||
// registry.k8s.io to avoid Docker Hub rate limits) labelled app=<name> with a
|
||||
// ClusterIP Service of the same name on port 80. Use it as the in-cluster target
|
||||
// for connectivity tests; expose it to the tailnet with [ExposeServiceToTailnet].
|
||||
func (k *K3sInContainer) DeployEchoServer(name string) error {
|
||||
manifest := fmt.Sprintf(`apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: %s
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: %s
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: %s
|
||||
spec:
|
||||
containers:
|
||||
- name: echo
|
||||
image: registry.k8s.io/e2e-test-images/agnhost:2.47
|
||||
args: ["netexec", "--http-port=80"]
|
||||
ports:
|
||||
- containerPort: 80
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: %s
|
||||
spec:
|
||||
selector:
|
||||
app: %s
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
`, name, name, name, name, name)
|
||||
|
||||
return k.ApplyManifest("echo-"+name, manifest)
|
||||
}
|
||||
|
||||
// ExposeServiceToTailnet creates a tailscale LoadBalancer Service named
|
||||
// "<name>-ts" that exposes the pods labelled app=<name> to the tailnet, tagged
|
||||
// with tags. The operator provisions an ingress proxy and registers a node, so a
|
||||
// node outside the cluster (a regular tsic client) can reach the service over
|
||||
// the tailnet.
|
||||
func (k *K3sInContainer) ExposeServiceToTailnet(name string, tags []string) error {
|
||||
manifest := fmt.Sprintf(`apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: %s-ts
|
||||
annotations:
|
||||
tailscale.com/tags: "%s"
|
||||
tailscale.com/proxy-class: %s
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
loadBalancerClass: tailscale
|
||||
selector:
|
||||
app: %s
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
`, name, strings.Join(tags, ","), DERPWebsocketProxyClass, name)
|
||||
|
||||
return k.ApplyManifest("expose-"+name, manifest)
|
||||
}
|
||||
|
||||
// yamlList renders items as a YAML block sequence indented by indent spaces,
|
||||
// e.g. " - tag:k8s". Returns "" for an empty list.
|
||||
func yamlList(items []string, indent int) string {
|
||||
var b strings.Builder
|
||||
|
||||
pad := strings.Repeat(" ", indent)
|
||||
for _, item := range items {
|
||||
fmt.Fprintf(&b, "%s- %s\n", pad, item)
|
||||
}
|
||||
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# Running the operator against a private-CA TLS Headscale
|
||||
|
||||
`TestK8sOperator` points the Tailscale Kubernetes operator at an **HTTP**
|
||||
Headscale (`hsic.WithoutTLS`, `loginServer = http://<ip>:<port>`). That keeps
|
||||
the harness small: the operator and proxy pods need no CA, so there is no image
|
||||
baking, no containerd import, and no CoreDNS hostname mapping.
|
||||
|
||||
This note records how to run the same test against a **TLS** Headscale serving a
|
||||
private CA, in case a future test needs to exercise realistic TLS. It is not
|
||||
wired up; reconstruct it from here.
|
||||
|
||||
## Why it is involved
|
||||
|
||||
tailscaled/tsnet verify the control connection against Go's
|
||||
`x509.SystemCertPool` (on the Alpine images: `/etc/ssl/certs/ca-certificates.crt`).
|
||||
A private CA must therefore be in the pod's **system trust store**.
|
||||
|
||||
As of the tailscale operator chart on `main`, there is no supported way to hand
|
||||
a CA _file_ to a proxy pod:
|
||||
|
||||
- `ProxyClass.spec.statefulSet.pod` has no `volumes`.
|
||||
- `ProxyClass...tailscaleContainer` has no `volumeMounts`, and its `env` is a
|
||||
reduced schema (`name`/`value` only — no `valueFrom`/`envFrom`).
|
||||
- `operatorConfig` exposes `extraEnv` but no `extraVolumes`.
|
||||
|
||||
`SSL_CERT_FILE`/`SSL_CERT_DIR` _are_ honoured by tailscaled, but they need a
|
||||
file that cannot be projected in. So the CA has to be baked into the images.
|
||||
|
||||
## The recipe
|
||||
|
||||
1. Serve Headscale with TLS (the `hsic` default) and grab `headscale.GetCert()`.
|
||||
Pass it to the cluster so in-container `helm`/`kubectl` trust it.
|
||||
|
||||
2. Bake the CA into derived operator and proxy images. For each of
|
||||
`tailscale/k8s-operator:<tag>` and `tailscale/tailscale:<tag>`, build:
|
||||
|
||||
```Dockerfile
|
||||
FROM <base>
|
||||
COPY headscale-ca.crt /usr/local/share/ca-certificates/headscale-ca.crt
|
||||
RUN update-ca-certificates
|
||||
```
|
||||
|
||||
Build on the host docker daemon (it has egress to pull the base images),
|
||||
`ExportImage` the result to a docker-format tarball, stream it into the k3s
|
||||
container, and import it into the kubelet's containerd namespace:
|
||||
|
||||
```
|
||||
ctr --namespace k8s.io images import <tarball>
|
||||
```
|
||||
|
||||
Tag the derived images `headscale.local/...:<tag>-ca` and run them with
|
||||
`imagePullPolicy: Never` (the `headscale.local/` prefix is never resolved by
|
||||
a registry).
|
||||
|
||||
3. Wire the derived images into the chart via `operatorConfig.image` /
|
||||
`proxyConfig.image`. The operator's proxy StatefulSet template hard-codes
|
||||
`imagePullPolicy: Always`, so a `ProxyClass` must override the proxy image
|
||||
**and** set `imagePullPolicy: Never`; Connectors must reference that
|
||||
ProxyClass explicitly (`defaultProxyClass` does not apply to them).
|
||||
|
||||
4. Pods resolve Headscale through CoreDNS, not the container's `/etc/hosts`, and
|
||||
the cert's only SAN is the Headscale hostname (dialing by IP fails TLS
|
||||
verification). Install a `coredns-custom` ConfigMap mapping the hostname to
|
||||
the Headscale IP and gate on the `kube-dns` Service having ready endpoints
|
||||
before starting the operator.
|
||||
|
||||
The full implementation lived in `integration/k3sic/k3sic.go` and
|
||||
`integration/k8s_operator_test.go` before the switch to HTTP; recover it from
|
||||
git history (`PrepareTailscaleImages`, `bakeAndImportImage`, `caBuildContext`,
|
||||
`ConfigureCoreDNSHost`) if needed.
|
||||
@@ -0,0 +1,266 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clientv1 "github.com/juanfont/headscale/gen/client/v1"
|
||||
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
|
||||
"github.com/juanfont/headscale/integration/hsic"
|
||||
"github.com/juanfont/headscale/integration/integrationutil"
|
||||
"github.com/juanfont/headscale/integration/k3sic"
|
||||
"github.com/juanfont/headscale/integration/tsic"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
const (
|
||||
tagK8sOperator = "tag:k8s-operator"
|
||||
tagK8s = "tag:k8s"
|
||||
)
|
||||
|
||||
// k8sOperatorPolicy is the tagOwners policy the Tailscale Kubernetes operator
|
||||
// requires: tag:k8s-operator is self/admin-owned, and the operator
|
||||
// (tag:k8s-operator) owns tag:k8s so it can mint auth keys for the proxy nodes
|
||||
// it spins up. The wildcard ACL lets the in-cluster proxies and the out-of-cluster
|
||||
// tsic client reach each other for the connectivity checks.
|
||||
func k8sOperatorPolicy() *policyv2.Policy {
|
||||
return &policyv2.Policy{
|
||||
TagOwners: policyv2.TagOwners{
|
||||
tagK8sOperator: policyv2.Owners{},
|
||||
tagK8s: policyv2.Owners{new(policyv2.Tag(tagK8sOperator))},
|
||||
},
|
||||
ACLs: []policyv2.ACL{
|
||||
{
|
||||
Action: "accept",
|
||||
Sources: []policyv2.Alias{policyv2.Wildcard},
|
||||
Destinations: []policyv2.AliasWithPorts{
|
||||
{Alias: policyv2.Wildcard, Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestK8sOperator verifies that the real Tailscale Kubernetes operator, installed
|
||||
// into a real k3s cluster via its Helm chart and pointed at an in-test Headscale,
|
||||
// can authenticate with OAuth client credentials, mint auth keys, and register
|
||||
// nodes that then interoperate with a regular tailnet node.
|
||||
//
|
||||
// The operator targets Headscale over plain HTTP by IP (hsic.WithoutTLS +
|
||||
// GetIPEndpoint), so the operator and proxy pods need no CA and no DNS entry for
|
||||
// Headscale. See integration/k3sic/tls-ca-baking.md for the TLS variant.
|
||||
//
|
||||
// The cluster-side steps are reusable building blocks on k3sic.K3sInContainer
|
||||
// (InstallOperator, DeployConnector, DeployEchoServer, ExposeServiceToTailnet,
|
||||
// DeployProxyGroup), so further operator scenarios are a few method calls.
|
||||
//
|
||||
// Run it with `go run ./cmd/hi run "TestK8sOperator"`.
|
||||
func TestK8sOperator(t *testing.T) {
|
||||
IntegrationSkip(t)
|
||||
|
||||
// One regular user+node provides the out-of-cluster tailnet peer used by the
|
||||
// connectivity subtest; the operator registers its own nodes on top.
|
||||
spec := ScenarioSpec{
|
||||
Users: []string{"k8s-user"},
|
||||
NodesPerUser: 1,
|
||||
}
|
||||
|
||||
scenario, err := NewScenario(spec)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer scenario.ShutdownAssertNoPanics(t)
|
||||
|
||||
err = scenario.CreateHeadscaleEnv(
|
||||
// The tsic client reaches the in-cluster proxy only via DERP (no direct
|
||||
// path to the k3s pod network), and hsic's embedded DERP is non-TLS, so the
|
||||
// client must reach DERP over plain-HTTP websockets — as the proxy pods do.
|
||||
[]tsic.Option{tsic.WithDERPOverHTTP()},
|
||||
hsic.WithTestName("k8soperator"),
|
||||
hsic.WithoutTLS(),
|
||||
hsic.WithACLPolicy(k8sOperatorPolicy()),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
headscale, err := scenario.Headscale()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Mint an OAuth client for the operator: devices:core + auth_keys scopes,
|
||||
// tagged tag:k8s-operator. CreateOAuthClient mints the admin API key and calls
|
||||
// the v2 keys API itself.
|
||||
clientID, clientSecret, err := headscale.CreateOAuthClient(
|
||||
t.Context(),
|
||||
[]string{"devices:core", "auth_keys"},
|
||||
[]string{tagK8sOperator},
|
||||
)
|
||||
require.NoError(t, err, "creating OAuth client (server-side OAuth must be implemented)")
|
||||
require.NotEmpty(t, clientID)
|
||||
require.NotEmpty(t, clientSecret)
|
||||
|
||||
// Bring up the k3s cluster on the scenario networks.
|
||||
k3s, err := k3sic.New(scenario.Pool(), scenario.Networks())
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() {
|
||||
shutdownErr := k3s.Shutdown()
|
||||
if shutdownErr != nil {
|
||||
t.Logf("shutting down k3s: %s", shutdownErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// Registered after Shutdown so it runs first (defers are LIFO): dump cluster
|
||||
// state while it is still up if anything below fails.
|
||||
defer func() {
|
||||
if t.Failed() {
|
||||
k3s.DumpDiagnostics()
|
||||
}
|
||||
}()
|
||||
|
||||
require.NoError(t, k3s.WaitForRunning())
|
||||
require.NoError(t, k3s.InstallHelm())
|
||||
|
||||
// The operator reaches the control plane by IP, but the embedded DERP map
|
||||
// references Headscale by hostname; teach CoreDNS to resolve it so the proxy
|
||||
// pods can connect to DERP and get a data path to nodes outside the cluster.
|
||||
hsIP := headscale.GetIPInNetwork(scenario.Networks()[0])
|
||||
require.NoError(t, k3s.ConfigureCoreDNSHost(headscale.GetHostname(), hsIP))
|
||||
|
||||
// loginServer is the in-cluster-reachable HTTP endpoint by IP; the operator
|
||||
// uses it for both the control plane and the management API.
|
||||
loginServer := headscale.GetIPEndpoint()
|
||||
require.NoError(t, k3s.InstallOperator(loginServer, clientID, clientSecret))
|
||||
|
||||
t.Run("operator-registers", func(t *testing.T) {
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
nodes, err := headscale.ListNodes()
|
||||
assert.NoError(c, err)
|
||||
assert.True(c, hasNodeWithTag(nodes, tagK8sOperator),
|
||||
"expected a node tagged %s registered by the operator, got %s",
|
||||
tagK8sOperator, describeNodes(nodes))
|
||||
}, integrationutil.ScaledTimeout(180*time.Second), 2*time.Second,
|
||||
"operator node should register and be tagged "+tagK8sOperator)
|
||||
})
|
||||
|
||||
t.Run("egress-connector", func(t *testing.T) {
|
||||
require.NoError(t, k3s.DeployConnector("k8s-egress", []string{tagK8s}, []string{"10.40.0.0/14"}))
|
||||
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
nodes, err := headscale.ListNodes()
|
||||
assert.NoError(c, err)
|
||||
assert.True(c, hasNodeWithTag(nodes, tagK8s),
|
||||
"expected a proxy node tagged %s registered by the operator, got %s",
|
||||
tagK8s, describeNodes(nodes))
|
||||
}, integrationutil.ScaledTimeout(180*time.Second), 2*time.Second,
|
||||
"egress proxy node should register and be tagged "+tagK8s)
|
||||
})
|
||||
|
||||
t.Run("ingress-service-reachable-from-tailnet", func(t *testing.T) {
|
||||
require.NoError(t, k3s.DeployEchoServer("echo"))
|
||||
require.NoError(t, k3s.ExposeServiceToTailnet("echo", []string{tagK8s}))
|
||||
|
||||
clients, err := scenario.ListTailscaleClients("k8s-user")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, clients)
|
||||
|
||||
// The operator registers the ingress proxy as a tailnet node named after
|
||||
// the exposed Service (<namespace>-<service>, here default-echo-ts). Read
|
||||
// its IP from Headscale rather than the Service's LoadBalancer status,
|
||||
// which the operator does not populate against Headscale.
|
||||
var svcIP string
|
||||
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
nodes, err := headscale.ListNodes()
|
||||
assert.NoError(c, err)
|
||||
|
||||
ip, ok := nodeIPv4ByName(nodes, "echo")
|
||||
assert.True(c, ok, "ingress proxy node for echo should register, got %s", describeNodes(nodes))
|
||||
|
||||
svcIP = ip
|
||||
}, integrationutil.ScaledTimeout(180*time.Second), 2*time.Second,
|
||||
"operator should register an ingress proxy node for the exposed service")
|
||||
|
||||
// The out-of-cluster node reaches the in-cluster service through the proxy
|
||||
// over the tailnet. /hostname is an agnhost endpoint that returns the
|
||||
// backend pod's hostname, proving the request reached the service.
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
body, err := clients[0].Curl("http://" + svcIP + "/hostname")
|
||||
assert.NoError(c, err)
|
||||
assert.NotEmpty(c, body, "expected a response from the exposed service")
|
||||
}, integrationutil.ScaledTimeout(120*time.Second), 2*time.Second,
|
||||
"tsic node should reach the k8s-exposed service over the tailnet")
|
||||
})
|
||||
|
||||
t.Run("proxy-group", func(t *testing.T) {
|
||||
nodes, err := headscale.ListNodes()
|
||||
require.NoError(t, err)
|
||||
|
||||
before := countNodesWithTag(nodes, tagK8s)
|
||||
|
||||
const replicas = 2
|
||||
|
||||
require.NoError(t, k3s.DeployProxyGroup("ts-ingress", "ingress", replicas, []string{tagK8s}))
|
||||
|
||||
// A ProxyGroup runs a pool of proxies; each replica registers its own node.
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
nodes, err := headscale.ListNodes()
|
||||
assert.NoError(c, err)
|
||||
assert.GreaterOrEqual(c, countNodesWithTag(nodes, tagK8s), before+replicas,
|
||||
"expected %d more %s nodes from the ProxyGroup, got %s",
|
||||
replicas, tagK8s, describeNodes(nodes))
|
||||
}, integrationutil.ScaledTimeout(180*time.Second), 2*time.Second,
|
||||
"ProxyGroup replicas should each register a node tagged "+tagK8s)
|
||||
})
|
||||
}
|
||||
|
||||
// hasNodeWithTag reports whether any node carries the given tag.
|
||||
func hasNodeWithTag(nodes []*clientv1.Node, tag string) bool {
|
||||
return countNodesWithTag(nodes, tag) > 0
|
||||
}
|
||||
|
||||
// countNodesWithTag counts the nodes carrying the given tag.
|
||||
func countNodesWithTag(nodes []*clientv1.Node, tag string) int {
|
||||
count := 0
|
||||
|
||||
for _, node := range nodes {
|
||||
if slices.Contains(node.Tags, tag) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
// nodeIPv4ByName returns the IPv4 of the first node whose given name contains
|
||||
// substr, identifying an operator-registered proxy by the Service/Connector it
|
||||
// fronts (e.g. "echo" matches the default-echo-ts ingress proxy).
|
||||
func nodeIPv4ByName(nodes []*clientv1.Node, substr string) (string, bool) {
|
||||
for _, node := range nodes {
|
||||
if !strings.Contains(node.GivenName, substr) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ip := range node.IpAddresses {
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
if err == nil && addr.Is4() {
|
||||
return ip, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// describeNodes renders a compact name->tags summary for failure messages.
|
||||
func describeNodes(nodes []*clientv1.Node) string {
|
||||
parts := make([]string, 0, len(nodes))
|
||||
for _, node := range nodes {
|
||||
parts = append(parts, fmt.Sprintf("%s%v", node.Name, node.Tags))
|
||||
}
|
||||
|
||||
return "[" + strings.Join(parts, " ") + "]"
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/juanfont/headscale/integration/dockertestutil"
|
||||
"github.com/juanfont/headscale/integration/hsic"
|
||||
"github.com/juanfont/headscale/integration/tsic"
|
||||
"github.com/ory/dockertest/v3"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/control/controlbase"
|
||||
"tailscale.com/control/controlhttp/controlhttpcommon"
|
||||
"tailscale.com/net/wsconn"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/util/rands"
|
||||
)
|
||||
|
||||
// Tailscale's JS/WASM control client opens /ts2021 as a browser WebSocket — an
|
||||
// HTTP GET upgrade — rather than the native client's HTTP POST upgrade. A router
|
||||
// that registers /ts2021 for POST only rejects that GET with 405 before the
|
||||
// Noise handshake starts, which breaks every WASM client (issue #3357).
|
||||
//
|
||||
// These two tests guard that path against real headscale:
|
||||
//
|
||||
// - TestTS2021WebSocketGET dials the WebSocket GET directly from the test
|
||||
// process using the same coder/websocket + controlbase primitives the WASM
|
||||
// client uses. It is fast and always on.
|
||||
// - TestTS2021WASMClientUnderNode runs the *actual* tailscale.com js/wasm
|
||||
// control dial (integration/wasmic/wasmclient, built for GOOS=js) inside a
|
||||
// Node container, alongside normal Tailscale clients, and asserts it
|
||||
// completes the Noise handshake with headscale over the WebSocket.
|
||||
//
|
||||
// The server cannot tell the two apart: both send GET /ts2021 with
|
||||
// Sec-WebSocket-Protocol: tailscale-control-protocol. Before the fix both fail
|
||||
// with 405; after it, both complete the handshake.
|
||||
|
||||
// TestTS2021WebSocketGET connects to /ts2021 over a WebSocket GET from the test
|
||||
// process and completes the Noise handshake, exactly as a browser/WASM client
|
||||
// would.
|
||||
func TestTS2021WebSocketGET(t *testing.T) {
|
||||
IntegrationSkip(t)
|
||||
t.Parallel()
|
||||
|
||||
spec := ScenarioSpec{
|
||||
NodesPerUser: 1,
|
||||
Users: []string{"user1"},
|
||||
}
|
||||
|
||||
scenario, err := NewScenario(spec)
|
||||
|
||||
require.NoErrorf(t, err, "failed to create scenario: %s", err)
|
||||
defer scenario.ShutdownAssertNoPanics(t)
|
||||
|
||||
err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("ts2021ws"))
|
||||
requireNoErrHeadscaleEnv(t, err)
|
||||
|
||||
headscale, err := scenario.Headscale()
|
||||
requireNoErrGetHeadscale(t, err)
|
||||
|
||||
conn, err := dialTS2021WebSocket(t, headscale.GetEndpoint(), headscale.GetCert())
|
||||
require.NoError(t, err,
|
||||
"WebSocket GET to /ts2021 must reach NoiseUpgradeHandler, not be rejected by the router with 405")
|
||||
require.NotNil(t, conn)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
t.Logf("noise established over websocket, protocol version %d", conn.ProtocolVersion())
|
||||
}
|
||||
|
||||
// TestTS2021WASMClientUnderNode runs the real tailscale.com js/wasm control dial
|
||||
// inside a Node container against real headscale, next to normal Tailscale
|
||||
// clients, and asserts the WASM client completes the /ts2021 handshake.
|
||||
func TestTS2021WASMClientUnderNode(t *testing.T) {
|
||||
IntegrationSkip(t)
|
||||
t.Parallel()
|
||||
|
||||
spec := ScenarioSpec{
|
||||
NodesPerUser: 2,
|
||||
Users: []string{"user1"},
|
||||
Networks: map[string]NetworkSpec{
|
||||
"wasmnet": {Users: []string{"user1"}},
|
||||
},
|
||||
ExtraService: map[string][]extraServiceFunc{
|
||||
"wasmnet": {wasmClientService},
|
||||
},
|
||||
// The wasm client image builds from this module; pair it with the
|
||||
// head Tailscale clients so the whole environment is current.
|
||||
Versions: []string{"head"},
|
||||
}
|
||||
|
||||
scenario, err := NewScenario(spec)
|
||||
|
||||
require.NoErrorf(t, err, "failed to create scenario: %s", err)
|
||||
defer scenario.ShutdownAssertNoPanics(t)
|
||||
|
||||
// The Tailscale JS/WASM client dials the control server as a WebSocket.
|
||||
// client_js.go only honours a custom port for ws:// (plain HTTP); over
|
||||
// wss:// it always targets :443, so it cannot reach a TLS control server on
|
||||
// :8080. Run headscale without TLS, matching the http:// setup in the issue.
|
||||
err = scenario.CreateHeadscaleEnv(
|
||||
[]tsic.Option{},
|
||||
hsic.WithTestName("ts2021wasm"),
|
||||
hsic.WithoutTLS(),
|
||||
)
|
||||
requireNoErrHeadscaleEnv(t, err)
|
||||
|
||||
allClients, err := scenario.ListTailscaleClients()
|
||||
requireNoErrListClients(t, err)
|
||||
|
||||
allIPs, err := scenario.ListTailscaleClientsIPs()
|
||||
requireNoErrListClientIPs(t, err)
|
||||
|
||||
// Normal Tailscale clients come up and form a working tailnet alongside the
|
||||
// WASM control client.
|
||||
err = scenario.WaitForTailscaleSync()
|
||||
requireNoErrSync(t, err)
|
||||
|
||||
headscale, err := scenario.Headscale()
|
||||
requireNoErrGetHeadscale(t, err)
|
||||
|
||||
// Sanity-check the tailnet the WASM client is joining: the normal clients
|
||||
// must be able to reach each other.
|
||||
allAddrs := lo.Map(allIPs, func(x netip.Addr, _ int) string { return x.String() })
|
||||
assertPingAll(t, allClients, allAddrs)
|
||||
|
||||
services, err := scenario.Services("wasmnet")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, services, 1, "expected the wasm client container")
|
||||
|
||||
wasm := services[0]
|
||||
controlURL := headscale.GetEndpoint()
|
||||
|
||||
// Fetch the server's Noise key here and pass it to the WASM client: Go's
|
||||
// net/http DNS resolver is unavailable under GOOS=js, so the client can only
|
||||
// use the JS WebSocket transport, not an HTTP GET to /key.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
controlKey, err := fetchServerNoiseKey(ctx, &http.Client{Timeout: 15 * time.Second}, controlURL)
|
||||
require.NoError(t, err)
|
||||
controlKeyText, err := controlKey.MarshalText()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Run the real js/wasm control client under Node: it dials /ts2021 as a
|
||||
// WebSocket GET; success means the Noise handshake completed.
|
||||
stdout, stderr, err := dockertestutil.ExecuteCommand(
|
||||
wasm,
|
||||
[]string{"node", "/app/wasm_exec_node.js", "/app/client.wasm", controlURL, string(controlKeyText)},
|
||||
[]string{},
|
||||
dockertestutil.ExecuteCommandTimeout(60*time.Second),
|
||||
)
|
||||
t.Logf("wasm client stdout:\n%s", stdout)
|
||||
t.Logf("wasm client stderr:\n%s", stderr)
|
||||
|
||||
require.NoError(t, err,
|
||||
"wasm control client must connect to /ts2021 over websocket (405 means the router rejected the GET)")
|
||||
require.Contains(t, stdout, "WASM_TS2021_OK",
|
||||
"wasm control client should report a completed Noise handshake")
|
||||
}
|
||||
|
||||
// wasmClientService builds and starts the Node + js/wasm control-client
|
||||
// container (Dockerfile.wasmclient) on the given network so it can reach
|
||||
// headscale by hostname. It idles; the test execs the client on demand.
|
||||
func wasmClientService(s *Scenario, networkName string) (*dockertest.Resource, error) {
|
||||
hash := rands.HexString(hsicOIDCMockHashLength)
|
||||
hostname := "hs-wasmclient-" + hash
|
||||
|
||||
network, ok := s.networks[s.prefixedNetworkName(networkName)]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("network does not exist: %s", networkName) //nolint:err113
|
||||
}
|
||||
|
||||
runOpts := &dockertest.RunOptions{
|
||||
Name: hostname,
|
||||
Networks: []*dockertest.Network{network},
|
||||
Env: []string{},
|
||||
}
|
||||
dockertestutil.DockerAddIntegrationLabels(runOpts, "wasmclient")
|
||||
|
||||
buildOpts := &dockertest.BuildOptions{
|
||||
Dockerfile: "Dockerfile.wasmclient",
|
||||
ContextDir: dockerContextPath,
|
||||
}
|
||||
|
||||
resource, err := s.pool.BuildAndRunWithBuildOptions(
|
||||
buildOpts,
|
||||
runOpts,
|
||||
dockertestutil.DockerRestartPolicy,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building wasm client container: %w", err)
|
||||
}
|
||||
|
||||
return resource, nil
|
||||
}
|
||||
|
||||
// dialTS2021WebSocket opens /ts2021 as a WebSocket GET (subprotocol
|
||||
// tailscale-control-protocol) and completes the Noise handshake, mirroring what
|
||||
// tailscale.com/control/controlhttp/client_js.go does in a browser. It returns
|
||||
// the established Noise connection, or an error (a router that only allows POST
|
||||
// returns 405 here).
|
||||
func dialTS2021WebSocket(t *testing.T, endpoint string, caCert []byte) (*controlbase.Conn, error) {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
u, err := url.Parse(endpoint)
|
||||
require.NoError(t, err)
|
||||
|
||||
httpClient := &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
if u.Scheme == "https" {
|
||||
pool := x509.NewCertPool()
|
||||
pool.AppendCertsFromPEM(caCert)
|
||||
httpClient.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12},
|
||||
}
|
||||
}
|
||||
|
||||
controlKey, err := fetchServerNoiseKey(ctx, httpClient, endpoint)
|
||||
require.NoError(t, err)
|
||||
|
||||
init, cont, err := controlbase.ClientDeferred(
|
||||
key.NewMachine(),
|
||||
controlKey,
|
||||
uint16(tailcfg.CurrentCapabilityVersion),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
wsScheme := "ws"
|
||||
if u.Scheme == "https" {
|
||||
wsScheme = "wss"
|
||||
}
|
||||
|
||||
wsURL := &url.URL{
|
||||
Scheme: wsScheme,
|
||||
Host: u.Host,
|
||||
Path: "/ts2021",
|
||||
RawQuery: url.Values{
|
||||
controlhttpcommon.HandshakeHeaderName: []string{base64.StdEncoding.EncodeToString(init)},
|
||||
}.Encode(),
|
||||
}
|
||||
|
||||
wsConn, resp, err := websocket.Dial(ctx, wsURL.String(), &websocket.DialOptions{
|
||||
Subprotocols: []string{controlhttpcommon.UpgradeHeaderValue},
|
||||
HTTPClient: httpClient,
|
||||
})
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
netConn := wsconn.NetConn(ctx, wsConn, websocket.MessageBinary, wsURL.String())
|
||||
|
||||
cbConn, err := cont(ctx, netConn)
|
||||
if err != nil {
|
||||
_ = netConn.Close()
|
||||
return nil, fmt.Errorf("noise handshake over websocket: %w", err)
|
||||
}
|
||||
|
||||
return cbConn, nil
|
||||
}
|
||||
|
||||
// fetchServerNoiseKey retrieves headscale's Noise public key from /key, the same
|
||||
// endpoint a real client consults before dialing /ts2021.
|
||||
func fetchServerNoiseKey(
|
||||
ctx context.Context,
|
||||
client *http.Client,
|
||||
endpoint string,
|
||||
) (key.MachinePublic, error) {
|
||||
var zero key.MachinePublic
|
||||
|
||||
keyURL := fmt.Sprintf("%s/key?v=%d", endpoint, tailcfg.CurrentCapabilityVersion)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, keyURL, nil)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var k tailcfg.OverTLSPublicKeyResponse
|
||||
|
||||
err = json.NewDecoder(resp.Body).Decode(&k)
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("decoding /key response: %w", err)
|
||||
}
|
||||
|
||||
if k.PublicKey.IsZero() {
|
||||
return zero, errors.New("server returned zero Noise public key") //nolint:err113
|
||||
}
|
||||
|
||||
return k.PublicKey, nil
|
||||
}
|
||||
@@ -98,6 +98,7 @@ type TailscaleInContainer struct {
|
||||
caCerts [][]byte
|
||||
headscaleHostname string
|
||||
withWebsocketDERP bool
|
||||
withDERPOverHTTP bool
|
||||
withSSH bool
|
||||
withTags []string
|
||||
withEntrypoint []string
|
||||
@@ -160,6 +161,18 @@ func WithWebsocketDERP(enabled bool) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithDERPOverHTTP makes the client reach the DERP server over plain-HTTP
|
||||
// websockets (TS_DEBUG_DERP_WS_CLIENT + TS_DEBUG_USE_DERP_HTTP). It is the
|
||||
// counterpart to [hsic.WithoutTLS]: a Headscale serving its embedded DERP without
|
||||
// TLS is otherwise unreachable, because the client defaults to dialing DERP over
|
||||
// HTTPS.
|
||||
func WithDERPOverHTTP() Option {
|
||||
return func(tsic *TailscaleInContainer) {
|
||||
tsic.withWebsocketDERP = true
|
||||
tsic.withDERPOverHTTP = true
|
||||
}
|
||||
}
|
||||
|
||||
// WithSSH enables SSH for the Tailscale instance.
|
||||
func WithSSH() Option {
|
||||
return func(tsic *TailscaleInContainer) {
|
||||
@@ -372,6 +385,12 @@ func New(
|
||||
tailscaleOptions.Env,
|
||||
fmt.Sprintf("TS_DEBUG_DERP_WS_CLIENT=%t", tsic.withWebsocketDERP),
|
||||
)
|
||||
|
||||
// Plain-HTTP DERP additionally needs the client to dial http:// instead of
|
||||
// the default https://; see [WithDERPOverHTTP].
|
||||
if tsic.withDERPOverHTTP {
|
||||
tailscaleOptions.Env = append(tailscaleOptions.Env, "TS_DEBUG_USE_DERP_HTTP=true")
|
||||
}
|
||||
}
|
||||
|
||||
tailscaleOptions.ExtraHosts = append(tailscaleOptions.ExtraHosts,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//go:build js
|
||||
|
||||
// Command wasmclient is a minimal Tailscale control client compiled to
|
||||
// GOOS=js/GOARCH=wasm and run under Node. It exercises the real
|
||||
// tailscale.com/control/controlhttp js/wasm dial path
|
||||
// (control/controlhttp/client_js.go), which opens /ts2021 as a browser-style
|
||||
// WebSocket GET — the exact transport a Tailscale JS/WASM client uses.
|
||||
//
|
||||
// It is the container-side half of the integration test guarding issue #3357:
|
||||
// headscale must register /ts2021 for GET, not POST only, or the WebSocket
|
||||
// upgrade is rejected with 405 before the Noise handshake can start.
|
||||
//
|
||||
// It is intentionally not the full tsconnect IPN — the regression is entirely
|
||||
// in the control-connection upgrade, and this drives the real upgrade code with
|
||||
// the smallest possible harness. On success it prints wasmSuccessMarker and
|
||||
// exits 0; on any failure it prints wasmFailureMarker and exits non-zero.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"tailscale.com/control/controlhttp"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
// These markers are matched by the integration test on the client's stdout.
|
||||
const (
|
||||
wasmSuccessMarker = "WASM_TS2021_OK"
|
||||
wasmFailureMarker = "WASM_TS2021_FAIL"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Printf("%s: usage: wasmclient <control-url> <noise-key>\n", wasmFailureMarker)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
if err := run(os.Args[1], os.Args[2]); err != nil {
|
||||
fmt.Printf("%s: %v\n", wasmFailureMarker, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// run dials /ts2021 exactly as tailscale.com/control/controlhttp/client_js.go
|
||||
// does in a browser: a WebSocket GET via the JS/undici WebSocket. The server's
|
||||
// Noise key is passed in (the test fetches /key) rather than fetched here,
|
||||
// because Go's net/http DNS resolver is unavailable under GOOS=js — only the
|
||||
// WebSocket transport, which runs through the JS host, works.
|
||||
func run(controlURL, noiseKeyText string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
u, err := url.Parse(controlURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse control url %q: %w", controlURL, err)
|
||||
}
|
||||
|
||||
var controlKey key.MachinePublic
|
||||
if err := controlKey.UnmarshalText([]byte(noiseKeyText)); err != nil {
|
||||
return fmt.Errorf("parse noise key %q: %w", noiseKeyText, err)
|
||||
}
|
||||
|
||||
port := u.Port()
|
||||
if port == "" {
|
||||
if u.Scheme == "https" {
|
||||
port = "443"
|
||||
} else {
|
||||
port = "80"
|
||||
}
|
||||
}
|
||||
|
||||
// client_js.go selects ws:// (and appends the port) only when HTTPPort is a
|
||||
// custom non-80 port and HTTPS is 443 or disabled; otherwise it dials wss://
|
||||
// on the default port. Set the fields to match the server's actual scheme.
|
||||
d := &controlhttp.Dialer{
|
||||
Hostname: u.Hostname(),
|
||||
MachineKey: key.NewMachine(),
|
||||
ControlKey: controlKey,
|
||||
ProtocolVersion: uint16(tailcfg.CurrentCapabilityVersion),
|
||||
}
|
||||
if u.Scheme == "https" {
|
||||
d.HTTPSPort = port
|
||||
} else {
|
||||
d.HTTPPort = port
|
||||
d.HTTPSPort = controlhttp.NoPort
|
||||
}
|
||||
|
||||
conn, err := d.Dial(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ts2021 websocket dial: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
fmt.Printf("%s: noise established over websocket, protocol version %d\n",
|
||||
wasmSuccessMarker, conn.ProtocolVersion())
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !js
|
||||
|
||||
// This package only does something when built for GOOS=js (see main.go). The
|
||||
// stub exists so `go build ./...` and `go vet ./...` on the host don't fail with
|
||||
// "build constraints exclude all Go files" for this directory.
|
||||
package main
|
||||
|
||||
func main() {}
|
||||
@@ -1,7 +1,6 @@
|
||||
[Unit]
|
||||
After=network.target
|
||||
Description=headscale coordination server for Tailscale
|
||||
X-Restart-Triggers=/etc/headscale/config.yaml
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
@@ -15,9 +14,11 @@ RestartSec=5
|
||||
WorkingDirectory=/var/lib/headscale
|
||||
ReadWritePaths=/var/lib/headscale
|
||||
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_CHOWN
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_CHOWN
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
DevicePolicy=closed
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
NoNewPrivileges=true
|
||||
PrivateDevices=true
|
||||
PrivateMounts=true
|
||||
@@ -42,9 +43,9 @@ RuntimeDirectoryMode=0750
|
||||
StateDirectory=headscale
|
||||
StateDirectoryMode=0750
|
||||
SystemCallArchitectures=native
|
||||
SystemCallFilter=@chown
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallFilter=~@privileged
|
||||
SystemCallFilter=~@resources
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
|
||||
Reference in New Issue
Block a user