mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-19 14:30:44 +09:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ef18fb010 | |||
| 596ecec1db | |||
| 091c65f067 | |||
| 1661a9122a | |||
| a9a84b3f0a | |||
| 9685a742a6 | |||
| 7794d30263 | |||
| 220a2e31f3 | |||
| 3a0cc30a3a | |||
| 47f851c43b | |||
| 4e0c2b8556 | |||
| bc9fb6d403 | |||
| 585d0c01bc | |||
| 01eb5402f9 | |||
| e597f4c8a0 | |||
| 01e548e030 | |||
| 9482cdf590 | |||
| 3d0f597b23 | |||
| 76ee29352b | |||
| 2b7f15abaa | |||
| ecaf56e0a0 | |||
| 94ec607bca | |||
| d1443a431c | |||
| 155e42f892 | |||
| 3d5c0af4e7 | |||
| 27c9113af8 | |||
| 7bb86f2c16 | |||
| 863fa2f815 | |||
| 9f7c8e9a07 | |||
| 66ac785c22 | |||
| 437754aeea | |||
| da927eb018 | |||
| 942313a10a | |||
| 1fe682b141 | |||
| 010a5564c5 | |||
| de60982d83 | |||
| bcfaf6ad68 | |||
| 0e10ca4e9a | |||
| ba251e7b47 | |||
| c7a0ca709f | |||
| a7d405a255 | |||
| 775bc3a715 | |||
| ea968e2f5d |
@@ -68,6 +68,7 @@ func findTests() []string {
|
||||
args := []string{
|
||||
"--type", "go",
|
||||
"--regexp", "func (Test.+)\\(.*",
|
||||
"--max-depth", "1",
|
||||
"../../integration/",
|
||||
"--replace", "$1",
|
||||
"--sort", "path",
|
||||
|
||||
@@ -239,6 +239,7 @@ jobs:
|
||||
- TestHASubnetRouterFailover
|
||||
- TestSubnetRouteACL
|
||||
- TestEnablingExitRoutes
|
||||
- TestExitRoutesWithAutogroupInternetACL
|
||||
- TestSubnetRouterMultiNetwork
|
||||
- TestSubnetRouterMultiNetworkExitNode
|
||||
- TestAutoApproveMultiNetwork/authkey-tag.*
|
||||
@@ -250,6 +251,9 @@ jobs:
|
||||
- TestSubnetRouteACLFiltering
|
||||
- TestGrantViaSubnetSteering
|
||||
- TestHASubnetRouterPingFailover
|
||||
- TestHASubnetRouterFailoverBothOffline
|
||||
- TestHASubnetRouterFailoverBothOfflineCablePull
|
||||
- TestHASubnetRouterFailoverDockerDisconnect
|
||||
- TestHeadscale
|
||||
- TestTailscaleNodesJoiningHeadcale
|
||||
- TestSSHOneUserToAll
|
||||
@@ -297,6 +301,7 @@ jobs:
|
||||
- TestTagsAuthKeyWithoutUserInheritsTags
|
||||
- TestTagsAuthKeyWithoutUserRejectsAdvertisedTags
|
||||
- TestTagsAuthKeyConvertToUserViaCLIRegister
|
||||
- TestTailscaleRustAxum
|
||||
uses: ./.github/workflows/integration-test-template.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
|
||||
@@ -13,6 +13,7 @@ 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
|
||||
|
||||
@@ -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 invariant.
|
||||
both. This is a load-bearing architectural rule.
|
||||
|
||||
- **Use `node.IsTagged()`** (`hscontrol/types/node.go:221`) to determine
|
||||
ownership, not `node.UserID().Valid()`. A tagged node may still have
|
||||
|
||||
@@ -27,6 +27,22 @@ 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)
|
||||
@@ -133,6 +149,8 @@ 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
|
||||
|
||||
@@ -151,9 +169,11 @@ 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
|
||||
|
||||
@@ -183,6 +203,8 @@ 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
@@ -1,6 +1,6 @@
|
||||
# For testing purposes only
|
||||
|
||||
FROM golang:1.26.2-alpine AS build-env
|
||||
FROM golang:1.26.3-alpine AS build-env
|
||||
|
||||
WORKDIR /go/src
|
||||
|
||||
|
||||
@@ -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.2-alpine AS build-env
|
||||
FROM golang:1.26.3-alpine AS build-env
|
||||
|
||||
WORKDIR /go/src
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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
|
||||
@@ -48,6 +48,7 @@ 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)
|
||||
}
|
||||
@@ -170,6 +171,11 @@ 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")
|
||||
|
||||
@@ -178,9 +184,56 @@ var checkPolicy = &cobra.Command{
|
||||
return fmt.Errorf("reading policy file: %w", err)
|
||||
}
|
||||
|
||||
_, err = policy.NewPolicyManager(policyBytes, nil, views.Slice[types.NodeView]{})
|
||||
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()
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing policy file: %w", err)
|
||||
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
|
||||
}
|
||||
|
||||
fmt.Println("Policy is valid")
|
||||
|
||||
@@ -3,7 +3,6 @@ package cli
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
@@ -22,11 +21,6 @@ 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
@@ -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 emty value to disable the metrics listener.
|
||||
# Use an empty value to disable the metrics listener.
|
||||
metrics_listen_addr: 127.0.0.1:9090
|
||||
|
||||
# Address to listen for gRPC.
|
||||
|
||||
@@ -22,5 +22,6 @@ 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.
|
||||
|
||||
@@ -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 Enrypt client with the HTTP-01 challenge is used. See [TLS](../ref/tls.md) for
|
||||
- Only required if the built-in Let's Encrypt client with the HTTP-01 challenge is used. See [TLS](../ref/tls.md) for
|
||||
details.
|
||||
- tcp/443
|
||||
- Expose publicly: yes
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${prev.stdenv.hostPlatform.system};
|
||||
buildGo = pkgs.buildGo126Module;
|
||||
vendorHash = "sha256-1jVYsI73Sa9/xigxldfvH0TkQThJIGGIq+1A7ARZ068=";
|
||||
vendorHash = "sha256-8vTEkPEMbJ6DSOjcoQrYRyKSYI8jjcllTmJ6RXmUV9w=";
|
||||
in
|
||||
{
|
||||
headscale = buildGo {
|
||||
|
||||
@@ -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\xeb\x19\n" +
|
||||
"\x15database_connectivity\x18\x01 \x01(\bR\x14databaseConnectivity2\xe0\x1a\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,7 +144,8 @@ 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\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\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" +
|
||||
"\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 (
|
||||
@@ -190,33 +191,35 @@ 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
|
||||
(*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
|
||||
(*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
|
||||
}
|
||||
var file_headscale_v1_headscale_proto_depIdxs = []int32{
|
||||
2, // 0: headscale.v1.HeadscaleService.CreateUser:input_type -> headscale.v1.CreateUserRequest
|
||||
@@ -246,37 +249,39 @@ 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
|
||||
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
|
||||
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, // [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
|
||||
|
||||
@@ -966,6 +966,33 @@ 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
|
||||
@@ -1533,6 +1560,26 @@ 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()
|
||||
@@ -2052,6 +2099,23 @@ 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()
|
||||
@@ -2100,6 +2164,7 @@ 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"}, ""))
|
||||
)
|
||||
|
||||
@@ -2131,5 +2196,6 @@ 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
|
||||
)
|
||||
|
||||
@@ -46,6 +46,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -86,6 +87,7 @@ 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)
|
||||
}
|
||||
@@ -368,6 +370,16 @@ 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)
|
||||
@@ -415,6 +427,7 @@ 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()
|
||||
@@ -508,6 +521,9 @@ 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")
|
||||
}
|
||||
@@ -1018,6 +1034,24 @@ 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 {
|
||||
@@ -1151,6 +1185,10 @@ var HeadscaleService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "SetPolicy",
|
||||
Handler: _HeadscaleService_SetPolicy_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CheckPolicy",
|
||||
Handler: _HeadscaleService_CheckPolicy_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Health",
|
||||
Handler: _HeadscaleService_Health_Handler,
|
||||
|
||||
@@ -206,6 +206,86 @@ 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 = "" +
|
||||
@@ -221,7 +301,10 @@ 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\tupdatedAtB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3"
|
||||
"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"
|
||||
|
||||
var (
|
||||
file_headscale_v1_policy_proto_rawDescOnce sync.Once
|
||||
@@ -235,17 +318,19 @@ 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, 4)
|
||||
var file_headscale_v1_policy_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
|
||||
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
|
||||
(*timestamppb.Timestamp)(nil), // 4: google.protobuf.Timestamp
|
||||
(*CheckPolicyRequest)(nil), // 4: headscale.v1.CheckPolicyRequest
|
||||
(*CheckPolicyResponse)(nil), // 5: headscale.v1.CheckPolicyResponse
|
||||
(*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp
|
||||
}
|
||||
var file_headscale_v1_policy_proto_depIdxs = []int32{
|
||||
4, // 0: headscale.v1.SetPolicyResponse.updated_at:type_name -> google.protobuf.Timestamp
|
||||
4, // 1: headscale.v1.GetPolicyResponse.updated_at:type_name -> google.protobuf.Timestamp
|
||||
6, // 0: headscale.v1.SetPolicyResponse.updated_at:type_name -> google.protobuf.Timestamp
|
||||
6, // 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
|
||||
@@ -264,7 +349,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: 4,
|
||||
NumMessages: 6,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
|
||||
@@ -660,6 +660,38 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/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",
|
||||
@@ -1044,6 +1076,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1CheckPolicyRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"policy": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1CheckPolicyResponse": {
|
||||
"type": "object"
|
||||
},
|
||||
"v1CreateApiKeyRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -54,6 +54,7 @@ require (
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
pgregory.net/rapid v1.2.0
|
||||
tailscale.com v1.96.5
|
||||
zombiezen.com/go/postgrestest v1.0.1
|
||||
)
|
||||
|
||||
@@ -1119,3 +1119,72 @@ 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 `®Req.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")
|
||||
}
|
||||
|
||||
@@ -3988,3 +3988,116 @@ 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")
|
||||
}
|
||||
|
||||
@@ -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 invariant).
|
||||
// Create a tagged node with no user_id (the rule for tagged nodes).
|
||||
node := types.Node{
|
||||
ID: 0,
|
||||
Hostname: "tagged-node",
|
||||
|
||||
@@ -26,6 +26,7 @@ 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"
|
||||
@@ -781,6 +782,35 @@ 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,
|
||||
|
||||
@@ -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 invariant).
|
||||
// Verify the registered node has nil UserID (enforced at registration).
|
||||
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
|
||||
require.True(t, found)
|
||||
require.True(t, node.IsTagged())
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/juanfont/headscale/hscontrol/routes"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -209,25 +208,30 @@ func TestTailNode(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
primary := routes.New()
|
||||
cfg := &types.Config{
|
||||
BaseDomain: tt.baseDomain,
|
||||
TailcfgDNSConfig: tt.dnsConfig,
|
||||
RandomizeClientPort: false,
|
||||
Taildrop: types.TaildropConfig{Enabled: true},
|
||||
}
|
||||
_ = primary.SetRoutes(tt.node.ID, tt.node.SubnetRoutes()...)
|
||||
|
||||
// This is a hack to avoid having a second node to test the primary route.
|
||||
// This should be baked into the test case proper if it is extended in the future.
|
||||
_ = primary.SetRoutes(2, netip.MustParsePrefix("192.168.0.0/24"))
|
||||
// Stub primary-route lookup: tt.node owns its SubnetRoutes,
|
||||
// node ID 2 owns 192.168.0.0/24 (a hack carried over from
|
||||
// the original routes-package-driven version of this test —
|
||||
// avoids spinning up a second node just to validate that
|
||||
// other nodes' primaries don't leak into tt.node's TailNode
|
||||
// output).
|
||||
primaries := map[types.NodeID][]netip.Prefix{
|
||||
tt.node.ID: tt.node.SubnetRoutes(),
|
||||
2: {netip.MustParsePrefix("192.168.0.0/24")},
|
||||
}
|
||||
nv := tt.node.View()
|
||||
got, err := nv.TailNode(
|
||||
0,
|
||||
func(id types.NodeID) []netip.Prefix {
|
||||
// Route function returns primaries + exit routes
|
||||
// (matching the real caller contract).
|
||||
return slices.Concat(primary.PrimaryRoutes(id), nv.ExitRoutes())
|
||||
return slices.Concat(primaries[id], nv.ExitRoutes())
|
||||
},
|
||||
cfg,
|
||||
)
|
||||
|
||||
@@ -951,11 +951,13 @@ func TestReduceNodesFromPolicy(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
node: n(1, "100.64.0.1", "mobile", "mobile"),
|
||||
// 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.
|
||||
// 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).
|
||||
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,
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"go4.org/netipx"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
@@ -18,6 +19,7 @@ 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
|
||||
@@ -56,6 +58,14 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +81,20 @@ 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
|
||||
|
||||
@@ -5,6 +5,7 @@ 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"
|
||||
@@ -671,11 +672,10 @@ func compileViaForNode(
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find matching destination prefixes.
|
||||
// Find matching destination prefixes. SubnetRoutes() excludes exit
|
||||
// routes, so the *Prefix check below sees only subnet advertisements;
|
||||
// the *AutoGroup AutoGroupInternet branch checks IsExitNode() instead.
|
||||
nodeSubnetRoutes := node.SubnetRoutes()
|
||||
if len(nodeSubnetRoutes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var viaDstPrefixes []netip.Prefix
|
||||
|
||||
@@ -689,8 +689,19 @@ func compileViaForNode(
|
||||
)
|
||||
}
|
||||
case *AutoGroup:
|
||||
// autogroup:internet via grants do not produce
|
||||
// PacketFilter rules on exit nodes.
|
||||
// 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()...,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -166,12 +166,6 @@ 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")
|
||||
|
||||
@@ -3668,6 +3668,27 @@ 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
|
||||
@@ -3724,11 +3745,12 @@ func TestCompileViaGrant(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
// 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",
|
||||
// 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",
|
||||
grant: Grant{
|
||||
Sources: Aliases{up("testuser@")},
|
||||
Destinations: Aliases{agp(string(AutoGroupInternet))},
|
||||
@@ -3738,7 +3760,7 @@ func TestCompileViaGrant(t *testing.T) {
|
||||
node: exitNode,
|
||||
nodes: types.Nodes{exitNode, srcNode},
|
||||
pol: &Policy{},
|
||||
want: nil,
|
||||
want: internetWant,
|
||||
},
|
||||
{
|
||||
name: "autogroup:internet without exit routes returns nil",
|
||||
@@ -4101,6 +4123,14 @@ 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
|
||||
@@ -4108,9 +4138,9 @@ func TestDestinationsToNetPortRange_AutogroupInternet(t *testing.T) {
|
||||
wantStar bool
|
||||
}{
|
||||
{
|
||||
name: "autogroup:internet produces no DstPorts",
|
||||
name: "autogroup:internet produces TheInternet DstPorts",
|
||||
dests: Aliases{agp(string(AutoGroupInternet))},
|
||||
wantLen: 0,
|
||||
wantLen: internetPrefixCount,
|
||||
},
|
||||
{
|
||||
name: "wildcard produces DstPorts with star",
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// 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")
|
||||
})
|
||||
}
|
||||
@@ -20,6 +20,7 @@ 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.
|
||||
@@ -73,6 +74,91 @@ 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.
|
||||
@@ -82,6 +168,11 @@ 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,
|
||||
@@ -351,6 +442,20 @@ 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)).
|
||||
@@ -359,6 +464,7 @@ 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
|
||||
@@ -903,10 +1009,16 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via
|
||||
matchedPrefixes = append(matchedPrefixes, dstPrefix)
|
||||
}
|
||||
case *AutoGroup:
|
||||
// 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.
|
||||
// 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()...,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
@@ -1639,10 +1640,14 @@ func TestViaRoutesForPeer(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
result := pm.ViaRoutesForPeer(nodes[0].View(), nodes[1].View())
|
||||
// Include should have only the subnet route.
|
||||
// autogroup:internet does not produce via route effects.
|
||||
// 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.
|
||||
require.Contains(t, result.Include, mp("10.0.0.0/24"))
|
||||
require.Len(t, result.Include, 1)
|
||||
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.Empty(t, result.Exclude)
|
||||
})
|
||||
|
||||
@@ -1712,17 +1717,20 @@ func TestViaRoutesForPeer(t *testing.T) {
|
||||
pm, err := NewPolicyManager([]byte(pol), users, nodes.ViewSlice())
|
||||
require.NoError(t, err)
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
resultExit := pm.ViaRoutesForPeer(nodes[0].View(), nodes[1].View())
|
||||
require.Empty(t, resultExit.Include)
|
||||
require.Contains(t, resultExit.Include, mp("0.0.0.0/0"))
|
||||
require.Contains(t, resultExit.Include, mp("::/0"))
|
||||
require.Empty(t, resultExit.Exclude)
|
||||
|
||||
resultOther := pm.ViaRoutesForPeer(nodes[0].View(), nodes[2].View())
|
||||
require.Empty(t, resultOther.Include)
|
||||
require.Empty(t, resultOther.Exclude)
|
||||
require.Contains(t, resultOther.Exclude, mp("0.0.0.0/0"))
|
||||
require.Contains(t, resultOther.Exclude, mp("::/0"))
|
||||
})
|
||||
|
||||
t.Run("via_routes_survive_reduce_routes", func(t *testing.T) {
|
||||
@@ -1806,3 +1814,211 @@ 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// 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 verbatim. Divergence means an emitter
|
||||
// Tailscale SaaS error message exactly. 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 verbatim (anonymization
|
||||
// Use the captured full policy as is (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 verbatim. Divergence means an
|
||||
// the Tailscale SaaS error message exactly. 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 verbatim. Divergence means an
|
||||
// the Tailscale SaaS error message exactly. Divergence means an
|
||||
// emitter needs to be aligned, not papered over with a translation
|
||||
// table.
|
||||
func assertRoutesErrorContains(
|
||||
|
||||
@@ -172,6 +172,16 @@ 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()
|
||||
@@ -204,7 +214,7 @@ func TestSSHDataCompat(t *testing.T) {
|
||||
// different node IPs.
|
||||
nodes := buildGrantsNodesFromCapture(users, tf)
|
||||
|
||||
// Use the captured full policy verbatim. Anonymization in
|
||||
// Use the captured full policy as is. Anonymization in
|
||||
// tscap already rewrites SaaS emails to @example.com.
|
||||
policyJSON := tf.Input.FullPolicy
|
||||
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
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")
|
||||
}
|
||||
Vendored
+10322
File diff suppressed because it is too large
Load Diff
+12404
File diff suppressed because it is too large
Load Diff
+8837
File diff suppressed because it is too large
Load Diff
+18748
File diff suppressed because it is too large
Load Diff
Vendored
+8841
File diff suppressed because it is too large
Load Diff
+8843
File diff suppressed because it is too large
Load Diff
Vendored
+8847
File diff suppressed because it is too large
Load Diff
Vendored
+8845
File diff suppressed because it is too large
Load Diff
hscontrol/policy/v2/testdata/policytest_results/policytest-accept-fail-proto-icmp-not-allowed.hujson
Vendored
+8848
File diff suppressed because it is too large
Load Diff
+8848
File diff suppressed because it is too large
Load Diff
hscontrol/policy/v2/testdata/policytest_results/policytest-accept-fail-proto-numeric-mismatch.hujson
Vendored
+8848
File diff suppressed because it is too large
Load Diff
Vendored
+8843
File diff suppressed because it is too large
Load Diff
Vendored
+8841
File diff suppressed because it is too large
Load Diff
Vendored
+7317
File diff suppressed because it is too large
Load Diff
Vendored
+7289
File diff suppressed because it is too large
Load Diff
Vendored
+7241
File diff suppressed because it is too large
Load Diff
+8843
File diff suppressed because it is too large
Load Diff
+8205
File diff suppressed because it is too large
Load Diff
+6383
File diff suppressed because it is too large
Load Diff
+7335
File diff suppressed because it is too large
Load Diff
+7245
File diff suppressed because it is too large
Load Diff
+9355
File diff suppressed because it is too large
Load Diff
Vendored
+8841
File diff suppressed because it is too large
Load Diff
+8843
File diff suppressed because it is too large
Load Diff
Vendored
+7757
File diff suppressed because it is too large
Load Diff
Vendored
+7746
File diff suppressed because it is too large
Load Diff
+8839
File diff suppressed because it is too large
Load Diff
Vendored
+8843
File diff suppressed because it is too large
Load Diff
Vendored
+8841
File diff suppressed because it is too large
Load Diff
+8839
File diff suppressed because it is too large
Load Diff
+8843
File diff suppressed because it is too large
Load Diff
+8847
File diff suppressed because it is too large
Load Diff
+8847
File diff suppressed because it is too large
Load Diff
+8831
File diff suppressed because it is too large
Load Diff
+8841
File diff suppressed because it is too large
Load Diff
+8847
File diff suppressed because it is too large
Load Diff
Vendored
+8845
File diff suppressed because it is too large
Load Diff
Vendored
+7232
File diff suppressed because it is too large
Load Diff
Vendored
+8839
File diff suppressed because it is too large
Load Diff
+8841
File diff suppressed because it is too large
Load Diff
Vendored
+8833
File diff suppressed because it is too large
Load Diff
Vendored
+8839
File diff suppressed because it is too large
Load Diff
Vendored
+8845
File diff suppressed because it is too large
Load Diff
+8843
File diff suppressed because it is too large
Load Diff
+8842
File diff suppressed because it is too large
Load Diff
+8847
File diff suppressed because it is too large
Load Diff
+8847
File diff suppressed because it is too large
Load Diff
+7229
File diff suppressed because it is too large
Load Diff
+8839
File diff suppressed because it is too large
Load Diff
+7229
File diff suppressed because it is too large
Load Diff
+8848
File diff suppressed because it is too large
Load Diff
+7240
File diff suppressed because it is too large
Load Diff
+7240
File diff suppressed because it is too large
Load Diff
+7235
File diff suppressed because it is too large
Load Diff
+8833
File diff suppressed because it is too large
Load Diff
+8841
File diff suppressed because it is too large
Load Diff
+8843
File diff suppressed because it is too large
Load Diff
+8843
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user