mirror of
https://github.com/juanfont/headscale.git
synced 2026-11-05 13:09:25 +09:00
SetPolicy returned a nil error for empty input it ignored, so the v1 API stored it as valid. Reload skips only the nil "no policy" case.
1462 lines
46 KiB
Go
1462 lines
46 KiB
Go
// This file replays admin API exchanges captured from Tailscale SaaS, in
|
|
// order and as the principal SaaS saw, and compares each answer with SaaS's.
|
|
//
|
|
// Test data source: servertest/testdata/steps/{oauth,policy-api,key-list}.hujson,
|
|
// loaded by name. oauth's late joins are also replayed with real clients by
|
|
// TestAPIv2OAuthClientDeleteCompat in servertest.
|
|
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
|
|
|
|
package hscontrol
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"maps"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"net/url"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
apiv2 "github.com/juanfont/headscale/hscontrol/api/v2"
|
|
"github.com/juanfont/headscale/hscontrol/mapper"
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/juanfont/headscale/hscontrol/types/testcapture"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/tailscale/hujson"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/types/key"
|
|
)
|
|
|
|
// apiv2CompatSkipReasons lists checks where headscale differs from SaaS, keyed
|
|
// <test_id>/<step>/<i>[/<node>]/<check>, where <i> is the action's index in
|
|
// its step, or <test_id>/<object>/[<shape>/]<check> for a field compared
|
|
// over every body that names the object (key:K, client:C, device:x), device
|
|
// fields once per read shape (default, all). Every call has a status check
|
|
// and an outcome check (allowed or refused), so a listed status still fails
|
|
// when the call flips. v1-* checks are TestAPIV1PolicyVerdictCompat's. A
|
|
// listed check that matches fails, so a fix must delete its entry.
|
|
var apiv2CompatSkipReasons = map[string]string{
|
|
"oauth/R2/4/status": missingScopeNotFound,
|
|
"oauth/R3/1/status": missingScopeNotFound,
|
|
"oauth/R3/2/status": missingScopeNotFound,
|
|
"oauth/R3/3/status": missingScopeNotFound,
|
|
"oauth/K3/0/status": missingScopeNotFound,
|
|
"oauth/J2/0/status": missingScopeNotFound,
|
|
"oauth/K4/0/status": bodyBeforeScope,
|
|
"oauth/D1/0/message": missingScopeText,
|
|
|
|
"oauth/O1/0/scope": scopeEchoAsSent,
|
|
"oauth/O3/0/status": mintScopeRefusalStatus,
|
|
"oauth/O4/0/scope": unknownScopeGranted,
|
|
|
|
"oauth/O4/0/tags": mintTagsNotEchoed,
|
|
"oauth/E1/0/tags": mintTagsNotEchoed,
|
|
"oauth/E9a/0/tags": mintTagsNotEchoed,
|
|
"oauth/T1/0/tags": mintTagsNotEchoed,
|
|
"oauth/D0/6/tags": mintTagsNotEchoed,
|
|
"oauth/B1/0/tags": mintTagsNotEchoed,
|
|
"oauth/B3/0/tags": mintTagsNotEchoed,
|
|
"oauth/B11/1/tags": mintTagsNotEchoed,
|
|
|
|
"key-list/setup-token-keys/0/tags": mintTagsNotEchoed,
|
|
"key-list/setup-token-keys/2/tags": mintTagsNotEchoed,
|
|
"key-list/setup-token-keys/4/tags": mintTagsNotEchoed,
|
|
"key-list/setup-token-keys/6/tags": mintTagsNotEchoed,
|
|
|
|
"oauth/K1/0/status": unknownPropertyRefused,
|
|
"oauth/K1/0/outcome": unknownPropertyRefused,
|
|
"oauth/K1/1/status": unknownPropertyRefused,
|
|
"oauth/K1/1/outcome": unknownPropertyRefused,
|
|
"oauth/K2/0/status": unknownKeyType,
|
|
"oauth/K2/0/outcome": unknownKeyType,
|
|
"oauth/K5/0/status": unknownKeyType,
|
|
|
|
"oauth/J3/1/hostnames": sshTestHostnames,
|
|
"oauth/J4/0/hostnames": sshTestHostnames,
|
|
"oauth/J3/1/detail": refusalDetail,
|
|
"oauth/J4/0/detail": refusalDetail,
|
|
|
|
"oauth/E2/0/status": taggedAllClientAnyTag,
|
|
"oauth/E2/0/outcome": taggedAllClientAnyTag,
|
|
"oauth/E9a/1/status": taggedAllClientAnyTag,
|
|
"oauth/E9a/1/outcome": taggedAllClientAnyTag,
|
|
|
|
"oauth/E4/0/status": tagRefusalStatus,
|
|
"oauth/E5/0/status": tagRefusalStatus,
|
|
"oauth/T2/1/status": tagRefusalStatus,
|
|
"oauth/T3/1/status": tagRefusalStatus,
|
|
|
|
"oauth/T4/1/status": retagRemovesUnownedTag,
|
|
"oauth/T4/1/outcome": retagRemovesUnownedTag,
|
|
"oauth/T4/2/tags": retagRemovesUnownedTag,
|
|
"oauth/T4/2/owner": retagRemovesUnownedTag,
|
|
"oauth/T5/1/status": retagRemovesUnownedTag,
|
|
"oauth/T5/1/outcome": retagRemovesUnownedTag,
|
|
"oauth/T5/2/tags": retagRemovesUnownedTag,
|
|
"oauth/T5/2/owner": retagRemovesUnownedTag,
|
|
|
|
"oauth/D0/1/listed": keyListWithoutAll,
|
|
"oauth/B11/0/listed": keyListWithoutAll,
|
|
"oauth/B11/2/listed": keyListWithoutAll,
|
|
"oauth/D0/2/listed": keyListInvalidKeys,
|
|
|
|
"key-list/L-untagged/1/listed": keyListWithoutAll,
|
|
"key-list/L-prod/1/listed": keyListWithoutAll,
|
|
"key-list/L-prod-server/1/listed": keyListWithoutAll,
|
|
"key-list/L-make-prod/0/listed": keyListWithoutAll,
|
|
"key-list/L-make-prod-server/0/listed": keyListWithoutAll,
|
|
|
|
"oauth/D7/2/status": deletedClientGone,
|
|
"oauth/D7/2/outcome": deletedClientGone,
|
|
"oauth/D7/3/message": deletedClientCredentialText,
|
|
"oauth/D7/4/message": deletedClientCredentialText,
|
|
"oauth/B7/0/message": deletedClientCredentialText,
|
|
"oauth/B7/1/message": deletedClientCredentialText,
|
|
|
|
"oauth/key:J-ci-runner/creator-user-id": taggedKeyNoUserID,
|
|
"oauth/key:J-managed/creator-user-id": taggedKeyNoUserID,
|
|
"oauth/key:J-mixed/creator-user-id": taggedKeyNoUserID,
|
|
"oauth/key:J-prod-db/creator-user-id": taggedKeyNoUserID,
|
|
"oauth/key:J-router/creator-user-id": taggedKeyNoUserID,
|
|
"oauth/key:K-ci/creator-user-id": taggedKeyNoUserID,
|
|
"oauth/key:K-mixed/creator-user-id": taggedKeyNoUserID,
|
|
"oauth/key:K-prod/creator-user-id": taggedKeyNoUserID,
|
|
"policy-api/key:J-server/creator-user-id": taggedKeyNoUserID,
|
|
|
|
"key-list/key:A-db/creator-user-id": taggedKeyNoUserID,
|
|
"key-list/key:A-late-prod/creator-user-id": taggedKeyNoUserID,
|
|
"key-list/key:A-prod-db/creator-user-id": taggedKeyNoUserID,
|
|
"key-list/key:A-prod-server/creator-user-id": taggedKeyNoUserID,
|
|
"key-list/key:A-prod/creator-user-id": taggedKeyNoUserID,
|
|
|
|
"oauth/device:before/all/device-oauth-client": deviceOAuthClientID,
|
|
"oauth/device:ci-runner/all/device-oauth-client": deviceOAuthClientID,
|
|
"oauth/device:joiner/all/device-oauth-client": deviceOAuthClientID,
|
|
"oauth/device:laptop/all/device-oauth-client": deviceOAuthClientID,
|
|
"oauth/device:managed/all/device-oauth-client": deviceOAuthClientID,
|
|
"oauth/device:mixed/all/device-oauth-client": deviceOAuthClientID,
|
|
"oauth/device:prod-db/all/device-oauth-client": deviceOAuthClientID,
|
|
"oauth/device:router/all/device-oauth-client": deviceOAuthClientID,
|
|
"oauth/device:ci-runner/default/device-oauth-client": deviceOAuthClientID,
|
|
"oauth/device:laptop/default/device-oauth-client": deviceOAuthClientID,
|
|
"oauth/device:managed/default/device-oauth-client": deviceOAuthClientID,
|
|
"oauth/device:mixed/default/device-oauth-client": deviceOAuthClientID,
|
|
"oauth/device:prod-db/default/device-oauth-client": deviceOAuthClientID,
|
|
"oauth/device:router/default/device-oauth-client": deviceOAuthClientID,
|
|
|
|
"oauth/device:before/all/device-user": taggedDeviceUser,
|
|
"oauth/device:ci-runner/all/device-user": taggedDeviceUser,
|
|
"oauth/device:joiner/all/device-user": taggedDeviceUser,
|
|
"oauth/device:laptop/all/device-user": taggedDeviceUser,
|
|
"oauth/device:managed/all/device-user": taggedDeviceUser,
|
|
"oauth/device:mixed/all/device-user": taggedDeviceUser,
|
|
"oauth/device:prod-db/all/device-user": taggedDeviceUser,
|
|
"oauth/device:router/all/device-user": taggedDeviceUser,
|
|
"oauth/device:ci-runner/default/device-user": taggedDeviceUser,
|
|
"oauth/device:managed/default/device-user": taggedDeviceUser,
|
|
"oauth/device:mixed/default/device-user": taggedDeviceUser,
|
|
"oauth/device:prod-db/default/device-user": taggedDeviceUser,
|
|
"oauth/device:router/default/device-user": taggedDeviceUser,
|
|
|
|
"oauth/device:before/empty-route-lists": emptyRouteListsOmitted,
|
|
"oauth/device:ci-runner/empty-route-lists": emptyRouteListsOmitted,
|
|
"oauth/device:joiner/empty-route-lists": emptyRouteListsOmitted,
|
|
"oauth/device:laptop/empty-route-lists": emptyRouteListsOmitted,
|
|
"oauth/device:managed/empty-route-lists": emptyRouteListsOmitted,
|
|
"oauth/device:mixed/empty-route-lists": emptyRouteListsOmitted,
|
|
"oauth/device:prod-db/empty-route-lists": emptyRouteListsOmitted,
|
|
|
|
"policy-api/P2/0/status": noV2Validate,
|
|
"policy-api/P2/0/message": noV2Validate,
|
|
"policy-api/P3/0/status": noV2Validate,
|
|
"policy-api/P3/0/message": noV2Validate,
|
|
"policy-api/P4/0/status": noV2Validate,
|
|
"policy-api/P4/0/outcome": noV2Validate,
|
|
"policy-api/P5-R1/0/status": noV2Validate,
|
|
"policy-api/P5-R1/0/message": noV2Validate,
|
|
"policy-api/P5-R2/0/status": noV2Validate,
|
|
"policy-api/P5-R2/0/message": noV2Validate,
|
|
"policy-api/P5-R2/0/detail": noV2Validate,
|
|
|
|
"policy-api/P3/1/detail": refusalDetail,
|
|
"policy-api/P5-R1/1/detail": refusalDetail,
|
|
"policy-api/P5-R2/1/detail": refusalDetail,
|
|
|
|
"policy-api/P7/0/body": hujsonFormatted,
|
|
"policy-api/P7/1/body": hujsonFormatted,
|
|
}
|
|
|
|
// apiv2CompatNotCompared lists, by key prefix, checks left out: SaaS's
|
|
// answer there is in doubt, so it is no reference.
|
|
var apiv2CompatNotCompared = []string{
|
|
"policy-api/P2/1/status",
|
|
"policy-api/P2/1/outcome",
|
|
"policy-api/P2/1/enforced",
|
|
"policy-api/P2/1/v1-verdict",
|
|
"policy-api/P2/1/v1-stored",
|
|
"policy-api/P2/1/v1-enforced",
|
|
"policy-api/P2/2/body",
|
|
"policy-api/P2/2/etag",
|
|
}
|
|
|
|
// apiv2CompatNotReplayed lists steps left out, keyed <test_id>/<step>, with
|
|
// the objects only they create, which listings then leave out: SaaS's answer
|
|
// there is in doubt, so it is no reference.
|
|
var apiv2CompatNotReplayed = map[string][]string{
|
|
"oauth/E7": {"{client:C-all-2}"},
|
|
"oauth/E9b": {"{key:K-E9b}"},
|
|
}
|
|
|
|
// notCompared reports whether key falls under one of prefixes.
|
|
func notCompared(prefixes []string, key string) bool {
|
|
return slices.ContainsFunc(prefixes, func(p string) bool {
|
|
return strings.HasPrefix(key, p)
|
|
})
|
|
}
|
|
|
|
// Differences a planned change removes.
|
|
const (
|
|
scopeEchoAsSent = "SCOPE_ECHO_AS_SENT: SaaS echoes a token's scopes sorted and without repeats; headscale echoes the request as sent"
|
|
bodyBeforeScope = "BODY_BEFORE_SCOPE: headscale validates a createKey body before the token's scope and answers 422; SaaS answers 404 for the missing scope"
|
|
taggedAllClientAnyTag = "TAGGED_ALL_CLIENT_ANY_TAG: SaaS lets a tagged all-scope client's token ask only for the client's tags; headscale lets it ask for any tag"
|
|
retagRemovesUnownedTag = "RETAG_REMOVES_UNOWNED_TAG: SaaS refuses a token's retag of a device holding a tag outside the token's grant with 400 cannot remove current tags; headscale replaces the tags"
|
|
sshTestHostnames = "SSH_TEST_HOSTNAMES: headscale names the nodes behind a failing sshTests destination; SaaS names the destination as written"
|
|
)
|
|
|
|
// Differences no planned change removes yet.
|
|
const (
|
|
mintScopeRefusalStatus = "TOKEN_SCOPE_REFUSAL_STATUS: SaaS refuses a token scope the client lacks with 403 and a message; headscale with 400 invalid_scope"
|
|
mintTagsNotEchoed = "TOKEN_TAGS_NOT_ECHOED: SaaS's token answer names the token's tags; headscale's has no tags"
|
|
unknownPropertyRefused = "UNKNOWN_PROPERTY_REFUSED: SaaS ignores a createKey property it does not define; headscale refuses the body with 422, so the key is never made"
|
|
unknownKeyType = "UNKNOWN_KEY_TYPE: SaaS refuses an unknown keyType with 400 before it checks scopes; headscale makes an auth key from it"
|
|
tagRefusalStatus = "TAG_REFUSAL_STATUS: SaaS refuses a tag outside the token's grant with 400 requested tags are invalid or not permitted; headscale with 403"
|
|
keyListWithoutAll = "KEY_LIST_WITHOUT_ALL: without all=true SaaS lists an OAuth token only the auth keys OAuth tokens made, whatever its tags and theirs; headscale lists every key"
|
|
keyListInvalidKeys = "KEY_LIST_INVALID_KEYS: SaaS leaves spent and revoked auth keys out of a listing; headscale lists them"
|
|
deletedClientGone = "DELETED_CLIENT_GONE: SaaS keeps a deleted OAuth client readable as invalid and revoked; headscale removes it"
|
|
deletedClientCredentialText = "DELETED_CLIENT_CREDENTIAL_TEXT: SaaS refuses a deleted client's secret and tokens with API token invalid; headscale says invalid client credentials and unauthorized"
|
|
missingScopeText = "MISSING_SCOPE_TEXT: SaaS refuses a delete outside the token's scopes with calling actor does not have enough permissions; headscale names the missing scope"
|
|
taggedKeyNoUserID = "TAGGED_KEY_NO_USER_ID: SaaS shows the creating user's userId on a tagged auth key the admin made; headscale leaves userId off every tagged key"
|
|
deviceOAuthClientID = "DEVICE_OAUTH_CLIENT_ID: SaaS device bodies carry oauthClientId, naming the join key or the OAuth client that made it; headscale's have no such field"
|
|
taggedDeviceUser = "TAGGED_DEVICE_USER: SaaS shows a tagged device's user as the login that made its key, empty for a key a token made; headscale shows tagged-devices"
|
|
emptyRouteListsOmitted = "EMPTY_ROUTE_LISTS_OMITTED: SaaS sends advertisedRoutes and enabledRoutes as [] for a device without routes under fields=all; headscale leaves both out"
|
|
refusalDetail = "REFUSAL_DETAIL: SaaS puts a refused policy's reason in message, with one detail per failing test naming the user; headscale answers setting policy with the reason as one detail without a user"
|
|
noV2Validate = "NO_V2_VALIDATE: headscale's v2 API has no /acl/validate"
|
|
hujsonFormatted = "HUJSON_FORMATTED: SaaS stores a HuJSON policy formatted, comments kept; headscale stores the bytes as sent"
|
|
)
|
|
|
|
// SaaS behaviour recorded as it is; following it is the maintainer's call.
|
|
const (
|
|
missingScopeNotFound = "MISSING_SCOPE_NOT_FOUND: SaaS answers a call outside the token's scopes with 404 not found; headscale answers 403"
|
|
unknownScopeGranted = "UNKNOWN_SCOPE_GRANTED: SaaS ignores an unknown requested scope and grants the client's own; headscale grants the unknown scope alone"
|
|
)
|
|
|
|
// TestAPIv2OAuthCompat replays OAuth clients, token exchanges, auth keys,
|
|
// device reads and tag writes by principal: admin-made clients of every
|
|
// scope, scope narrowing, token-made clients and their tokens, tag writes on
|
|
// devices tagged outside the token's grant, key listing and deletes by scope,
|
|
// and a parent client deleted while its key and child client live on.
|
|
func TestAPIv2OAuthCompat(t *testing.T) {
|
|
runAPICompat(t, "oauth", false)
|
|
}
|
|
|
|
// TestAPIv2KeyListCompat replays auth key listings, with and without
|
|
// all=true, by read-only OAuth tokens untagged and of one and two tags and
|
|
// by the tokens that made keys, over keys of tags inside, overlapping and
|
|
// outside theirs that the admin or a token made, an untagged key the admin
|
|
// made, and an admin key made after the token-made ones.
|
|
func TestAPIv2KeyListCompat(t *testing.T) {
|
|
runAPICompat(t, "key-list", false)
|
|
}
|
|
|
|
// TestAPIv2ACLCompat replays policy writes over a fixed baseline: an
|
|
// identical write, zero-length, newline-only and empty-object bodies, a cut
|
|
// short body, a failing test, If-Match with a stale and the current ETag and
|
|
// ts-default, and HuJSON with a comment. It compares statuses, messages, the
|
|
// stored body, ETag relations and whether the enforced policy changed.
|
|
func TestAPIv2ACLCompat(t *testing.T) {
|
|
runAPICompat(t, "policy-api", false)
|
|
}
|
|
|
|
// TestAPIV1PolicyVerdictCompat sends the same policy bodies through the v1
|
|
// API: each write through PUT /api/v1/policy and each validate through
|
|
// /api/v1/policy/check. The verdict, whether the stored policy changed and
|
|
// whether the enforced policy changed must match SaaS's.
|
|
func TestAPIV1PolicyVerdictCompat(t *testing.T) {
|
|
runAPICompat(t, "policy-api", true)
|
|
}
|
|
|
|
func runAPICompat(t *testing.T, id string, v1 bool) {
|
|
t.Helper()
|
|
|
|
c, err := testcapture.Read(filepath.Join("servertest", "testdata", "steps", id+".hujson"))
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, c.Steps, "%s: not a step capture", id)
|
|
|
|
r := newAPIReplay(t, c, v1)
|
|
|
|
for _, step := range c.Steps {
|
|
// A setup-failed step sent nothing.
|
|
require.Contains(t, []string{"observed", "setup-failed"}, step.Result, step.Name)
|
|
|
|
if objs, ok := apiv2CompatNotReplayed[id+"/"+step.Name]; ok {
|
|
r.unlisted = append(r.unlisted, objs...)
|
|
|
|
continue
|
|
}
|
|
|
|
r.step(t, step)
|
|
}
|
|
|
|
if !v1 {
|
|
r.aggregateChecks(t)
|
|
}
|
|
|
|
for k := range apiv2CompatSkipReasons {
|
|
check := k[strings.LastIndex(k, "/")+1:]
|
|
if strings.HasPrefix(k, id+"/") && strings.HasPrefix(check, "v1-") == v1 && !r.evaluated[k] {
|
|
t.Errorf("skip entry %q names no check", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// apiReplay replays one capture's actions against headscale. SaaS labels
|
|
// ({key:K}, {client:C}, {device:x}, {etag:N}) map to headscale's IDs and
|
|
// ETags as the replay creates them.
|
|
type apiReplay struct {
|
|
c *testcapture.Capture
|
|
app *Headscale
|
|
v2 http.Handler
|
|
v1 http.Handler
|
|
useV1 bool
|
|
|
|
admin string
|
|
names map[string]string // SaaS email → capture user name
|
|
|
|
ids map[string]string // SaaS label → headscale ID
|
|
labels map[string]string // "<kind>/<headscale ID>" → SaaS label
|
|
secrets map[string]string // key or client label → secret
|
|
tokens map[string]string // principal → access token
|
|
addrs map[string]string // SaaS node address → headscale's
|
|
|
|
// ETag relations: where each ETag was first seen, per side.
|
|
saasETag, hsETag map[string]string
|
|
hsByLabel map[string]string
|
|
|
|
// saasCurrentETag is SaaS's last ETag, for the v1 replay.
|
|
saasCurrentETag string
|
|
|
|
// saasBaseline is the enforced policy every policy row starts from.
|
|
saasBaseline map[string]string
|
|
|
|
refusals map[string][2][]byte
|
|
|
|
// aggregates holds per-object observations checked over the whole
|
|
// capture: check → object label → [SaaS, headscale] values seen.
|
|
aggregates map[string]map[string][2]map[string]bool
|
|
|
|
// unlisted holds labels of objects made by steps left out.
|
|
unlisted []string
|
|
|
|
evaluated map[string]bool
|
|
}
|
|
|
|
func newAPIReplay(t *testing.T, c *testcapture.Capture, useV1 bool) *apiReplay {
|
|
t.Helper()
|
|
|
|
app := newAddressedTestApp(t)
|
|
|
|
r := &apiReplay{
|
|
c: c,
|
|
app: app,
|
|
v1: newHumaTestHandler(app),
|
|
useV1: useV1,
|
|
names: map[string]string{},
|
|
ids: map[string]string{},
|
|
labels: map[string]string{},
|
|
secrets: map[string]string{},
|
|
tokens: map[string]string{},
|
|
addrs: map[string]string{},
|
|
saasETag: map[string]string{},
|
|
hsETag: map[string]string{},
|
|
hsByLabel: map[string]string{},
|
|
refusals: map[string][2][]byte{},
|
|
aggregates: map[string]map[string][2]map[string]bool{},
|
|
evaluated: map[string]bool{},
|
|
}
|
|
|
|
users := map[string]*types.User{}
|
|
for _, u := range c.Topology.Users {
|
|
users[u.Name] = app.state.CreateUserForTest(u.Name)
|
|
r.names[u.Email] = u.Name
|
|
}
|
|
|
|
// SaaS admin calls act as the tailnet owner.
|
|
owner, ok := users[r.names[c.Tailnet]]
|
|
require.True(t, ok, "%s: tailnet %q is not a capture user", c.TestID, c.Tailnet)
|
|
|
|
admin, apiKey, err := app.state.CreateAPIKey(nil)
|
|
require.NoError(t, err)
|
|
require.NoError(t, app.state.SetAPIKeyUser(apiKey.ID, types.UserID(owner.ID)))
|
|
|
|
r.admin = admin
|
|
|
|
r.v2, _ = apiv2.Handler(apiv2.Backend{State: app.state, Change: app.Change, Cfg: app.cfg})
|
|
|
|
return r
|
|
}
|
|
|
|
// newAddressedTestApp is [createTestApp] with address prefixes, so joined
|
|
// nodes get addresses the replayed policies can name.
|
|
func newAddressedTestApp(t *testing.T) *Headscale {
|
|
t.Helper()
|
|
|
|
tmpDir := t.TempDir()
|
|
prefixV4 := netip.MustParsePrefix("100.64.0.0/10")
|
|
prefixV6 := netip.MustParsePrefix("fd7a:115c:a1e0::/48")
|
|
|
|
cfg := types.Config{
|
|
ServerURL: "http://localhost:8080",
|
|
NoisePrivateKeyPath: tmpDir + "/noise_private.key",
|
|
PrefixV4: &prefixV4,
|
|
PrefixV6: &prefixV6,
|
|
IPAllocation: types.IPAllocationStrategySequential,
|
|
Database: types.DatabaseConfig{
|
|
Type: "sqlite3",
|
|
Sqlite: types.SqliteConfig{Path: tmpDir + "/headscale_test.db"},
|
|
},
|
|
Policy: types.PolicyConfig{Mode: types.PolicyModeDB},
|
|
Tuning: types.Tuning{BatchChangeDelay: 100 * time.Millisecond, BatcherWorkers: 1},
|
|
}
|
|
|
|
app, err := NewHeadscale(&cfg)
|
|
require.NoError(t, err)
|
|
|
|
app.mapBatcher = mapper.NewBatcherAndMapper(&cfg, app.state)
|
|
app.mapBatcher.Start()
|
|
t.Cleanup(app.mapBatcher.Close)
|
|
|
|
return app
|
|
}
|
|
|
|
// apiCheck is one rendering of an outcome: SaaS's and headscale's.
|
|
type apiCheck struct {
|
|
name, want, got string
|
|
}
|
|
|
|
func (r *apiReplay) step(t *testing.T, step testcapture.Step) {
|
|
t.Helper()
|
|
|
|
var checks []apiCheck
|
|
|
|
for i, a := range step.Actions {
|
|
prefix := strconv.Itoa(i) + "/"
|
|
|
|
var got []apiCheck
|
|
|
|
switch a.Kind {
|
|
case "join":
|
|
got = r.join(t, step, a)
|
|
case "token":
|
|
got = r.token(t, a)
|
|
case "api", "policy":
|
|
got = r.api(t, step, i, a)
|
|
case "cli", "wait", "retire":
|
|
// Client settings, SaaS settle time and the run's cleanup.
|
|
default:
|
|
t.Fatalf("%s: no replay for action %q", step.Name, a.Kind)
|
|
}
|
|
|
|
for _, ch := range got {
|
|
// The v1 replay sets up through v2, whose checks the v2 tests own.
|
|
if strings.HasPrefix(ch.name, "v1-") == r.useV1 {
|
|
ch.name = prefix + ch.name
|
|
checks = append(checks, ch)
|
|
}
|
|
}
|
|
}
|
|
|
|
keyPrefix := r.c.TestID + "/" + step.Name + "/"
|
|
checks = slices.DeleteFunc(checks, func(ch apiCheck) bool {
|
|
return notCompared(apiv2CompatNotCompared, keyPrefix+ch.name)
|
|
})
|
|
|
|
if len(checks) == 0 {
|
|
return
|
|
}
|
|
|
|
// Marked here, so a run filtered to other steps does not call these
|
|
// entries stale.
|
|
for _, ch := range checks {
|
|
r.evaluated[keyPrefix+ch.name] = true
|
|
}
|
|
|
|
t.Run(step.Name, func(t *testing.T) {
|
|
for _, ch := range checks {
|
|
t.Run(ch.name, func(t *testing.T) {
|
|
apiCompatCheck(t, keyPrefix+ch.name, ch.want, ch.got)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
func apiCompatCheck(t *testing.T, key, want, got string) {
|
|
t.Helper()
|
|
|
|
reason, listed := apiv2CompatSkipReasons[key]
|
|
|
|
switch {
|
|
case listed && want == got:
|
|
t.Errorf("matches SaaS; delete apiv2CompatSkipReasons[%q] (%s)", key, reason)
|
|
case listed:
|
|
t.Skip(reason)
|
|
default:
|
|
assert.Equal(t, want, got)
|
|
}
|
|
}
|
|
|
|
// join registers the node with the labelled key, as the capture's stock
|
|
// client did, and records its device ID.
|
|
func (r *apiReplay) join(t *testing.T, step testcapture.Step, a testcapture.Action) []apiCheck {
|
|
t.Helper()
|
|
|
|
secret, ok := r.secrets[a.Key]
|
|
require.True(t, ok, "%s: join with unknown key %s", step.Name, a.Key)
|
|
|
|
hi := &tailcfg.Hostinfo{Hostname: a.Hostname}
|
|
if a.RequestTags != nil {
|
|
hi.RequestTags = *a.RequestTags
|
|
}
|
|
|
|
for _, route := range a.Routes {
|
|
hi.RoutableIPs = append(hi.RoutableIPs, netip.MustParsePrefix(route))
|
|
}
|
|
|
|
node, change, err := r.app.state.HandleNodeFromPreAuthKey(tailcfg.RegisterRequest{
|
|
Auth: &tailcfg.RegisterResponseAuth{AuthKey: secret},
|
|
NodeKey: key.NewNode().Public(),
|
|
Hostinfo: hi,
|
|
Expiry: time.Now().Add(24 * time.Hour),
|
|
}, key.NewMachine().Public())
|
|
r.app.Change(change)
|
|
|
|
got := "accepted"
|
|
if err != nil {
|
|
got = "refused: " + err.Error()
|
|
} else {
|
|
label := "{device:" + a.Node + "}"
|
|
r.ids[label] = node.StringID()
|
|
r.labels["device/"+node.StringID()] = label
|
|
|
|
// Policies name SaaS's addresses for the node.
|
|
topo := r.c.Topology.Nodes[a.Node]
|
|
|
|
for _, ip := range node.IPs() {
|
|
if ip.Is4() {
|
|
r.addrs[topo.IPv4] = ip.String()
|
|
} else {
|
|
r.addrs[topo.IPv6] = ip.String()
|
|
}
|
|
}
|
|
}
|
|
|
|
want := "refused"
|
|
if att := step.Register[a.Node]; len(att) > 0 && att[len(att)-1].MachineAuthorized != nil &&
|
|
*att[len(att)-1].MachineAuthorized {
|
|
want = "accepted"
|
|
}
|
|
|
|
return []apiCheck{{"join", want, got}}
|
|
}
|
|
|
|
// token runs the client-credentials exchange the action records. The form
|
|
// carries the client's secret, scope and tags as SaaS received them.
|
|
func (r *apiReplay) token(t *testing.T, a testcapture.Action) []apiCheck {
|
|
t.Helper()
|
|
|
|
secret, ok := r.secrets[a.Client]
|
|
require.True(t, ok, "token for unknown client %s", a.Client)
|
|
|
|
form := url.Values{"grant_type": {"client_credentials"}, "client_secret": {secret}}
|
|
if a.Scope != nil {
|
|
form.Set("scope", *a.Scope)
|
|
}
|
|
|
|
if a.RequestTags != nil {
|
|
form.Set("tags", strings.Join(*a.RequestTags, " "))
|
|
}
|
|
|
|
status, _, body := r.serve(t, http.MethodPost, "/api/v2/oauth/token",
|
|
map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, []byte(form.Encode()), r.v2)
|
|
|
|
checks := []apiCheck{
|
|
{"status", strconv.Itoa(a.Status), strconv.Itoa(status)},
|
|
{"outcome", verdict(a.Status == http.StatusOK), verdict(status == http.StatusOK)},
|
|
}
|
|
|
|
var want, got struct {
|
|
AccessToken string `json:"access_token"`
|
|
Scope string `json:"scope"`
|
|
Tags string `json:"tags"`
|
|
TokenType string `json:"token_type"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
}
|
|
|
|
require.NoError(t, json.Unmarshal(a.Response, &want))
|
|
require.NoErrorf(t, json.Unmarshal(body, &got), "token body: %s", body)
|
|
|
|
switch {
|
|
case a.Status == http.StatusOK && status == http.StatusOK:
|
|
r.tokens[a.Principal] = got.AccessToken
|
|
|
|
checks = append(checks,
|
|
apiCheck{"scope", want.Scope, got.Scope},
|
|
apiCheck{"tags", want.Tags, got.Tags},
|
|
apiCheck{
|
|
"token",
|
|
fmt.Sprintf("type %s expires_in %d", want.TokenType, want.ExpiresIn),
|
|
fmt.Sprintf("type %s expires_in %d", got.TokenType, got.ExpiresIn),
|
|
},
|
|
)
|
|
case status == http.StatusOK:
|
|
// SaaS sent nothing with a token it refused.
|
|
r.tokens[a.Principal] = got.AccessToken
|
|
case a.Status == status:
|
|
checks = append(checks, messageCheck(t, a.Response, body))
|
|
}
|
|
|
|
return checks
|
|
}
|
|
|
|
var labelRE = regexp.MustCompile(`\{(?:device|key|client):[^}]+\}`)
|
|
|
|
// api sends one admin API call as its principal and compares the answer.
|
|
func (r *apiReplay) api(t *testing.T, step testcapture.Step, i int, a testcapture.Action) []apiCheck {
|
|
t.Helper()
|
|
|
|
endpoint, _, _ := strings.Cut(a.Path, "?")
|
|
isPolicy := endpoint == "/tailnet/-/acl" || endpoint == "/tailnet/-/acl/validate"
|
|
|
|
if r.useV1 && isPolicy {
|
|
return r.v1Policy(t, step, i, a)
|
|
}
|
|
|
|
if r.useV1 && a.Method == http.MethodGet {
|
|
return nil
|
|
}
|
|
|
|
isWrite := a.Method == http.MethodPost && endpoint == "/tailnet/-/acl"
|
|
isValidate := endpoint == "/tailnet/-/acl/validate"
|
|
path, header, body := r.request(t, a, isPolicy)
|
|
|
|
var before string
|
|
if isWrite {
|
|
before = r.enforcedNow(t)
|
|
}
|
|
|
|
status, respHeader, resp := r.serve(t, a.Method, path, header, body, r.v2)
|
|
|
|
saasOK := a.Status/100 == 2
|
|
hsOK := status/100 == 2
|
|
|
|
// SaaS's validate answers some refusals with 200 and a message.
|
|
saasRefused := !saasOK || isValidate && len(errorTexts(a.Response)) > 0
|
|
hsRefused := !hsOK || isValidate && len(errorTexts(resp)) > 0
|
|
|
|
checks := []apiCheck{
|
|
{"status", strconv.Itoa(a.Status), strconv.Itoa(status)},
|
|
{"outcome", verdict(!saasRefused), verdict(!hsRefused)},
|
|
}
|
|
|
|
if len(a.Response) > 0 && (!saasOK && a.Status == status || isValidate && saasRefused) {
|
|
checks = append(checks, messageCheck(t, a.Response, resp))
|
|
}
|
|
|
|
// Labelled also when SaaS refused it, so listings name what headscale
|
|
// made.
|
|
if hsOK {
|
|
r.record(t, a, resp)
|
|
}
|
|
|
|
if saasOK && hsOK {
|
|
checks = append(checks, r.bodyChecks(t, a, endpoint, resp)...)
|
|
}
|
|
|
|
if label := a.RespHeader["ETag"]; label != "" && saasOK && hsOK {
|
|
checks = append(checks, r.etagCheck(step, i, label, respHeader.Get("ETag")))
|
|
}
|
|
|
|
if a.Kind == "api" && saasRefused && (isWrite || isValidate) {
|
|
checks = append(checks, r.refusalChecks(t, a, endpoint, body, resp)...)
|
|
}
|
|
|
|
if isWrite {
|
|
if ch, ok := r.enforcedCheck(t, step, a.Kind, before); ok {
|
|
checks = append(checks, ch)
|
|
}
|
|
}
|
|
|
|
return checks
|
|
}
|
|
|
|
// request is the call a sent, with SaaS's labels, emails and addresses
|
|
// replaced by headscale's and its principal's credential.
|
|
func (r *apiReplay) request(t *testing.T, a testcapture.Action, isPolicy bool) (string, map[string]string, []byte) {
|
|
t.Helper()
|
|
|
|
body := []byte(a.Body)
|
|
if isPolicy {
|
|
body = r.policyBody(t, a.Body)
|
|
}
|
|
|
|
header := maps.Clone(a.Header)
|
|
if header == nil {
|
|
header = map[string]string{}
|
|
}
|
|
|
|
if hs, ok := r.hsByLabel[header["If-Match"]]; ok {
|
|
header["If-Match"] = hs
|
|
}
|
|
|
|
if _, ok := header["Content-Type"]; !ok && a.Body != nil {
|
|
header["Content-Type"] = "application/json"
|
|
}
|
|
|
|
switch {
|
|
case a.Principal == "admin":
|
|
header["Authorization"] = "Bearer " + r.admin
|
|
case strings.HasPrefix(a.Principal, "token:"):
|
|
// A token headscale never issued is refused like a missing one.
|
|
header["Authorization"] = "Bearer " + r.tokens[a.Principal]
|
|
default:
|
|
t.Fatalf("unknown principal %q", a.Principal)
|
|
}
|
|
|
|
path := "/api/v2" + labelRE.ReplaceAllStringFunc(a.Path, func(l string) string {
|
|
if id, ok := r.ids[l]; ok {
|
|
return id
|
|
}
|
|
|
|
return "unknown"
|
|
})
|
|
|
|
return path, header, body
|
|
}
|
|
|
|
// serve sends one request to handler and returns its answer.
|
|
func (r *apiReplay) serve(
|
|
t *testing.T,
|
|
method, path string,
|
|
header map[string]string,
|
|
body []byte,
|
|
handler http.Handler,
|
|
) (int, http.Header, []byte) {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), method, path, bytes.NewReader(body))
|
|
for k, v := range header {
|
|
req.Header.Set(k, v)
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
return rec.Code, rec.Header(), rec.Body.Bytes()
|
|
}
|
|
|
|
// policyBody is a policy body as headscale must receive it: HuJSON and
|
|
// zero-length bodies are stored as JSON strings, and SaaS's user emails and
|
|
// node addresses become headscale's.
|
|
func (r *apiReplay) policyBody(t *testing.T, raw json.RawMessage) []byte {
|
|
t.Helper()
|
|
|
|
s := string(raw)
|
|
if strings.HasPrefix(s, `"`) {
|
|
require.NoError(t, json.Unmarshal(raw, &s))
|
|
}
|
|
|
|
for email, name := range r.names {
|
|
s = strings.ReplaceAll(s, email, name+"@")
|
|
}
|
|
|
|
// Quoted, so one address cannot rewrite a longer one it prefixes.
|
|
for saas, hs := range r.addrs {
|
|
s = strings.ReplaceAll(s, `"`+saas+`"`, `"`+hs+`"`)
|
|
}
|
|
|
|
return []byte(s)
|
|
}
|
|
|
|
// apiBody is the union of the v2 bodies the checks read: keys, OAuth
|
|
// clients, devices and errors. Raw fields keep absent apart from null and [].
|
|
type apiBody struct {
|
|
ID string `json:"id"`
|
|
KeyType string `json:"keyType"`
|
|
Description string `json:"description"`
|
|
ExpirySeconds int64 `json:"expirySeconds"`
|
|
Invalid bool `json:"invalid"`
|
|
Revoked *string `json:"revoked"`
|
|
UserID *string `json:"userId"`
|
|
Scopes []string `json:"scopes"`
|
|
Tags []string `json:"tags"`
|
|
Capabilities *struct {
|
|
Devices struct {
|
|
Create struct {
|
|
Reusable bool `json:"reusable"`
|
|
Ephemeral bool `json:"ephemeral"`
|
|
Preauthorized bool `json:"preauthorized"`
|
|
Tags []string `json:"tags"`
|
|
} `json:"create"`
|
|
} `json:"devices"`
|
|
} `json:"capabilities"`
|
|
|
|
User string `json:"user"`
|
|
AdvertisedRoutes json.RawMessage `json:"advertisedRoutes"`
|
|
EnabledRoutes json.RawMessage `json:"enabledRoutes"`
|
|
OAuthClientID *string `json:"oauthClientId"`
|
|
|
|
Keys json.RawMessage `json:"keys"`
|
|
Devices []apiBody `json:"devices"`
|
|
}
|
|
|
|
func decodeBody(t *testing.T, raw []byte) apiBody {
|
|
t.Helper()
|
|
|
|
var b apiBody
|
|
if len(raw) > 0 {
|
|
require.NoErrorf(t, json.Unmarshal(raw, &b), "body: %s", raw)
|
|
}
|
|
|
|
return b
|
|
}
|
|
|
|
// record learns the IDs and secrets of what a create returned.
|
|
func (r *apiReplay) record(t *testing.T, a testcapture.Action, resp []byte) {
|
|
t.Helper()
|
|
|
|
if a.Method != http.MethodPost || a.Path != "/tailnet/-/keys" {
|
|
return
|
|
}
|
|
|
|
var created struct {
|
|
ID string `json:"id"`
|
|
Key string `json:"key"`
|
|
KeyType string `json:"keyType"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(resp, &created))
|
|
|
|
name, label := a.Key, "{key:"+a.Key+"}"
|
|
if created.KeyType == "client" {
|
|
name, label = a.Client, "{client:"+a.Client+"}"
|
|
}
|
|
|
|
r.ids[label] = created.ID
|
|
r.labels[created.KeyType+"/"+created.ID] = label
|
|
r.secrets[name] = created.Key
|
|
}
|
|
|
|
// bodyChecks compares what a successful call returned, by endpoint.
|
|
func (r *apiReplay) bodyChecks(t *testing.T, a testcapture.Action, endpoint string, resp []byte) []apiCheck {
|
|
t.Helper()
|
|
|
|
switch {
|
|
case endpoint == "/tailnet/-/acl":
|
|
return []apiCheck{r.policyCheck(t, a, resp)}
|
|
|
|
case endpoint == "/tailnet/-/keys" && a.Method == http.MethodGet:
|
|
return []apiCheck{{"listed", r.listedKeys(t, a.Response), r.listedKeys(t, resp)}}
|
|
|
|
case strings.HasPrefix(endpoint, "/tailnet/-/keys") && a.Method != http.MethodDelete:
|
|
return r.keyChecks(decodeBody(t, a.Response), decodeBody(t, resp))
|
|
|
|
case endpoint == "/tailnet/-/devices":
|
|
return r.deviceListChecks(t, a, resp)
|
|
|
|
case strings.HasSuffix(endpoint, "/routes"):
|
|
want, got := decodeBody(t, a.Response), decodeBody(t, resp)
|
|
|
|
return []apiCheck{{"routes", routesOf(want), routesOf(got)}}
|
|
|
|
case strings.HasPrefix(endpoint, "/device/") && a.Method == http.MethodGet:
|
|
alias := strings.TrimSuffix(strings.TrimPrefix(endpoint, "/device/{device:"), "}")
|
|
|
|
return r.deviceChecks("", alias, strings.Contains(a.Path, "fields=all"), decodeBody(t, a.Response), decodeBody(t, resp))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// keyChecks compares an auth key or OAuth client.
|
|
func (r *apiReplay) keyChecks(want, got apiBody) []apiCheck {
|
|
r.observe("creator-user-id", want.ID, presence(want.UserID != nil), presence(got.UserID != nil))
|
|
|
|
state := func(b apiBody) string {
|
|
return fmt.Sprintf("invalid %t revoked %s", b.Invalid, presence(b.Revoked != nil))
|
|
}
|
|
|
|
checks := []apiCheck{
|
|
{"key-type", want.KeyType, got.KeyType},
|
|
{"description", want.Description, got.Description},
|
|
{"state", state(want), state(got)},
|
|
}
|
|
|
|
if want.KeyType == "client" {
|
|
return append(checks,
|
|
apiCheck{"scopes", sortedStrings(want.Scopes), sortedStrings(got.Scopes)},
|
|
apiCheck{"tags", sortedStrings(want.Tags), sortedStrings(got.Tags)},
|
|
)
|
|
}
|
|
|
|
caps := func(b apiBody) string {
|
|
if b.Capabilities == nil {
|
|
return "none"
|
|
}
|
|
|
|
c := b.Capabilities.Devices.Create
|
|
|
|
return fmt.Sprintf("reusable %t ephemeral %t preauthorized %t tags %s",
|
|
c.Reusable, c.Ephemeral, c.Preauthorized, sortedStrings(c.Tags))
|
|
}
|
|
|
|
return append(checks,
|
|
apiCheck{"capabilities", caps(want), caps(got)},
|
|
apiCheck{"expiry", strconv.FormatInt(want.ExpirySeconds, 10), strconv.FormatInt(got.ExpirySeconds, 10)},
|
|
)
|
|
}
|
|
|
|
// listedKeys renders a key listing as the labels it holds.
|
|
func (r *apiReplay) listedKeys(t *testing.T, raw []byte) string {
|
|
t.Helper()
|
|
|
|
b := decodeBody(t, raw)
|
|
if string(b.Keys) == "null" {
|
|
return "null"
|
|
}
|
|
|
|
var keys []apiBody
|
|
require.NoError(t, json.Unmarshal(b.Keys, &keys))
|
|
|
|
labels := make([]string, 0, len(keys))
|
|
for _, k := range keys {
|
|
if l := r.label(k.KeyType, k.ID); !slices.Contains(r.unlisted, l) {
|
|
labels = append(labels, l)
|
|
}
|
|
}
|
|
|
|
return sortedStrings(labels)
|
|
}
|
|
|
|
// label maps an ID from either side to its SaaS label.
|
|
func (r *apiReplay) label(kind, id string) string {
|
|
if strings.HasPrefix(id, "{") {
|
|
return id
|
|
}
|
|
|
|
if l, ok := r.labels[kind+"/"+id]; ok {
|
|
return l
|
|
}
|
|
|
|
return "{unmatched}"
|
|
}
|
|
|
|
func (r *apiReplay) deviceListChecks(t *testing.T, a testcapture.Action, resp []byte) []apiCheck {
|
|
t.Helper()
|
|
|
|
all := strings.Contains(a.Path, "fields=all")
|
|
want, got := decodeBody(t, a.Response), decodeBody(t, resp)
|
|
|
|
byLabel := func(devs []apiBody) map[string]apiBody {
|
|
m := make(map[string]apiBody, len(devs))
|
|
for _, d := range devs {
|
|
m[r.label("device", d.ID)] = d
|
|
}
|
|
|
|
return m
|
|
}
|
|
|
|
wantBy, gotBy := byLabel(want.Devices), byLabel(got.Devices)
|
|
checks := []apiCheck{{"listed", sortedStrings(slices.Collect(maps.Keys(wantBy))), sortedStrings(slices.Collect(maps.Keys(gotBy)))}}
|
|
|
|
for _, l := range slices.Sorted(maps.Keys(wantBy)) {
|
|
g, ok := gotBy[l]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
alias := strings.TrimSuffix(strings.TrimPrefix(l, "{device:"), "}")
|
|
checks = append(checks, r.deviceChecks(alias+"/", alias, all, wantBy[l], g)...)
|
|
}
|
|
|
|
return checks
|
|
}
|
|
|
|
// deviceChecks compares a device's tags, owner and, with fields=all, routes.
|
|
func (r *apiReplay) deviceChecks(prefix, alias string, all bool, want, got apiBody) []apiCheck {
|
|
label := "{device:" + alias + "}"
|
|
|
|
// Apart per read shape, so a field fixed in one shape only shows.
|
|
shape := "default/"
|
|
if all {
|
|
shape = "all/"
|
|
}
|
|
|
|
r.observe(shape+"device-oauth-client", label, optional(want.OAuthClientID), r.credentialLabel(got.OAuthClientID))
|
|
r.observe(shape+"device-user", label, r.userName(want.User), got.User)
|
|
|
|
checks := []apiCheck{
|
|
{prefix + "tags", sortedStrings(want.Tags), sortedStrings(got.Tags)},
|
|
{prefix + "owner", deviceOwner(want.Tags, r.userName(want.User)), deviceOwner(got.Tags, got.User)},
|
|
}
|
|
|
|
if all {
|
|
checks = append(checks, apiCheck{prefix + "routes", routesOf(want), routesOf(got)})
|
|
|
|
if routesOf(want) == routesOf(apiBody{}) {
|
|
r.observe("empty-route-lists", label, listShape(want), listShape(got))
|
|
}
|
|
}
|
|
|
|
return checks
|
|
}
|
|
|
|
func (r *apiReplay) userName(email string) string {
|
|
if name, ok := r.names[email]; ok {
|
|
return name
|
|
}
|
|
|
|
return email
|
|
}
|
|
|
|
func deviceOwner(tags []string, user string) string {
|
|
if len(tags) > 0 {
|
|
return "tags " + sortedStrings(tags)
|
|
}
|
|
|
|
return "user " + user
|
|
}
|
|
|
|
func routesOf(b apiBody) string {
|
|
list := func(raw json.RawMessage) []string {
|
|
var out []string
|
|
if len(raw) > 0 {
|
|
_ = json.Unmarshal(raw, &out)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
return fmt.Sprintf("advertised %s enabled %s", sortedStrings(list(b.AdvertisedRoutes)), sortedStrings(list(b.EnabledRoutes)))
|
|
}
|
|
|
|
// listShape is how a body wrote its route lists: absent, null or [].
|
|
func listShape(b apiBody) string {
|
|
shape := func(raw json.RawMessage) string {
|
|
if raw == nil {
|
|
return "absent"
|
|
}
|
|
|
|
return string(raw)
|
|
}
|
|
|
|
return "advertised " + shape(b.AdvertisedRoutes) + " enabled " + shape(b.EnabledRoutes)
|
|
}
|
|
|
|
func presence(set bool) string {
|
|
if set {
|
|
return "set"
|
|
}
|
|
|
|
return "absent"
|
|
}
|
|
|
|
func optional(s *string) string {
|
|
if s == nil {
|
|
return "absent"
|
|
}
|
|
|
|
return *s
|
|
}
|
|
|
|
// credentialLabel is the SaaS label of an auth key or OAuth client ID
|
|
// headscale sent.
|
|
func (r *apiReplay) credentialLabel(id *string) string {
|
|
if id == nil {
|
|
return "absent"
|
|
}
|
|
|
|
for _, kind := range []string{"auth", "client"} {
|
|
if l, ok := r.labels[kind+"/"+*id]; ok {
|
|
return l
|
|
}
|
|
}
|
|
|
|
return "{unmatched}"
|
|
}
|
|
|
|
// observe records one object's value on each side for a check over every
|
|
// body that names it.
|
|
func (r *apiReplay) observe(check, label, want, got string) {
|
|
if r.aggregates[check] == nil {
|
|
r.aggregates[check] = map[string][2]map[string]bool{}
|
|
}
|
|
|
|
seen, ok := r.aggregates[check][label]
|
|
if !ok {
|
|
seen = [2]map[string]bool{{}, {}}
|
|
r.aggregates[check][label] = seen
|
|
}
|
|
|
|
seen[0][want] = true
|
|
seen[1][got] = true
|
|
}
|
|
|
|
// aggregateChecks compares, per object and check, the values each side
|
|
// showed over the capture.
|
|
func (r *apiReplay) aggregateChecks(t *testing.T) {
|
|
t.Helper()
|
|
|
|
for _, check := range slices.Sorted(maps.Keys(r.aggregates)) {
|
|
for _, label := range slices.Sorted(maps.Keys(r.aggregates[check])) {
|
|
seen := r.aggregates[check][label]
|
|
name := strings.Trim(label, "{}") + "/" + check
|
|
key := r.c.TestID + "/" + name
|
|
r.evaluated[key] = true
|
|
|
|
t.Run(name, func(t *testing.T) {
|
|
apiCompatCheck(t, key,
|
|
strings.Join(slices.Sorted(maps.Keys(seen[0])), "|"),
|
|
strings.Join(slices.Sorted(maps.Keys(seen[1])), "|"))
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// messageCheck passes when SaaS's error message appears in headscale's
|
|
// answer, as its message, OAuth error description or one of its details.
|
|
func messageCheck(t *testing.T, saas, hs []byte) apiCheck {
|
|
t.Helper()
|
|
|
|
var want struct {
|
|
Message string `json:"message"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(saas, &want))
|
|
|
|
texts := errorTexts(hs)
|
|
for _, s := range texts {
|
|
if strings.Contains(s, want.Message) {
|
|
return apiCheck{"message", want.Message, want.Message}
|
|
}
|
|
}
|
|
|
|
return apiCheck{"message", want.Message, strings.Join(texts, " | ")}
|
|
}
|
|
|
|
func errorTexts(raw []byte) []string {
|
|
var body struct {
|
|
Message string `json:"message"`
|
|
Error string `json:"error"`
|
|
ErrorDescription string `json:"error_description"`
|
|
Data []struct {
|
|
Errors []string `json:"errors"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
if json.Unmarshal(raw, &body) != nil {
|
|
return []string{string(raw)}
|
|
}
|
|
|
|
texts := []string{body.Message, body.ErrorDescription}
|
|
for _, d := range body.Data {
|
|
texts = append(texts, d.Errors...)
|
|
}
|
|
|
|
return slices.DeleteFunc(texts, func(s string) bool { return s == "" })
|
|
}
|
|
|
|
// policyCheck compares a policy answer: the HuJSON text byte for byte when
|
|
// SaaS sent text, else the document.
|
|
func (r *apiReplay) policyCheck(t *testing.T, a testcapture.Action, resp []byte) apiCheck {
|
|
t.Helper()
|
|
|
|
want := string(r.policyBody(t, a.Response))
|
|
if strings.HasPrefix(string(a.Response), `"`) {
|
|
return apiCheck{"body", want, string(resp)}
|
|
}
|
|
|
|
return apiCheck{"body", canonicalPolicy(t, []byte(want)), canonicalPolicy(t, resp)}
|
|
}
|
|
|
|
func canonicalPolicy(t *testing.T, raw []byte) string {
|
|
t.Helper()
|
|
|
|
std, err := hujson.Standardize(bytes.Clone(raw))
|
|
require.NoErrorf(t, err, "policy: %s", raw)
|
|
|
|
var v any
|
|
require.NoError(t, json.Unmarshal(std, &v))
|
|
|
|
out, err := json.Marshal(v)
|
|
require.NoError(t, err)
|
|
|
|
return string(out)
|
|
}
|
|
|
|
// etagCheck compares where each side first saw the answer's ETag.
|
|
func (r *apiReplay) etagCheck(step testcapture.Step, i int, label, hs string) apiCheck {
|
|
here := fmt.Sprintf("%s/%d", step.Name, i)
|
|
|
|
first := func(seen map[string]string, v string) string {
|
|
if at, ok := seen[v]; ok {
|
|
return "same as " + at
|
|
}
|
|
|
|
seen[v] = here
|
|
|
|
return "new"
|
|
}
|
|
|
|
if _, ok := r.hsByLabel[label]; !ok {
|
|
r.hsByLabel[label] = hs
|
|
}
|
|
|
|
return apiCheck{"etag", first(r.saasETag, label), first(r.hsETag, hs)}
|
|
}
|
|
|
|
// refusalChecks compare a refused policy write or validate's detail: the
|
|
// hostnames it names that the policy does not, how many detail entries for
|
|
// which users, and whether a second principal sending the same body to the
|
|
// same endpoint got the same answer.
|
|
func (r *apiReplay) refusalChecks(t *testing.T, a testcapture.Action, endpoint string, sent, resp []byte) []apiCheck {
|
|
t.Helper()
|
|
|
|
var hostnames []string
|
|
|
|
for _, n := range r.c.Topology.Nodes {
|
|
if !bytes.Contains(sent, []byte(n.Hostname)) {
|
|
hostnames = append(hostnames, n.Hostname)
|
|
}
|
|
}
|
|
|
|
named := func(texts []string) string {
|
|
var out []string
|
|
|
|
for _, h := range hostnames {
|
|
if slices.ContainsFunc(texts, func(s string) bool { return strings.Contains(s, h) }) {
|
|
out = append(out, h)
|
|
}
|
|
}
|
|
|
|
return sortedStrings(out)
|
|
}
|
|
|
|
var want, got struct {
|
|
Data []struct {
|
|
User string `json:"user"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
require.NoError(t, json.Unmarshal(a.Response, &want))
|
|
|
|
// Headscale has no v2 validate, so only there may its answer not be JSON.
|
|
if err := json.Unmarshal(resp, &got); endpoint != "/tailnet/-/acl/validate" {
|
|
require.NoErrorf(t, err, "body: %s", resp)
|
|
}
|
|
|
|
users := func(data []struct {
|
|
User string `json:"user"`
|
|
},
|
|
) string {
|
|
out := make([]string, 0, len(data))
|
|
for _, d := range data {
|
|
out = append(out, r.userName(d.User))
|
|
}
|
|
|
|
return fmt.Sprintf("%q", out)
|
|
}
|
|
|
|
checks := []apiCheck{
|
|
{"hostnames", named(errorTexts(a.Response)), named(errorTexts(resp))},
|
|
{"detail", users(want.Data), users(got.Data)},
|
|
}
|
|
|
|
sameBody := endpoint + " " + string(sent)
|
|
if prev, ok := r.refusals[sameBody]; ok && bytes.Equal(prev[0], a.Response) {
|
|
checks = append(checks, apiCheck{"same-answer", "same", map[bool]string{true: "same", false: "differs"}[bytes.Equal(prev[1], resp)]})
|
|
}
|
|
|
|
r.refusals[sameBody] = [2][]byte{a.Response, resp}
|
|
|
|
return checks
|
|
}
|
|
|
|
// enforcedNow renders what every replayed node enforces: its filter rules and
|
|
// SSH rules.
|
|
func (r *apiReplay) enforcedNow(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
var b strings.Builder
|
|
|
|
for _, label := range slices.Sorted(maps.Keys(r.ids)) {
|
|
if !strings.HasPrefix(label, "{device:") {
|
|
continue
|
|
}
|
|
|
|
id, err := strconv.ParseUint(r.ids[label], 10, 64)
|
|
require.NoError(t, err)
|
|
|
|
nv, ok := r.app.state.GetNodeByID(types.NodeID(id))
|
|
require.True(t, ok, label)
|
|
|
|
rules, err := r.app.state.FilterForNode(nv)
|
|
require.NoError(t, err)
|
|
|
|
ssh, err := r.app.state.SSHPolicy(nv)
|
|
require.NoError(t, err)
|
|
|
|
out, err := json.Marshal([]any{rules, ssh})
|
|
require.NoError(t, err)
|
|
|
|
fmt.Fprintf(&b, "%s %s\n", label, out)
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// enforcedCheck compares whether a policy row changed what nodes enforce.
|
|
// Every row starts from the baseline the first captured step installed, so
|
|
// SaaS's change is its nodes' rules against that step's.
|
|
func (r *apiReplay) enforcedCheck(t *testing.T, step testcapture.Step, kind, before string) (apiCheck, bool) {
|
|
t.Helper()
|
|
|
|
if len(step.Captures) == 0 || kind != "api" {
|
|
return apiCheck{}, false
|
|
}
|
|
|
|
saas := make(map[string]string, len(step.Captures))
|
|
|
|
for alias, n := range step.Captures {
|
|
out, err := json.Marshal([]any{n.PacketFilterRules, n.SSHRules})
|
|
require.NoError(t, err)
|
|
|
|
saas[alias] = string(out)
|
|
}
|
|
|
|
if r.saasBaseline == nil {
|
|
r.saasBaseline = saas
|
|
|
|
return apiCheck{}, false
|
|
}
|
|
|
|
name := "enforced"
|
|
if r.useV1 {
|
|
name = "v1-enforced"
|
|
}
|
|
|
|
return apiCheck{name, changed(!maps.Equal(saas, r.saasBaseline)), changed(r.enforcedNow(t) != before)}, true
|
|
}
|
|
|
|
// v1Policy sends a policy write or validate through the v1 API. Writes
|
|
// refused by SaaS only for their If-Match have no v1 form and are not sent.
|
|
func (r *apiReplay) v1Policy(t *testing.T, step testcapture.Step, i int, a testcapture.Action) []apiCheck {
|
|
t.Helper()
|
|
|
|
endpoint, _, _ := strings.Cut(a.Path, "?")
|
|
|
|
if a.Method == http.MethodGet {
|
|
if label := a.RespHeader["ETag"]; label != "" {
|
|
r.saasCurrentETag = label
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
if _, ok := a.Header["If-Match"]; ok && a.Status == http.StatusPreconditionFailed {
|
|
return nil
|
|
}
|
|
|
|
body, err := json.Marshal(map[string]string{"policy": string(r.policyBody(t, a.Body))})
|
|
require.NoError(t, err)
|
|
|
|
if endpoint == "/tailnet/-/acl/validate" {
|
|
res := callHandler(r.v1, http.MethodPost, "/api/v1/policy/check", body)
|
|
|
|
// SaaS's validate answers 200 with a message for some refusals.
|
|
var saas struct {
|
|
Message string `json:"message"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(a.Response, &saas))
|
|
|
|
return []apiCheck{{"v1-verdict", verdict(a.Status == http.StatusOK && saas.Message == ""), verdict(isSuccess(res.status))}}
|
|
}
|
|
|
|
storedBefore := r.storedPolicy()
|
|
before := r.enforcedNow(t)
|
|
etagBefore := r.saasCurrentETag
|
|
|
|
res := callHandler(r.v1, http.MethodPut, "/api/v1/policy", body)
|
|
|
|
if label := a.RespHeader["ETag"]; label != "" {
|
|
r.saasCurrentETag = label
|
|
}
|
|
|
|
if a.Kind == "policy" {
|
|
require.Truef(t, isSuccess(res.status), "%s: setup policy: %s", step.Name, res.body)
|
|
|
|
return nil
|
|
}
|
|
|
|
checks := []apiCheck{{"v1-verdict", verdict(a.Status/100 == 2), verdict(isSuccess(res.status))}}
|
|
|
|
// SaaS's stored policy changed when the step's next read has a new ETag.
|
|
for _, next := range step.Actions[i+1:] {
|
|
if next.Method == http.MethodGet && next.RespHeader["ETag"] != "" {
|
|
checks = append(checks, apiCheck{
|
|
"v1-stored",
|
|
changed(next.RespHeader["ETag"] != etagBefore),
|
|
changed(r.storedPolicy() != storedBefore),
|
|
})
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
if ch, ok := r.enforcedCheck(t, step, a.Kind, before); ok {
|
|
checks = append(checks, ch)
|
|
}
|
|
|
|
return checks
|
|
}
|
|
|
|
func verdict(ok bool) string {
|
|
if ok {
|
|
return "accepted"
|
|
}
|
|
|
|
return "refused"
|
|
}
|
|
|
|
func changed(c bool) string {
|
|
if c {
|
|
return "changed"
|
|
}
|
|
|
|
return "unchanged"
|
|
}
|
|
|
|
func (r *apiReplay) storedPolicy() string {
|
|
p, err := r.app.state.GetPolicy()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
return p.Data
|
|
}
|