Compare commits

..

14 Commits

Author SHA1 Message Date
Kristoffer Dalby 9bc43bf324 integration: relax HA docker disconnect non-bug stability checks
docker network disconnect of one router triggers bridge
reconfiguration that transiently fails probes on OTHER routers
on the same network — a test-infrastructure quirk that does not
occur with a real cable pull. The lifecycle test's
"stays primary across X seconds" assertions on the prev-primary
return legs (phases 2b, 3b, 4d) tripped on this side effect.

Drop the identity assertions on those legs; keep traffic-flow
checks. Phases 4b and 5a (the no-flap regression barriers for
the algorithm fix) keep their stability windows.

Updates #3203
2026-04-29 13:35:03 +00:00
Kristoffer Dalby c1ecc562d2 state: preserve previous primary when all HA advertisers unhealthy
electPrimaryRoutes' all-unhealthy fallback used to pick
candidates[0] (lowest NodeID) regardless of who was prev primary.
Under cable-pull semantics IsOnline lags reality (long-poll TCP
half-open), so both routers stay in candidates and both go
Unhealthy via the prober — the fallback then churned primary to
a node that was itself unreachable.

Prefer prev when still in candidates; fall through to
candidates[0] only when prev is gone. Anti-blackhole property is
preserved (peers still see *some* primary), but no flap to a
known-bad node.

Update the property test's reference model to mirror the new
algorithm and split the unit test into existence
(KeepsAPrimary) and identity (PreservesPrevious) cases — the
old "lowest-ID wins" assertion encoded the bug.

Fixes #3203
2026-04-29 13:34:51 +00:00
Kristoffer Dalby 06ac9bdf77 integration: expand HA docker disconnect to lifecycle test
Cover 5 phases of cable-pull lifecycle: initial primary, single
failure+recovery either side, sequential dual failure (the bug),
simultaneous dual failure. Phases 4b and 5a are the no-flap
regression barriers; the rest guard against future algorithm
changes regressing one lifecycle while fixing another.

Currently fails at phase 4b on this branch — primary flaps to
offline r1 within ~10s of r2 cable pull.

Updates #3203
2026-04-29 12:54:02 +00:00
Kristoffer Dalby 4ab4fbd4b9 integration: regenerate workflow for HA docker disconnect test
Updates #3203
2026-04-29 12:27:40 +00:00
Kristoffer Dalby 5228818a12 integration: reproduce HA primary flap to offline node via docker disconnect
Add a docker-network-disconnect primitive — `DisconnectFromNetwork`
/ `ReconnectToNetwork` on `TailscaleClient`, backed by
`pool.Client.DisconnectNetwork` — so an integration test can
faithfully simulate the cable-pull scenario the issue reporter
observed in Proxmox. Iptables-based simulations leave the socket
open at the container kernel and miss the bug.

Use it in `TestHASubnetRouterFailoverDockerDisconnect` to assert
the no-flap invariant: once primary has failed over from r1 to r2
and r2 also loses connectivity, primary must not switch back to
the still-offline r1. The assertion fails on the current branch —
the all-unhealthy fallback in `electPrimaryRoutes` flips primary
to the lowest-NodeID candidate regardless of online state. Locking
the failure in via TDD before designing the fix.

Updates #3203
2026-04-29 12:25:40 +00:00
Kristoffer Dalby 2d04fff605 state: clear Unhealthy when node leaves HA candidate set
The legacy routes.PrimaryRoutes.SetRoutes(node, empty) implicitly
deleted the node from the unhealthy set; the snapshot refactor lost
that side-effect, so a probe timeout that landed during a
SetApprovedRoutes(empty) (or just before a Disconnect) left
Unhealthy=true forever. The bit then surfaced in DebugRoutes and
broke TestHASubnetRouterFailover.

Restore the invariant at the write boundaries that drop HA
candidacy (Disconnect, SetApprovedRoutes, UpdateNodeFromMapRequest)
and add a defensive guard in SetNodeUnhealthy so the prober's
post-timeout write cannot install a stale bit when the node has
since left the candidate set.

