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