Updates #3203
2026-04-29 08:02:45 +00:00
Kristoffer Dalby 436aa298b7 state: port primary route tests, delete routes package
The primary route algorithm now lives in hscontrol/state/node_store.go
(snapshotFromNodes / computePrimaries). Move the test coverage that
used to live in hscontrol/routes alongside it: primaries_test.go ports
the named scenarios (anti-flap, HA failover, recovery-no-flap, exit
routes excluded, the issue #3203 both-offline regression) and HANodes,
primaries_property_test.go ports the rapid-driven invariant check.

Both tests drive the algorithm via NodeStore.UpdateNode rather than
the deleted PrimaryRoutes API. With every consumer migrated and the
algorithm relocated, the routes package has no remaining users and
gets removed.

Updates #3203
2026-04-29 08:02:45 +00:00
Kristoffer Dalby 5dd622a31b state: fold primary route election into NodeStore snapshot
primaryRoutes lived as a separate write-then-read store guarded by an
external mutex. Each Connect, Disconnect, SetApprovedRoutes, and HA
prober write touched both NodeStore and primaryRoutes in sequence,
opening a TOCTOU window in which a stale Disconnect could overwrite a
fresh Connect's route assignment (issue #3203).

The election algorithm is a pure function of the snapshot: every node
contributes its IsOnline + AllApprovedRoutes + a runtime Unhealthy
bit. Move the computation into snapshotFromNodes so it runs in the
NodeStore writer goroutine and recomputes from immutable input on
every batch — no separate primaryRoutes mutation is required.

Caller-side changes are local. GetNodePrimaryRoutes, RoutesForPeer,
the HA prober and the debug endpoint all read PrimaryRoutesForNode /
HANodes / DebugRoutes off NodeStore. Health writes flow through
State.SetNodeUnhealthy, which captures the snapshot's primaries
before/after and signals back whether failover happened. The
SetApprovedRoutes and UpdateNodeFromMapRequest paths use the same
prevPrimaries / maps.Equal pattern to detect when announced/approved
changes shifted a primary.

The per-node lock and connectGen counter that closed the original
TOCTOU window are gone too: Connect bumps a SessionEpoch field on
the node inside its UpdateNode closure, Disconnect re-checks it in
its closure and no-ops on mismatch. The NodeStore writer goroutine
serialises both batches so the check + mutation are atomic by
construction.

Updates #3203
2026-04-29 08:02:45 +00:00
Kristoffer Dalby c68e763e62 types: move DebugRoutes from routes to types
Routes package gets folded into NodeStore in a follow-up; the debug
endpoint payload moves to a neutral home so integration consumers
do not need to import the soon-removed routes package.

Updates #3203
2026-04-29 08:02:45 +00:00
Kristoffer Dalby 3feb307a12 state: compute primary routes inside NodeStore snapshot
Add primaries + isPrimary fields to Snapshot. snapshotFromNodes
now elects per-prefix primary advertisers from the current node
set, mirroring routes.PrimaryRoutes.updatePrimaryLocked: skip
exit routes, prefer the previous primary if still a valid
healthy advertiser, fall back to the lowest healthy NodeID, then
to the lowest NodeID overall when all are unhealthy.

Anti-flap memory rides the previous snapshot through applyBatch.
Caller-side coordination is unnecessary; the writer goroutine
serialises every mutation that could change the primary set.

Add NodeStore reader methods (PrimaryFor,
PrimaryRoutesForNode, HANodes, IsNodeHealthy) that mirror the
legacy routes.PrimaryRoutes API on the snapshot. Consumers will
switch in the next commit.

No behaviour change yet — primaries are computed but not read.

Updates #3203
2026-04-29 08:02:45 +00:00
Kristoffer Dalby 6baee3e6c3 types: add Unhealthy and SessionEpoch fields to Node
Runtime-only fields (gorm:"-") that the upcoming refactor folds
into NodeStore's snapshot:

  - Unhealthy: replaces routes.PrimaryRoutes.unhealthy set; written
    by HA prober.
  - SessionEpoch: replaces the external connectGen sync.Map; bumped
    inside Connect's NodeStore update closure so stale Disconnect
    rejection becomes atomic with the mutation.

No behaviour change yet — fields are unread.

Updates #3203
2026-04-29 08:02:45 +00:00
Kristoffer Dalby 4320aa1348 state: serialise Connect/Disconnect with per-node lock
The generation check added in b09af384 closed the most obvious
window for stale Disconnect calls but left a residual TOCTOU:
the read of the gen counter was not atomic with the subsequent
NodeStore.UpdateNode and primaryRoutes.SetRoutes mutations. A
concurrent Connect that bumped the gen and rewrote primary
routes could be silently overtaken by a stale Disconnect's
SetRoutes(empty), leaving the node online in NodeStore but with
no primary route — the persistent broken state described in
the issue.

Replace the bare sync.Map[NodeID]*atomic.Uint64 with a
nodeLock pairing the counter with a sync.Mutex. Connect bumps
gen and runs its mutations under the lock; Disconnect acquires
the lock first, re-checks gen, and only proceeds on a match.

Per-node, so unrelated nodes do not contend; same-node steady
state has near-zero contention (one Connect at session start,
one Disconnect ~10s after grace period). Holding the lock
across persistNodeToDB is safe — that path goes through
nodeStore.RebuildPeerMaps which queues onto the writer
goroutine but is invoked from the caller, so no re-entrance.

TestConnectDisconnectRace drives the race directly via State,
deterministically reproducing the empty-primary state on main
and passing under the lock.

Fixes #3203
2026-04-29 08:02:45 +00:00
Kristoffer Dalby 7b080e8cfa servertest, integration: regression tests for HA both-offline recovery
Cover the user-reported sequence in #3203: two subnet routers
advertise the same prefix, both go offline, one returns. Three
variants:

  - servertest TestRoutes/ha_secondary_recovers_after_all_offline:
    in-process Disconnect/Reconnect.
  - integration TestHASubnetRouterFailoverBothOffline: tailscale
    down/up between two routers, asserts both server and client
    state restore.
  - integration TestHASubnetRouterFailoverBothOfflineCablePull:
    iptables -j DROP between router and headscale, mimicking a
    real cable pull that breaks the ESTABLISHED long-poll.

All three pass on main — the production failure narrows to a
direct State.Connect/State.Disconnect race that the cable-pull
harness does not reliably hit, addressed in a follow-up commit.
The tests stay as regression coverage for the user's scenario.

Updates #3203
2026-04-29 08:02:45 +00:00
Kristoffer Dalby 3eefe3c2cc routes: add property test for PrimaryRoutes algorithm
Generate randomised sequences of ConnectAdvertise, Disconnect,
ProbeUnhealthy, ProbeHealthy and ApprovedRoutesChange operations
with rapid and check the per-prefix primary chosen by the
implementation matches a reference model after every step.

Used to broaden the search for #3203 beyond hand-enumerated
disconnect/reconnect sequences. Algorithm passes 5000 checks;
kept as a safety net for future changes to primary route
selection.

Updates #3203
2026-04-29 08:02:45 +00:00
158 changed files with 5663 additions and 518360 deletions
@@ -68,7 +68,6 @@ func findTests() []string {
args := []string{
"--type", "go",
"--regexp", "func (Test.+)\\(.*",
"--max-depth", "1",
"../../integration/",
"--replace", "$1",
"--sort", "path",
-2
View File
@@ -239,7 +239,6 @@ jobs:
- TestHASubnetRouterFailover
- TestSubnetRouteACL
- TestEnablingExitRoutes
- TestExitRoutesWithAutogroupInternetACL
- TestSubnetRouterMultiNetwork
- TestSubnetRouterMultiNetworkExitNode
- TestAutoApproveMultiNetwork/authkey-tag.*
@@ -301,7 +300,6 @@ jobs:
- TestTagsAuthKeyWithoutUserInheritsTags
- TestTagsAuthKeyWithoutUserRejectsAdvertisedTags
- TestTagsAuthKeyConvertToUserViaCLIRegister
- TestTailscaleRustAxum
uses: ./.github/workflows/integration-test-template.yml
secrets: inherit
with:
-1
View File
@@ -13,7 +13,6 @@ repos:
rev: v6.0.0
hooks:
- id: check-added-large-files
args: [--maxkb=1024]
- id: check-case-conflict
- id: check-executables-have-shebangs
- id: check-json
+1 -1
View File
@@ -198,7 +198,7 @@ databases. The `migrationsRequiringFKDisabled` map in
Headscale enforces **tags XOR user ownership**: every node is either
tagged (owned by tags) or user-owned (owned by a user namespace), never
both. This is a load-bearing architectural rule.
both. This is a load-bearing architectural invariant.
- **Use `node.IsTagged()`** (`hscontrol/types/node.go:221`) to determine
ownership, not `node.UserID().Valid()`. A tagged node may still have
-22
View File
@@ -27,22 +27,6 @@ A new `headscale auth` CLI command group supports the approval flow:
[#1850](https://github.com/juanfont/headscale/pull/1850)
[#3180](https://github.com/juanfont/headscale/pull/3180)
### Policy tests (beta)
Headscale now evaluates the `tests` block in a policy file. Tests assert reachability between
named sources and destinations and cover the whole policy — both `acls` and `grants` rules
contribute. They run on user-initiated writes via `headscale policy set`, the file watcher, and
`headscale policy check`. A failing test rejects the write before it is applied, with the same
error message Tailscale SaaS would return for the same policy.
Tests do not run at boot. An already-stored policy that no longer passes — for example because a
referenced user was deleted while the server was offline — logs a warning and the server keeps
running.
This feature is **beta** while behavioural coverage against Tailscale SaaS broadens.
[#3229](https://github.com/juanfont/headscale/pull/3229)
### Grants
We now support [Tailscale grants](https://tailscale.com/docs/features/access-control/grants)
@@ -149,8 +133,6 @@ connected" routers that maintain their control session but cannot route packets.
- Fix non-wildcard source IPs being dropped when combined with wildcard `*` in the same ACL rule [#2180](https://github.com/juanfont/headscale/pull/2180)
- Fix exit node approval not triggering filter rule recalculation for peers [#2180](https://github.com/juanfont/headscale/pull/2180)
- Policy validation error messages now include field context (e.g., `src=`, `dst=`) and are more descriptive [#2180](https://github.com/juanfont/headscale/pull/2180)
- Reject policies whose `user@` tokens match multiple DB users; rename the duplicate via `headscale users rename` to load [#3160](https://github.com/juanfont/headscale/issues/3160)
- Evaluate the policy `tests` block on user-initiated writes across both `acls` and `grants`; reject policies whose tests fail (beta) [#1803](https://github.com/juanfont/headscale/issues/1803)
#### Grants
@@ -169,11 +151,9 @@ connected" routers that maintain their control session but cannot route packets.
- Add `headscale auth register`, `headscale auth approve`, and `headscale auth reject` CLI commands [#1850](https://github.com/juanfont/headscale/pull/1850)
- Deprecate `headscale nodes register --key` in favour of `headscale auth register --auth-id` [#1850](https://github.com/juanfont/headscale/pull/1850)
- `headscale policy check --bypass-grpc-and-access-database-directly` validates `user@` tokens against the live user database [#3160](https://github.com/juanfont/headscale/issues/3160)
- Remove deprecated `--namespace` flag from `nodes list`, `nodes register`, and `debug create-node` commands (use `--user` instead) [#3093](https://github.com/juanfont/headscale/pull/3093)
- Remove deprecated `namespace`/`ns` command aliases for `users` and `machine`/`machines` aliases for `nodes` [#3093](https://github.com/juanfont/headscale/pull/3093)
- **User deletion**: Fix `DestroyUser` deleting all pre-auth keys in the database instead of only the target user's keys [#3155](https://github.com/juanfont/headscale/pull/3155)
- `headscale policy check` evaluates the `tests` block when invoked with `--bypass-grpc-and-access-database-directly`; without the flag it warns instead of running the tests against empty data [#1803](https://github.com/juanfont/headscale/issues/1803)
#### API
@@ -203,8 +183,6 @@ connected" routers that maintain their control session but cannot route packets.
- Remove old migrations for the debian package [#3185](https://github.com/juanfont/headscale/pull/3185)
- Install `config-example.yaml` as example for the debian package [#3186](https://github.com/juanfont/headscale/pull/3186)
- **Node Expiry**: Fix user owned re registration with zero client expiry and no default storing `0001-01-01 00:00:00` in the database instead of NULL [#3199](https://github.com/juanfont/headscale/pull/3199)
- Pre-existing rows with `0001-01-01 00:00:00` are not backfilled; they clear themselves the next time the node re-registers
## 0.28.0 (2026-02-04)
+1 -1
View File
@@ -1,6 +1,6 @@
# For testing purposes only
FROM golang:1.26.3-alpine AS build-env
FROM golang:1.26.2-alpine AS build-env
WORKDIR /go/src
+1 -1
View File
@@ -4,7 +4,7 @@
# This Dockerfile is more or less lifted from tailscale/tailscale
# to ensure a similar build process when testing the HEAD of tailscale.
FROM golang:1.26.3-alpine AS build-env
FROM golang:1.26.2-alpine AS build-env
WORKDIR /go/src
-26
View File
@@ -1,26 +0,0 @@
FROM rust:1.94-bookworm AS builder
ARG TAILSCALE_RS_REPO=https://github.com/tailscale/tailscale-rs.git
ARG TAILSCALE_RS_REF=main
WORKDIR /app
RUN git clone --depth 1 --branch "$TAILSCALE_RS_REF" "$TAILSCALE_RS_REPO" .
# Re-export ts_control's insecure-keyfetch feature through the tailscale
# crate so the axum example can fetch the headscale control key over
# plain HTTP. The integration harness serves the control plane without
# TLS, and upstream only allows plain-HTTP key fetches when this Cargo
# feature is compiled in.
RUN sed -i '/^axum = \["dep:axum"\]/a insecure-keyfetch = ["ts_control/insecure-keyfetch"]' Cargo.toml
RUN cargo build --release --features axum,insecure-keyfetch --example axum
FROM debian:bookworm-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
iproute2 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/examples/axum /usr/local/bin/axum
+2 -55
View File
@@ -48,7 +48,6 @@ func init() {
policyCmd.AddCommand(setPolicy)
checkPolicy.Flags().StringP("file", "f", "", "Path to a policy file in HuJSON format")
checkPolicy.Flags().BoolP(bypassFlag, "", false, "Open the database directly (no gRPC, no running server) to validate user@ token references and to evaluate the policy's tests block. Required when those checks are needed.")
mustMarkRequired(checkPolicy, "file")
policyCmd.AddCommand(checkPolicy)
}
@@ -171,11 +170,6 @@ var setPolicy = &cobra.Command{
var checkPolicy = &cobra.Command{
Use: "check",
Short: "Check the Policy file for errors",
Long: `
Check validates the policy against the server's live users and nodes,
running any "tests" block. By default the command is a thin frontend
for a gRPC call to a running headscale; pass --` + bypassFlag + ` to
open the database directly when headscale is not running.`,
RunE: func(cmd *cobra.Command, args []string) error {
policyPath, _ := cmd.Flags().GetString("file")
@@ -184,56 +178,9 @@ var checkPolicy = &cobra.Command{
return fmt.Errorf("reading policy file: %w", err)
}
if bypass, _ := cmd.Flags().GetBool(bypassFlag); bypass {
if !confirmAction(cmd, "DO NOT run this command if an instance of headscale is running, are you sure headscale is not running?") {
return errAborted
}
d, err := bypassDatabase()
if err != nil {
return err
}
defer d.Close()
users, err := d.ListUsers()
if err != nil {
return fmt.Errorf("loading users: %w", err)
}
nodes, err := d.ListNodes()
if err != nil {
return fmt.Errorf("loading nodes: %w", err)
}
// NewPolicyManager validates structure and user references
// but intentionally skips test evaluation (boot path).
// SetPolicy is the user-write boundary and is what runs the
// tests block.
pm, err := policy.NewPolicyManager(policyBytes, users, nodes.ViewSlice())
if err != nil {
return fmt.Errorf("parsing policy file: %w", err)
}
_, err = pm.SetPolicy(policyBytes)
if err != nil {
return err
}
fmt.Println("Policy is valid")
return nil
}
ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig()
_, err = policy.NewPolicyManager(policyBytes, nil, views.Slice[types.NodeView]{})
if err != nil {
return fmt.Errorf("connecting to headscale: %w", err)
}
defer cancel()
defer conn.Close()
_, err = client.CheckPolicy(ctx, &v1.CheckPolicyRequest{Policy: string(policyBytes)})
if err != nil {
return err
return fmt.Errorf("parsing policy file: %w", err)
}
fmt.Println("Policy is valid")
+6
View File
@@ -3,6 +3,7 @@ package cli
import (
"os"
"runtime"
"slices"
"strings"
"github.com/juanfont/headscale/hscontrol/types"
@@ -21,6 +22,11 @@ func init() {
return
}
if slices.Contains(os.Args, "policy") && slices.Contains(os.Args, "check") {
zerolog.SetGlobalLevel(zerolog.Disabled)
return
}
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().
StringVarP(&cfgFile, "config", "c", "", "config file (default is /etc/headscale/config.yaml)")
+1 -1
View File
@@ -20,7 +20,7 @@ listen_addr: 127.0.0.1:8080
# Address to listen to /metrics and /debug, you may want
# to keep this endpoint private to your internal network
# Use an empty value to disable the metrics listener.
# Use an emty value to disable the metrics listener.
metrics_listen_addr: 127.0.0.1:9090
# Address to listen for gRPC.
-1
View File
@@ -22,6 +22,5 @@ Headscale doesn't provide a built-in web interface but users may pick one from t
- [headscale-piying](https://github.com/wszgrcy/headscale-piying) - headscale web ui,support visual ACL configuration
- [HeadControl](https://github.com/ahmadzip/HeadControl) - Minimal Headscale admin dashboard, built with Go and HTMX
- [Headscale Manager](https://github.com/hkdone/headscalemanager) - Headscale UI for Android
- [Headscale UI](https://github.com/MunMunMiao/headscale-ui) - Headscale UI online and Self-hosting
You can ask for support on our [Discord server](https://discord.gg/c84AZQhmpx) in the "web-interfaces" channel.
+1 -1
View File
@@ -17,7 +17,7 @@ The ports in use vary with the intended scenario and enabled features. Some of t
- tcp/80
- Expose publicly: yes
- HTTP, used by Let's Encrypt to verify ownership via the HTTP-01 challenge.
- Only required if the built-in Let's Encrypt client with the HTTP-01 challenge is used. See [TLS](../ref/tls.md) for
- Only required if the built-in Let's Enrypt client with the HTTP-01 challenge is used. See [TLS](../ref/tls.md) for
details.
- tcp/443
- Expose publicly: yes
+60 -65
View File
@@ -109,7 +109,7 @@ const file_headscale_v1_headscale_proto_rawDesc = "" +
"\x1cheadscale/v1/headscale.proto\x12\fheadscale.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17headscale/v1/user.proto\x1a\x1dheadscale/v1/preauthkey.proto\x1a\x17headscale/v1/node.proto\x1a\x19headscale/v1/apikey.proto\x1a\x17headscale/v1/auth.proto\x1a\x19headscale/v1/policy.proto\"\x0f\n" +
"\rHealthRequest\"E\n" +
"\x0eHealthResponse\x123\n" +
"\x15database_connectivity\x18\x01 \x01(\bR\x14databaseConnectivity2\xe0\x1a\n" +
"\x15database_connectivity\x18\x01 \x01(\bR\x14databaseConnectivity2\xeb\x19\n" +
"\x10HeadscaleService\x12h\n" +
"\n" +
"CreateUser\x12\x1f.headscale.v1.CreateUserRequest\x1a .headscale.v1.CreateUserResponse\"\x17\x82\xd3\xe4\x93\x02\x11:\x01*\"\f/api/v1/user\x12\x80\x01\n" +
@@ -144,8 +144,7 @@ const file_headscale_v1_headscale_proto_rawDesc = "" +
"\vListApiKeys\x12 .headscale.v1.ListApiKeysRequest\x1a!.headscale.v1.ListApiKeysResponse\"\x16\x82\xd3\xe4\x93\x02\x10\x12\x0e/api/v1/apikey\x12v\n" +
"\fDeleteApiKey\x12!.headscale.v1.DeleteApiKeyRequest\x1a\".headscale.v1.DeleteApiKeyResponse\"\x1f\x82\xd3\xe4\x93\x02\x19*\x17/api/v1/apikey/{prefix}\x12d\n" +
"\tGetPolicy\x12\x1e.headscale.v1.GetPolicyRequest\x1a\x1f.headscale.v1.GetPolicyResponse\"\x16\x82\xd3\xe4\x93\x02\x10\x12\x0e/api/v1/policy\x12g\n" +
"\tSetPolicy\x12\x1e.headscale.v1.SetPolicyRequest\x1a\x1f.headscale.v1.SetPolicyResponse\"\x19\x82\xd3\xe4\x93\x02\x13:\x01*\x1a\x0e/api/v1/policy\x12s\n" +
"\vCheckPolicy\x12 .headscale.v1.CheckPolicyRequest\x1a!.headscale.v1.CheckPolicyResponse\"\x1f\x82\xd3\xe4\x93\x02\x19:\x01*\"\x14/api/v1/policy/check\x12[\n" +
"\tSetPolicy\x12\x1e.headscale.v1.SetPolicyRequest\x1a\x1f.headscale.v1.SetPolicyResponse\"\x19\x82\xd3\xe4\x93\x02\x13:\x01*\x1a\x0e/api/v1/policy\x12[\n" +
"\x06Health\x12\x1b.headscale.v1.HealthRequest\x1a\x1c.headscale.v1.HealthResponse\"\x16\x82\xd3\xe4\x93\x02\x10\x12\x0e/api/v1/healthB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3"
var (
@@ -191,35 +190,33 @@ var file_headscale_v1_headscale_proto_goTypes = []any{
(*DeleteApiKeyRequest)(nil), // 26: headscale.v1.DeleteApiKeyRequest
(*GetPolicyRequest)(nil), // 27: headscale.v1.GetPolicyRequest
(*SetPolicyRequest)(nil), // 28: headscale.v1.SetPolicyRequest
(*CheckPolicyRequest)(nil), // 29: headscale.v1.CheckPolicyRequest
(*CreateUserResponse)(nil), // 30: headscale.v1.CreateUserResponse
(*RenameUserResponse)(nil), // 31: headscale.v1.RenameUserResponse
(*DeleteUserResponse)(nil), // 32: headscale.v1.DeleteUserResponse
(*ListUsersResponse)(nil), // 33: headscale.v1.ListUsersResponse
(*CreatePreAuthKeyResponse)(nil), // 34: headscale.v1.CreatePreAuthKeyResponse
(*ExpirePreAuthKeyResponse)(nil), // 35: headscale.v1.ExpirePreAuthKeyResponse
(*DeletePreAuthKeyResponse)(nil), // 36: headscale.v1.DeletePreAuthKeyResponse
(*ListPreAuthKeysResponse)(nil), // 37: headscale.v1.ListPreAuthKeysResponse
(*DebugCreateNodeResponse)(nil), // 38: headscale.v1.DebugCreateNodeResponse
(*GetNodeResponse)(nil), // 39: headscale.v1.GetNodeResponse
(*SetTagsResponse)(nil), // 40: headscale.v1.SetTagsResponse
(*SetApprovedRoutesResponse)(nil), // 41: headscale.v1.SetApprovedRoutesResponse
(*RegisterNodeResponse)(nil), // 42: headscale.v1.RegisterNodeResponse
(*DeleteNodeResponse)(nil), // 43: headscale.v1.DeleteNodeResponse
(*ExpireNodeResponse)(nil), // 44: headscale.v1.ExpireNodeResponse
(*RenameNodeResponse)(nil), // 45: headscale.v1.RenameNodeResponse
(*ListNodesResponse)(nil), // 46: headscale.v1.ListNodesResponse
(*BackfillNodeIPsResponse)(nil), // 47: headscale.v1.BackfillNodeIPsResponse
(*AuthRegisterResponse)(nil), // 48: headscale.v1.AuthRegisterResponse
(*AuthApproveResponse)(nil), // 49: headscale.v1.AuthApproveResponse
(*AuthRejectResponse)(nil), // 50: headscale.v1.AuthRejectResponse
(*CreateApiKeyResponse)(nil), // 51: headscale.v1.CreateApiKeyResponse
(*ExpireApiKeyResponse)(nil), // 52: headscale.v1.ExpireApiKeyResponse
(*ListApiKeysResponse)(nil), // 53: headscale.v1.ListApiKeysResponse
(*DeleteApiKeyResponse)(nil), // 54: headscale.v1.DeleteApiKeyResponse
(*GetPolicyResponse)(nil), // 55: headscale.v1.GetPolicyResponse
(*SetPolicyResponse)(nil), // 56: headscale.v1.SetPolicyResponse
(*CheckPolicyResponse)(nil), // 57: headscale.v1.CheckPolicyResponse
(*CreateUserResponse)(nil), // 29: headscale.v1.CreateUserResponse
(*RenameUserResponse)(nil), // 30: headscale.v1.RenameUserResponse
(*DeleteUserResponse)(nil), // 31: headscale.v1.DeleteUserResponse
(*ListUsersResponse)(nil), // 32: headscale.v1.ListUsersResponse
(*CreatePreAuthKeyResponse)(nil), // 33: headscale.v1.CreatePreAuthKeyResponse
(*ExpirePreAuthKeyResponse)(nil), // 34: headscale.v1.ExpirePreAuthKeyResponse
(*DeletePreAuthKeyResponse)(nil), // 35: headscale.v1.DeletePreAuthKeyResponse
(*ListPreAuthKeysResponse)(nil), // 36: headscale.v1.ListPreAuthKeysResponse
(*DebugCreateNodeResponse)(nil), // 37: headscale.v1.DebugCreateNodeResponse
(*GetNodeResponse)(nil), // 38: headscale.v1.GetNodeResponse
(*SetTagsResponse)(nil), // 39: headscale.v1.SetTagsResponse
(*SetApprovedRoutesResponse)(nil), // 40: headscale.v1.SetApprovedRoutesResponse
(*RegisterNodeResponse)(nil), // 41: headscale.v1.RegisterNodeResponse
(*DeleteNodeResponse)(nil), // 42: headscale.v1.DeleteNodeResponse
(*ExpireNodeResponse)(nil), // 43: headscale.v1.ExpireNodeResponse
(*RenameNodeResponse)(nil), // 44: headscale.v1.RenameNodeResponse
(*ListNodesResponse)(nil), // 45: headscale.v1.ListNodesResponse
(*BackfillNodeIPsResponse)(nil), // 46: headscale.v1.BackfillNodeIPsResponse
(*AuthRegisterResponse)(nil), // 47: headscale.v1.AuthRegisterResponse
(*AuthApproveResponse)(nil), // 48: headscale.v1.AuthApproveResponse
(*AuthRejectResponse)(nil), // 49: headscale.v1.AuthRejectResponse
(*CreateApiKeyResponse)(nil), // 50: headscale.v1.CreateApiKeyResponse
(*ExpireApiKeyResponse)(nil), // 51: headscale.v1.ExpireApiKeyResponse
(*ListApiKeysResponse)(nil), // 52: headscale.v1.ListApiKeysResponse
(*DeleteApiKeyResponse)(nil), // 53: headscale.v1.DeleteApiKeyResponse
(*GetPolicyResponse)(nil), // 54: headscale.v1.GetPolicyResponse
(*SetPolicyResponse)(nil), // 55: headscale.v1.SetPolicyResponse
}
var file_headscale_v1_headscale_proto_depIdxs = []int32{
2, // 0: headscale.v1.HeadscaleService.CreateUser:input_type -> headscale.v1.CreateUserRequest
@@ -249,39 +246,37 @@ var file_headscale_v1_headscale_proto_depIdxs = []int32{
26, // 24: headscale.v1.HeadscaleService.DeleteApiKey:input_type -> headscale.v1.DeleteApiKeyRequest
27, // 25: headscale.v1.HeadscaleService.GetPolicy:input_type -> headscale.v1.GetPolicyRequest
28, // 26: headscale.v1.HeadscaleService.SetPolicy:input_type -> headscale.v1.SetPolicyRequest
29, // 27: headscale.v1.HeadscaleService.CheckPolicy:input_type -> headscale.v1.CheckPolicyRequest
0, // 28: headscale.v1.HeadscaleService.Health:input_type -> headscale.v1.HealthRequest
30, // 29: headscale.v1.HeadscaleService.CreateUser:output_type -> headscale.v1.CreateUserResponse
31, // 30: headscale.v1.HeadscaleService.RenameUser:output_type -> headscale.v1.RenameUserResponse
32, // 31: headscale.v1.HeadscaleService.DeleteUser:output_type -> headscale.v1.DeleteUserResponse
33, // 32: headscale.v1.HeadscaleService.ListUsers:output_type -> headscale.v1.ListUsersResponse
34, // 33: headscale.v1.HeadscaleService.CreatePreAuthKey:output_type -> headscale.v1.CreatePreAuthKeyResponse
35, // 34: headscale.v1.HeadscaleService.ExpirePreAuthKey:output_type -> headscale.v1.ExpirePreAuthKeyResponse
36, // 35: headscale.v1.HeadscaleService.DeletePreAuthKey:output_type -> headscale.v1.DeletePreAuthKeyResponse
37, // 36: headscale.v1.HeadscaleService.ListPreAuthKeys:output_type -> headscale.v1.ListPreAuthKeysResponse
38, // 37: headscale.v1.HeadscaleService.DebugCreateNode:output_type -> headscale.v1.DebugCreateNodeResponse
39, // 38: headscale.v1.HeadscaleService.GetNode:output_type -> headscale.v1.GetNodeResponse
40, // 39: headscale.v1.HeadscaleService.SetTags:output_type -> headscale.v1.SetTagsResponse
41, // 40: headscale.v1.HeadscaleService.SetApprovedRoutes:output_type -> headscale.v1.SetApprovedRoutesResponse
42, // 41: headscale.v1.HeadscaleService.RegisterNode:output_type -> headscale.v1.RegisterNodeResponse
43, // 42: headscale.v1.HeadscaleService.DeleteNode:output_type -> headscale.v1.DeleteNodeResponse
44, // 43: headscale.v1.HeadscaleService.ExpireNode:output_type -> headscale.v1.ExpireNodeResponse
45, // 44: headscale.v1.HeadscaleService.RenameNode:output_type -> headscale.v1.RenameNodeResponse
46, // 45: headscale.v1.HeadscaleService.ListNodes:output_type -> headscale.v1.ListNodesResponse
47, // 46: headscale.v1.HeadscaleService.BackfillNodeIPs:output_type -> headscale.v1.BackfillNodeIPsResponse
48, // 47: headscale.v1.HeadscaleService.AuthRegister:output_type -> headscale.v1.AuthRegisterResponse
49, // 48: headscale.v1.HeadscaleService.AuthApprove:output_type -> headscale.v1.AuthApproveResponse
50, // 49: headscale.v1.HeadscaleService.AuthReject:output_type -> headscale.v1.AuthRejectResponse
51, // 50: headscale.v1.HeadscaleService.CreateApiKey:output_type -> headscale.v1.CreateApiKeyResponse
52, // 51: headscale.v1.HeadscaleService.ExpireApiKey:output_type -> headscale.v1.ExpireApiKeyResponse
53, // 52: headscale.v1.HeadscaleService.ListApiKeys:output_type -> headscale.v1.ListApiKeysResponse
54, // 53: headscale.v1.HeadscaleService.DeleteApiKey:output_type -> headscale.v1.DeleteApiKeyResponse
55, // 54: headscale.v1.HeadscaleService.GetPolicy:output_type -> headscale.v1.GetPolicyResponse
56, // 55: headscale.v1.HeadscaleService.SetPolicy:output_type -> headscale.v1.SetPolicyResponse
57, // 56: headscale.v1.HeadscaleService.CheckPolicy:output_type -> headscale.v1.CheckPolicyResponse
1, // 57: headscale.v1.HeadscaleService.Health:output_type -> headscale.v1.HealthResponse
29, // [29:58] is the sub-list for method output_type
0, // [0:29] is the sub-list for method input_type
0, // 27: headscale.v1.HeadscaleService.Health:input_type -> headscale.v1.HealthRequest
29, // 28: headscale.v1.HeadscaleService.CreateUser:output_type -> headscale.v1.CreateUserResponse
30, // 29: headscale.v1.HeadscaleService.RenameUser:output_type -> headscale.v1.RenameUserResponse
31, // 30: headscale.v1.HeadscaleService.DeleteUser:output_type -> headscale.v1.DeleteUserResponse
32, // 31: headscale.v1.HeadscaleService.ListUsers:output_type -> headscale.v1.ListUsersResponse
33, // 32: headscale.v1.HeadscaleService.CreatePreAuthKey:output_type -> headscale.v1.CreatePreAuthKeyResponse
34, // 33: headscale.v1.HeadscaleService.ExpirePreAuthKey:output_type -> headscale.v1.ExpirePreAuthKeyResponse
35, // 34: headscale.v1.HeadscaleService.DeletePreAuthKey:output_type -> headscale.v1.DeletePreAuthKeyResponse
36, // 35: headscale.v1.HeadscaleService.ListPreAuthKeys:output_type -> headscale.v1.ListPreAuthKeysResponse
37, // 36: headscale.v1.HeadscaleService.DebugCreateNode:output_type -> headscale.v1.DebugCreateNodeResponse
38, // 37: headscale.v1.HeadscaleService.GetNode:output_type -> headscale.v1.GetNodeResponse
39, // 38: headscale.v1.HeadscaleService.SetTags:output_type -> headscale.v1.SetTagsResponse
40, // 39: headscale.v1.HeadscaleService.SetApprovedRoutes:output_type -> headscale.v1.SetApprovedRoutesResponse
41, // 40: headscale.v1.HeadscaleService.RegisterNode:output_type -> headscale.v1.RegisterNodeResponse
42, // 41: headscale.v1.HeadscaleService.DeleteNode:output_type -> headscale.v1.DeleteNodeResponse
43, // 42: headscale.v1.HeadscaleService.ExpireNode:output_type -> headscale.v1.ExpireNodeResponse
44, // 43: headscale.v1.HeadscaleService.RenameNode:output_type -> headscale.v1.RenameNodeResponse
45, // 44: headscale.v1.HeadscaleService.ListNodes:output_type -> headscale.v1.ListNodesResponse
46, // 45: headscale.v1.HeadscaleService.BackfillNodeIPs:output_type -> headscale.v1.BackfillNodeIPsResponse
47, // 46: headscale.v1.HeadscaleService.AuthRegister:output_type -> headscale.v1.AuthRegisterResponse
48, // 47: headscale.v1.HeadscaleService.AuthApprove:output_type -> headscale.v1.AuthApproveResponse
49, // 48: headscale.v1.HeadscaleService.AuthReject:output_type -> headscale.v1.AuthRejectResponse
50, // 49: headscale.v1.HeadscaleService.CreateApiKey:output_type -> headscale.v1.CreateApiKeyResponse
51, // 50: headscale.v1.HeadscaleService.ExpireApiKey:output_type -> headscale.v1.ExpireApiKeyResponse
52, // 51: headscale.v1.HeadscaleService.ListApiKeys:output_type -> headscale.v1.ListApiKeysResponse
53, // 52: headscale.v1.HeadscaleService.DeleteApiKey:output_type -> headscale.v1.DeleteApiKeyResponse
54, // 53: headscale.v1.HeadscaleService.GetPolicy:output_type -> headscale.v1.GetPolicyResponse
55, // 54: headscale.v1.HeadscaleService.SetPolicy:output_type -> headscale.v1.SetPolicyResponse
1, // 55: headscale.v1.HeadscaleService.Health:output_type -> headscale.v1.HealthResponse
28, // [28:56] is the sub-list for method output_type
0, // [0:28] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
-66
View File
@@ -966,33 +966,6 @@ func local_request_HeadscaleService_SetPolicy_0(ctx context.Context, marshaler r
return msg, metadata, err
}
func request_HeadscaleService_CheckPolicy_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq CheckPolicyRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if req.Body != nil {
_, _ = io.Copy(io.Discard, req.Body)
}
msg, err := client.CheckPolicy(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_HeadscaleService_CheckPolicy_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq CheckPolicyRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.CheckPolicy(ctx, &protoReq)
return msg, metadata, err
}
func request_HeadscaleService_Health_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq HealthRequest
@@ -1560,26 +1533,6 @@ func RegisterHeadscaleServiceHandlerServer(ctx context.Context, mux *runtime.Ser
}
forward_HeadscaleService_SetPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_HeadscaleService_CheckPolicy_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/CheckPolicy", runtime.WithHTTPPathPattern("/api/v1/policy/check"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_HeadscaleService_CheckPolicy_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_HeadscaleService_CheckPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_HeadscaleService_Health_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@@ -2099,23 +2052,6 @@ func RegisterHeadscaleServiceHandlerClient(ctx context.Context, mux *runtime.Ser
}
forward_HeadscaleService_SetPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_HeadscaleService_CheckPolicy_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/CheckPolicy", runtime.WithHTTPPathPattern("/api/v1/policy/check"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_HeadscaleService_CheckPolicy_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_HeadscaleService_CheckPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_HeadscaleService_Health_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@@ -2164,7 +2100,6 @@ var (
pattern_HeadscaleService_DeleteApiKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"api", "v1", "apikey", "prefix"}, ""))
pattern_HeadscaleService_GetPolicy_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "policy"}, ""))
pattern_HeadscaleService_SetPolicy_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "policy"}, ""))
pattern_HeadscaleService_CheckPolicy_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "policy", "check"}, ""))
pattern_HeadscaleService_Health_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "health"}, ""))
)
@@ -2196,6 +2131,5 @@ var (
forward_HeadscaleService_DeleteApiKey_0 = runtime.ForwardResponseMessage
forward_HeadscaleService_GetPolicy_0 = runtime.ForwardResponseMessage
forward_HeadscaleService_SetPolicy_0 = runtime.ForwardResponseMessage
forward_HeadscaleService_CheckPolicy_0 = runtime.ForwardResponseMessage
forward_HeadscaleService_Health_0 = runtime.ForwardResponseMessage
)
-38
View File
@@ -46,7 +46,6 @@ const (
HeadscaleService_DeleteApiKey_FullMethodName = "/headscale.v1.HeadscaleService/DeleteApiKey"
HeadscaleService_GetPolicy_FullMethodName = "/headscale.v1.HeadscaleService/GetPolicy"
HeadscaleService_SetPolicy_FullMethodName = "/headscale.v1.HeadscaleService/SetPolicy"
HeadscaleService_CheckPolicy_FullMethodName = "/headscale.v1.HeadscaleService/CheckPolicy"
HeadscaleService_Health_FullMethodName = "/headscale.v1.HeadscaleService/Health"
)
@@ -87,7 +86,6 @@ type HeadscaleServiceClient interface {
// --- Policy start ---
GetPolicy(ctx context.Context, in *GetPolicyRequest, opts ...grpc.CallOption) (*GetPolicyResponse, error)
SetPolicy(ctx context.Context, in *SetPolicyRequest, opts ...grpc.CallOption) (*SetPolicyResponse, error)
CheckPolicy(ctx context.Context, in *CheckPolicyRequest, opts ...grpc.CallOption) (*CheckPolicyResponse, error)
// --- Health start ---
Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error)
}
@@ -370,16 +368,6 @@ func (c *headscaleServiceClient) SetPolicy(ctx context.Context, in *SetPolicyReq
return out, nil
}
func (c *headscaleServiceClient) CheckPolicy(ctx context.Context, in *CheckPolicyRequest, opts ...grpc.CallOption) (*CheckPolicyResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CheckPolicyResponse)
err := c.cc.Invoke(ctx, HeadscaleService_CheckPolicy_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *headscaleServiceClient) Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(HealthResponse)
@@ -427,7 +415,6 @@ type HeadscaleServiceServer interface {
// --- Policy start ---
GetPolicy(context.Context, *GetPolicyRequest) (*GetPolicyResponse, error)
SetPolicy(context.Context, *SetPolicyRequest) (*SetPolicyResponse, error)
CheckPolicy(context.Context, *CheckPolicyRequest) (*CheckPolicyResponse, error)
// --- Health start ---
Health(context.Context, *HealthRequest) (*HealthResponse, error)
mustEmbedUnimplementedHeadscaleServiceServer()
@@ -521,9 +508,6 @@ func (UnimplementedHeadscaleServiceServer) GetPolicy(context.Context, *GetPolicy
func (UnimplementedHeadscaleServiceServer) SetPolicy(context.Context, *SetPolicyRequest) (*SetPolicyResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetPolicy not implemented")
}
func (UnimplementedHeadscaleServiceServer) CheckPolicy(context.Context, *CheckPolicyRequest) (*CheckPolicyResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CheckPolicy not implemented")
}
func (UnimplementedHeadscaleServiceServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Health not implemented")
}
@@ -1034,24 +1018,6 @@ func _HeadscaleService_SetPolicy_Handler(srv interface{}, ctx context.Context, d
return interceptor(ctx, in, info, handler)
}
func _HeadscaleService_CheckPolicy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CheckPolicyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(HeadscaleServiceServer).CheckPolicy(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: HeadscaleService_CheckPolicy_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(HeadscaleServiceServer).CheckPolicy(ctx, req.(*CheckPolicyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _HeadscaleService_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(HealthRequest)
if err := dec(in); err != nil {
@@ -1185,10 +1151,6 @@ var HeadscaleService_ServiceDesc = grpc.ServiceDesc{
MethodName: "SetPolicy",
Handler: _HeadscaleService_SetPolicy_Handler,
},
{
MethodName: "CheckPolicy",
Handler: _HeadscaleService_CheckPolicy_Handler,
},
{
MethodName: "Health",
Handler: _HeadscaleService_Health_Handler,
+6 -91
View File
@@ -206,86 +206,6 @@ func (x *GetPolicyResponse) GetUpdatedAt() *timestamppb.Timestamp {
return nil
}
type CheckPolicyRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Policy string `protobuf:"bytes,1,opt,name=policy,proto3" json:"policy,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CheckPolicyRequest) Reset() {
*x = CheckPolicyRequest{}
mi := &file_headscale_v1_policy_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CheckPolicyRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CheckPolicyRequest) ProtoMessage() {}
func (x *CheckPolicyRequest) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_policy_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CheckPolicyRequest.ProtoReflect.Descriptor instead.
func (*CheckPolicyRequest) Descriptor() ([]byte, []int) {
return file_headscale_v1_policy_proto_rawDescGZIP(), []int{4}
}
func (x *CheckPolicyRequest) GetPolicy() string {
if x != nil {
return x.Policy
}
return ""
}
type CheckPolicyResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CheckPolicyResponse) Reset() {
*x = CheckPolicyResponse{}
mi := &file_headscale_v1_policy_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CheckPolicyResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CheckPolicyResponse) ProtoMessage() {}
func (x *CheckPolicyResponse) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_policy_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CheckPolicyResponse.ProtoReflect.Descriptor instead.
func (*CheckPolicyResponse) Descriptor() ([]byte, []int) {
return file_headscale_v1_policy_proto_rawDescGZIP(), []int{5}
}
var File_headscale_v1_policy_proto protoreflect.FileDescriptor
const file_headscale_v1_policy_proto_rawDesc = "" +
@@ -301,10 +221,7 @@ const file_headscale_v1_policy_proto_rawDesc = "" +
"\x11GetPolicyResponse\x12\x16\n" +
"\x06policy\x18\x01 \x01(\tR\x06policy\x129\n" +
"\n" +
"updated_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\",\n" +
"\x12CheckPolicyRequest\x12\x16\n" +
"\x06policy\x18\x01 \x01(\tR\x06policy\"\x15\n" +
"\x13CheckPolicyResponseB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3"
"updated_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAtB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3"
var (
file_headscale_v1_policy_proto_rawDescOnce sync.Once
@@ -318,19 +235,17 @@ func file_headscale_v1_policy_proto_rawDescGZIP() []byte {
return file_headscale_v1_policy_proto_rawDescData
}
var file_headscale_v1_policy_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
var file_headscale_v1_policy_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
var file_headscale_v1_policy_proto_goTypes = []any{
(*SetPolicyRequest)(nil), // 0: headscale.v1.SetPolicyRequest
(*SetPolicyResponse)(nil), // 1: headscale.v1.SetPolicyResponse
(*GetPolicyRequest)(nil), // 2: headscale.v1.GetPolicyRequest
(*GetPolicyResponse)(nil), // 3: headscale.v1.GetPolicyResponse
(*CheckPolicyRequest)(nil), // 4: headscale.v1.CheckPolicyRequest
(*CheckPolicyResponse)(nil), // 5: headscale.v1.CheckPolicyResponse
(*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp
(*timestamppb.Timestamp)(nil), // 4: google.protobuf.Timestamp
}
var file_headscale_v1_policy_proto_depIdxs = []int32{
6, // 0: headscale.v1.SetPolicyResponse.updated_at:type_name -> google.protobuf.Timestamp
6, // 1: headscale.v1.GetPolicyResponse.updated_at:type_name -> google.protobuf.Timestamp
4, // 0: headscale.v1.SetPolicyResponse.updated_at:type_name -> google.protobuf.Timestamp
4, // 1: headscale.v1.GetPolicyResponse.updated_at:type_name -> google.protobuf.Timestamp
2, // [2:2] is the sub-list for method output_type
2, // [2:2] is the sub-list for method input_type
2, // [2:2] is the sub-list for extension type_name
@@ -349,7 +264,7 @@ func file_headscale_v1_policy_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_policy_proto_rawDesc), len(file_headscale_v1_policy_proto_rawDesc)),
NumEnums: 0,
NumMessages: 6,
NumMessages: 4,
NumExtensions: 0,
NumServices: 0,
},
@@ -660,38 +660,6 @@
]
}
},
"/api/v1/policy/check": {
"post": {
"operationId": "HeadscaleService_CheckPolicy",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/v1CheckPolicyResponse"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/v1CheckPolicyRequest"
}
}
],
"tags": [
"HeadscaleService"
]
}
},
"/api/v1/preauthkey": {
"get": {
"operationId": "HeadscaleService_ListPreAuthKeys",
@@ -1076,17 +1044,6 @@
}
}
},
"v1CheckPolicyRequest": {
"type": "object",
"properties": {
"policy": {
"type": "string"
}
}
},
"v1CheckPolicyResponse": {
"type": "object"
},
"v1CreateApiKeyRequest": {
"type": "object",
"properties": {
-69
View File
@@ -1119,72 +1119,3 @@ func TestReregistrationAppliesDefaultExpiry(t *testing.T) {
assert.True(t, node2.Expiry().Get().After(firstExpiry),
"re-registration expiry should be later than initial registration expiry")
}
// TestReregistrationZeroExpiryStaysNil tests that when a user-owned node
// re-registers with zero client expiry and node.expiry is disabled (0),
// the node's expiry stays nil rather than being set to a pointer to zero
// time. Regression test for the else branch introduced in commit 6337a3db
// which assigned `&regReq.Expiry` (pointer to time.Time{}) instead of nil,
// causing the database row to hold `0001-01-01 00:00:00` instead of NULL.
//
// The same !regReq.Expiry.IsZero() gate at state.go:2221-2228 is shared by
// the tags-only PreAuthKey path (createAndSaveNewNode also receives nil
// when the client sends zero expiry), so this regression is covered for
// tagged nodes by inspection.
func TestReregistrationZeroExpiryStaysNil(t *testing.T) {
t.Parallel()
// node.expiry = 0 means "no default expiry"
app := createTestAppWithNodeExpiry(t, 0)
user := app.state.CreateUserForTest("node-owner")
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, nil)
require.NoError(t, err)
machineKey := key.NewMachine()
nodeKey := key.NewNode()
// Initial registration with zero client expiry
regReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{
AuthKey: pak.Key,
},
NodeKey: nodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: "reregister-zero-expiry",
},
Expiry: time.Time{},
}
resp, err := app.handleRegisterWithAuthKey(regReq, machineKey.Public())
require.NoError(t, err)
require.True(t, resp.MachineAuthorized)
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
assert.False(t, node.Expiry().Valid(),
"initial registration with zero expiry and no default should leave expiry nil")
// Re-register with a new node key but same machine key + user
nodeKey2 := key.NewNode()
regReq2 := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{
AuthKey: pak.Key,
},
NodeKey: nodeKey2.Public(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: "reregister-zero-expiry",
},
Expiry: time.Time{}, // still zero
}
resp2, err := app.handleRegisterWithAuthKey(regReq2, machineKey.Public())
require.NoError(t, err)
require.True(t, resp2.MachineAuthorized)
node2, found := app.state.GetNodeByNodeKey(nodeKey2.Public())
require.True(t, found)
assert.False(t, node2.Expiry().Valid(),
"re-registration with zero client expiry and no default should leave expiry nil, not pointer to zero time")
}
-113
View File
@@ -3988,116 +3988,3 @@ func TestTaggedNodeWithoutUserToDifferentUser(t *testing.T) {
nodeAfterReauth.ID().Uint64(), nodeAfterReauth.Tags().AsSlice(),
nodeAfterReauth.IsTagged(), nodeAfterReauth.UserID().Get())
}
// TestHandleNodeFromPreAuthKey_OldUserNil_NoPanic asserts that
// HandleNodeFromPreAuthKey does not panic when the in-memory NodeStore
// holds a non-tagged node whose UserID points at a user but whose User
// pointer is nil (orphan snapshot, e.g. a Preload("User") join missed
// the row). Re-registering the same machine key under a different user
// enters the "different user" branch and would otherwise crash at
// oldUser.Name() in UserView.Name when the backing pointer is nil.
func TestHandleNodeFromPreAuthKey_OldUserNil_NoPanic(t *testing.T) {
app := createTestApp(t)
userA := app.state.CreateUserForTest("preauth-orphan-old")
userB := app.state.CreateUserForTest("preauth-orphan-new")
machineKey := key.NewMachine()
orphanNodeKey := key.NewNode()
userIDA := userA.ID
orphan := types.Node{
ID: 99001,
MachineKey: machineKey.Public(),
NodeKey: orphanNodeKey.Public(),
Hostname: "preauth-orphan",
GivenName: "preauth-orphan",
UserID: &userIDA,
User: nil,
RegisterMethod: "authkey",
}
app.state.PutNodeInStoreForTest(orphan)
pakB, err := app.state.CreatePreAuthKey(userB.TypedID(), true, false, nil, nil)
require.NoError(t, err)
newNodeKey := key.NewNode()
regReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{
AuthKey: pakB.Key,
},
NodeKey: newNodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: "preauth-orphan-newuser",
},
Expiry: time.Now().Add(24 * time.Hour),
}
resp, err := app.handleRegisterWithAuthKey(regReq, machineKey.Public())
require.NoError(t, err, "registration must not panic when old user is nil")
require.NotNil(t, resp)
require.True(t, resp.MachineAuthorized)
var registered types.NodeView
require.EventuallyWithT(t, func(c *assert.CollectT) {
var found bool
registered, found = app.state.GetNodeByNodeKey(newNodeKey.Public())
assert.True(c, found, "new node should be available in NodeStore")
}, 1*time.Second, 50*time.Millisecond, "waiting for new node")
assert.NotEqual(t, types.NodeID(99001), registered.ID(), "new node, not orphan")
assert.Equal(t, userB.ID, registered.UserID().Get(), "new node belongs to userB")
}
// TestHandleNodeFromAuthPath_OldUserNil_NoPanic is the parallel guard
// for the gRPC/OIDC entry point. Same orphan shape as
// TestHandleNodeFromPreAuthKey_OldUserNil_NoPanic; HandleNodeFromAuthPath
// has its own oldUser.Name() log line in the existingNodeOwnedByOtherUser
// branch and panics independently of the noise registration path.
func TestHandleNodeFromAuthPath_OldUserNil_NoPanic(t *testing.T) {
app := createTestApp(t)
userA := app.state.CreateUserForTest("authpath-orphan-old")
userB := app.state.CreateUserForTest("authpath-orphan-new")
machineKey := key.NewMachine()
orphanNodeKey := key.NewNode()
userIDA := userA.ID
orphan := types.Node{
ID: 99002,
MachineKey: machineKey.Public(),
NodeKey: orphanNodeKey.Public(),
Hostname: "authpath-orphan",
GivenName: "authpath-orphan",
UserID: &userIDA,
User: nil,
RegisterMethod: "oidc",
}
app.state.PutNodeInStoreForTest(orphan)
newNodeKey := key.NewNode()
authID := types.MustAuthID()
regEntry := types.NewRegisterAuthRequest(&types.RegistrationData{
MachineKey: machineKey.Public(),
NodeKey: newNodeKey.Public(),
Hostname: "authpath-orphan-newuser",
Hostinfo: &tailcfg.Hostinfo{
Hostname: "authpath-orphan-newuser",
},
})
app.state.SetAuthCacheEntry(authID, regEntry)
node, _, err := app.state.HandleNodeFromAuthPath(
authID,
types.UserID(userB.ID),
nil,
"oidc",
)
require.NoError(t, err, "auth-path registration must not panic on nil old user")
require.True(t, node.Valid())
assert.NotEqual(t, types.NodeID(99002), node.ID(), "new node, not orphan")
assert.Equal(t, userB.ID, node.UserID().Get(), "new node belongs to userB")
}
+1 -1
View File
@@ -100,7 +100,7 @@ func TestDestroyUserErrors(t *testing.T) {
user, err := db.CreateUser(types.User{Name: "test"})
require.NoError(t, err)
// Create a tagged node with no user_id (the rule for tagged nodes).
// Create a tagged node with no user_id (the invariant).
node := types.Node{
ID: 0,
Hostname: "tagged-node",
-30
View File
@@ -26,7 +26,6 @@ import (
"tailscale.com/types/views"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
"github.com/juanfont/headscale/hscontrol/state"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
@@ -782,35 +781,6 @@ func (api headscaleV1APIServer) SetPolicy(
return response, nil
}
// CheckPolicy validates the given policy against the server's live users
// and nodes, running its `tests` block as a sandbox. Nothing is persisted
// and the live PolicyManager is not touched. Works regardless of
// policy.mode so operators can validate a policy file before storing it.
func (api headscaleV1APIServer) CheckPolicy(
_ context.Context,
request *v1.CheckPolicyRequest,
) (*v1.CheckPolicyResponse, error) {
polB := []byte(request.GetPolicy())
users, err := api.h.state.ListAllUsers()
if err != nil {
return nil, status.Errorf(codes.Internal, "loading users: %s", err)
}
nodes := api.h.state.ListNodes()
pm, err := policyv2.NewPolicyManager(polB, users, nodes)
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
if _, err := pm.SetPolicy(polB); err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
return &v1.CheckPolicyResponse{}, nil
}
// The following service calls are for testing and debugging
func (api headscaleV1APIServer) DebugCreateNode(
ctx context.Context,
+1 -1
View File
@@ -596,7 +596,7 @@ func TestDeleteUser_TaggedNodeSurvives(t *testing.T) {
require.NoError(t, err)
require.True(t, resp.MachineAuthorized)
// Verify the registered node has nil UserID (enforced at registration).
// Verify the registered node has nil UserID (enforced invariant).
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
require.True(t, node.IsTagged())
+3 -5
View File
@@ -951,13 +951,11 @@ func TestReduceNodesFromPolicy(t *testing.T) {
]
}`,
node: n(1, "100.64.0.1", "mobile", "mobile"),
// autogroup:internet emits no client packet filter, but it
// must still produce a matcher: Node.CanAccess uses
// matcher.DestsIsTheInternet() + IsExitNode() to surface
// exit-node peers (juanfont/headscale#3212).
// autogroup:internet does not generate packet filters - it's handled
// by exit node routing via AllowedIPs, not by packet filtering.
// Only server is visible through the mobile -> server:80 rule.
want: types.Nodes{
n(2, "100.64.0.2", "server", "server"),
n(3, "100.64.0.3", "exit", "server", "0.0.0.0/0", "::/0"),
},
wantMatchers: 1,
},
-24
View File
@@ -6,7 +6,6 @@ import (
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
"go4.org/netipx"
"tailscale.com/tailcfg"
)
@@ -19,7 +18,6 @@ import (
func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcfg.FilterRule {
ret := []tailcfg.FilterRule{}
subnetRoutes := node.SubnetRoutes()
hasExitRoutes := node.IsExitNode()
for _, rule := range rules {
// Handle CapGrant rules separately — they use CapGrant[].Dsts
@@ -58,14 +56,6 @@ func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcf
// AllowedIPs/routing.
if slices.ContainsFunc(subnetRoutes, expanded.OverlapsPrefix) {
dests = append(dests, dest)
continue
}
// Exit-route advertisers need rules targeting the
// public internet so the kernel filter accepts
// traffic forwarded by autogroup:internet sources.
if hasExitRoutes && ipSetSubsetOf(expanded, util.TheInternet()) {
dests = append(dests, dest)
}
}
@@ -81,20 +71,6 @@ func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcf
return ret
}
func ipSetSubsetOf(candidate, container *netipx.IPSet) bool {
if candidate == nil || container == nil {
return false
}
for _, pref := range candidate.Prefixes() {
if !container.ContainsPrefix(pref) {
return false
}
}
return true
}
// reduceCapGrantRule filters a CapGrant rule to only include CapGrant
// entries whose Dsts match the given node's IPs. When a broad prefix
// (e.g. 100.64.0.0/10 from dst:*) contains a node's IP, it is
+6 -17
View File
@@ -5,7 +5,6 @@ import (
"slices"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/rs/zerolog/log"
"go4.org/netipx"
"tailscale.com/tailcfg"
@@ -672,10 +671,11 @@ func compileViaForNode(
return nil
}
// Find matching destination prefixes. SubnetRoutes() excludes exit
// routes, so the *Prefix check below sees only subnet advertisements;
// the *AutoGroup AutoGroupInternet branch checks IsExitNode() instead.
// Find matching destination prefixes.
nodeSubnetRoutes := node.SubnetRoutes()
if len(nodeSubnetRoutes) == 0 {
return nil
}
var viaDstPrefixes []netip.Prefix
@@ -689,19 +689,8 @@ func compileViaForNode(
)
}
case *AutoGroup:
// autogroup:internet on a via-tagged exit advertiser
// becomes a rule whose DstPorts enumerate
// util.TheInternet(). The matchers derived from this
// rule let Node.CanAccess surface the exit node to the
// grant source via DestsIsTheInternet. ReduceFilterRules
// strips the rule from the wire format on non-exit
// advertisers, preserving SaaS PacketFilter encoding.
if d.Is(AutoGroupInternet) && node.IsExitNode() {
viaDstPrefixes = append(
viaDstPrefixes,
util.TheInternet().Prefixes()...,
)
}
// autogroup:internet via grants do not produce
// PacketFilter rules on exit nodes.
}
}
+6
View File
@@ -166,6 +166,12 @@ func (pol *Policy) destinationsToNetPortRange(
continue
}
// autogroup:internet does not generate packet filters - it's handled
// by exit node routing via AllowedIPs, not by packet filtering.
if ag, isAutoGroup := dest.(*AutoGroup); isAutoGroup && ag.Is(AutoGroupInternet) {
continue
}
ips, err := dest.Resolve(pol, users, nodes)
if err != nil {
log.Trace().Caller().Err(err).Msgf("resolving destination ips")
+8 -38
View File
@@ -3668,27 +3668,6 @@ func TestCompileViaGrant(t *testing.T) {
Hostinfo: &tailcfg.Hostinfo{},
}
// Expected rule for autogroup:internet on a via-tagged exit
// advertiser: SrcIPs scoped to the grant source, DstPorts
// enumerating util.TheInternet() prefixes.
internetDstPorts := make(
[]tailcfg.NetPortRange, 0, len(util.TheInternet().Prefixes()),
)
for _, p := range util.TheInternet().Prefixes() {
internetDstPorts = append(internetDstPorts, tailcfg.NetPortRange{
IP: p.String(),
Ports: tailcfg.PortRangeAny,
})
}
internetWant := []tailcfg.FilterRule{
{
SrcIPs: []string{"100.64.0.10"},
DstPorts: internetDstPorts,
},
}
tests := []struct {
name string
grant Grant
@@ -3745,12 +3724,11 @@ func TestCompileViaGrant(t *testing.T) {
},
},
{
// autogroup:internet on a via-tagged exit advertiser
// produces a rule with DstPorts enumerating
// util.TheInternet(). The matchers derived from this
// rule let Node.CanAccess surface the exit node to
// grant sources via DestsIsTheInternet.
name: "autogroup:internet with exit routes produces TheInternet rule",
// autogroup:internet via grants do NOT produce PacketFilter rules
// on exit nodes. Tailscale SaaS handles exit traffic forwarding
// through the client's exit node mechanism, not PacketFilter.
// Verified by golden captures GRANT-V14 through GRANT-V36.
name: "autogroup:internet with exit routes produces no rules",
grant: Grant{
Sources: Aliases{up("testuser@")},
Destinations: Aliases{agp(string(AutoGroupInternet))},
@@ -3760,7 +3738,7 @@ func TestCompileViaGrant(t *testing.T) {
node: exitNode,
nodes: types.Nodes{exitNode, srcNode},
pol: &Policy{},
want: internetWant,
want: nil,
},
{
name: "autogroup:internet without exit routes returns nil",
@@ -4123,14 +4101,6 @@ func TestDestinationsToNetPortRange_AutogroupInternet(t *testing.T) {
pol := &Policy{}
ports := []tailcfg.PortRange{tailcfg.PortRangeAny}
// autogroup:internet must surface as DstPorts (not be skipped at
// compile time). The matcher derived from these FilterRules is
// what makes Node.CanAccess return true for exit-node peers via
// DestsIsTheInternet (#3212). The wire format is currently the
// canonical CIDR breakdown of util.TheInternet(); aligning it to
// the SaaS range form is tracked separately.
internetPrefixCount := len(util.TheInternet().Prefixes())
tests := []struct {
name string
dests Aliases
@@ -4138,9 +4108,9 @@ func TestDestinationsToNetPortRange_AutogroupInternet(t *testing.T) {
wantStar bool
}{
{
name: "autogroup:internet produces TheInternet DstPorts",
name: "autogroup:internet produces no DstPorts",
dests: Aliases{agp(string(AutoGroupInternet))},
wantLen: internetPrefixCount,
wantLen: 0,
},
{
name: "wildcard produces DstPorts with star",
-172
View File
@@ -1,172 +0,0 @@
// Tests pinned against tscap captures for juanfont/headscale#3212.
//
// The captures were taken on 2026-04-28 against a live Tailscale SaaS
// tailnet. They reproduce the literal #3212 setup: an ACL granting
// access to autogroup:internet:* combined with autoApprovers.exitNode
// approving exit routes on tagged exit nodes. SaaS surfaces those exit
// nodes as peers in the ACL source's netmap with 0.0.0.0/0 and ::/0 in
// AllowedIPs. Headscale must do the same — that is the user-visible UX
// driving `tailscale exit-node list`.
//
// Captures live under testdata/issue_3212/ rather than testdata/
// routes_results/ so the broader TestRoutesCompat / *PeerAllowedIPs /
// *ReduceRoutes machinery does not pull them in. Those tests assume a
// PacketFilterRules wire format (CIDR prefix per dest entry) that
// differs from what SaaS emits for autogroup:internet (range form per
// IPSet range — e.g. "0.0.0.0-9.255.255.255"). Aligning that wire
// format is tracked separately; the #3212 fix is about peer
// visibility, not packet-filter encoding.
package v2
import (
"path/filepath"
"slices"
"strings"
"testing"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/net/tsaddr"
)
// TestIssue3212AutogroupInternetExitVisibility loads the b17/b18
// SaaS captures and asserts headscale's BuildPeerMap surfaces every
// exit-route advertiser to every ACL-source node — matching the peer
// list in the captured netmap.
//
// The bug fixed by this PR (#3212) was that headscale skipped
// autogroup:internet during FilterRule compilation, which silently
// dropped the matchers that Node.CanAccess reads via DestsIsTheInternet.
// The captures pin the SaaS-equivalent expectation as a regression
// guard so the same skip cannot sneak back in unnoticed.
func TestIssue3212AutogroupInternetExitVisibility(t *testing.T) {
t.Parallel()
files := []string{
"routes-b17-autogroup-internet-with-exit-autoapprover",
"routes-b18-autogroup-internet-wildcard-src-with-exit-autoapprover",
}
for _, testID := range files {
path := filepath.Join(
"testdata", "issue_3212", testID+".hujson",
)
tf := loadRoutesTestFile(t, path)
t.Run(tf.TestID, func(t *testing.T) {
t.Parallel()
users, nodes := buildRoutesUsersAndNodes(t, tf.Topology)
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
pm, err := NewPolicyManager(
policyJSON, users, nodes.ViewSlice(),
)
require.NoErrorf(t, err,
"%s: failed to create PolicyManager", tf.TestID,
)
peerMap := pm.BuildPeerMap(nodes.ViewSlice())
expected := expectedExitPeerVisibility(t, tf, nodes)
require.NotEmptyf(t, expected,
"%s: capture exposes no source→exit relationships — "+
"the test is meaningless if SaaS itself never "+
"surfaced an exit node to a source",
tf.TestID,
)
for srcName, exitNames := range expected {
srcNode := findNodeByGivenName(nodes, srcName)
require.NotNilf(t, srcNode,
"%s: src node %q missing from topology",
tf.TestID, srcName,
)
peerIDs := make(
map[types.NodeID]struct{},
len(peerMap[srcNode.ID]),
)
for _, p := range peerMap[srcNode.ID] {
peerIDs[p.ID()] = struct{}{}
}
for _, exitName := range exitNames {
exitNode := findNodeByGivenName(nodes, exitName)
require.NotNilf(t, exitNode,
"%s: exit node %q missing from topology",
tf.TestID, exitName,
)
_, found := peerIDs[exitNode.ID]
assert.Truef(t, found,
"%s: source %q must see exit node %q "+
"as a peer via the autogroup:internet "+
"ACL — Tailscale SaaS does (#3212)",
tf.TestID, srcName, exitName,
)
}
}
})
}
}
// expectedExitPeerVisibility extracts (source-node, exit-node) pairs
// the capture's netmaps witness. A node is treated as an exit-route
// advertiser when its ApprovedRoutes contain 0.0.0.0/0 or ::/0;
// a (source, exit) pair is recorded when the source's captured netmap
// lists the advertiser as a peer with 0.0.0.0/0 or ::/0 in AllowedIPs.
func expectedExitPeerVisibility(
t *testing.T,
tf *testcapture.Capture,
nodes types.Nodes,
) map[string][]string {
t.Helper()
v4Exit := tsaddr.AllIPv4()
v6Exit := tsaddr.AllIPv6()
exitAdvertisers := make(map[string]bool)
for _, n := range nodes {
if slices.Contains(n.ApprovedRoutes, v4Exit) ||
slices.Contains(n.ApprovedRoutes, v6Exit) {
exitAdvertisers[n.GivenName] = true
}
}
expected := make(map[string][]string)
for srcName, capture := range tf.Captures {
if capture.Netmap == nil {
continue
}
var seen []string
for _, peer := range capture.Netmap.Peers {
peerName := strings.Split(peer.Name(), ".")[0]
if !exitAdvertisers[peerName] {
continue
}
peerAllowed := peer.AllowedIPs().AsSlice()
if !slices.Contains(peerAllowed, v4Exit) &&
!slices.Contains(peerAllowed, v6Exit) {
continue
}
seen = append(seen, peerName)
}
if len(seen) > 0 {
expected[srcName] = seen
}
}
return expected
}
-106
View File
@@ -1,106 +0,0 @@
// A via grant scoping autogroup:internet to a tag must surface only
// the matching exit node to the source — not strip every exit node
// from the source's view.
//
// Spec: https://tailscale.com/docs/features/access-control/grants/grants-via#route-users-through-exit-nodes-based-on-location
package v2
import (
"net/netip"
"slices"
"testing"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
)
// TestIssue3233ViaInternetExitVisibility loads a policy where alice's
// only access to autogroup:internet is via tag:exit1. Alice sees her
// tag:exit1 exit node as a peer with 0.0.0.0/0 + ::/0 in AllowedIPs,
// and does not see bob's tag:exit2 exit node.
func TestIssue3233ViaInternetExitVisibility(t *testing.T) {
t.Parallel()
users := types.Users{
{Model: gorm.Model{ID: 1}, Name: "alice", Email: "alice@headscale.net"},
{Model: gorm.Model{ID: 2}, Name: "bob", Email: "bob@headscale.net"},
}
exitRoutes := []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()}
aliceLaptop := node("alice-laptop", "100.64.0.10", "fd7a:115c:a1e0::a", users[0])
aliceLaptop.ID = 1
aliceExit := node("alice-exit", "100.64.0.11", "fd7a:115c:a1e0::b", users[0])
aliceExit.ID = 2
aliceExit.Tags = []string{"tag:exit1"}
aliceExit.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: exitRoutes}
aliceExit.ApprovedRoutes = exitRoutes
bobExit := node("bob-exit", "100.64.0.21", "fd7a:115c:a1e0::15", users[1])
bobExit.ID = 3
bobExit.Tags = []string{"tag:exit2"}
bobExit.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: exitRoutes}
bobExit.ApprovedRoutes = exitRoutes
nodes := types.Nodes{aliceLaptop, aliceExit, bobExit}
policy := `{
"tagOwners": {
"tag:exit1": ["alice@headscale.net"],
"tag:exit2": ["bob@headscale.net"]
},
"grants": [
{
"src": ["alice@headscale.net"],
"dst": ["autogroup:internet"],
"via": ["tag:exit1"],
"ip": ["*"]
}
]
}`
pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice())
require.NoError(t, err)
t.Run("BuildPeerMap_includes_via_tagged_exit", func(t *testing.T) {
t.Parallel()
peerMap := pm.BuildPeerMap(nodes.ViewSlice())
require.True(t,
slices.ContainsFunc(peerMap[aliceLaptop.ID], func(n types.NodeView) bool {
return n.ID() == aliceExit.ID
}),
"alice must see her tag:exit1 exit node as a peer")
require.False(t,
slices.ContainsFunc(peerMap[aliceLaptop.ID], func(n types.NodeView) bool {
return n.ID() == bobExit.ID
}),
"alice must not see bob's tag:exit2 exit node — via grant scopes to tag:exit1")
})
t.Run("ViaRoutesForPeer_includes_exit_for_matching_tag", func(t *testing.T) {
t.Parallel()
result := pm.ViaRoutesForPeer(aliceLaptop.View(), aliceExit.View())
require.Contains(t, result.Include, tsaddr.AllIPv4(),
"alice viewing tag:exit1 exit must Include 0.0.0.0/0 — drives AllowedIPs in state.RoutesForPeer")
require.Contains(t, result.Include, tsaddr.AllIPv6(),
"alice viewing tag:exit1 exit must Include ::/0 — drives AllowedIPs in state.RoutesForPeer")
})
t.Run("ViaRoutesForPeer_excludes_exit_for_other_tag", func(t *testing.T) {
t.Parallel()
result := pm.ViaRoutesForPeer(aliceLaptop.View(), bobExit.View())
require.Contains(t, result.Exclude, tsaddr.AllIPv4(),
"alice viewing tag:exit2 exit must Exclude 0.0.0.0/0 — strips it from AllowedIPs")
require.Contains(t, result.Exclude, tsaddr.AllIPv6(),
"alice viewing tag:exit2 exit must Exclude ::/0 — strips it from AllowedIPs")
})
}
+4 -116
View File
@@ -20,7 +20,6 @@ import (
"tailscale.com/tailcfg"
"tailscale.com/types/views"
"tailscale.com/util/deephash"
"tailscale.com/util/multierr"
)
// ErrInvalidTagOwner is returned when a tag owner is not an Alias type.
@@ -74,91 +73,6 @@ type filterAndPolicy struct {
Policy *Policy
}
// validateUserReferences surfaces ambiguous user@ tokens at policy load so
// duplicate DB rows fail loudly instead of silently dropping rules (#3160).
// Missing-user tokens stay tolerant (#2863). Empty users → no-op for
// syntax-only checks.
func validateUserReferences(pol *Policy, users types.Users) error {
if pol == nil || len(users) == 0 {
return nil
}
var errs []error
check := func(u *Username) {
if u == nil {
return
}
_, err := u.resolveUser(users)
if err != nil && errors.Is(err, ErrMultipleUsersFound) {
errs = append(errs, err)
}
}
checkAlias := func(a Alias) {
if u, ok := a.(*Username); ok {
check(u)
}
}
checkOwner := func(o Owner) {
if u, ok := o.(*Username); ok {
check(u)
}
}
checkAutoApprover := func(aa AutoApprover) {
if u, ok := aa.(*Username); ok {
check(u)
}
}
for _, usernames := range pol.Groups {
for i := range usernames {
check(&usernames[i])
}
}
for _, owners := range pol.TagOwners {
for _, o := range owners {
checkOwner(o)
}
}
for _, approvers := range pol.AutoApprovers.Routes {
for _, aa := range approvers {
checkAutoApprover(aa)
}
}
for _, aa := range pol.AutoApprovers.ExitNode {
checkAutoApprover(aa)
}
for _, acl := range pol.ACLs {
for _, src := range acl.Sources {
checkAlias(src)
}
for _, dst := range acl.Destinations {
checkAlias(dst.Alias)
}
}
for _, ssh := range pol.SSHs {
for _, src := range ssh.Sources {
checkAlias(src)
}
for _, dst := range ssh.Destinations {
checkAlias(dst)
}
}
return multierr.New(errs...)
}
// NewPolicyManager creates a new PolicyManager from a policy file and a list of users and nodes.
// It returns an error if the policy file is invalid.
// The policy manager will update the filter rules based on the users and nodes.
@@ -168,11 +82,6 @@ func NewPolicyManager(b []byte, users []types.User, nodes views.Slice[types.Node
return nil, fmt.Errorf("parsing policy: %w", err)
}
err = validateUserReferences(policy, users)
if err != nil {
return nil, fmt.Errorf("validating policy user references: %w", err)
}
pm := PolicyManager{
pol: policy,
users: users,
@@ -442,20 +351,6 @@ func (pm *PolicyManager) SetPolicy(polB []byte) (bool, error) {
pm.mu.Lock()
defer pm.mu.Unlock()
err = validateUserReferences(pol, pm.users)
if err != nil {
return false, fmt.Errorf("validating policy user references: %w", err)
}
// SetPolicy is the user-write boundary. Tests evaluate against a
// sandbox compiled from the new policy + current users/nodes; if
// they fail, return without mutating the live PolicyManager so the
// failed write does not knock the running config offline.
err = evaluateTests(pol, pm.users, pm.nodes)
if err != nil {
return false, err
}
// Log policy metadata for debugging
log.Debug().
Int("policy.bytes", len(polB)).
@@ -464,7 +359,6 @@ func (pm *PolicyManager) SetPolicy(polB []byte) (bool, error) {
Int("hosts.count", len(pol.Hosts)).
Int("tagOwners.count", len(pol.TagOwners)).
Int("autoApprovers.routes.count", len(pol.AutoApprovers.Routes)).
Int("tests.count", len(pol.Tests)).
Msg("Policy parsed successfully")
pm.pol = pol
@@ -1009,16 +903,10 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via
matchedPrefixes = append(matchedPrefixes, dstPrefix)
}
case *AutoGroup:
// Per-viewer steering for autogroup:internet: a peer
// advertising approved exit routes is the via-tagged
// node's analogue of "advertises the destination".
// The downstream Include/Exclude split below restricts
// alice to exit nodes carrying the via tag.
if d.Is(AutoGroupInternet) && peer.IsExitNode() {
matchedPrefixes = append(
matchedPrefixes, peer.ExitRoutes()...,
)
}
// autogroup:internet via grants do NOT affect AllowedIPs or
// route steering for exit nodes. Tailscale SaaS handles exit
// traffic forwarding through the client's exit node selection
// mechanism, not through AllowedIPs.
}
}
+9 -225
View File
@@ -10,7 +10,6 @@ import (
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
)
@@ -1640,14 +1639,10 @@ func TestViaRoutesForPeer(t *testing.T) {
require.NoError(t, err)
result := pm.ViaRoutesForPeer(nodes[0].View(), nodes[1].View())
// Include contains the subnet route plus the peer's approved
// exit routes — the peer holds tag:router and advertises exit
// routes, so autogroup:internet steering applies alongside the
// explicit prefix.
// Include should have only the subnet route.
// autogroup:internet does not produce via route effects.
require.Contains(t, result.Include, mp("10.0.0.0/24"))
require.Contains(t, result.Include, mp("0.0.0.0/0"))
require.Contains(t, result.Include, mp("::/0"))
require.Len(t, result.Include, 3)
require.Len(t, result.Include, 1)
require.Empty(t, result.Exclude)
})
@@ -1717,20 +1712,17 @@ func TestViaRoutesForPeer(t *testing.T) {
pm, err := NewPolicyManager([]byte(pol), users, nodes.ViewSlice())
require.NoError(t, err)
// autogroup:internet via grants surface the peer's approved
// exit routes when the peer carries the via tag, and exclude
// them when it does not — restricting which exit nodes the
// viewer may use, per Tailscale's grants-via spec for
// autogroup:internet.
// autogroup:internet via grants do NOT affect AllowedIPs or
// route steering. Tailscale SaaS handles exit traffic through
// the client's exit node mechanism, not ViaRoutesForPeer.
// Verified by golden captures GRANT-V14 through GRANT-V36.
resultExit := pm.ViaRoutesForPeer(nodes[0].View(), nodes[1].View())
require.Contains(t, resultExit.Include, mp("0.0.0.0/0"))
require.Contains(t, resultExit.Include, mp("::/0"))
require.Empty(t, resultExit.Include)
require.Empty(t, resultExit.Exclude)
resultOther := pm.ViaRoutesForPeer(nodes[0].View(), nodes[2].View())
require.Empty(t, resultOther.Include)
require.Contains(t, resultOther.Exclude, mp("0.0.0.0/0"))
require.Contains(t, resultOther.Exclude, mp("::/0"))
require.Empty(t, resultOther.Exclude)
})
t.Run("via_routes_survive_reduce_routes", func(t *testing.T) {
@@ -1814,211 +1806,3 @@ func TestViaRoutesForPeer(t *testing.T) {
"state.RoutesForPeer adds via routes after ReduceRoutes to fix this")
})
}
// TestBuildPeerMap_AutogroupInternetMakesExitNodeVisible reproduces
// juanfont/headscale#3212. An ACL that grants access only via
// `autogroup:internet` must keep the exit node visible to the source
// in BuildPeerMap so the Tailscale client surfaces it in
// `tailscale exit-node list`. Authoritative SaaS captures
// (tscap routes-b17/b18, 2026-04-28) confirm SaaS includes the exit
// node in the source's Peers with 0.0.0.0/0 and ::/0 in AllowedIPs.
func TestBuildPeerMap_AutogroupInternetMakesExitNodeVisible(t *testing.T) {
t.Parallel()
users := types.Users{
{Model: gorm.Model{ID: 1}, Name: "alice", Email: "alice@headscale.net"},
}
aliceNode := node("alice-laptop", "100.64.0.10", "fd7a:115c:a1e0::a", users[0])
aliceNode.ID = 1
exitRoutes := []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()}
exitNode := node("alice-exit", "100.64.0.1", "fd7a:115c:a1e0::1", users[0])
exitNode.ID = 2
exitNode.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: exitRoutes}
exitNode.ApprovedRoutes = exitRoutes
nodes := types.Nodes{aliceNode, exitNode}
policy := `{
"acls": [
{"action": "accept", "src": ["alice@headscale.net"], "dst": ["autogroup:internet:*"]}
]
}`
pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice())
require.NoError(t, err)
peerMap := pm.BuildPeerMap(nodes.ViewSlice())
require.True(t,
slices.ContainsFunc(peerMap[aliceNode.ID], func(n types.NodeView) bool {
return n.ID() == exitNode.ID
}),
"alice should see the exit node as a peer when an ACL grants autogroup:internet (#3212)")
_, matchers := pm.Filter()
require.True(t, aliceNode.View().CanAccess(matchers, exitNode.View()),
"alice.CanAccess(exit) should be true via DestsIsTheInternet()+IsExitNode() (#3212)")
}
// Reproduction for #3160: ambiguous user@ used to silently drop rules.
func TestNewPolicyManager_DuplicateUsername(t *testing.T) {
users := types.Users{
{Model: gorm.Model{ID: 2}, Name: "yala"},
{Model: gorm.Model{ID: 7}, Name: "yala", Email: "yala@yala.yala"},
}
polB := []byte(`{
"groups": {"group:admins": ["yala@"]},
"tagOwners": {"tag:ssh": ["group:admins"]},
"acls": [{"action":"accept","src":["*"],"dst":["*:*"]}],
"ssh": [
{"action":"accept","src":["group:admins"],"dst":["tag:ssh"],"users":["root"]}
]
}`)
_, err := NewPolicyManager(polB, users, types.Nodes{}.ViewSlice())
require.Error(t, err, "NewPolicyManager must reject policy with ambiguous username")
require.ErrorIs(t, err, ErrMultipleUsersFound)
require.Contains(t, err.Error(), "yala@",
"error must name the offending token")
}
// Missing-user tokens stay tolerant per #2863; only multi-match blocks load.
func TestNewPolicyManager_UnknownUsernameTolerant(t *testing.T) {
users := types.Users{
{Model: gorm.Model{ID: 1}, Name: "alice"},
}
polB := []byte(`{
"acls": [{"action":"accept","src":["ghost@"],"dst":["*:*"]}]
}`)
_, err := NewPolicyManager(polB, users, types.Nodes{}.ViewSlice())
require.NoError(t, err, "missing-user references must not block policy load (#2863)")
}
// Rejected SetPolicy must keep the previous policy intact.
func TestSetPolicy_DuplicateUsername(t *testing.T) {
users := types.Users{
{Model: gorm.Model{ID: 2}, Name: "yala"},
{Model: gorm.Model{ID: 7}, Name: "yala", Email: "yala@yala.yala"},
}
good := []byte(`{
"acls": [{"action":"accept","src":["*"],"dst":["*:*"]}]
}`)
pm, err := NewPolicyManager(good, users, types.Nodes{}.ViewSlice())
require.NoError(t, err)
bad := []byte(`{
"groups": {"group:admins": ["yala@"]},
"acls": [{"action":"accept","src":["group:admins"],"dst":["*:*"]}]
}`)
_, err = pm.SetPolicy(bad)
require.Error(t, err)
require.ErrorIs(t, err, ErrMultipleUsersFound)
filter, _ := pm.Filter()
require.NotNil(t, filter, "filter must remain populated after rejected SetPolicy")
}
// Empty users → syntax-only check, used by `headscale policy check`.
func TestValidateUserReferences_EmptyUsersTolerant(t *testing.T) {
polB := []byte(`{
"groups": {"group:admins": ["yala@"]},
"tagOwners": {"tag:ssh": ["group:admins"]},
"acls": [{"action":"accept","src":["yala@"],"dst":["*:*"]}],
"ssh": [
{"action":"accept","src":["yala@"],"dst":["tag:ssh"],"users":["root"]}
]
}`)
_, err := NewPolicyManager(polB, nil, types.Nodes{}.ViewSlice())
require.NoError(t, err, "nil users must skip user-reference validation")
_, err = NewPolicyManager(polB, types.Users{}, types.Nodes{}.ViewSlice())
require.NoError(t, err, "empty users must skip user-reference validation")
}
// One case per AST site so a dropped walk fails the matching subtest.
func TestValidateUserReferences_AllSites(t *testing.T) {
users := types.Users{
{Model: gorm.Model{ID: 1}, Name: "alice"},
{Model: gorm.Model{ID: 2}, Name: "dup"},
{Model: gorm.Model{ID: 3}, Name: "dup"},
}
tests := []struct {
name string
pol string
}{
{
name: "groups",
pol: `{
"groups": {"group:admins": ["dup@"]},
"acls": [{"action":"accept","src":["group:admins"],"dst":["*:*"]}]
}`,
},
{
name: "tagOwners",
pol: `{
"tagOwners": {"tag:ssh": ["dup@"]},
"acls": [{"action":"accept","src":["*"],"dst":["*:*"]}]
}`,
},
{
name: "autoApprovers.routes",
pol: `{
"autoApprovers": {"routes": {"10.0.0.0/8": ["dup@"]}},
"acls": [{"action":"accept","src":["*"],"dst":["*:*"]}]
}`,
},
{
name: "autoApprovers.exitNode",
pol: `{
"autoApprovers": {"exitNode": ["dup@"]},
"acls": [{"action":"accept","src":["*"],"dst":["*:*"]}]
}`,
},
{
name: "acls.src",
pol: `{
"acls": [{"action":"accept","src":["dup@"],"dst":["*:*"]}]
}`,
},
{
name: "acls.dst",
pol: `{
"acls": [{"action":"accept","src":["alice@"],"dst":["dup@:*"]}]
}`,
},
{
name: "ssh.src",
pol: `{
"tagOwners": {"tag:ssh": ["alice@"]},
"acls": [{"action":"accept","src":["*"],"dst":["*:*"]}],
"ssh": [{"action":"accept","src":["dup@"],"dst":["tag:ssh"],"users":["root"]}]
}`,
},
{
// ErrSSHUserDestRequiresSameUser forces src==dst when dst is a user.
name: "ssh.dst",
pol: `{
"acls": [{"action":"accept","src":["*"],"dst":["*:*"]}],
"ssh": [{"action":"accept","src":["dup@"],"dst":["dup@"],"users":["root"]}]
}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewPolicyManager([]byte(tt.pol), users, types.Nodes{}.ViewSlice())
require.Error(t, err, "site %q must surface duplicate-user errors", tt.name)
require.ErrorIs(t, err, ErrMultipleUsersFound)
})
}
}
@@ -1,170 +0,0 @@
// Compatibility tests for the policy `tests` block, replaying captures
// recorded against a real Tailscale SaaS tailnet. The runner mirrors the
// pattern in tailscale_grants_compat_test.go: a single Glob over a
// testdata directory, one t.Run per file. Each capture is one of:
//
// - APIResponseCode != 200 — the policy was rejected by the SaaS, the
// captured Message is the byte-exact body the user saw, and headscale
// must reject the same input with an error string that contains the
// same body (substring match, allowing wrapping like "test(s)
// failed:\n…").
// - APIResponseCode == 200 — the SaaS accepted the policy (its `tests`
// block passed); headscale's RunTests must also pass.
//
// Captures live in testdata/policytest_results/*.hujson. Scenarios in
// knownPolicyTesterDivergences are skipped with their tracking note —
// these are real Tailscale ↔ headscale divergences uncovered by the
// captures that need engine-level fixes in follow-up PRs.
//
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
package v2
import (
"path/filepath"
"strings"
"testing"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"tailscale.com/tailcfg"
)
// knownPolicyTesterDivergences lists scenarios where headscale's evaluator
// disagrees with Tailscale SaaS on whether the policy should be accepted.
// Each entry is a real bug to fix in a follow-up; documenting them here
// keeps the compat suite green and the divergence list visible.
var knownPolicyTesterDivergences = map[string]string{} //nolint:gosec // strings here are human-readable notes, not credentials
// policyTesterCompatUsers / policyTesterCompatNodes mirror the small
// shared topology used to record the captures. When more captures land
// we'll also exercise an autogroup-heavy second topology — for now this
// minimal one is enough to make the runner go.
func policyTesterCompatUsers() types.Users {
return types.Users{
{Model: gorm.Model{ID: 1}, Name: "odin", Email: "odin@example.com"},
{Model: gorm.Model{ID: 2}, Name: "thor", Email: "thor@example.org"},
{Model: gorm.Model{ID: 3}, Name: "freya", Email: "freya@example.com"},
}
}
func policyTesterCompatNodes(users types.Users) types.Nodes {
return types.Nodes{
{
ID: 1,
GivenName: "bulbasaur",
User: &users[0],
UserID: &users[0].ID,
IPv4: ptrAddr("100.90.199.68"),
IPv6: ptrAddr("fd7a:115c:a1e0::2d01:c747"),
Hostinfo: &tailcfg.Hostinfo{},
},
{
ID: 2,
GivenName: "ivysaur",
User: &users[1],
UserID: &users[1].ID,
IPv4: ptrAddr("100.110.121.96"),
IPv6: ptrAddr("fd7a:115c:a1e0::1737:7960"),
Hostinfo: &tailcfg.Hostinfo{},
},
{
ID: 3,
GivenName: "venusaur",
User: &users[2],
UserID: &users[2].ID,
IPv4: ptrAddr("100.103.90.82"),
IPv6: ptrAddr("fd7a:115c:a1e0::9e37:5a52"),
Hostinfo: &tailcfg.Hostinfo{},
},
{
ID: 4,
GivenName: "beedrill",
IPv4: ptrAddr("100.108.74.26"),
IPv6: ptrAddr("fd7a:115c:a1e0::b901:4a87"),
Tags: []string{"tag:server"},
Hostinfo: &tailcfg.Hostinfo{},
},
{
ID: 5,
GivenName: "kakuna",
IPv4: ptrAddr("100.103.8.15"),
IPv6: ptrAddr("fd7a:115c:a1e0::5b37:80f"),
Tags: []string{"tag:client"},
Hostinfo: &tailcfg.Hostinfo{},
},
}
}
// TestPolicyTesterCompat replays every capture under
// testdata/policytest_results/ against the engine. With no captures the
// test is a no-op — committed early so the layout/wiring lands before
// the bulk import.
func TestPolicyTesterCompat(t *testing.T) {
t.Parallel()
files, err := filepath.Glob(filepath.Join("testdata", "policytest_results", "*.hujson"))
require.NoError(t, err, "failed to glob test files")
if len(files) == 0 {
t.Skip("no policytest captures yet")
}
users := policyTesterCompatUsers()
nodes := policyTesterCompatNodes(users)
for _, file := range files {
c, err := testcapture.Read(file)
require.NoError(t, err, "reading %s", file)
t.Run(c.TestID, func(t *testing.T) {
t.Parallel()
if reason, skip := knownPolicyTesterDivergences[c.TestID]; skip {
t.Skip(reason)
}
policyJSON := []byte(c.Input.FullPolicy)
pm, parseErr := NewPolicyManager(policyJSON, users, nodes.ViewSlice())
// Tailscale validates and runs tests as one POST step:
// either failure mode produces the same 400. Headscale
// splits structural validation (parse) from test
// evaluation (SetPolicy). For the compat assertion, the
// two are equivalent — whichever surfaces first carries
// the captured body.
if c.Input.APIResponseCode == 200 {
require.NoError(t, parseErr, "tailscale accepted this policy; headscale must parse it")
_, setErr := pm.SetPolicy(policyJSON)
require.NoError(t, setErr, "tailscale accepted this policy; headscale tests should pass")
return
}
var got error
switch {
case parseErr != nil:
got = parseErr
default:
_, setErr := pm.SetPolicy(policyJSON)
got = setErr
}
require.Error(t, got, "tailscale rejected; headscale must reject too")
if c.Input.APIResponseBody == nil || c.Input.APIResponseBody.Message == "" {
return
}
want := c.Input.APIResponseBody.Message
if !strings.Contains(got.Error(), want) {
t.Errorf("error body mismatch\n tailscale wants: %q\n headscale got: %q", want, got.Error())
}
})
}
}
@@ -344,7 +344,7 @@ func testACLError(t *testing.T, tf *testcapture.Capture) {
}
// assertACLErrorContains requires that headscale's error contains the
// Tailscale SaaS error message exactly. Divergence means an emitter
// Tailscale SaaS error message verbatim. Divergence means an emitter
// needs to be aligned, not papered over with a translation table.
func assertACLErrorContains(
t *testing.T,
@@ -409,7 +409,7 @@ func TestGrantsCompat(t *testing.T) {
// different node IPs.
nodes := buildGrantsNodesFromCapture(users, tf)
// Use the captured full policy as is (anonymization
// Use the captured full policy verbatim (anonymization
// in tscap already rewrote SaaS emails).
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
@@ -457,7 +457,7 @@ func testGrantError(t *testing.T, policyJSON []byte, tf *testcapture.Capture) {
}
// assertGrantErrorContains requires that headscale's error contains
// the Tailscale SaaS error message exactly. Divergence means an
// the Tailscale SaaS error message verbatim. Divergence means an
// emitter needs to be aligned, not papered over with a translation
// table.
func assertGrantErrorContains(t *testing.T, err error, wantMsg string, testID string) {
@@ -1552,7 +1552,7 @@ func testRoutesError(t *testing.T, tf *testcapture.Capture) {
}
// assertRoutesErrorContains requires that headscale's error contains
// the Tailscale SaaS error message exactly. Divergence means an
// the Tailscale SaaS error message verbatim. Divergence means an
// emitter needs to be aligned, not papered over with a translation
// table.
func assertRoutesErrorContains(
@@ -172,16 +172,6 @@ func TestSSHDataCompat(t *testing.T) {
"no ssh-*.hujson test files found in testdata/ssh_results/",
)
allHujson, err := filepath.Glob(
filepath.Join("testdata", "ssh_results", "*.hujson"),
)
require.NoError(t, err, "failed to glob all hujson files")
require.Lenf(t, files, len(allHujson),
"ssh_results/ contains hujson files not picked up by the ssh-*.hujson loader; "+
"loader sees %d, directory has %d. Stale fixtures should be deleted.",
len(files), len(allHujson),
)
t.Logf("Loaded %d SSH test files", len(files))
users := setupSSHDataCompatUsers()
@@ -214,7 +204,7 @@ func TestSSHDataCompat(t *testing.T) {
// different node IPs.
nodes := buildGrantsNodesFromCapture(users, tf)
// Use the captured full policy as is. Anonymization in
// Use the captured full policy verbatim. Anonymization in
// tscap already rewrites SaaS emails to @example.com.
policyJSON := tf.Input.FullPolicy
-426
View File
@@ -1,426 +0,0 @@
package v2
import (
"errors"
"fmt"
"net/netip"
"slices"
"strings"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
"tailscale.com/tailcfg"
"tailscale.com/types/views"
)
// Tailscale's policy file `tests` block validates a policy against operator
// assertions: from a given src, named dst:port pairs must be accepted, and
// (optionally) other dst:port pairs must be denied. They run at user-write
// boundaries — `headscale policy set`, file-mode reload after a change,
// `headscale policy check` — and reject the write if any assertion fails.
// Boot-time reload of an already-stored policy does not run them, so a
// stale referenced entity (e.g. a deleted user) cannot lock the server out.
//
// The tests evaluate against the compiled global filter rules, which fold in
// both `acls` and `grants`, so the `tests` block validates the whole policy.
// errPolicyTestsFailed wraps the rendered failure body so callers can
// type-assert when they need to react differently to test failures vs. parse
// errors. The Error() prefix is "test(s) failed", the same string Tailscale
// SaaS returns in the api_response_body.message — see
// hscontrol/policy/v2/testdata/policytest_results/.
var (
errPolicyTestsFailed = errors.New("test(s) failed")
errTestDestinationNoIP = errors.New("destination resolved to no IP addresses")
)
// PolicyTest is one entry in the policy's `tests` block.
type PolicyTest struct {
// Src is a single source alias (user, group, tag, host, autogroup, or IP).
// Tailscale only supports a single src per test entry.
Src string `json:"src"`
// Proto restricts the test to one protocol. Empty matches the default
// set the client applies when proto is omitted (TCP/UDP/ICMP).
Proto Protocol `json:"proto,omitempty"`
// Accept lists destinations in `host:port` form that must be reachable
// from Src. A test fails if any entry is denied by the compiled filter.
Accept []string `json:"accept,omitempty"`
// Deny lists destinations in `host:port` form that must NOT be reachable
// from Src. A test fails if any entry is allowed by the compiled filter.
Deny []string `json:"deny,omitempty"`
}
// PolicyTestResult is the outcome of a single PolicyTest.
type PolicyTestResult struct {
Src string `json:"src"`
Proto Protocol `json:"proto,omitempty"`
Passed bool `json:"passed"`
// Errors are non-assertion problems: src failed to resolve, dst was
// malformed, etc. These cause the test to fail.
Errors []string `json:"errors,omitempty"`
// AcceptOK / AcceptFail / DenyOK / DenyFail partition the per-dst
// outcomes for diagnostics.
AcceptOK []string `json:"accept_ok,omitempty"`
AcceptFail []string `json:"accept_fail,omitempty"`
DenyOK []string `json:"deny_ok,omitempty"`
DenyFail []string `json:"deny_fail,omitempty"`
}
// PolicyTestResults aggregates a run.
type PolicyTestResults struct {
AllPassed bool `json:"all_passed"`
Results []PolicyTestResult `json:"results"`
}
// Errors renders the per-test failure breakdown joined by newlines.
// Tailscale SaaS itself only returns the literal "test(s) failed" — we
// keep the per-test detail because it is significantly more useful in
// CLI / config-reload paths where the user does not have a separate
// audit endpoint to consult.
func (r PolicyTestResults) Errors() string {
if r.AllPassed {
return ""
}
var lines []string
for _, res := range r.Results {
if res.Passed {
continue
}
protoSuffix := ""
if res.Proto != "" {
protoSuffix = fmt.Sprintf(" (%s)", res.Proto)
}
for _, e := range res.Errors {
lines = append(lines, fmt.Sprintf("%s%s: %s", res.Src, protoSuffix, e))
}
for _, dst := range res.AcceptFail {
lines = append(lines, fmt.Sprintf("%s -> %s%s: expected ALLOWED, got DENIED", res.Src, dst, protoSuffix))
}
for _, dst := range res.DenyFail {
lines = append(lines, fmt.Sprintf("%s -> %s%s: expected DENIED, got ALLOWED", res.Src, dst, protoSuffix))
}
}
return strings.Join(lines, "\n")
}
// RunTests evaluates the policy's own `tests` block against the live compiled
// filter and returns a wrapped error when any test fails. Callers that need
// the per-test breakdown can call runPolicyTests directly.
func (pm *PolicyManager) RunTests() error {
if pm == nil || pm.pol == nil || len(pm.pol.Tests) == 0 {
return nil
}
pm.mu.Lock()
defer pm.mu.Unlock()
results := runPolicyTests(pm.pol, pm.filter, pm.users, pm.nodes)
if results.AllPassed {
return nil
}
return fmt.Errorf("%w:\n%s", errPolicyTestsFailed, results.Errors())
}
// evaluateTests runs the `tests` block against a fresh compilation of pol.
// It is the user-write sandbox: the live PolicyManager state is left
// untouched, so a failing test rejects the write without side effects.
func evaluateTests(pol *Policy, users []types.User, nodes views.Slice[types.NodeView]) error {
if pol == nil || len(pol.Tests) == 0 {
return nil
}
grants := pol.compileGrants(users, nodes)
var filter []tailcfg.FilterRule
if pol.ACLs == nil && pol.Grants == nil {
filter = tailcfg.FilterAllowAll
} else {
filter = globalFilterRules(grants)
}
results := runPolicyTests(pol, filter, users, nodes)
if results.AllPassed {
return nil
}
return fmt.Errorf("%w:\n%s", errPolicyTestsFailed, results.Errors())
}
// runPolicyTests is the pure evaluation function: given a policy, the
// compiled filter rules derived from it, and the active users/nodes, run
// every test and return the aggregated outcome. It does not lock anything
// or mutate any input.
func runPolicyTests(pol *Policy, filter []tailcfg.FilterRule, users []types.User, nodes views.Slice[types.NodeView]) PolicyTestResults {
results := PolicyTestResults{
AllPassed: true,
Results: make([]PolicyTestResult, 0, len(pol.Tests)),
}
for _, test := range pol.Tests {
res := runPolicyTest(test, pol, filter, users, nodes)
if !res.Passed {
results.AllPassed = false
}
results.Results = append(results.Results, res)
}
return results
}
// runPolicyTest evaluates one PolicyTest.
func runPolicyTest(test PolicyTest, pol *Policy, filter []tailcfg.FilterRule, users []types.User, nodes views.Slice[types.NodeView]) PolicyTestResult {
res := PolicyTestResult{
Src: test.Src,
Proto: test.Proto,
Passed: true,
}
srcPrefixes, err := resolveTestSource(test.Src, pol, users, nodes)
if err != nil {
res.Passed = false
res.Errors = append(res.Errors, fmt.Sprintf("failed to resolve source %q: %v", test.Src, err))
return res
}
if len(srcPrefixes) == 0 {
res.Passed = false
res.Errors = append(res.Errors, fmt.Sprintf("source %q resolved to no IP addresses", test.Src))
return res
}
for _, dst := range test.Accept {
allowed, err := evalReachability(srcPrefixes, dst, test.Proto, pol, filter, users, nodes)
if err != nil {
res.Passed = false
res.Errors = append(res.Errors, fmt.Sprintf("error testing %q: %v", dst, err))
continue
}
if allowed {
res.AcceptOK = append(res.AcceptOK, dst)
} else {
res.Passed = false
res.AcceptFail = append(res.AcceptFail, dst)
}
}
for _, dst := range test.Deny {
allowed, err := evalReachability(srcPrefixes, dst, test.Proto, pol, filter, users, nodes)
if err != nil {
res.Passed = false
res.Errors = append(res.Errors, fmt.Sprintf("error testing %q: %v", dst, err))
continue
}
if !allowed {
res.DenyOK = append(res.DenyOK, dst)
} else {
res.Passed = false
res.DenyFail = append(res.DenyFail, dst)
}
}
return res
}
// resolveTestSource resolves the Src alias of a PolicyTest into a slice of
// netip.Prefix. parseAlias + Alias.Resolve cover every alias type the rest
// of the policy engine supports, so tests inherit alias semantics for free.
func resolveTestSource(src string, pol *Policy, users []types.User, nodes views.Slice[types.NodeView]) ([]netip.Prefix, error) {
alias, err := parseAlias(src)
if err != nil {
return nil, fmt.Errorf("invalid alias: %w", err)
}
addrs, err := alias.Resolve(pol, users, nodes)
if err != nil {
return nil, fmt.Errorf("resolving: %w", err)
}
if addrs == nil || addrs.Empty() {
return nil, nil
}
return addrs.Prefixes(), nil
}
// evalReachability reports whether traffic from any srcPrefix to dst (in
// `host:port` form) is allowed by filter for the requested protocol.
//
// Empty proto means the default set the client applies when proto is
// omitted (TCP/UDP/ICMP) — we accept a rule whose IPProto list contains
// any of those, or rules with no IPProto restriction at all.
func evalReachability(srcPrefixes []netip.Prefix, dst string, proto Protocol, pol *Policy, filter []tailcfg.FilterRule, users []types.User, nodes views.Slice[types.NodeView]) (bool, error) {
awp, err := parseDestinationAlias(dst)
if err != nil {
return false, fmt.Errorf("invalid destination %q: %w", dst, err)
}
dstAddrs, err := awp.Resolve(pol, users, nodes)
if err != nil {
return false, fmt.Errorf("resolving destination: %w", err)
}
if dstAddrs == nil || dstAddrs.Empty() {
return false, fmt.Errorf("%w: %q", errTestDestinationNoIP, dst)
}
dstPrefixes := dstAddrs.Prefixes()
// Tailscale's tests semantics: ALL src prefixes must reach the dst for
// the test to consider it allowed. A partial allow is a fail.
for _, src := range srcPrefixes {
if !srcReachesDst(src, dstPrefixes, awp.Ports, proto, filter) {
return false, nil
}
}
return true, nil
}
// parseDestinationAlias is a thin wrapper over AliasWithPorts.UnmarshalJSON
// so callers can hand it a bare `"host:port"` string without re-implementing
// the parse logic.
func parseDestinationAlias(dst string) (*AliasWithPorts, error) {
var awp AliasWithPorts
// AliasWithPorts.UnmarshalJSON expects a quoted JSON string, so wrap.
err := awp.UnmarshalJSON([]byte(`"` + dst + `"`))
if err != nil {
return nil, err
}
return &awp, nil
}
// srcReachesDst walks the compiled filter rules and reports whether
// traffic from src to any prefix in dstPrefixes on at least one of ports
// (or any port when ports is empty) is allowed under proto.
//
// An empty test proto means the Tailscale client default set
// {TCP, UDP, ICMP, ICMPv6} — the protocols the client tries when proto
// is omitted. The captured Tailscale matches show these four IANA
// numbers explicitly when no proto is set, so a rule restricted to any
// of them satisfies an empty-proto test.
func srcReachesDst(src netip.Prefix, dstPrefixes []netip.Prefix, ports []tailcfg.PortRange, proto Protocol, filter []tailcfg.FilterRule) bool {
requestedProtos := proto.toIANAProtocolNumbers()
if len(requestedProtos) == 0 {
requestedProtos = []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}
}
for _, rule := range filter {
if !ruleMatchesSource(rule, src) {
continue
}
if !ruleMatchesProto(rule, requestedProtos) {
continue
}
if ruleAllowsAnyDest(rule, dstPrefixes, ports) {
return true
}
}
return false
}
// ruleMatchesSource reports whether the rule's source list contains src.
// SrcIPs may be CIDR, single addresses, IP ranges (`a-b`), or `*`; we use
// util.ParseIPSet to cover all of those uniformly. Unparseable entries
// are skipped (the rule compiler emits well-formed strings, so this is
// defence-in-depth, not error handling).
func ruleMatchesSource(rule tailcfg.FilterRule, src netip.Prefix) bool {
for _, raw := range rule.SrcIPs {
set, err := util.ParseIPSet(raw, nil)
if err != nil {
continue
}
if set.OverlapsPrefix(src) {
return true
}
}
return false
}
// ruleMatchesProto reports whether the rule permits any of requestedProtos.
// An unset rule.IPProto means "any protocol" and matches everything.
// requestedProtos is the per-test protocol set: a single proto for an
// explicit test.Proto, or the default set when test.Proto is empty.
func ruleMatchesProto(rule tailcfg.FilterRule, requestedProtos []int) bool {
if len(rule.IPProto) == 0 {
return true
}
for _, ruleProto := range rule.IPProto {
if slices.Contains(requestedProtos, ruleProto) {
return true
}
}
return false
}
// ruleAllowsAnyDest reports whether at least one destination prefix in
// dstPrefixes is allowed by at least one of the rule's DstPorts entries
// for at least one of ports (or any port when ports is empty).
func ruleAllowsAnyDest(rule tailcfg.FilterRule, dstPrefixes []netip.Prefix, ports []tailcfg.PortRange) bool {
for _, dp := range rule.DstPorts {
if !destEntryMatchesPrefixes(dp, dstPrefixes) {
continue
}
if portsAllowed(ports, dp.Ports) {
return true
}
}
return false
}
// destEntryMatchesPrefixes reports whether the rule's NetPortRange.IP
// (CIDR, single IP, IP range, or "*") covers any prefix in dstPrefixes.
func destEntryMatchesPrefixes(dp tailcfg.NetPortRange, dstPrefixes []netip.Prefix) bool {
set, err := util.ParseIPSet(dp.IP, nil)
if err != nil {
return false
}
return slices.ContainsFunc(dstPrefixes, set.OverlapsPrefix)
}
// portsAllowed reports whether at least one requested port is contained
// in allowed. Empty requested means "any port".
func portsAllowed(requested []tailcfg.PortRange, allowed tailcfg.PortRange) bool {
if len(requested) == 0 {
return true
}
for _, r := range requested {
if r.First >= allowed.First && r.Last <= allowed.Last {
return true
}
}
return false
}
-424
View File
@@ -1,424 +0,0 @@
package v2
import (
"strings"
"testing"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
// policyTestUsers/policyTestNodes are reused across the test cases below to
// keep each table row focussed on the policy + tests under exercise.
func policyTestUsers() types.Users {
return types.Users{
{Model: gorm.Model{ID: 1}, Name: "alice", Email: "alice@headscale.net"},
{Model: gorm.Model{ID: 2}, Name: "bob", Email: "bob@headscale.net"},
}
}
func policyTestNodes(users types.Users) types.Nodes {
nodes := types.Nodes{
// alice's user-owned laptop
{
ID: 1,
Hostname: "alice-laptop",
IPv4: ap("100.64.0.1"),
IPv6: ap("fd7a:115c:a1e0::1"),
User: &users[0],
UserID: &users[0].ID,
},
// bob's user-owned laptop
{
ID: 2,
Hostname: "bob-laptop",
IPv4: ap("100.64.0.2"),
IPv6: ap("fd7a:115c:a1e0::2"),
User: &users[1],
UserID: &users[1].ID,
},
// tagged server (created via tagged preauth key from alice)
{
ID: 3,
Hostname: "server",
IPv4: ap("100.64.0.3"),
IPv6: ap("fd7a:115c:a1e0::3"),
User: &users[0],
UserID: &users[0].ID,
Tags: []string{"tag:server"},
},
}
return nodes
}
// TestRunTests covers the engine's per-test outcome reporting. Each row
// constructs a PolicyManager (which also runs SetPolicy's sandbox) and
// checks the resulting RunTests behaviour. SetPolicy gating is exercised
// separately in TestSetPolicyRejectsFailingTests.
func TestRunTests(t *testing.T) {
users := policyTestUsers()
nodes := policyTestNodes(users)
tests := []struct {
name string
policy string
wantPass bool
wantErrSub []string // substrings expected in the rendered error
wantNoErrIs error // sentinel the error must wrap
}{
{
name: "all-pass-user-to-tag",
policy: `{
"tagOwners": { "tag:server": ["alice@headscale.net"] },
"acls": [{
"action": "accept",
"src": ["alice@headscale.net"],
"dst": ["tag:server:22"]
}],
"tests": [{
"src": "alice@headscale.net",
"accept": ["tag:server:22"]
}]
}`,
wantPass: true,
},
{
name: "accept-fail-blocked-by-policy",
policy: `{
"tagOwners": { "tag:server": ["alice@headscale.net"] },
"acls": [{
"action": "accept",
"src": ["alice@headscale.net"],
"dst": ["tag:server:22"]
}],
"tests": [{
"src": "bob@headscale.net",
"accept": ["tag:server:22"]
}]
}`,
wantPass: false,
wantErrSub: []string{"bob@headscale.net", "tag:server:22", "expected ALLOWED"},
wantNoErrIs: errPolicyTestsFailed,
},
{
name: "deny-fail-policy-allows-traffic",
policy: `{
"tagOwners": { "tag:server": ["alice@headscale.net"] },
"acls": [{
"action": "accept",
"src": ["alice@headscale.net"],
"dst": ["tag:server:22"]
}],
"tests": [{
"src": "alice@headscale.net",
"deny": ["tag:server:22"]
}]
}`,
wantPass: false,
wantErrSub: []string{"alice@headscale.net", "tag:server:22", "expected DENIED"},
wantNoErrIs: errPolicyTestsFailed,
},
{
name: "unknown-src-user",
policy: `{
"acls": [{
"action": "accept",
"src": ["*"],
"dst": ["*:*"]
}],
"tests": [{
"src": "ghost@headscale.net",
"accept": ["alice-laptop:22"]
}]
}`,
wantPass: false,
wantErrSub: []string{"ghost@headscale.net", "failed to resolve source"},
wantNoErrIs: errPolicyTestsFailed,
},
// "malformed-dst-missing-port" used to live here; structural
// shape errors are now caught at parse by validateTests, so
// RunTests no longer sees them. The parse-side behaviour is
// covered by TestUnmarshalPolicy/tests-* in types_test.go.
{
name: "wildcard-src-passes",
policy: `{
"tagOwners": { "tag:server": ["alice@headscale.net"] },
"acls": [{
"action": "accept",
"src": ["*"],
"dst": ["tag:server:80"]
}],
"tests": [{
"src": "alice@headscale.net",
"accept": ["tag:server:80"]
}]
}`,
wantPass: true,
},
{
name: "proto-restrict-tcp-only",
policy: `{
"tagOwners": { "tag:server": ["alice@headscale.net"] },
"acls": [{
"action": "accept",
"proto": "tcp",
"src": ["alice@headscale.net"],
"dst": ["tag:server:22"]
}],
"tests": [
{
"src": "alice@headscale.net",
"proto": "tcp",
"accept": ["tag:server:22"]
},
{
"src": "alice@headscale.net",
"proto": "udp",
"deny": ["tag:server:22"]
}
]
}`,
wantPass: true,
},
{
name: "grants-only-policy-evaluated",
policy: `{
"tagOwners": { "tag:server": ["alice@headscale.net"] },
"grants": [{
"src": ["alice@headscale.net"],
"dst": ["tag:server"],
"ip": ["22"]
}],
"tests": [{
"src": "alice@headscale.net",
"accept": ["tag:server:22"]
}]
}`,
wantPass: true,
},
{
name: "mixed-pass-and-fail-reports-failure",
policy: `{
"tagOwners": { "tag:server": ["alice@headscale.net"] },
"acls": [{
"action": "accept",
"src": ["alice@headscale.net"],
"dst": ["tag:server:22"]
}],
"tests": [
{
"src": "alice@headscale.net",
"accept": ["tag:server:22"]
},
{
"src": "bob@headscale.net",
"accept": ["tag:server:22"]
}
]
}`,
wantPass: false,
wantErrSub: []string{"bob@headscale.net", "expected ALLOWED"},
wantNoErrIs: errPolicyTestsFailed,
},
{
name: "no-tests-block-is-no-op",
policy: `{
"acls": [{
"action": "accept",
"src": ["*"],
"dst": ["*:*"]
}]
}`,
wantPass: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pm, err := NewPolicyManager([]byte(tt.policy), users, nodes.ViewSlice())
require.NoError(t, err, "policy must parse and compile")
runErr := pm.RunTests()
if tt.wantPass {
require.NoError(t, runErr, "tests should pass")
return
}
require.Error(t, runErr, "tests should fail")
require.ErrorIs(t, runErr, tt.wantNoErrIs, "error should wrap errPolicyTestsFailed")
for _, sub := range tt.wantErrSub {
assert.Contains(t, runErr.Error(), sub, "rendered error should mention %q", sub)
}
})
}
}
// TestSetPolicyRejectsFailingTests asserts that SetPolicy is the user-write
// boundary: a policy whose tests fail must be rejected without mutating the
// live PolicyManager. NewPolicyManager (boot path) does not run tests.
func TestSetPolicyRejectsFailingTests(t *testing.T) {
users := policyTestUsers()
nodes := policyTestNodes(users)
good := `{
"tagOwners": { "tag:server": ["alice@headscale.net"] },
"acls": [{
"action": "accept",
"src": ["alice@headscale.net"],
"dst": ["tag:server:22"]
}],
"tests": [{
"src": "alice@headscale.net",
"accept": ["tag:server:22"]
}]
}`
bad := `{
"tagOwners": { "tag:server": ["alice@headscale.net"] },
"acls": [{
"action": "accept",
"src": ["alice@headscale.net"],
"dst": ["tag:server:22"]
}],
"tests": [{
"src": "bob@headscale.net",
"accept": ["tag:server:22"]
}]
}`
pm, err := NewPolicyManager([]byte(good), users, nodes.ViewSlice())
require.NoError(t, err)
beforeFilter, _ := pm.Filter()
changed, err := pm.SetPolicy([]byte(bad))
require.Error(t, err, "SetPolicy must reject a policy whose tests fail")
require.False(t, changed, "SetPolicy must report no change when rejected")
require.ErrorIs(t, err, errPolicyTestsFailed)
require.Contains(t, err.Error(), "expected ALLOWED")
afterFilter, _ := pm.Filter()
require.Len(t, afterFilter, len(beforeFilter), "live filter must not change after a rejected SetPolicy")
}
// TestNewPolicyManagerSkipsTests asserts the boot path does not evaluate
// tests, so a stale stored policy referencing a now-deleted user does not
// stop the server from booting.
func TestNewPolicyManagerSkipsTests(t *testing.T) {
users := policyTestUsers()
nodes := policyTestNodes(users)
// Tests reference "ghost@headscale.net" which doesn't exist. Boot
// must not error.
stale := `{
"acls": [{
"action": "accept",
"src": ["*"],
"dst": ["*:*"]
}],
"tests": [{
"src": "ghost@headscale.net",
"accept": ["alice-laptop:22"]
}]
}`
pm, err := NewPolicyManager([]byte(stale), users, nodes.ViewSlice())
require.NoError(t, err, "boot must not run tests")
require.NotNil(t, pm)
// And a subsequent SetPolicy of the same body must reject — that's
// the user-write path.
_, err = pm.SetPolicy([]byte(stale))
require.Error(t, err)
require.ErrorIs(t, err, errPolicyTestsFailed)
}
// TestRunTestsEmptyProtoMatchesDefaultProtocols captures the bug where a
// test entry with no `proto` field fails to match a filter rule whose
// IPProto is restricted to a default protocol (TCP, UDP, ICMP, ICMPv6).
// Tailscale's client default set is {6, 17, 1, 58} when proto is omitted,
// so a TCP-only rule must satisfy an empty-proto test.
//
// The capture
// testdata/policytest_results/policytest-allpass-acls-and-grants-mixed.hujson
// is the captured signal for this same bug (api_response_code 200, two
// passing tests including `tag:client → webserver:80` with no proto over
// a `ip: tcp:80` grant).
func TestRunTestsEmptyProtoMatchesDefaultProtocols(t *testing.T) {
users := types.Users{
{Model: gorm.Model{ID: 1}, Name: "odin", Email: "odin@example.com"},
}
nodes := types.Nodes{
{
ID: 1,
Hostname: "client",
IPv4: ap("100.64.0.10"),
IPv6: ap("fd7a:115c:a1e0::a"),
Tags: []string{"tag:client"},
},
{
ID: 2,
Hostname: "webserver",
IPv4: ap("100.64.0.16"),
IPv6: ap("fd7a:115c:a1e0::10"),
Tags: []string{"tag:server"},
},
}
policy := `{
"tagOwners": {
"tag:client": ["odin@example.com"],
"tag:server": ["odin@example.com"]
},
"hosts": {
"webserver": "100.64.0.16"
},
"grants": [
{"src": ["tag:client"], "dst": ["webserver"], "ip": ["tcp:80"]}
],
"tests": [
{"src": "tag:client", "accept": ["webserver:80"]}
]
}`
pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice())
require.NoError(t, err, "policy must parse and compile")
require.NoError(t, pm.RunTests(),
"empty-proto test must match a tcp-only grant rule (TCP is in the client default set)")
}
// TestPolicyTestResultsErrorsRendering checks the multi-line render layout
// since the body becomes the user-facing error.
func TestPolicyTestResultsErrorsRendering(t *testing.T) {
results := PolicyTestResults{
AllPassed: false,
Results: []PolicyTestResult{
{
Src: "alice@headscale.net",
AcceptFail: []string{"tag:server:22"},
},
{
Src: "bob@headscale.net",
Proto: "tcp",
DenyFail: []string{"tag:server:443"},
},
},
}
rendered := results.Errors()
for _, sub := range []string{
"alice@headscale.net -> tag:server:22: expected ALLOWED, got DENIED",
"bob@headscale.net -> tag:server:443 (tcp): expected DENIED, got ALLOWED",
} {
assert.Contains(t, rendered, sub)
}
// Lines should be newline-separated, not space-joined.
assert.Equal(t, 2, strings.Count(rendered, "\n")+1, "expected one line per failing assertion")
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+99
View File
@@ -0,0 +1,99 @@
// SSH-A1
//
// SSH: accept: src=['autogroup:member'] dst=['autogroup:self'] users=['root']
//
// Expected: SSH rules on 3 of 5 nodes
{
"test_id": "SSH-A1",
"ssh_section": [
{
"action": "accept",
"src": [
"autogroup:member"
],
"dst": [
"autogroup:self"
],
"users": [
"root"
]
}
],
"nodes": {
"user1": {
"rules": [
{
"principals": [
{
"nodeIP": "100.90.199.68"
},
{
"nodeIP": "fd7a:115c:a1e0::2d01:c747"
}
],
"sshUsers": {
"root": "root"
},
"action": {
"accept": true,
"allowAgentForwarding": true,
"allowLocalPortForwarding": true,
"allowRemotePortForwarding": true
}
}
]
},
"user-kris": {
"rules": [
{
"principals": [
{
"nodeIP": "100.110.121.96"
},
{
"nodeIP": "fd7a:115c:a1e0::1737:7960"
}
],
"sshUsers": {
"root": "root"
},
"action": {
"accept": true,
"allowAgentForwarding": true,
"allowLocalPortForwarding": true,
"allowRemotePortForwarding": true
}
}
]
},
"user-mon": {
"rules": [
{
"principals": [
{
"nodeIP": "100.103.90.82"
},
{
"nodeIP": "fd7a:115c:a1e0::9e37:5a52"
}
],
"sshUsers": {
"root": "root"
},
"action": {
"accept": true,
"allowAgentForwarding": true,
"allowLocalPortForwarding": true,
"allowRemotePortForwarding": true
}
}
]
},
"tagged-server": {
"rules": []
},
"tagged-prod": {
"rules": []
}
}
}
+102
View File
@@ -0,0 +1,102 @@
// SSH-A2
//
// SSH: accept: src=['autogroup:member'] dst=['autogroup:self'] users=['autogroup:nonroot']
//
// Expected: SSH rules on 3 of 5 nodes
{
"test_id": "SSH-A2",
"ssh_section": [
{
"action": "accept",
"src": [
"autogroup:member"
],
"dst": [
"autogroup:self"
],
"users": [
"autogroup:nonroot"
]
}
],
"nodes": {
"user1": {
"rules": [
{
"principals": [
{
"nodeIP": "100.90.199.68"
},
{
"nodeIP": "fd7a:115c:a1e0::2d01:c747"
}
],
"sshUsers": {
"*": "=",
"root": ""
},
"action": {
"accept": true,
"allowAgentForwarding": true,
"allowLocalPortForwarding": true,
"allowRemotePortForwarding": true
}
}
]
},
"user-kris": {
"rules": [
{
"principals": [
{
"nodeIP": "100.110.121.96"
},
{
"nodeIP": "fd7a:115c:a1e0::1737:7960"
}
],
"sshUsers": {
"*": "=",
"root": ""
},
"action": {
"accept": true,
"allowAgentForwarding": true,
"allowLocalPortForwarding": true,
"allowRemotePortForwarding": true
}
}
]
},
"user-mon": {
"rules": [
{
"principals": [
{
"nodeIP": "100.103.90.82"
},
{
"nodeIP": "fd7a:115c:a1e0::9e37:5a52"
}
],
"sshUsers": {
"*": "=",
"root": ""
},
"action": {
"accept": true,
"allowAgentForwarding": true,
"allowLocalPortForwarding": true,
"allowRemotePortForwarding": true
}
}
]
},
"tagged-server": {
"rules": []
},
"tagged-prod": {
"rules": []
}
}
}

Some files were not shown because too many files have changed in this diff Show More