mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-23 00:10:43 +09:00
Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ef18fb010 | |||
| 596ecec1db | |||
| 091c65f067 | |||
| 1661a9122a | |||
| a9a84b3f0a | |||
| 9685a742a6 | |||
| 7794d30263 | |||
| 220a2e31f3 | |||
| 3a0cc30a3a | |||
| 47f851c43b | |||
| 4e0c2b8556 | |||
| bc9fb6d403 | |||
| 585d0c01bc | |||
| 01eb5402f9 | |||
| e597f4c8a0 | |||
| 01e548e030 | |||
| 9482cdf590 | |||
| 3d0f597b23 | |||
| 76ee29352b | |||
| 2b7f15abaa | |||
| ecaf56e0a0 | |||
| 94ec607bca | |||
| d1443a431c | |||
| 155e42f892 | |||
| 3d5c0af4e7 | |||
| 27c9113af8 | |||
| 7bb86f2c16 | |||
| 863fa2f815 | |||
| 9f7c8e9a07 | |||
| 66ac785c22 | |||
| 437754aeea | |||
| da927eb018 | |||
| 942313a10a | |||
| 1fe682b141 | |||
| 010a5564c5 | |||
| de60982d83 | |||
| bcfaf6ad68 | |||
| 0e10ca4e9a | |||
| ba251e7b47 | |||
| c7a0ca709f | |||
| a7d405a255 | |||
| 775bc3a715 | |||
| ea968e2f5d | |||
| 2e1a716a9a | |||
| 174e409da6 | |||
| 3672a2df3a | |||
| ce5d1ba8f8 | |||
| 4e1d83ecef | |||
| d6dfdc100c | |||
| a2c3ac095e | |||
| f1494a32ce | |||
| 7e6c7924ad | |||
| 9ea09ea4b6 | |||
| 436d3db28e | |||
| 978f1e3947 | |||
| 2530d86f1b | |||
| 1a58b77271 | |||
| 427b2f15ee | |||
| 93e8c7285f | |||
| 842f36225e | |||
| 0567cb6da3 | |||
| 5a7cafdf85 | |||
| f3eb9a7bba | |||
| 3a4af8cf87 | |||
| ec48f34e1c | |||
| 164d659dd2 | |||
| 7d104b8c8d | |||
| a7c9721faa | |||
| f34dec2754 | |||
| 1059c678c4 | |||
| affaa1a31d | |||
| ded51a4d30 | |||
| b051e7b2bc | |||
| b01e67e8e5 | |||
| f49c42e716 | |||
| 813eb2d733 | |||
| 1b6ab52f9e | |||
| af26bab17a | |||
| 0378e2d2c6 | |||
| 8a97dd134b | |||
| 90e65ccd63 | |||
| 786ce2dce8 | |||
| 99a93c126b | |||
| c9dbea5c18 | |||
| 0e5569c3fc | |||
| 461a0e2bea | |||
| 0cf27eba77 | |||
| 97778c9930 | |||
| b113655b71 | |||
| 32e1d77663 | |||
| de5b1eab68 | |||
| f066d12153 | |||
| 3918020551 | |||
| 93860a5c06 | |||
| 814226f327 | |||
| 78990491da | |||
| c15caff48c | |||
| 61c9ae81e4 |
@@ -59,7 +59,7 @@ jobs:
|
||||
nix develop --command -- ko build \
|
||||
--bare \
|
||||
--platform=linux/amd64,linux/arm64 \
|
||||
--tags=main-${GITHUB_SHA::7} \
|
||||
--tags=main-${GITHUB_SHA::7},development \
|
||||
./cmd/headscale
|
||||
|
||||
- name: Push to Docker Hub
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
nix develop --command -- ko build \
|
||||
--bare \
|
||||
--platform=linux/amd64,linux/arm64 \
|
||||
--tags=main-${GITHUB_SHA::7} \
|
||||
--tags=main-${GITHUB_SHA::7},development \
|
||||
./cmd/headscale
|
||||
|
||||
binaries:
|
||||
|
||||
@@ -68,6 +68,7 @@ func findTests() []string {
|
||||
args := []string{
|
||||
"--type", "go",
|
||||
"--regexp", "func (Test.+)\\(.*",
|
||||
"--max-depth", "1",
|
||||
"../../integration/",
|
||||
"--replace", "$1",
|
||||
"--sort", "path",
|
||||
|
||||
@@ -58,15 +58,16 @@ jobs:
|
||||
|
||||
# Find when needs-more-info was last added
|
||||
let events = (gh api $"repos/($env.GH_REPO)/issues/($number)/events"
|
||||
--paginate | from json | flatten)
|
||||
--paginate | from json)
|
||||
let label_event = ($events
|
||||
| where event == "labeled" and label.name == "needs-more-info"
|
||||
| where event == "labeled"
|
||||
| where label.name == "needs-more-info"
|
||||
| last)
|
||||
let label_added_at = ($label_event.created_at | into datetime)
|
||||
|
||||
# Check for non-bot comments after the label was added
|
||||
let comments = (gh api $"repos/($env.GH_REPO)/issues/($number)/comments"
|
||||
--paginate | from json | flatten)
|
||||
--paginate | from json)
|
||||
let human_responses = ($comments
|
||||
| where user.type != "Bot"
|
||||
| where { ($in.created_at | into datetime) > $label_added_at })
|
||||
|
||||
@@ -239,6 +239,7 @@ jobs:
|
||||
- TestHASubnetRouterFailover
|
||||
- TestSubnetRouteACL
|
||||
- TestEnablingExitRoutes
|
||||
- TestExitRoutesWithAutogroupInternetACL
|
||||
- TestSubnetRouterMultiNetwork
|
||||
- TestSubnetRouterMultiNetworkExitNode
|
||||
- TestAutoApproveMultiNetwork/authkey-tag.*
|
||||
@@ -249,6 +250,10 @@ jobs:
|
||||
- TestAutoApproveMultiNetwork/webauth-group.*
|
||||
- TestSubnetRouteACLFiltering
|
||||
- TestGrantViaSubnetSteering
|
||||
- TestHASubnetRouterPingFailover
|
||||
- TestHASubnetRouterFailoverBothOffline
|
||||
- TestHASubnetRouterFailoverBothOfflineCablePull
|
||||
- TestHASubnetRouterFailoverDockerDisconnect
|
||||
- TestHeadscale
|
||||
- TestTailscaleNodesJoiningHeadcale
|
||||
- TestSSHOneUserToAll
|
||||
@@ -296,6 +301,7 @@ jobs:
|
||||
- TestTagsAuthKeyWithoutUserInheritsTags
|
||||
- TestTagsAuthKeyWithoutUserRejectsAdvertisedTags
|
||||
- TestTagsAuthKeyConvertToUserViaCLIRegister
|
||||
- TestTailscaleRustAxum
|
||||
uses: ./.github/workflows/integration-test-template.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
|
||||
+4
-3
@@ -42,10 +42,9 @@ source:
|
||||
- "vendor/"
|
||||
|
||||
nfpms:
|
||||
# Configure nFPM for .deb and .rpm releases
|
||||
# Configure nFPM for .deb releases
|
||||
#
|
||||
# See https://nfpm.goreleaser.com/configuration/
|
||||
# and https://goreleaser.com/customization/nfpm/
|
||||
# See https://goreleaser.com/customization/package/nfpm/
|
||||
#
|
||||
# Useful tools for debugging .debs:
|
||||
# List file contents: dpkg -c dist/headscale...deb
|
||||
@@ -79,6 +78,8 @@ nfpms:
|
||||
dst: /usr/lib/systemd/system/headscale.service
|
||||
- dst: /var/lib/headscale
|
||||
type: dir
|
||||
- src: ./config-example.yaml
|
||||
dst: /usr/share/doc/headscale/examples/config-example.yaml
|
||||
- src: LICENSE
|
||||
dst: /usr/share/doc/headscale/copyright
|
||||
scripts:
|
||||
|
||||
@@ -13,6 +13,7 @@ repos:
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: check-added-large-files
|
||||
args: [--maxkb=1024]
|
||||
- id: check-case-conflict
|
||||
- id: check-executables-have-shebangs
|
||||
- id: check-json
|
||||
|
||||
@@ -198,7 +198,7 @@ databases. The `migrationsRequiringFKDisabled` map in
|
||||
|
||||
Headscale enforces **tags XOR user ownership**: every node is either
|
||||
tagged (owned by tags) or user-owned (owned by a user namespace), never
|
||||
both. This is a load-bearing architectural invariant.
|
||||
both. This is a load-bearing architectural rule.
|
||||
|
||||
- **Use `node.IsTagged()`** (`hscontrol/types/node.go:221`) to determine
|
||||
ownership, not `node.UserID().Valid()`. A tagged node may still have
|
||||
|
||||
+153
-49
@@ -27,13 +27,29 @@ 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/kb/1324/grants) alongside ACLs. Grants
|
||||
extend what you can express in a policy beyond packet filtering: the `app` field controls
|
||||
application-level features like Taildrive file sharing and peer relay, and the `via` field steers
|
||||
traffic through specific tagged subnet routers or exit nodes. The `ip` field works like an ACL rule.
|
||||
Grants can be mixed with ACLs in the same policy file.
|
||||
We now support [Tailscale grants](https://tailscale.com/docs/features/access-control/grants)
|
||||
alongside ACLs. Grants extend what you can express in a policy beyond packet filtering: the `app`
|
||||
field controls application-level features like Taildrive file sharing and peer relay, and the `via`
|
||||
field steers traffic through specific tagged subnet routers or exit nodes. The `ip` field works like
|
||||
an ACL rule. Grants can be mixed with ACLs in the same policy file.
|
||||
[#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
|
||||
As part of this, we added `autogroup:danger-all`. It resolves to `0.0.0.0/0` and `::/0` — all IP
|
||||
@@ -41,66 +57,154 @@ addresses, including those outside the tailnet. This replaces the old behaviour
|
||||
all IPs (see BREAKING below). The name is intentionally scary: accepting traffic from the entire
|
||||
internet is a security-sensitive choice. `autogroup:danger-all` can only be used as a source.
|
||||
|
||||
### Hostname handling (cleanroom rewrite)
|
||||
|
||||
The hostname ingest pipeline has been rewritten to match Tailscale SaaS byte-for-byte.
|
||||
Headscale previously had three overlapping regexes and two disagreeing entry points
|
||||
(registration vs map-request update), which caused a recurring class of bugs: names
|
||||
containing apostrophes, spaces, dots, or non-ASCII characters were alternately rejected
|
||||
(dropping updates with log spam) or stored as `invalid-<rand>` surrogates
|
||||
([#3188](https://github.com/juanfont/headscale/issues/3188),
|
||||
[#2926](https://github.com/juanfont/headscale/issues/2926),
|
||||
[#2343](https://github.com/juanfont/headscale/issues/2343),
|
||||
[#2762](https://github.com/juanfont/headscale/issues/2762),
|
||||
[#2177](https://github.com/juanfont/headscale/issues/2177),
|
||||
[#2121](https://github.com/juanfont/headscale/issues/2121),
|
||||
[#2449](https://github.com/juanfont/headscale/issues/2449),
|
||||
[#363](https://github.com/juanfont/headscale/issues/363)).
|
||||
|
||||
What changed:
|
||||
|
||||
- Sanitisation and validation now come directly from
|
||||
`tailscale.com/util/dnsname.SanitizeHostname` / `ValidLabel`.
|
||||
- Admin rename (`headscale nodes rename`) now validates via `dnsname.ValidLabel` and
|
||||
rejects labels already held by another node (previously coerced invalid input silently).
|
||||
|
||||
Examples that previously regressed and now work:
|
||||
|
||||
| Input | Raw (Hostname) | DNS label (GivenName) |
|
||||
| -------------------- | -------------------- | --------------------- |
|
||||
| `Joe's Mac mini` | `Joe's Mac mini` | `joes-mac-mini` |
|
||||
| `Yuri's MacBook Pro` | `Yuri's MacBook Pro` | `yuris-macbook-pro` |
|
||||
| `Test@Host` | `Test@Host` | `test-host` |
|
||||
| `mail.server` | `mail.server` | `mail-server` |
|
||||
| `My-PC!` | `My-PC!` | `my-pc` |
|
||||
| `我的电脑` | `我的电脑` | `node` |
|
||||
|
||||
### BREAKING
|
||||
|
||||
- **ACL Policy**: Wildcard (`*`) in ACL sources and destinations now resolves to Tailscale's CGNAT range (`100.64.0.0/10`) and ULA range (`fd7a:115c:a1e0::/48`) instead of all IPs (`0.0.0.0/0` and `::/0`) [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
#### Hostname handling
|
||||
|
||||
- The `GivenName` collision policy changed from an 8-char random hash suffix (`laptop-abc12xyz`) to a monotonic numeric suffix (`laptop`, `laptop-1`, `laptop-2`, …), matching Tailscale SaaS. Empty / all-non-ASCII hostnames now fall back to the literal `node` instead of `invalid-<rand>`. MagicDNS names change on upgrade for any node whose previous label was a random-suffix form; the raw `Hostname` column is unchanged.
|
||||
|
||||
#### ACL Policy
|
||||
|
||||
- Wildcard (`*`) in ACL sources and destinations now resolves to Tailscale's CGNAT range (`100.64.0.0/10`) and ULA range (`fd7a:115c:a1e0::/48`) instead of all IPs (`0.0.0.0/0` and `::/0`) [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- This better matches Tailscale's security model where `*` means "any node in the tailnet" rather than "any IP address"
|
||||
- Policies that need to match all IP addresses including non-Tailscale IPs should use `autogroup:danger-all` as a source, or explicit CIDR ranges as destinations [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- `autogroup:danger-all` can only be used as a source; it cannot be used as a destination
|
||||
- **Note**: Users with non-standard IP ranges configured in `prefixes.ipv4` or `prefixes.ipv6` (which is unsupported and produces a warning) will need to explicitly specify their CIDR ranges in ACL rules instead of using `*`
|
||||
- **ACL Policy**: Validate autogroup:self source restrictions matching Tailscale behavior - tags, hosts, and IPs are rejected as sources for autogroup:self destinations [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- Validate autogroup:self source restrictions matching Tailscale behavior - tags, hosts, and IPs are rejected as sources for autogroup:self destinations [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- Policies using tags, hosts, or IP addresses as sources for autogroup:self destinations will now fail validation
|
||||
- **Upgrade path**: Headscale now enforces a strict version upgrade path [#3083](https://github.com/juanfont/headscale/pull/3083)
|
||||
- The `proto:icmp` protocol name now only includes ICMPv4 (protocol 1), matching Tailscale behavior [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- Previously, `proto:icmp` included both ICMPv4 and ICMPv6
|
||||
- Use `proto:ipv6-icmp` or protocol number `58` explicitly for ICMPv6
|
||||
|
||||
#### Upgrade Path
|
||||
|
||||
- Headscale now enforces a strict version upgrade path [#3083](https://github.com/juanfont/headscale/pull/3083)
|
||||
- Skipping minor versions (e.g. 0.27 → 0.29) is blocked; upgrade one minor version at a time
|
||||
- Downgrading to a previous minor version is blocked
|
||||
- Patch version changes within the same minor are always allowed
|
||||
- **ACL Policy**: The `proto:icmp` protocol name now only includes ICMPv4 (protocol 1), matching Tailscale behavior [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- Previously, `proto:icmp` included both ICMPv4 and ICMPv6
|
||||
- Use `proto:ipv6-icmp` or protocol number `58` explicitly for ICMPv6
|
||||
- **CLI**: `headscale nodes register` is deprecated in favour of `headscale auth register --auth-id <id> --user <user>` [#1850](https://github.com/juanfont/headscale/pull/1850)
|
||||
|
||||
#### CLI
|
||||
|
||||
- `headscale nodes register` is deprecated in favour of `headscale auth register --auth-id <id> --user <user>` [#1850](https://github.com/juanfont/headscale/pull/1850)
|
||||
- The old command continues to work but will be removed in a future release
|
||||
|
||||
### HA subnet router health probing
|
||||
|
||||
Headscale now actively probes HA subnet routers to detect nodes that are connected but not
|
||||
forwarding traffic. The control plane periodically pings HA subnet routers via the Noise
|
||||
control channel and fails over to a healthy standby if the primary stops responding. This is
|
||||
enabled by default (`node.routes.ha.probe_interval: 10s`, `probe_timeout: 5s`) and only
|
||||
active when HA routes exist (2+ nodes advertising the same prefix). Set `probe_interval` to
|
||||
`0` to disable. This complements the existing disconnect-based failover, catching "zombie
|
||||
connected" routers that maintain their control session but cannot route packets.
|
||||
|
||||
### Changes
|
||||
|
||||
- **OIDC registration**: Add a confirmation page before completing node registration, showing the device hostname and machine key fingerprint [#3180](https://github.com/juanfont/headscale/pull/3180)
|
||||
- **Debug endpoints**: Omit secret fields (`Pass`, `ClientSecret`, `APIKey`) from `/debug/config` JSON output [#3180](https://github.com/juanfont/headscale/pull/3180)
|
||||
- **Debug endpoints**: Route `statsviz` through `tsweb.Protected` [#3180](https://github.com/juanfont/headscale/pull/3180)
|
||||
- Remove gRPC reflection from the remote (TCP) server [#3180](https://github.com/juanfont/headscale/pull/3180)
|
||||
- **Node Expiry**: Add `node.expiry` configuration option to set a default node key expiry for nodes registered via auth key [#3122](https://github.com/juanfont/headscale/pull/3122)
|
||||
- Tagged nodes (registered with tagged pre-auth keys) are exempt from default expiry
|
||||
- `oidc.expiry` has been removed; use `node.expiry` instead (applies to all registration methods including OIDC)
|
||||
- `ephemeral_node_inactivity_timeout` is deprecated in favour of `node.ephemeral.inactivity_timeout`
|
||||
- **SSH Policy**: Add support for `localpart:*@<domain>` in SSH rule `users` field, mapping each matching user's email local-part as their OS username [#3091](https://github.com/juanfont/headscale/pull/3091)
|
||||
- **ACL Policy**: Add ICMP and IPv6-ICMP protocols to default filter rules when no protocol is specified [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- **ACL Policy**: Fix autogroup:self handling for tagged nodes - tagged nodes no longer incorrectly receive autogroup:self filter rules [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- **ACL Policy**: Use CIDR format for autogroup:self destination IPs matching Tailscale behavior [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- **ACL Policy**: Merge filter rules with identical SrcIPs and IPProto matching Tailscale behavior - multiple ACL rules with the same source now produce a single FilterRule with combined DstPorts [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- 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)
|
||||
- Add SSH `check` action support with OIDC and CLI-based approval flows [#1850](https://github.com/juanfont/headscale/pull/1850)
|
||||
- Add `headscale auth register`, `headscale auth approve`, and `headscale auth reject` CLI commands [#1850](https://github.com/juanfont/headscale/pull/1850)
|
||||
- Add `auth` related routes to the API. The `auth/register` endpoint now expects data as JSON [#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)
|
||||
- Generalise auth templates into reusable `AuthSuccess` and `AuthWeb` components [#1850](https://github.com/juanfont/headscale/pull/1850)
|
||||
- Unify auth pipeline with `AuthVerdict` type, supporting registration, reauthentication, and SSH checks [#1850](https://github.com/juanfont/headscale/pull/1850)
|
||||
- Add support for policy grants with `ip`, `app`, and `via` fields [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Add `autogroup:danger-all` as a source-only autogroup resolving to all IP addresses [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Add capability grants for Taildrive (`cap/drive`) and peer relay (`cap/relay`) with automatic companion capabilities [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Add per-viewer via route steering — grants with `via` tags control which subnet router or exit node handles traffic for each group of viewers [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Enable Taildrive node attributes on all nodes; actual access is controlled by `cap/drive` grants [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Fix exit nodes incorrectly receiving filter rules for destinations that only overlap via exit routes [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
#### ACL Policy
|
||||
|
||||
- Fix subnet-to-subnet peer visibility — subnet routers now correctly become peers when ACL rules reference only subnet CIDRs as sources, without requiring node IP rules [#3175](https://github.com/juanfont/headscale/pull/3175)
|
||||
- Fix filter rule reduction to use only approved subnet routes instead of all advertised routes, matching Tailscale SaaS behavior [#3175](https://github.com/juanfont/headscale/pull/3175)
|
||||
- Add ICMP and IPv6-ICMP protocols to default filter rules when no protocol is specified [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- Fix autogroup:self handling for tagged nodes - tagged nodes no longer incorrectly receive autogroup:self filter rules [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- Use CIDR format for autogroup:self destination IPs matching Tailscale behavior [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- Merge filter rules with identical SrcIPs and IPProto matching Tailscale behavior - multiple ACL rules with the same source now produce a single FilterRule with combined DstPorts [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- Fix exit nodes incorrectly receiving filter rules for destinations that only overlap via exit routes [#3169](https://github.com/juanfont/headscale/issues/3169) [#3175](https://github.com/juanfont/headscale/pull/3175)
|
||||
- Fix address-based aliases (hosts, raw IPs) incorrectly expanding to include the matching node's other address family [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Fix identity-based aliases (tags, users, groups) resolving to IPv4 only; they now include both IPv4 and IPv6 matching Tailscale behavior [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Fix wildcard (`*`) source in ACLs now using actually-approved subnet routes instead of autoApprover policy prefixes [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- 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)
|
||||
|
||||
## 0.28.1 (202x-xx-xx)
|
||||
#### Grants
|
||||
|
||||
### Changes
|
||||
- Add support for policy grants with `ip`, `app`, and `via` fields [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Add `autogroup:danger-all` as a source-only autogroup resolving to all IP addresses [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Add capability grants for Taildrive (`cap/drive`) and peer relay (`cap/relay`) with automatic companion capabilities [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Add per-viewer via route steering — grants with `via` tags control which subnet router or exit node handles traffic for each group of viewers [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Enable Taildrive node attributes on all nodes; actual access is controlled by `cap/drive` grants [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
|
||||
#### SSH Policy
|
||||
|
||||
- Add support for `localpart:*@<domain>` in SSH rule `users` field, mapping each matching user's email local-part as their OS username [#3091](https://github.com/juanfont/headscale/pull/3091)
|
||||
- Add SSH `check` action support with OIDC and CLI-based approval flows [#1850](https://github.com/juanfont/headscale/pull/1850)
|
||||
|
||||
#### CLI
|
||||
|
||||
- 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
|
||||
|
||||
- Add `auth` related routes. The `auth/register` endpoint now expects data as JSON [#1850](https://github.com/juanfont/headscale/pull/1850)
|
||||
- Remove gRPC reflection from the remote (TCP) server [#3180](https://github.com/juanfont/headscale/pull/3180)
|
||||
|
||||
#### OIDC
|
||||
|
||||
- Add a confirmation page before completing node registration, showing the device hostname and machine key fingerprint [#3180](https://github.com/juanfont/headscale/pull/3180)
|
||||
- Generalise auth templates into reusable `AuthSuccess` and `AuthWeb` components [#1850](https://github.com/juanfont/headscale/pull/1850)
|
||||
- Unify auth pipeline with `AuthVerdict` type, supporting registration, reauthentication, and SSH checks [#1850](https://github.com/juanfont/headscale/pull/1850)
|
||||
|
||||
#### Configuration
|
||||
|
||||
- Add `node.expiry` configuration option to set a default node key expiry for nodes registered via auth key [#3122](https://github.com/juanfont/headscale/pull/3122)
|
||||
- Tagged nodes (registered with tagged pre-auth keys) are exempt from default expiry
|
||||
- `oidc.expiry` has been removed; use `node.expiry` instead (applies to all registration methods including OIDC)
|
||||
- `ephemeral_node_inactivity_timeout` is deprecated in favour of `node.ephemeral.inactivity_timeout`
|
||||
|
||||
#### Debug
|
||||
|
||||
- Add node connectivity ping page for verifying control-plane reachability [#3183](https://github.com/juanfont/headscale/pull/3183)
|
||||
- Omit secret fields (`Pass`, `ClientSecret`, `APIKey`) from `/debug/config` JSON output [#3180](https://github.com/juanfont/headscale/pull/3180)
|
||||
- Route `statsviz` through `tsweb.Protected` [#3180](https://github.com/juanfont/headscale/pull/3180)
|
||||
|
||||
#### Other
|
||||
|
||||
- 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)
|
||||
|
||||
@@ -111,7 +215,7 @@ internet is a security-sensitive choice. `autogroup:danger-all` can only be used
|
||||
Tags are now implemented following the Tailscale model where tags and user ownership are mutually exclusive. Devices can be either
|
||||
user-owned (authenticated via web/OIDC) or tagged (authenticated via tagged PreAuthKeys). Tagged devices receive their identity from
|
||||
tags rather than users, making them suitable for servers and infrastructure. Applying a tag to a device removes user-based
|
||||
ownership. See the [Tailscale tags documentation](https://tailscale.com/kb/1068/tags) for details on how tags work.
|
||||
ownership. See the [Tailscale tags documentation](https://tailscale.com/docs/features/tags) for details on how tags work.
|
||||
|
||||
User-owned nodes can now request tags during registration using `--advertise-tags`. Tags are validated against the `tagOwners` policy
|
||||
and applied at registration time. Tags can be managed via the CLI or API after registration. Tagged nodes can return to user-owned
|
||||
@@ -210,7 +314,7 @@ sequentially through each stable release, selecting the latest patch version ava
|
||||
|
||||
- **SSH Policy**: SSH source/destination validation now enforces Tailscale's security model [#3010](https://github.com/juanfont/headscale/issues/3010)
|
||||
|
||||
Per [Tailscale SSH documentation](https://tailscale.com/kb/1193/tailscale-ssh), the following rules are now enforced:
|
||||
Per [Tailscale SSH documentation](https://tailscale.com/docs/features/tailscale-ssh), the following rules are now enforced:
|
||||
1. **Tags cannot SSH to user-owned devices**: SSH rules with `tag:*` or `autogroup:tagged` as source cannot have username destinations (e.g., `alice@`) or `autogroup:member`/`autogroup:self` as destination
|
||||
2. **Username destinations require same-user source**: If destination is a specific username (e.g., `alice@`), the source must be that exact same user only. Use `autogroup:self` for same-user SSH access instead
|
||||
|
||||
@@ -339,8 +443,8 @@ DERPMap updates when upstream is changed.
|
||||
|
||||
This release adds support for the three missing autogroups: `self`
|
||||
(experimental), `member`, and `tagged`. Please refer to the
|
||||
[documentation](https://tailscale.com/kb/1018/autogroups/) for a detailed
|
||||
explanation.
|
||||
[documentation](https://tailscale.com/docs/reference/targets-and-selectors#autogroups)
|
||||
for a detailed explanation.
|
||||
|
||||
`autogroup:self` is marked as experimental and should be used with caution, but
|
||||
we need help testing it. Experimental here means two things; first, generating
|
||||
@@ -503,7 +607,7 @@ The SSH policy has been reworked to be more consistent with the rest of the
|
||||
policy. In addition, several inconsistencies between our implementation and
|
||||
Tailscale's upstream has been closed and this might be a breaking change for
|
||||
some users. Please refer to the
|
||||
[upstream documentation](https://tailscale.com/kb/1337/acl-syntax#tailscale-ssh)
|
||||
[upstream documentation](https://tailscale.com/docs/reference/syntax/policy-file#tailscale-ssh)
|
||||
for more information on which types are allowed in `src`, `dst` and `users`.
|
||||
|
||||
There is one large inconsistency left, we allow `*` as a destination as we
|
||||
@@ -1017,7 +1121,7 @@ part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460).
|
||||
|
||||
- Added support for Tailscale TS2021 protocol [#738](https://github.com/juanfont/headscale/pull/738)
|
||||
- Add experimental support for
|
||||
[SSH ACL](https://tailscale.com/kb/1018/acls/#tailscale-ssh) (see docs for
|
||||
[SSH ACL](https://tailscale.com/docs/reference/syntax/policy-file#tailscale-ssh) (see docs for
|
||||
limitations) [#847](https://github.com/juanfont/headscale/pull/847)
|
||||
- Please note that this support should be considered _partially_ implemented
|
||||
- SSH ACLs status:
|
||||
@@ -1094,7 +1198,7 @@ part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460).
|
||||
### BREAKING
|
||||
|
||||
- Old ACL syntax is no longer supported ("users" & "ports" -> "src" & "dst").
|
||||
Please check [the new syntax](https://tailscale.com/kb/1018/acls/).
|
||||
Please check [the new syntax](https://tailscale.com/docs/features/access-control/acls).
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1124,7 +1228,7 @@ part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460).
|
||||
- Add -c option to specify config file from command line [#285](https://github.com/juanfont/headscale/issues/285)
|
||||
[#612](https://github.com/juanfont/headscale/pull/601)
|
||||
- Add configuration option to allow Tailscale clients to use a random WireGuard
|
||||
port. [kb/1181/firewalls](https://tailscale.com/kb/1181/firewalls)
|
||||
port. [Tailscale docs](https://tailscale.com/docs/reference/syntax/policy-file#randomizeclientport)
|
||||
[#624](https://github.com/juanfont/headscale/pull/624)
|
||||
- Improve obtuse UX regarding missing configuration
|
||||
(`ephemeral_node_inactivity_timeout` not set)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# For testing purposes only
|
||||
|
||||
FROM golang:1.26.2-alpine AS build-env
|
||||
FROM golang:1.26.3-alpine AS build-env
|
||||
|
||||
WORKDIR /go/src
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# This Dockerfile is more or less lifted from tailscale/tailscale
|
||||
# to ensure a similar build process when testing the HEAD of tailscale.
|
||||
|
||||
FROM golang:1.26.2-alpine AS build-env
|
||||
FROM golang:1.26.3-alpine AS build-env
|
||||
|
||||
WORKDIR /go/src
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
FROM rust:1.94-bookworm AS builder
|
||||
|
||||
ARG TAILSCALE_RS_REPO=https://github.com/tailscale/tailscale-rs.git
|
||||
ARG TAILSCALE_RS_REF=main
|
||||
|
||||
WORKDIR /app
|
||||
RUN git clone --depth 1 --branch "$TAILSCALE_RS_REF" "$TAILSCALE_RS_REPO" .
|
||||
|
||||
# Re-export ts_control's insecure-keyfetch feature through the tailscale
|
||||
# crate so the axum example can fetch the headscale control key over
|
||||
# plain HTTP. The integration harness serves the control plane without
|
||||
# TLS, and upstream only allows plain-HTTP key fetches when this Cargo
|
||||
# feature is compiled in.
|
||||
RUN sed -i '/^axum = \["dep:axum"\]/a insecure-keyfetch = ["ts_control/insecure-keyfetch"]' Cargo.toml
|
||||
|
||||
RUN cargo build --release --features axum,insecure-keyfetch --example axum
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
iproute2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app/target/release/examples/axum /usr/local/bin/axum
|
||||
@@ -105,6 +105,11 @@ clean:
|
||||
.PHONY: dev
|
||||
dev: fmt lint test build
|
||||
|
||||
# Start a local headscale dev server (use mts to add nodes)
|
||||
.PHONY: dev-server
|
||||
dev-server:
|
||||
go run ./cmd/dev
|
||||
|
||||
# Help target
|
||||
.PHONY: help
|
||||
help:
|
||||
|
||||
@@ -30,8 +30,8 @@ nodes in the Tailscale network. It assigns the IP addresses of the clients,
|
||||
creates the boundaries between each user, enables sharing machines between users,
|
||||
and exposes the advertised routes of your nodes.
|
||||
|
||||
A [Tailscale network (tailnet)](https://tailscale.com/kb/1136/tailnet/) is private
|
||||
network which Tailscale assigns to a user in terms of private users or an
|
||||
A [Tailscale network (tailnet)](https://tailscale.com/docs/concepts/tailnet) is
|
||||
private network which Tailscale assigns to a user in terms of private users or an
|
||||
organisation.
|
||||
|
||||
## Design goal
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# cmd/dev -- Local Development Environment
|
||||
|
||||
Starts a headscale server on localhost with a pre-created user and
|
||||
pre-auth key. Pair with `mts` to add real tailscale nodes.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Terminal 1: start headscale
|
||||
go run ./cmd/dev
|
||||
|
||||
# Terminal 2: start mts server
|
||||
go tool mts server run
|
||||
|
||||
# Terminal 3: add and connect nodes
|
||||
go tool mts server add node1
|
||||
go tool mts server add node2
|
||||
|
||||
# Disable logtail (avoids startup delays, see "Known issues" below)
|
||||
for n in node1 node2; do
|
||||
cat > ~/.config/multi-tailscale-dev/$n/env.txt << 'EOF'
|
||||
TS_NO_LOGS_NO_SUPPORT=true
|
||||
EOF
|
||||
done
|
||||
|
||||
# Restart nodes so env.txt takes effect
|
||||
go tool mts server stop node1 && go tool mts server start node1
|
||||
go tool mts server stop node2 && go tool mts server start node2
|
||||
|
||||
# Connect to headscale (use the auth key printed by cmd/dev)
|
||||
go tool mts node1 up --login-server=http://127.0.0.1:8080 --authkey=<KEY> --reset
|
||||
go tool mts node2 up --login-server=http://127.0.0.1:8080 --authkey=<KEY> --reset
|
||||
|
||||
# Verify
|
||||
go tool mts node1 status
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Default | Description |
|
||||
| -------- | ------- | ---------------------------- |
|
||||
| `--port` | 8080 | Headscale listen port |
|
||||
| `--keep` | false | Keep state directory on exit |
|
||||
|
||||
The metrics/debug port is `port + 1010` (default 9090) and the gRPC
|
||||
port is `port + 42363` (default 50443).
|
||||
|
||||
## What it does
|
||||
|
||||
1. Builds the headscale binary into a temp directory
|
||||
2. Writes a minimal dev config (SQLite, public DERP, debug logging)
|
||||
3. Starts `headscale serve` as a subprocess
|
||||
4. Creates a "dev" user and a reusable 24h pre-auth key via the CLI
|
||||
5. Prints a banner with server URL, auth key, and usage instructions
|
||||
6. Blocks until Ctrl+C, then kills headscale
|
||||
|
||||
State lives in `/tmp/headscale-dev-*/`. Pass `--keep` to preserve it
|
||||
across restarts (useful for inspecting the database or reusing keys).
|
||||
|
||||
## Useful endpoints
|
||||
|
||||
- `http://127.0.0.1:8080/health` -- health check
|
||||
- `http://127.0.0.1:9090/debug/ping` -- interactive ping UI
|
||||
- `http://127.0.0.1:9090/debug/ping?node=1` -- quick-ping a node
|
||||
- `POST http://127.0.0.1:9090/debug/ping` with `node=<id>` -- trigger ping
|
||||
|
||||
## Managing headscale
|
||||
|
||||
The banner prints the full path to the built binary and config. Use it
|
||||
for any headscale CLI command:
|
||||
|
||||
```bash
|
||||
/tmp/headscale-dev-*/headscale -c /tmp/headscale-dev-*/config.yaml nodes list
|
||||
/tmp/headscale-dev-*/headscale -c /tmp/headscale-dev-*/config.yaml users list
|
||||
```
|
||||
|
||||
## Known issues
|
||||
|
||||
### Logtail delays on mts nodes
|
||||
|
||||
Freshly created `mts` instances may take 30+ seconds to start if
|
||||
`~/.local/share/tailscale/` contains stale logtail cache from previous
|
||||
tailscaled runs. The daemon blocks trying to upload old logs before
|
||||
creating its socket.
|
||||
|
||||
Fix: write `TS_NO_LOGS_NO_SUPPORT=true` to each instance's `env.txt`
|
||||
before starting (or restart after writing). See the quick start above.
|
||||
|
||||
### mts node cleanup
|
||||
|
||||
`mts` stores state in `~/.config/multi-tailscale-dev/`. Old instances
|
||||
accumulate over time. Clean them with:
|
||||
|
||||
```bash
|
||||
go tool mts server rm <name>
|
||||
```
|
||||
+314
@@ -0,0 +1,314 @@
|
||||
// cmd/dev starts a local headscale development server with a pre-created
|
||||
// user and pre-auth key, ready for connecting tailscale nodes via mts.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
port = flag.Int("port", 8080, "headscale listen port")
|
||||
keep = flag.Bool("keep", false, "keep state directory on exit")
|
||||
)
|
||||
|
||||
var errHealthTimeout = errors.New("health check timed out")
|
||||
|
||||
var errEmptyAuthKey = errors.New("empty auth key in response")
|
||||
|
||||
// maxDevPort is the highest --port value that keeps both the derived
|
||||
// metrics port (port+1010) and gRPC port (port+42363) inside the valid
|
||||
// 1..65535 TCP range.
|
||||
const maxDevPort = 23172
|
||||
|
||||
const devConfig = `---
|
||||
server_url: http://127.0.0.1:%d
|
||||
listen_addr: 127.0.0.1:%d
|
||||
metrics_listen_addr: 127.0.0.1:%d
|
||||
grpc_listen_addr: 127.0.0.1:%d
|
||||
grpc_allow_insecure: true
|
||||
|
||||
noise:
|
||||
private_key_path: %s/noise_private.key
|
||||
|
||||
prefixes:
|
||||
v4: 100.64.0.0/10
|
||||
v6: fd7a:115c:a1e0::/48
|
||||
allocation: sequential
|
||||
|
||||
database:
|
||||
type: sqlite
|
||||
sqlite:
|
||||
path: %s/db.sqlite
|
||||
write_ahead_log: true
|
||||
|
||||
derp:
|
||||
server:
|
||||
enabled: false
|
||||
urls:
|
||||
- https://controlplane.tailscale.com/derpmap/default
|
||||
auto_update_enabled: false
|
||||
|
||||
dns:
|
||||
magic_dns: true
|
||||
base_domain: headscale.dev
|
||||
override_local_dns: false
|
||||
|
||||
log:
|
||||
level: debug
|
||||
format: text
|
||||
|
||||
policy:
|
||||
mode: database
|
||||
|
||||
unix_socket: %s/headscale.sock
|
||||
unix_socket_permission: "0770"
|
||||
`
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
log.SetFlags(0)
|
||||
|
||||
if *port < 1 || *port > maxDevPort {
|
||||
log.Fatalf(
|
||||
"--port must be in 1..%d (higher values overflow the derived gRPC port); got %d",
|
||||
maxDevPort, *port,
|
||||
)
|
||||
}
|
||||
|
||||
http.DefaultClient.Timeout = 2 * time.Second
|
||||
http.DefaultClient.CheckRedirect = func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
|
||||
err := run()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
metricsPort := *port + 1010 // default 9090
|
||||
grpcPort := *port + 42363 // default 50443
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "headscale-dev-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating temp dir: %w", err)
|
||||
}
|
||||
|
||||
if !*keep {
|
||||
defer os.RemoveAll(tmpDir)
|
||||
}
|
||||
|
||||
// Write config.
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
configContent := fmt.Sprintf(devConfig,
|
||||
*port, *port, metricsPort, grpcPort,
|
||||
tmpDir, tmpDir, tmpDir,
|
||||
)
|
||||
|
||||
err = os.WriteFile(configPath, []byte(configContent), 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing config: %w", err)
|
||||
}
|
||||
|
||||
// Build headscale.
|
||||
fmt.Println("Building headscale...")
|
||||
|
||||
hsBin := filepath.Join(tmpDir, "headscale")
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
build := exec.CommandContext(ctx, "go", "build", "-o", hsBin, "./cmd/headscale")
|
||||
build.Stdout = os.Stdout
|
||||
build.Stderr = os.Stderr
|
||||
|
||||
err = build.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("building headscale: %w", err)
|
||||
}
|
||||
|
||||
// Start headscale serve.
|
||||
fmt.Println("Starting headscale server...")
|
||||
|
||||
serve := exec.CommandContext(ctx, hsBin, "serve", "-c", configPath)
|
||||
serve.Stdout = os.Stdout
|
||||
serve.Stderr = os.Stderr
|
||||
|
||||
err = serve.Start()
|
||||
if err != nil {
|
||||
return fmt.Errorf("starting headscale: %w", err)
|
||||
}
|
||||
|
||||
// Wait for server to be ready.
|
||||
healthURL := fmt.Sprintf("http://127.0.0.1:%d/health", *port)
|
||||
|
||||
err = waitForHealth(ctx, healthURL, 30*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("waiting for headscale: %w", err)
|
||||
}
|
||||
|
||||
// Create user.
|
||||
fmt.Println("Creating user and pre-auth key...")
|
||||
|
||||
userJSON, err := runHS(ctx, hsBin, configPath, "users", "create", "dev", "-o", "json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating user: %w", err)
|
||||
}
|
||||
|
||||
userID, err := extractUserID(userJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing user: %w", err)
|
||||
}
|
||||
|
||||
// Create pre-auth key.
|
||||
keyJSON, err := runHS(
|
||||
ctx, hsBin, configPath,
|
||||
"preauthkeys", "create",
|
||||
"-u", strconv.FormatUint(userID, 10),
|
||||
"--reusable",
|
||||
"-e", "24h",
|
||||
"-o", "json",
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating pre-auth key: %w", err)
|
||||
}
|
||||
|
||||
authKey, err := extractAuthKey(keyJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing pre-auth key: %w", err)
|
||||
}
|
||||
|
||||
// Print banner.
|
||||
fmt.Printf(`
|
||||
=== Headscale Dev Environment ===
|
||||
Server: http://127.0.0.1:%d
|
||||
Metrics: http://127.0.0.1:%d
|
||||
Debug: http://127.0.0.1:%d/debug/ping
|
||||
Config: %s
|
||||
State: %s
|
||||
|
||||
Pre-auth key: %s
|
||||
|
||||
Connect nodes with mts:
|
||||
go tool mts server run # start mts (once, another terminal)
|
||||
go tool mts server add node1 # create a node
|
||||
go tool mts node1 up --login-server=http://127.0.0.1:%d --authkey=%s
|
||||
go tool mts node1 status # check connection
|
||||
|
||||
Manage headscale:
|
||||
%s -c %s nodes list
|
||||
%s -c %s users list
|
||||
|
||||
Press Ctrl+C to stop.
|
||||
`,
|
||||
*port, metricsPort, metricsPort,
|
||||
configPath, tmpDir,
|
||||
authKey,
|
||||
*port, authKey,
|
||||
hsBin, configPath,
|
||||
hsBin, configPath,
|
||||
)
|
||||
|
||||
// Wait for headscale to exit.
|
||||
err = serve.Wait()
|
||||
if err != nil {
|
||||
// Context cancellation is expected on Ctrl+C.
|
||||
if ctx.Err() != nil {
|
||||
fmt.Println("\nShutting down...")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("headscale exited: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForHealth polls the health endpoint until it returns 200 or the
|
||||
// timeout expires.
|
||||
func waitForHealth(ctx context.Context, url string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Busy-wait is acceptable for a dev tool polling a local server.
|
||||
time.Sleep(200 * time.Millisecond) //nolint:forbidigo
|
||||
}
|
||||
|
||||
return errHealthTimeout
|
||||
}
|
||||
|
||||
// runHS executes a headscale CLI command and returns its stdout.
|
||||
func runHS(ctx context.Context, bin, config string, args ...string) ([]byte, error) {
|
||||
fullArgs := append([]string{"-c", config}, args...)
|
||||
cmd := exec.CommandContext(ctx, bin, fullArgs...)
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
return cmd.Output()
|
||||
}
|
||||
|
||||
// extractUserID parses the JSON output of "users create" and returns the
|
||||
// user ID.
|
||||
func extractUserID(data []byte) (uint64, error) {
|
||||
var user struct {
|
||||
ID uint64 `json:"id"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal(data, &user)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("unmarshalling user JSON: %w (raw: %s)", err, data)
|
||||
}
|
||||
|
||||
return user.ID, nil
|
||||
}
|
||||
|
||||
// extractAuthKey parses the JSON output of "preauthkeys create" and
|
||||
// returns the key string.
|
||||
func extractAuthKey(data []byte) (string, error) {
|
||||
var key struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal(data, &key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unmarshalling key JSON: %w (raw: %s)", err, data)
|
||||
}
|
||||
|
||||
if key.Key == "" {
|
||||
return "", errEmptyAuthKey
|
||||
}
|
||||
|
||||
return key.Key, nil
|
||||
}
|
||||
@@ -81,7 +81,7 @@ var createAPIKeyCmd = &cobra.Command{
|
||||
Long: `
|
||||
Creates a new Api key, the Api key is only visible on creation
|
||||
and cannot be retrieved again.
|
||||
If you loose a key, create a new one and revoke (expire) the old one.`,
|
||||
If you lose a key, create a new one and revoke (expire) the old one.`,
|
||||
Aliases: []string{"c", "new"},
|
||||
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
|
||||
expiration, err := expirationFromFlag(cmd)
|
||||
|
||||
@@ -175,8 +175,10 @@ Use --disable to disable key expiry (node will never expire).`,
|
||||
now := time.Now()
|
||||
|
||||
expiryTime := now
|
||||
|
||||
if expiry != "" {
|
||||
var err error
|
||||
|
||||
expiryTime, err = time.Parse(time.RFC3339, expiry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing expiry time: %w", err)
|
||||
@@ -397,6 +399,7 @@ func nodesToPtables(
|
||||
}
|
||||
|
||||
var ipBuilder strings.Builder
|
||||
|
||||
for _, addr := range node.GetIpAddresses() {
|
||||
ip, err := netip.ParseAddr(addr)
|
||||
if err == nil {
|
||||
|
||||
@@ -48,6 +48,7 @@ func init() {
|
||||
policyCmd.AddCommand(setPolicy)
|
||||
|
||||
checkPolicy.Flags().StringP("file", "f", "", "Path to a policy file in HuJSON format")
|
||||
checkPolicy.Flags().BoolP(bypassFlag, "", false, "Open the database directly (no gRPC, no running server) to validate user@ token references and to evaluate the policy's tests block. Required when those checks are needed.")
|
||||
mustMarkRequired(checkPolicy, "file")
|
||||
policyCmd.AddCommand(checkPolicy)
|
||||
}
|
||||
@@ -63,6 +64,7 @@ var getPolicy = &cobra.Command{
|
||||
Aliases: []string{"show", "view", "fetch"},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var policyData string
|
||||
|
||||
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
|
||||
@@ -169,6 +171,11 @@ var setPolicy = &cobra.Command{
|
||||
var checkPolicy = &cobra.Command{
|
||||
Use: "check",
|
||||
Short: "Check the Policy file for errors",
|
||||
Long: `
|
||||
Check validates the policy against the server's live users and nodes,
|
||||
running any "tests" block. By default the command is a thin frontend
|
||||
for a gRPC call to a running headscale; pass --` + bypassFlag + ` to
|
||||
open the database directly when headscale is not running.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
policyPath, _ := cmd.Flags().GetString("file")
|
||||
|
||||
@@ -177,9 +184,56 @@ var checkPolicy = &cobra.Command{
|
||||
return fmt.Errorf("reading policy file: %w", err)
|
||||
}
|
||||
|
||||
_, err = policy.NewPolicyManager(policyBytes, nil, views.Slice[types.NodeView]{})
|
||||
if bypass, _ := cmd.Flags().GetBool(bypassFlag); bypass {
|
||||
if !confirmAction(cmd, "DO NOT run this command if an instance of headscale is running, are you sure headscale is not running?") {
|
||||
return errAborted
|
||||
}
|
||||
|
||||
d, err := bypassDatabase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
users, err := d.ListUsers()
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading users: %w", err)
|
||||
}
|
||||
|
||||
nodes, err := d.ListNodes()
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading nodes: %w", err)
|
||||
}
|
||||
|
||||
// NewPolicyManager validates structure and user references
|
||||
// but intentionally skips test evaluation (boot path).
|
||||
// SetPolicy is the user-write boundary and is what runs the
|
||||
// tests block.
|
||||
pm, err := policy.NewPolicyManager(policyBytes, users, nodes.ViewSlice())
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing policy file: %w", err)
|
||||
}
|
||||
|
||||
_, err = pm.SetPolicy(policyBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("Policy is valid")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing policy file: %w", err)
|
||||
return fmt.Errorf("connecting to headscale: %w", err)
|
||||
}
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
_, err = client.CheckPolicy(ctx, &v1.CheckPolicyRequest{Policy: string(policyBytes)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("Policy is valid")
|
||||
|
||||
@@ -3,7 +3,6 @@ package cli
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
@@ -22,11 +21,6 @@ func init() {
|
||||
return
|
||||
}
|
||||
|
||||
if slices.Contains(os.Args, "policy") && slices.Contains(os.Args, "check") {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
return
|
||||
}
|
||||
|
||||
cobra.OnInitialize(initConfig)
|
||||
rootCmd.PersistentFlags().
|
||||
StringVarP(&cfgFile, "config", "c", "", "config file (default is /etc/headscale/config.yaml)")
|
||||
|
||||
+33
-13
@@ -20,7 +20,7 @@ listen_addr: 127.0.0.1:8080
|
||||
|
||||
# Address to listen to /metrics and /debug, you may want
|
||||
# to keep this endpoint private to your internal network
|
||||
# Use an emty value to disable the metrics listener.
|
||||
# Use an empty value to disable the metrics listener.
|
||||
metrics_listen_addr: 127.0.0.1:9090
|
||||
|
||||
# Address to listen for gRPC.
|
||||
@@ -128,7 +128,7 @@ derp:
|
||||
#
|
||||
# This option is mostly interesting for people hosting
|
||||
# their own DERP servers:
|
||||
# https://tailscale.com/kb/1118/custom-derp-servers/
|
||||
# https://tailscale.com/docs/reference/derp-servers/custom-derp-servers
|
||||
#
|
||||
# paths:
|
||||
# - /etc/headscale/derp-example.yaml
|
||||
@@ -165,6 +165,26 @@ node:
|
||||
# Time before an inactive ephemeral node is deleted.
|
||||
inactivity_timeout: 30m
|
||||
|
||||
# HA subnet router health probing.
|
||||
#
|
||||
# When HA routes exist (2+ nodes advertising the same prefix), headscale
|
||||
# pings each HA node every probe_interval via the Noise channel. If a node
|
||||
# fails to respond within probe_timeout it is marked unhealthy and the
|
||||
# primary role moves to the next healthy node. A node that later responds
|
||||
# is marked healthy again but does NOT reclaim primary (avoids flapping).
|
||||
#
|
||||
# Worst-case detection time is probe_interval + probe_timeout (15s default).
|
||||
# No-op when no HA routes exist. Set probe_interval to 0 to disable.
|
||||
routes:
|
||||
ha:
|
||||
# How often to ping HA subnet routers. Set to 0 to disable probing.
|
||||
# Must be >= 2s when enabled.
|
||||
probe_interval: 10s
|
||||
|
||||
# How long to wait for a ping response before marking a node unhealthy.
|
||||
# Must be >= 1s and less than probe_interval.
|
||||
probe_timeout: 5s
|
||||
|
||||
database:
|
||||
# Database type. Available options: sqlite, postgres
|
||||
# Please note that using Postgres is highly discouraged as it is only supported for legacy reasons.
|
||||
@@ -263,7 +283,7 @@ log:
|
||||
## Policy
|
||||
# headscale supports Tailscale's ACL policies.
|
||||
# Please have a look to their KB to better
|
||||
# understand the concepts: https://tailscale.com/kb/1018/acls/
|
||||
# understand the concepts: https://tailscale.com/docs/features/access-control/acls
|
||||
policy:
|
||||
# The mode can be "file" or "database" that defines
|
||||
# where the ACL policies are stored and read from.
|
||||
@@ -277,9 +297,9 @@ policy:
|
||||
# headscale supports Tailscale's DNS configuration and MagicDNS.
|
||||
# Please have a look to their KB to better understand the concepts:
|
||||
#
|
||||
# - https://tailscale.com/kb/1054/dns/
|
||||
# - https://tailscale.com/kb/1081/magicdns/
|
||||
# - https://tailscale.com/blog/2021-09-private-dns-with-magicdns/
|
||||
# - https://tailscale.com/docs/features/access-control/acls
|
||||
# - https://tailscale.com/docs/features/magicdns
|
||||
# - https://tailscale.com/blog/2021-09-private-dns-with-magicdns
|
||||
#
|
||||
# Please note that for the DNS configuration to have any effect,
|
||||
# clients must have the `--accept-dns=true` option enabled. This is the
|
||||
@@ -289,12 +309,12 @@ policy:
|
||||
# Setting _any_ of the configuration and `--accept-dns=true` on the
|
||||
# clients will integrate with the DNS manager on the client or
|
||||
# overwrite /etc/resolv.conf.
|
||||
# https://tailscale.com/kb/1235/resolv-conf
|
||||
# https://tailscale.com/docs/reference/faq/dns-resolv-conf
|
||||
#
|
||||
# If you want stop Headscale from managing the DNS configuration
|
||||
# all the fields under `dns` should be set to empty values.
|
||||
dns:
|
||||
# Whether to use [MagicDNS](https://tailscale.com/kb/1081/magicdns/).
|
||||
# Whether to use [MagicDNS](https://tailscale.com/docs/features/magicdns).
|
||||
magic_dns: true
|
||||
|
||||
# Defines the base domain to create the hostnames for MagicDNS.
|
||||
@@ -316,11 +336,11 @@ dns:
|
||||
- 2606:4700:4700::1111
|
||||
- 2606:4700:4700::1001
|
||||
|
||||
# NextDNS (see https://tailscale.com/kb/1218/nextdns/).
|
||||
# NextDNS (see https://tailscale.com/docs/integrations/nextdns).
|
||||
# "abc123" is example NextDNS ID, replace with yours.
|
||||
# - https://dns.nextdns.io/abc123
|
||||
|
||||
# Split DNS (see https://tailscale.com/kb/1054/dns/),
|
||||
# Split DNS (see https://tailscale.com/docs/reference/dns-in-tailscale#restricted-nameservers),
|
||||
# a map of domains and which DNS server to use for each.
|
||||
split: {}
|
||||
# foo.bar.com:
|
||||
@@ -425,7 +445,7 @@ unix_socket_permission: "0770"
|
||||
# Logtail is Tailscales logging and auditing infrastructure, it allows the
|
||||
# control panel to instruct tailscale nodes to log their activity to a remote
|
||||
# server. To disable logging on the client side, please refer to:
|
||||
# https://tailscale.com/kb/1011/log-mesh-traffic#opting-out-of-client-logging
|
||||
# https://tailscale.com/docs/features/logging#opt-out-of-client-logging
|
||||
logtail:
|
||||
# Enable logtail for tailscale nodes of this Headscale instance.
|
||||
# As there is currently no support for overriding the log server in Headscale, this is
|
||||
@@ -434,12 +454,12 @@ logtail:
|
||||
|
||||
# Enabling this option makes devices prefer a random port for WireGuard traffic over the
|
||||
# default static port 41641. This option is intended as a workaround for some buggy
|
||||
# firewall devices. See https://tailscale.com/kb/1181/firewalls/ for more information.
|
||||
# firewall devices. See https://tailscale.com/docs/integrations/firewalls for more information.
|
||||
randomize_client_port: false
|
||||
|
||||
# Taildrop configuration
|
||||
# Taildrop is the file sharing feature of Tailscale, allowing nodes to send files to each other.
|
||||
# https://tailscale.com/kb/1106/taildrop/
|
||||
# https://tailscale.com/docs/features/taildrop
|
||||
taildrop:
|
||||
# Enable or disable Taildrop for all nodes.
|
||||
# When enabled, nodes can send files to other nodes owned by the same user.
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
# If you plan to somehow use headscale, please deploy your own DERP infra: https://tailscale.com/kb/1118/custom-derp-servers/
|
||||
# If you plan to somehow use headscale, please deploy your own DERP infra.
|
||||
# See: https://tailscale.com/docs/reference/derp-servers/custom-derp-servers
|
||||
regions:
|
||||
1: null # Disable DERP region with ID 1
|
||||
900:
|
||||
|
||||
+4
-4
@@ -145,7 +145,7 @@ This is essentially how Tailscale works. If traffic is allowed to flow in one di
|
||||
in their output of `tailscale status`. Traffic is still filtered according to the ACL, with the exception of
|
||||
`tailscale ping` which is always allowed in either direction.
|
||||
|
||||
See also <https://tailscale.com/kb/1087/device-visibility>.
|
||||
See also <https://tailscale.com/docs/concepts/device-visibility>.
|
||||
|
||||
## My policy is stored in the database and Headscale refuses to start due to an invalid policy. How can I recover?
|
||||
|
||||
@@ -199,7 +199,7 @@ Nodes should reconnect within a few seconds and pickup their newly assigned IP a
|
||||
## How can I avoid to send logs to Tailscale Inc?
|
||||
|
||||
A Tailscale client [collects logs about its operation and connection attempts with other
|
||||
clients](https://tailscale.com/kb/1011/log-mesh-traffic#client-logs) and sends them to a central log service operated by
|
||||
clients](https://tailscale.com/docs/features/logging#client-logs) and sends them to a central log service operated by
|
||||
Tailscale Inc.
|
||||
|
||||
Headscale, by default, instructs clients to disable log submission to the central log service. This configuration is
|
||||
@@ -209,5 +209,5 @@ applied by a client once it successfully connected with Headscale. See the confi
|
||||
Alternatively, logging can also be disabled on the client side. This is independent of Headscale and opting out of
|
||||
client logging disables log submission early during client startup. The configuration is operating system specific and
|
||||
is usually achieved by setting the environment variable `TS_NO_LOGS_NO_SUPPORT=true` or by passing the flag
|
||||
`--no-logs-no-support` to `tailscaled`. See
|
||||
<https://tailscale.com/kb/1011/log-mesh-traffic#opting-out-of-client-logging> for details.
|
||||
`--no-logs-no-support` to `tailscaled`. See <https://tailscale.com/docs/features/logging#opt-out-of-client-logging> for
|
||||
details.
|
||||
|
||||
+12
-11
@@ -9,11 +9,11 @@ provides on overview of Headscale's feature and compatibility with the Tailscale
|
||||
- [x] [Web authentication](../ref/registration.md#web-authentication)
|
||||
- [x] [Pre authenticated key](../ref/registration.md#pre-authenticated-key)
|
||||
- [x] [DNS](../ref/dns.md)
|
||||
- [x] [MagicDNS](https://tailscale.com/kb/1081/magicdns)
|
||||
- [x] [Global and restricted nameservers (split DNS)](https://tailscale.com/kb/1054/dns#nameservers)
|
||||
- [x] [search domains](https://tailscale.com/kb/1054/dns#search-domains)
|
||||
- [x] [MagicDNS](https://tailscale.com/docs/features/magicdns)
|
||||
- [x] [Global and restricted nameservers (split DNS)](https://tailscale.com/docs/reference/dns-in-tailscale#nameservers)
|
||||
- [x] [search domains](https://tailscale.com/docs/reference/dns-in-tailscale#search-domains)
|
||||
- [x] [Extra DNS records (Headscale only)](../ref/dns.md#setting-extra-dns-records)
|
||||
- [x] [Taildrop (File Sharing)](https://tailscale.com/kb/1106/taildrop)
|
||||
- [x] [Taildrop (File Sharing)](https://tailscale.com/docs/features/taildrop)
|
||||
- [x] [Tags](../ref/tags.md)
|
||||
- [x] [Routes](../ref/routes.md)
|
||||
- [x] [Subnet routers](../ref/routes.md#subnet-router)
|
||||
@@ -23,16 +23,17 @@ provides on overview of Headscale's feature and compatibility with the Tailscale
|
||||
- [x] Embedded [DERP server](../ref/derp.md)
|
||||
- [x] Access control lists ([GitHub label "policy"](https://github.com/juanfont/headscale/labels/policy%20%F0%9F%93%9D))
|
||||
- [x] ACL management via API
|
||||
- [x] Some [Autogroups](https://tailscale.com/kb/1396/targets#autogroups), currently: `autogroup:internet`,
|
||||
`autogroup:nonroot`, `autogroup:member`, `autogroup:tagged`, `autogroup:self`
|
||||
- [x] [Auto approvers](https://tailscale.com/kb/1337/acl-syntax#auto-approvers) for [subnet
|
||||
- [x] Some [Autogroups](https://tailscale.com/docs/reference/targets-and-selectors#autogroups), currently:
|
||||
`autogroup:internet`, `autogroup:nonroot`, `autogroup:member`, `autogroup:tagged`, `autogroup:self`,
|
||||
`autogroup:danger-all`
|
||||
- [x] [Auto approvers](https://tailscale.com/docs/reference/syntax/policy-file#auto-approvers) for [subnet
|
||||
routers](../ref/routes.md#automatically-approve-routes-of-a-subnet-router) and [exit
|
||||
nodes](../ref/routes.md#automatically-approve-an-exit-node-with-auto-approvers)
|
||||
- [x] [Tailscale SSH](https://tailscale.com/kb/1193/tailscale-ssh)
|
||||
- [x] [Tailscale SSH](https://tailscale.com/docs/features/tailscale-ssh)
|
||||
- [x] [Node registration using Single-Sign-On (OpenID Connect)](../ref/oidc.md) ([GitHub label "OIDC"](https://github.com/juanfont/headscale/labels/OIDC))
|
||||
- [x] Basic registration
|
||||
- [x] Update user profile from identity provider
|
||||
- [ ] OIDC groups cannot be used in ACLs
|
||||
- [ ] [Funnel](https://tailscale.com/kb/1223/funnel) ([#1040](https://github.com/juanfont/headscale/issues/1040))
|
||||
- [ ] [Serve](https://tailscale.com/kb/1312/serve) ([#1234](https://github.com/juanfont/headscale/issues/1921))
|
||||
- [ ] [Network flow logs](https://tailscale.com/kb/1219/network-flow-logs) ([#1687](https://github.com/juanfont/headscale/issues/1687))
|
||||
- [ ] [Funnel](https://tailscale.com/docs/features/tailscale-funnel) ([#1040](https://github.com/juanfont/headscale/issues/1040))
|
||||
- [ ] [Serve](https://tailscale.com/docs/features/tailscale-serve) ([#1234](https://github.com/juanfont/headscale/issues/1921))
|
||||
- [ ] [Network flow logs](https://tailscale.com/docs/features/logging/network-flow-logs) ([#1687](https://github.com/juanfont/headscale/issues/1687))
|
||||
|
||||
+13
-6
@@ -3,7 +3,8 @@ Headscale implements the same policy ACLs as Tailscale.com, adapted to the self-
|
||||
For instance, instead of referring to users when defining groups you must
|
||||
use users (which are the equivalent to user/logins in Tailscale.com).
|
||||
|
||||
Please check https://tailscale.com/kb/1018/acls/ for further information.
|
||||
Please check [manage permissions using ACLs](https://tailscale.com/docs/features/access-control/acls) for further
|
||||
information.
|
||||
|
||||
When using ACL's the User borders are no longer applied. All machines
|
||||
whichever the User have the ability to communicate with other hosts as
|
||||
@@ -15,8 +16,8 @@ To enable and configure ACLs in Headscale, you need to specify the path to your
|
||||
|
||||
Your ACL policy file must be formatted using [huJSON](https://github.com/tailscale/hujson).
|
||||
|
||||
Info on how these policies are written can be found
|
||||
[here](https://tailscale.com/kb/1018/acls/).
|
||||
Info on how these policies are written can be found in [Tailscale's ACL
|
||||
documentation](https://tailscale.com/docs/features/access-control/acls).
|
||||
|
||||
Please reload or restart Headscale after updating the ACL file. Headscale may be reloaded either via its systemd service
|
||||
(`sudo systemctl reload headscale`) or by sending a SIGHUP signal (`sudo kill -HUP $(pidof headscale)`) to the main
|
||||
@@ -24,13 +25,13 @@ process. Headscale logs the result of ACL policy processing after each reload.
|
||||
|
||||
## Simple Examples
|
||||
|
||||
- [**Allow All**](https://tailscale.com/kb/1192/acl-samples#allow-all-default-acl): If you define an ACL file but completely omit the `"acls"` field from its content, Headscale will default to an "allow all" policy. This means all devices connected to your tailnet will be able to communicate freely with each other.
|
||||
- [**Allow All**](https://tailscale.com/docs/reference/examples/acls#allow-all-default-acl): If you define an ACL file but completely omit the `"acls"` field from its content, Headscale will default to an "allow all" policy. This means all devices connected to your tailnet will be able to communicate freely with each other.
|
||||
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
- [**Deny All**](https://tailscale.com/kb/1192/acl-samples#deny-all): To prevent all communication within your tailnet, you can include an empty array for the `"acls"` field in your policy file.
|
||||
- [**Deny All**](https://tailscale.com/docs/reference/examples/acls#deny-all): To prevent all communication within your tailnet, you can include an empty array for the `"acls"` field in your policy file.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -87,7 +88,7 @@ Here are the ACL's to implement the same permissions as above:
|
||||
"group:intern": ["intern1@"]
|
||||
},
|
||||
// tagOwners in tailscale is an association between a TAG and the people allowed to set this TAG on a server.
|
||||
// This is documented [here](https://tailscale.com/kb/1068/acl-tags#defining-a-tag)
|
||||
// This is documented [here](https://tailscale.com/docs/features/tags)
|
||||
// and explained [here](https://tailscale.com/blog/rbac-like-it-was-meant-to-be/)
|
||||
"tagOwners": {
|
||||
// the administrators can add servers in production
|
||||
@@ -286,3 +287,9 @@ Used in Tailscale SSH rules to allow access to any user except root. Can only be
|
||||
"users": ["autogroup:nonroot"]
|
||||
}
|
||||
```
|
||||
|
||||
### `autogroup:danger-all`
|
||||
|
||||
This autogroup resolves to all IP addresses (`0.0.0.0/0` and `::/0`) which also includes all IP addresses outside the
|
||||
standard Tailscale IP ranges. [This autogroup can only be used as
|
||||
source](https://tailscale.com/docs/reference/targets-and-selectors#autogroupdanger-all).
|
||||
|
||||
+5
-5
@@ -3,16 +3,16 @@
|
||||
Headscale and Tailscale provide debug and introspection capabilities that can be helpful when things don't work as
|
||||
expected. This page explains some debugging techniques to help pinpoint problems.
|
||||
|
||||
Please also have a look at [Tailscale's Troubleshooting guide](https://tailscale.com/kb/1023/troubleshooting). It offers
|
||||
a many tips and suggestions to troubleshoot common issues.
|
||||
Please also have a look at [Tailscale's Troubleshooting guide](https://tailscale.com/docs/reference/troubleshooting). It
|
||||
offers a many tips and suggestions to troubleshoot common issues.
|
||||
|
||||
## Tailscale
|
||||
|
||||
The Tailscale client itself offers many commands to introspect its state as well as the state of the network:
|
||||
|
||||
- [Check local network conditions](https://tailscale.com/kb/1080/cli#netcheck): `tailscale netcheck`
|
||||
- [Get the client status](https://tailscale.com/kb/1080/cli#status): `tailscale status --json`
|
||||
- [Get DNS status](https://tailscale.com/kb/1080/cli#dns): `tailscale dns status --all`
|
||||
- [Check local network conditions](https://tailscale.com/docs/reference/tailscale-cli#netcheck): `tailscale netcheck`
|
||||
- [Get the client status](https://tailscale.com/docs/reference/tailscale-cli#status): `tailscale status --json`
|
||||
- [Get DNS status](https://tailscale.com/docs/reference/tailscale-cli#dns): `tailscale dns status --all`
|
||||
- Client logs: `tailscale debug daemon-logs`
|
||||
- Client netmap: `tailscale debug netmap`
|
||||
- Test DERP connection: `tailscale debug derp headscale`
|
||||
|
||||
+7
-7
@@ -1,8 +1,8 @@
|
||||
# DERP
|
||||
|
||||
A [DERP (Designated Encrypted Relay for Packets) server](https://tailscale.com/kb/1232/derp-servers) is mainly used to
|
||||
relay traffic between two nodes in case a direct connection can't be established. Headscale provides an embedded DERP
|
||||
server to ensure seamless connectivity between nodes.
|
||||
A [DERP (Designated Encrypted Relay for Packets) server](https://tailscale.com/docs/reference/derp-servers) is mainly
|
||||
used to relay traffic between two nodes in case a direct connection can't be established. Headscale provides an embedded
|
||||
DERP server to ensure seamless connectivity between nodes.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -31,8 +31,8 @@ traversal. [Check DERP server connectivity](#check-derp-server-connectivity) to
|
||||
### Remove Tailscale's DERP servers
|
||||
|
||||
Once enabled, Headscale's embedded DERP is added to the list of free-to-use [DERP
|
||||
servers](https://tailscale.com/kb/1232/derp-servers) offered by Tailscale Inc. To only use Headscale's embedded DERP
|
||||
server, disable the loading of the default DERP map:
|
||||
servers](https://tailscale.com/docs/reference/derp-servers) offered by Tailscale Inc. To only use Headscale's embedded
|
||||
DERP server, disable the loading of the default DERP map:
|
||||
|
||||
```yaml title="config.yaml" hl_lines="6"
|
||||
derp:
|
||||
@@ -59,8 +59,8 @@ maps fetched via URL or to offer your own, custom DERP servers to nodes.
|
||||
|
||||
=== "Remove specific DERP regions"
|
||||
|
||||
The free-to-use [DERP servers](https://tailscale.com/kb/1232/derp-servers) are organized into regions via a region
|
||||
ID. You can explicitly disable a specific region by setting its region ID to `null`. The following sample
|
||||
The free-to-use [DERP servers](https://tailscale.com/docs/reference/derp-servers) are organized into regions via a
|
||||
region ID. You can explicitly disable a specific region by setting its region ID to `null`. The following sample
|
||||
`derp.yaml` disables the New York DERP region (which has the region ID 1):
|
||||
|
||||
```yaml title="derp.yaml"
|
||||
|
||||
+2
-2
@@ -6,8 +6,8 @@ within the `dns` section of the [configuration file](./configuration.md).
|
||||
## Setting extra DNS records
|
||||
|
||||
Headscale allows to set extra DNS records which are made available via
|
||||
[MagicDNS](https://tailscale.com/kb/1081/magicdns). Extra DNS records can be configured either via static entries in the
|
||||
[configuration file](./configuration.md) or from a JSON file that Headscale continuously watches for changes:
|
||||
[MagicDNS](https://tailscale.com/docs/features/magicdns). Extra DNS records can be configured either via static entries
|
||||
in the [configuration file](./configuration.md) or from a JSON file that Headscale continuously watches for changes:
|
||||
|
||||
- Use the `dns.extra_records` option in the [configuration file](./configuration.md) for entries that are static and
|
||||
don't change while Headscale is running. Those entries are processed when Headscale is starting up and changes to the
|
||||
|
||||
@@ -22,5 +22,6 @@ Headscale doesn't provide a built-in web interface but users may pick one from t
|
||||
- [headscale-piying](https://github.com/wszgrcy/headscale-piying) - headscale web ui,support visual ACL configuration
|
||||
- [HeadControl](https://github.com/ahmadzip/HeadControl) - Minimal Headscale admin dashboard, built with Go and HTMX
|
||||
- [Headscale Manager](https://github.com/hkdone/headscalemanager) - Headscale UI for Android
|
||||
- [Headscale UI](https://github.com/MunMunMiao/headscale-ui) - Headscale UI online and Self-hosting
|
||||
|
||||
You can ask for support on our [Discord server](https://discord.gg/c84AZQhmpx) in the "web-interfaces" channel.
|
||||
|
||||
@@ -11,8 +11,8 @@ Tailscale's identity model distinguishes between personal and tagged nodes:
|
||||
workstations or mobile phones. End-user devices are managed by a single user.
|
||||
- A tagged node (or service-based node or non-human node) provides services to the network. Common examples include web-
|
||||
and database servers. Those nodes are typically managed by a team of users. Some additional restrictions apply for
|
||||
tagged nodes, e.g. a tagged node is not allowed to [Tailscale SSH](https://tailscale.com/kb/1193/tailscale-ssh) into a
|
||||
personal node.
|
||||
tagged nodes, e.g. a tagged node is not allowed to [Tailscale SSH](https://tailscale.com/docs/features/tailscale-ssh)
|
||||
into a personal node.
|
||||
|
||||
Headscale implements Tailscale's identity model and distinguishes between personal and tagged nodes where a personal
|
||||
node is owned by a Headscale user and a tagged node is owned by a tag. Tagged devices are grouped under the special user
|
||||
@@ -61,8 +61,8 @@ headscale users create <USER>
|
||||
=== "Tagged devices"
|
||||
|
||||
Your Headscale user needs to be authorized to register tagged devices. This authorization is specified in the
|
||||
[`tagOwners`](https://tailscale.com/kb/1337/policy-syntax#tag-owners) section of the [ACL](acls.md). A simple
|
||||
example looks like this:
|
||||
[`tagOwners`](https://tailscale.com/docs/reference/syntax/policy-file#tag-owners) section of the [ACL](acls.md). A
|
||||
simple example looks like this:
|
||||
|
||||
```json title="The user alice can register nodes tagged with tag:server"
|
||||
{
|
||||
|
||||
+19
-20
@@ -1,7 +1,8 @@
|
||||
# Routes
|
||||
|
||||
Headscale supports route advertising and can be used to manage [subnet routers](https://tailscale.com/kb/1019/subnets)
|
||||
and [exit nodes](https://tailscale.com/kb/1103/exit-nodes) for a tailnet.
|
||||
Headscale supports route advertising and can be used to manage [subnet
|
||||
routers](https://tailscale.com/docs/features/subnet-routers) and [exit
|
||||
nodes](https://tailscale.com/docs/features/exit-nodes) for a tailnet.
|
||||
|
||||
- [Subnet routers](#subnet-router) may be used to connect an existing network such as a virtual
|
||||
private cloud or an on-premise network with your tailnet. Use a subnet router to access devices where Tailscale can't
|
||||
@@ -72,8 +73,8 @@ $ sudo tailscale set --accept-routes
|
||||
```
|
||||
|
||||
Please refer to the official [Tailscale
|
||||
documentation](https://tailscale.com/kb/1019/subnets#use-your-subnet-routes-from-other-devices) for how to use a subnet
|
||||
router on different operating systems.
|
||||
documentation](https://tailscale.com/docs/features/subnet-routers#use-your-subnet-routes-from-other-devices) for how to
|
||||
use a subnet router on different operating systems.
|
||||
|
||||
### Restrict the use of a subnet router with ACL
|
||||
|
||||
@@ -135,8 +136,9 @@ Advertise the route `192.168.0.0/24` from a subnet router that also advertises t
|
||||
$ sudo tailscale up --login-server <YOUR_HEADSCALE_URL> --advertise-tags tag:router --advertise-routes 192.168.0.0/24
|
||||
```
|
||||
|
||||
Please see the [official Tailscale documentation](https://tailscale.com/kb/1337/acl-syntax#autoapprovers) for more
|
||||
information on auto approvers.
|
||||
Please see the [official Tailscale
|
||||
documentation](https://tailscale.com/docs/reference/syntax/policy-file#auto-approvers) for more information on auto
|
||||
approvers.
|
||||
|
||||
## Exit node
|
||||
|
||||
@@ -199,8 +201,8 @@ The exit node can now be used on a node with:
|
||||
$ sudo tailscale set --exit-node myexit
|
||||
```
|
||||
|
||||
Please refer to the official [Tailscale documentation](https://tailscale.com/kb/1103/exit-nodes#use-the-exit-node) for
|
||||
how to use an exit node on different operating systems.
|
||||
Please refer to the official [Tailscale documentation](https://tailscale.com/docs/features/exit-nodes#use-the-exit-node)
|
||||
for how to use an exit node on different operating systems.
|
||||
|
||||
### Restrict the use of an exit node with ACL
|
||||
|
||||
@@ -282,26 +284,23 @@ Advertise a node as exit node and also advertise the tag `tag:exit` when joining
|
||||
$ sudo tailscale up --login-server <YOUR_HEADSCALE_URL> --advertise-tags tag:exit --advertise-exit-node
|
||||
```
|
||||
|
||||
Please see the [official Tailscale documentation](https://tailscale.com/kb/1337/acl-syntax#autoapprovers) for more
|
||||
information on auto approvers.
|
||||
Please see the [official Tailscale documentation](https://tailscale.com/docs/reference/syntax/policy-file#autoapprovers)
|
||||
for more information on auto approvers.
|
||||
|
||||
## High availability
|
||||
|
||||
Headscale has limited support for high availability routing. Multiple subnet routers with overlapping routes or multiple
|
||||
exit nodes can be used to provide high availability for users. If one router node goes offline, another one can serve
|
||||
the same routes to clients. Please see the official [Tailscale documentation on high
|
||||
availability](https://tailscale.com/kb/1115/high-availability#subnet-router-high-availability) for details.
|
||||
Headscale supports high availability routing. Multiple subnet routers with overlapping routes or multiple exit nodes can
|
||||
be used to provide high availability for users. If one router node goes offline, another one can serve the same routes
|
||||
to clients. Please see the official [Tailscale documentation on high
|
||||
availability](https://tailscale.com/docs/how-to/set-up-high-availability#subnet-router-high-availability) for details.
|
||||
|
||||
!!! bug
|
||||
|
||||
In certain situations it might take up to 16 minutes for Headscale to detect a node as offline. A failover node
|
||||
might not be selected fast enough, if such a node is used as subnet router or exit node causing service
|
||||
interruptions for clients. See [issue 2129](https://github.com/juanfont/headscale/issues/2129) for more information.
|
||||
This feature is enabled by default when at least two nodes advertise the same prefix. See the configuration options
|
||||
`node.routes.ha` in the [configuration file](./configuration.md) for details.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Enable IP forwarding
|
||||
|
||||
A subnet router or exit node is routing traffic on behalf of other nodes and thus requires IP forwarding. Check the
|
||||
official [Tailscale documentation](https://tailscale.com/kb/1019/subnets/?tab=linux#enable-ip-forwarding) for how to
|
||||
official [Tailscale documentation](https://tailscale.com/docs/features/subnet-routers#enable-ip-forwarding) for how to
|
||||
enable IP forwarding.
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
# Tags
|
||||
|
||||
Headscale supports Tailscale tags. Please read [Tailscale's tag documentation](https://tailscale.com/kb/1068/tags) to
|
||||
learn how tags work and how to use them.
|
||||
Headscale supports Tailscale tags. Please read [Tailscale's tag documentation](https://tailscale.com/docs/features/tags)
|
||||
to learn how tags work and how to use them.
|
||||
|
||||
Tags can be applied during [node registration](registration.md):
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ distributions are Ubuntu 22.04 or newer, Debian 12 or newer.
|
||||
sudo apt install ./headscale.deb
|
||||
```
|
||||
|
||||
1. [Configure headscale by editing the configuration file](../../ref/configuration.md):
|
||||
1. [Configure headscale by editing the configuration file](../../ref/configuration.md). An up-to date example
|
||||
configuration file is also available in `/usr/share/doc/headscale/examples/config-example.yaml`:
|
||||
|
||||
```shell
|
||||
sudo nano /etc/headscale/config.yaml
|
||||
|
||||
@@ -17,7 +17,7 @@ The ports in use vary with the intended scenario and enabled features. Some of t
|
||||
- tcp/80
|
||||
- Expose publicly: yes
|
||||
- HTTP, used by Let's Encrypt to verify ownership via the HTTP-01 challenge.
|
||||
- Only required if the built-in Let's Enrypt client with the HTTP-01 challenge is used. See [TLS](../ref/tls.md) for
|
||||
- Only required if the built-in Let's Encrypt client with the HTTP-01 challenge is used. See [TLS](../ref/tls.md) for
|
||||
details.
|
||||
- tcp/443
|
||||
- Expose publicly: yes
|
||||
|
||||
@@ -25,7 +25,8 @@ Install the official Tailscale iOS client from the [App Store](https://apps.appl
|
||||
|
||||
### Installation
|
||||
|
||||
Choose one of the available [Tailscale clients for macOS](https://tailscale.com/kb/1065/macos-variants) and install it.
|
||||
Choose one of the available [Tailscale clients for macOS](https://tailscale.com/docs/concepts/macos-variants) and
|
||||
install it.
|
||||
|
||||
### Configuring the headscale URL
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@ all the time, please enable "Unattended mode":
|
||||
- Enable `Run unattended`
|
||||
- Confirm the "Unattended mode" message
|
||||
|
||||
See also [Keep Tailscale running when I'm not logged in to my computer](https://tailscale.com/kb/1088/run-unattended)
|
||||
See also [Keep Tailscale running when I'm not logged in to my
|
||||
computer](https://tailscale.com/docs/how-to/run-unattended).
|
||||
|
||||
### Failing node registration
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${prev.stdenv.hostPlatform.system};
|
||||
buildGo = pkgs.buildGo126Module;
|
||||
vendorHash = "sha256-x0xXxa7sjyDwWLq8fO0Z/pbPefctzctK3TAdBea7FtY=";
|
||||
vendorHash = "sha256-8vTEkPEMbJ6DSOjcoQrYRyKSYI8jjcllTmJ6RXmUV9w=";
|
||||
in
|
||||
{
|
||||
headscale = buildGo {
|
||||
|
||||
@@ -109,7 +109,7 @@ const file_headscale_v1_headscale_proto_rawDesc = "" +
|
||||
"\x1cheadscale/v1/headscale.proto\x12\fheadscale.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17headscale/v1/user.proto\x1a\x1dheadscale/v1/preauthkey.proto\x1a\x17headscale/v1/node.proto\x1a\x19headscale/v1/apikey.proto\x1a\x17headscale/v1/auth.proto\x1a\x19headscale/v1/policy.proto\"\x0f\n" +
|
||||
"\rHealthRequest\"E\n" +
|
||||
"\x0eHealthResponse\x123\n" +
|
||||
"\x15database_connectivity\x18\x01 \x01(\bR\x14databaseConnectivity2\xeb\x19\n" +
|
||||
"\x15database_connectivity\x18\x01 \x01(\bR\x14databaseConnectivity2\xe0\x1a\n" +
|
||||
"\x10HeadscaleService\x12h\n" +
|
||||
"\n" +
|
||||
"CreateUser\x12\x1f.headscale.v1.CreateUserRequest\x1a .headscale.v1.CreateUserResponse\"\x17\x82\xd3\xe4\x93\x02\x11:\x01*\"\f/api/v1/user\x12\x80\x01\n" +
|
||||
@@ -144,7 +144,8 @@ const file_headscale_v1_headscale_proto_rawDesc = "" +
|
||||
"\vListApiKeys\x12 .headscale.v1.ListApiKeysRequest\x1a!.headscale.v1.ListApiKeysResponse\"\x16\x82\xd3\xe4\x93\x02\x10\x12\x0e/api/v1/apikey\x12v\n" +
|
||||
"\fDeleteApiKey\x12!.headscale.v1.DeleteApiKeyRequest\x1a\".headscale.v1.DeleteApiKeyResponse\"\x1f\x82\xd3\xe4\x93\x02\x19*\x17/api/v1/apikey/{prefix}\x12d\n" +
|
||||
"\tGetPolicy\x12\x1e.headscale.v1.GetPolicyRequest\x1a\x1f.headscale.v1.GetPolicyResponse\"\x16\x82\xd3\xe4\x93\x02\x10\x12\x0e/api/v1/policy\x12g\n" +
|
||||
"\tSetPolicy\x12\x1e.headscale.v1.SetPolicyRequest\x1a\x1f.headscale.v1.SetPolicyResponse\"\x19\x82\xd3\xe4\x93\x02\x13:\x01*\x1a\x0e/api/v1/policy\x12[\n" +
|
||||
"\tSetPolicy\x12\x1e.headscale.v1.SetPolicyRequest\x1a\x1f.headscale.v1.SetPolicyResponse\"\x19\x82\xd3\xe4\x93\x02\x13:\x01*\x1a\x0e/api/v1/policy\x12s\n" +
|
||||
"\vCheckPolicy\x12 .headscale.v1.CheckPolicyRequest\x1a!.headscale.v1.CheckPolicyResponse\"\x1f\x82\xd3\xe4\x93\x02\x19:\x01*\"\x14/api/v1/policy/check\x12[\n" +
|
||||
"\x06Health\x12\x1b.headscale.v1.HealthRequest\x1a\x1c.headscale.v1.HealthResponse\"\x16\x82\xd3\xe4\x93\x02\x10\x12\x0e/api/v1/healthB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3"
|
||||
|
||||
var (
|
||||
@@ -190,33 +191,35 @@ var file_headscale_v1_headscale_proto_goTypes = []any{
|
||||
(*DeleteApiKeyRequest)(nil), // 26: headscale.v1.DeleteApiKeyRequest
|
||||
(*GetPolicyRequest)(nil), // 27: headscale.v1.GetPolicyRequest
|
||||
(*SetPolicyRequest)(nil), // 28: headscale.v1.SetPolicyRequest
|
||||
(*CreateUserResponse)(nil), // 29: headscale.v1.CreateUserResponse
|
||||
(*RenameUserResponse)(nil), // 30: headscale.v1.RenameUserResponse
|
||||
(*DeleteUserResponse)(nil), // 31: headscale.v1.DeleteUserResponse
|
||||
(*ListUsersResponse)(nil), // 32: headscale.v1.ListUsersResponse
|
||||
(*CreatePreAuthKeyResponse)(nil), // 33: headscale.v1.CreatePreAuthKeyResponse
|
||||
(*ExpirePreAuthKeyResponse)(nil), // 34: headscale.v1.ExpirePreAuthKeyResponse
|
||||
(*DeletePreAuthKeyResponse)(nil), // 35: headscale.v1.DeletePreAuthKeyResponse
|
||||
(*ListPreAuthKeysResponse)(nil), // 36: headscale.v1.ListPreAuthKeysResponse
|
||||
(*DebugCreateNodeResponse)(nil), // 37: headscale.v1.DebugCreateNodeResponse
|
||||
(*GetNodeResponse)(nil), // 38: headscale.v1.GetNodeResponse
|
||||
(*SetTagsResponse)(nil), // 39: headscale.v1.SetTagsResponse
|
||||
(*SetApprovedRoutesResponse)(nil), // 40: headscale.v1.SetApprovedRoutesResponse
|
||||
(*RegisterNodeResponse)(nil), // 41: headscale.v1.RegisterNodeResponse
|
||||
(*DeleteNodeResponse)(nil), // 42: headscale.v1.DeleteNodeResponse
|
||||
(*ExpireNodeResponse)(nil), // 43: headscale.v1.ExpireNodeResponse
|
||||
(*RenameNodeResponse)(nil), // 44: headscale.v1.RenameNodeResponse
|
||||
(*ListNodesResponse)(nil), // 45: headscale.v1.ListNodesResponse
|
||||
(*BackfillNodeIPsResponse)(nil), // 46: headscale.v1.BackfillNodeIPsResponse
|
||||
(*AuthRegisterResponse)(nil), // 47: headscale.v1.AuthRegisterResponse
|
||||
(*AuthApproveResponse)(nil), // 48: headscale.v1.AuthApproveResponse
|
||||
(*AuthRejectResponse)(nil), // 49: headscale.v1.AuthRejectResponse
|
||||
(*CreateApiKeyResponse)(nil), // 50: headscale.v1.CreateApiKeyResponse
|
||||
(*ExpireApiKeyResponse)(nil), // 51: headscale.v1.ExpireApiKeyResponse
|
||||
(*ListApiKeysResponse)(nil), // 52: headscale.v1.ListApiKeysResponse
|
||||
(*DeleteApiKeyResponse)(nil), // 53: headscale.v1.DeleteApiKeyResponse
|
||||
(*GetPolicyResponse)(nil), // 54: headscale.v1.GetPolicyResponse
|
||||
(*SetPolicyResponse)(nil), // 55: headscale.v1.SetPolicyResponse
|
||||
(*CheckPolicyRequest)(nil), // 29: headscale.v1.CheckPolicyRequest
|
||||
(*CreateUserResponse)(nil), // 30: headscale.v1.CreateUserResponse
|
||||
(*RenameUserResponse)(nil), // 31: headscale.v1.RenameUserResponse
|
||||
(*DeleteUserResponse)(nil), // 32: headscale.v1.DeleteUserResponse
|
||||
(*ListUsersResponse)(nil), // 33: headscale.v1.ListUsersResponse
|
||||
(*CreatePreAuthKeyResponse)(nil), // 34: headscale.v1.CreatePreAuthKeyResponse
|
||||
(*ExpirePreAuthKeyResponse)(nil), // 35: headscale.v1.ExpirePreAuthKeyResponse
|
||||
(*DeletePreAuthKeyResponse)(nil), // 36: headscale.v1.DeletePreAuthKeyResponse
|
||||
(*ListPreAuthKeysResponse)(nil), // 37: headscale.v1.ListPreAuthKeysResponse
|
||||
(*DebugCreateNodeResponse)(nil), // 38: headscale.v1.DebugCreateNodeResponse
|
||||
(*GetNodeResponse)(nil), // 39: headscale.v1.GetNodeResponse
|
||||
(*SetTagsResponse)(nil), // 40: headscale.v1.SetTagsResponse
|
||||
(*SetApprovedRoutesResponse)(nil), // 41: headscale.v1.SetApprovedRoutesResponse
|
||||
(*RegisterNodeResponse)(nil), // 42: headscale.v1.RegisterNodeResponse
|
||||
(*DeleteNodeResponse)(nil), // 43: headscale.v1.DeleteNodeResponse
|
||||
(*ExpireNodeResponse)(nil), // 44: headscale.v1.ExpireNodeResponse
|
||||
(*RenameNodeResponse)(nil), // 45: headscale.v1.RenameNodeResponse
|
||||
(*ListNodesResponse)(nil), // 46: headscale.v1.ListNodesResponse
|
||||
(*BackfillNodeIPsResponse)(nil), // 47: headscale.v1.BackfillNodeIPsResponse
|
||||
(*AuthRegisterResponse)(nil), // 48: headscale.v1.AuthRegisterResponse
|
||||
(*AuthApproveResponse)(nil), // 49: headscale.v1.AuthApproveResponse
|
||||
(*AuthRejectResponse)(nil), // 50: headscale.v1.AuthRejectResponse
|
||||
(*CreateApiKeyResponse)(nil), // 51: headscale.v1.CreateApiKeyResponse
|
||||
(*ExpireApiKeyResponse)(nil), // 52: headscale.v1.ExpireApiKeyResponse
|
||||
(*ListApiKeysResponse)(nil), // 53: headscale.v1.ListApiKeysResponse
|
||||
(*DeleteApiKeyResponse)(nil), // 54: headscale.v1.DeleteApiKeyResponse
|
||||
(*GetPolicyResponse)(nil), // 55: headscale.v1.GetPolicyResponse
|
||||
(*SetPolicyResponse)(nil), // 56: headscale.v1.SetPolicyResponse
|
||||
(*CheckPolicyResponse)(nil), // 57: headscale.v1.CheckPolicyResponse
|
||||
}
|
||||
var file_headscale_v1_headscale_proto_depIdxs = []int32{
|
||||
2, // 0: headscale.v1.HeadscaleService.CreateUser:input_type -> headscale.v1.CreateUserRequest
|
||||
@@ -246,37 +249,39 @@ var file_headscale_v1_headscale_proto_depIdxs = []int32{
|
||||
26, // 24: headscale.v1.HeadscaleService.DeleteApiKey:input_type -> headscale.v1.DeleteApiKeyRequest
|
||||
27, // 25: headscale.v1.HeadscaleService.GetPolicy:input_type -> headscale.v1.GetPolicyRequest
|
||||
28, // 26: headscale.v1.HeadscaleService.SetPolicy:input_type -> headscale.v1.SetPolicyRequest
|
||||
0, // 27: headscale.v1.HeadscaleService.Health:input_type -> headscale.v1.HealthRequest
|
||||
29, // 28: headscale.v1.HeadscaleService.CreateUser:output_type -> headscale.v1.CreateUserResponse
|
||||
30, // 29: headscale.v1.HeadscaleService.RenameUser:output_type -> headscale.v1.RenameUserResponse
|
||||
31, // 30: headscale.v1.HeadscaleService.DeleteUser:output_type -> headscale.v1.DeleteUserResponse
|
||||
32, // 31: headscale.v1.HeadscaleService.ListUsers:output_type -> headscale.v1.ListUsersResponse
|
||||
33, // 32: headscale.v1.HeadscaleService.CreatePreAuthKey:output_type -> headscale.v1.CreatePreAuthKeyResponse
|
||||
34, // 33: headscale.v1.HeadscaleService.ExpirePreAuthKey:output_type -> headscale.v1.ExpirePreAuthKeyResponse
|
||||
35, // 34: headscale.v1.HeadscaleService.DeletePreAuthKey:output_type -> headscale.v1.DeletePreAuthKeyResponse
|
||||
36, // 35: headscale.v1.HeadscaleService.ListPreAuthKeys:output_type -> headscale.v1.ListPreAuthKeysResponse
|
||||
37, // 36: headscale.v1.HeadscaleService.DebugCreateNode:output_type -> headscale.v1.DebugCreateNodeResponse
|
||||
38, // 37: headscale.v1.HeadscaleService.GetNode:output_type -> headscale.v1.GetNodeResponse
|
||||
39, // 38: headscale.v1.HeadscaleService.SetTags:output_type -> headscale.v1.SetTagsResponse
|
||||
40, // 39: headscale.v1.HeadscaleService.SetApprovedRoutes:output_type -> headscale.v1.SetApprovedRoutesResponse
|
||||
41, // 40: headscale.v1.HeadscaleService.RegisterNode:output_type -> headscale.v1.RegisterNodeResponse
|
||||
42, // 41: headscale.v1.HeadscaleService.DeleteNode:output_type -> headscale.v1.DeleteNodeResponse
|
||||
43, // 42: headscale.v1.HeadscaleService.ExpireNode:output_type -> headscale.v1.ExpireNodeResponse
|
||||
44, // 43: headscale.v1.HeadscaleService.RenameNode:output_type -> headscale.v1.RenameNodeResponse
|
||||
45, // 44: headscale.v1.HeadscaleService.ListNodes:output_type -> headscale.v1.ListNodesResponse
|
||||
46, // 45: headscale.v1.HeadscaleService.BackfillNodeIPs:output_type -> headscale.v1.BackfillNodeIPsResponse
|
||||
47, // 46: headscale.v1.HeadscaleService.AuthRegister:output_type -> headscale.v1.AuthRegisterResponse
|
||||
48, // 47: headscale.v1.HeadscaleService.AuthApprove:output_type -> headscale.v1.AuthApproveResponse
|
||||
49, // 48: headscale.v1.HeadscaleService.AuthReject:output_type -> headscale.v1.AuthRejectResponse
|
||||
50, // 49: headscale.v1.HeadscaleService.CreateApiKey:output_type -> headscale.v1.CreateApiKeyResponse
|
||||
51, // 50: headscale.v1.HeadscaleService.ExpireApiKey:output_type -> headscale.v1.ExpireApiKeyResponse
|
||||
52, // 51: headscale.v1.HeadscaleService.ListApiKeys:output_type -> headscale.v1.ListApiKeysResponse
|
||||
53, // 52: headscale.v1.HeadscaleService.DeleteApiKey:output_type -> headscale.v1.DeleteApiKeyResponse
|
||||
54, // 53: headscale.v1.HeadscaleService.GetPolicy:output_type -> headscale.v1.GetPolicyResponse
|
||||
55, // 54: headscale.v1.HeadscaleService.SetPolicy:output_type -> headscale.v1.SetPolicyResponse
|
||||
1, // 55: headscale.v1.HeadscaleService.Health:output_type -> headscale.v1.HealthResponse
|
||||
28, // [28:56] is the sub-list for method output_type
|
||||
0, // [0:28] is the sub-list for method input_type
|
||||
29, // 27: headscale.v1.HeadscaleService.CheckPolicy:input_type -> headscale.v1.CheckPolicyRequest
|
||||
0, // 28: headscale.v1.HeadscaleService.Health:input_type -> headscale.v1.HealthRequest
|
||||
30, // 29: headscale.v1.HeadscaleService.CreateUser:output_type -> headscale.v1.CreateUserResponse
|
||||
31, // 30: headscale.v1.HeadscaleService.RenameUser:output_type -> headscale.v1.RenameUserResponse
|
||||
32, // 31: headscale.v1.HeadscaleService.DeleteUser:output_type -> headscale.v1.DeleteUserResponse
|
||||
33, // 32: headscale.v1.HeadscaleService.ListUsers:output_type -> headscale.v1.ListUsersResponse
|
||||
34, // 33: headscale.v1.HeadscaleService.CreatePreAuthKey:output_type -> headscale.v1.CreatePreAuthKeyResponse
|
||||
35, // 34: headscale.v1.HeadscaleService.ExpirePreAuthKey:output_type -> headscale.v1.ExpirePreAuthKeyResponse
|
||||
36, // 35: headscale.v1.HeadscaleService.DeletePreAuthKey:output_type -> headscale.v1.DeletePreAuthKeyResponse
|
||||
37, // 36: headscale.v1.HeadscaleService.ListPreAuthKeys:output_type -> headscale.v1.ListPreAuthKeysResponse
|
||||
38, // 37: headscale.v1.HeadscaleService.DebugCreateNode:output_type -> headscale.v1.DebugCreateNodeResponse
|
||||
39, // 38: headscale.v1.HeadscaleService.GetNode:output_type -> headscale.v1.GetNodeResponse
|
||||
40, // 39: headscale.v1.HeadscaleService.SetTags:output_type -> headscale.v1.SetTagsResponse
|
||||
41, // 40: headscale.v1.HeadscaleService.SetApprovedRoutes:output_type -> headscale.v1.SetApprovedRoutesResponse
|
||||
42, // 41: headscale.v1.HeadscaleService.RegisterNode:output_type -> headscale.v1.RegisterNodeResponse
|
||||
43, // 42: headscale.v1.HeadscaleService.DeleteNode:output_type -> headscale.v1.DeleteNodeResponse
|
||||
44, // 43: headscale.v1.HeadscaleService.ExpireNode:output_type -> headscale.v1.ExpireNodeResponse
|
||||
45, // 44: headscale.v1.HeadscaleService.RenameNode:output_type -> headscale.v1.RenameNodeResponse
|
||||
46, // 45: headscale.v1.HeadscaleService.ListNodes:output_type -> headscale.v1.ListNodesResponse
|
||||
47, // 46: headscale.v1.HeadscaleService.BackfillNodeIPs:output_type -> headscale.v1.BackfillNodeIPsResponse
|
||||
48, // 47: headscale.v1.HeadscaleService.AuthRegister:output_type -> headscale.v1.AuthRegisterResponse
|
||||
49, // 48: headscale.v1.HeadscaleService.AuthApprove:output_type -> headscale.v1.AuthApproveResponse
|
||||
50, // 49: headscale.v1.HeadscaleService.AuthReject:output_type -> headscale.v1.AuthRejectResponse
|
||||
51, // 50: headscale.v1.HeadscaleService.CreateApiKey:output_type -> headscale.v1.CreateApiKeyResponse
|
||||
52, // 51: headscale.v1.HeadscaleService.ExpireApiKey:output_type -> headscale.v1.ExpireApiKeyResponse
|
||||
53, // 52: headscale.v1.HeadscaleService.ListApiKeys:output_type -> headscale.v1.ListApiKeysResponse
|
||||
54, // 53: headscale.v1.HeadscaleService.DeleteApiKey:output_type -> headscale.v1.DeleteApiKeyResponse
|
||||
55, // 54: headscale.v1.HeadscaleService.GetPolicy:output_type -> headscale.v1.GetPolicyResponse
|
||||
56, // 55: headscale.v1.HeadscaleService.SetPolicy:output_type -> headscale.v1.SetPolicyResponse
|
||||
57, // 56: headscale.v1.HeadscaleService.CheckPolicy:output_type -> headscale.v1.CheckPolicyResponse
|
||||
1, // 57: headscale.v1.HeadscaleService.Health:output_type -> headscale.v1.HealthResponse
|
||||
29, // [29:58] is the sub-list for method output_type
|
||||
0, // [0:29] is the sub-list for method input_type
|
||||
0, // [0:0] is the sub-list for extension type_name
|
||||
0, // [0:0] is the sub-list for extension extendee
|
||||
0, // [0:0] is the sub-list for field type_name
|
||||
|
||||
@@ -966,6 +966,33 @@ func local_request_HeadscaleService_SetPolicy_0(ctx context.Context, marshaler r
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_HeadscaleService_CheckPolicy_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq CheckPolicyRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
if req.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, req.Body)
|
||||
}
|
||||
msg, err := client.CheckPolicy(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_HeadscaleService_CheckPolicy_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq CheckPolicyRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := server.CheckPolicy(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_HeadscaleService_Health_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq HealthRequest
|
||||
@@ -1533,6 +1560,26 @@ func RegisterHeadscaleServiceHandlerServer(ctx context.Context, mux *runtime.Ser
|
||||
}
|
||||
forward_HeadscaleService_SetPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_HeadscaleService_CheckPolicy_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/CheckPolicy", runtime.WithHTTPPathPattern("/api/v1/policy/check"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_HeadscaleService_CheckPolicy_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_HeadscaleService_CheckPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodGet, pattern_HeadscaleService_Health_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
@@ -2052,6 +2099,23 @@ func RegisterHeadscaleServiceHandlerClient(ctx context.Context, mux *runtime.Ser
|
||||
}
|
||||
forward_HeadscaleService_SetPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_HeadscaleService_CheckPolicy_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/CheckPolicy", runtime.WithHTTPPathPattern("/api/v1/policy/check"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_HeadscaleService_CheckPolicy_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_HeadscaleService_CheckPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodGet, pattern_HeadscaleService_Health_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
@@ -2100,6 +2164,7 @@ var (
|
||||
pattern_HeadscaleService_DeleteApiKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"api", "v1", "apikey", "prefix"}, ""))
|
||||
pattern_HeadscaleService_GetPolicy_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "policy"}, ""))
|
||||
pattern_HeadscaleService_SetPolicy_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "policy"}, ""))
|
||||
pattern_HeadscaleService_CheckPolicy_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "policy", "check"}, ""))
|
||||
pattern_HeadscaleService_Health_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "health"}, ""))
|
||||
)
|
||||
|
||||
@@ -2131,5 +2196,6 @@ var (
|
||||
forward_HeadscaleService_DeleteApiKey_0 = runtime.ForwardResponseMessage
|
||||
forward_HeadscaleService_GetPolicy_0 = runtime.ForwardResponseMessage
|
||||
forward_HeadscaleService_SetPolicy_0 = runtime.ForwardResponseMessage
|
||||
forward_HeadscaleService_CheckPolicy_0 = runtime.ForwardResponseMessage
|
||||
forward_HeadscaleService_Health_0 = runtime.ForwardResponseMessage
|
||||
)
|
||||
|
||||
@@ -46,6 +46,7 @@ const (
|
||||
HeadscaleService_DeleteApiKey_FullMethodName = "/headscale.v1.HeadscaleService/DeleteApiKey"
|
||||
HeadscaleService_GetPolicy_FullMethodName = "/headscale.v1.HeadscaleService/GetPolicy"
|
||||
HeadscaleService_SetPolicy_FullMethodName = "/headscale.v1.HeadscaleService/SetPolicy"
|
||||
HeadscaleService_CheckPolicy_FullMethodName = "/headscale.v1.HeadscaleService/CheckPolicy"
|
||||
HeadscaleService_Health_FullMethodName = "/headscale.v1.HeadscaleService/Health"
|
||||
)
|
||||
|
||||
@@ -86,6 +87,7 @@ type HeadscaleServiceClient interface {
|
||||
// --- Policy start ---
|
||||
GetPolicy(ctx context.Context, in *GetPolicyRequest, opts ...grpc.CallOption) (*GetPolicyResponse, error)
|
||||
SetPolicy(ctx context.Context, in *SetPolicyRequest, opts ...grpc.CallOption) (*SetPolicyResponse, error)
|
||||
CheckPolicy(ctx context.Context, in *CheckPolicyRequest, opts ...grpc.CallOption) (*CheckPolicyResponse, error)
|
||||
// --- Health start ---
|
||||
Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error)
|
||||
}
|
||||
@@ -368,6 +370,16 @@ func (c *headscaleServiceClient) SetPolicy(ctx context.Context, in *SetPolicyReq
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *headscaleServiceClient) CheckPolicy(ctx context.Context, in *CheckPolicyRequest, opts ...grpc.CallOption) (*CheckPolicyResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CheckPolicyResponse)
|
||||
err := c.cc.Invoke(ctx, HeadscaleService_CheckPolicy_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *headscaleServiceClient) Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(HealthResponse)
|
||||
@@ -415,6 +427,7 @@ type HeadscaleServiceServer interface {
|
||||
// --- Policy start ---
|
||||
GetPolicy(context.Context, *GetPolicyRequest) (*GetPolicyResponse, error)
|
||||
SetPolicy(context.Context, *SetPolicyRequest) (*SetPolicyResponse, error)
|
||||
CheckPolicy(context.Context, *CheckPolicyRequest) (*CheckPolicyResponse, error)
|
||||
// --- Health start ---
|
||||
Health(context.Context, *HealthRequest) (*HealthResponse, error)
|
||||
mustEmbedUnimplementedHeadscaleServiceServer()
|
||||
@@ -508,6 +521,9 @@ func (UnimplementedHeadscaleServiceServer) GetPolicy(context.Context, *GetPolicy
|
||||
func (UnimplementedHeadscaleServiceServer) SetPolicy(context.Context, *SetPolicyRequest) (*SetPolicyResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method SetPolicy not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) CheckPolicy(context.Context, *CheckPolicyRequest) (*CheckPolicyResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method CheckPolicy not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method Health not implemented")
|
||||
}
|
||||
@@ -1018,6 +1034,24 @@ func _HeadscaleService_SetPolicy_Handler(srv interface{}, ctx context.Context, d
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _HeadscaleService_CheckPolicy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CheckPolicyRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(HeadscaleServiceServer).CheckPolicy(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: HeadscaleService_CheckPolicy_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(HeadscaleServiceServer).CheckPolicy(ctx, req.(*CheckPolicyRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _HeadscaleService_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(HealthRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@@ -1151,6 +1185,10 @@ var HeadscaleService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "SetPolicy",
|
||||
Handler: _HeadscaleService_SetPolicy_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CheckPolicy",
|
||||
Handler: _HeadscaleService_CheckPolicy_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Health",
|
||||
Handler: _HeadscaleService_Health_Handler,
|
||||
|
||||
@@ -206,6 +206,86 @@ func (x *GetPolicyResponse) GetUpdatedAt() *timestamppb.Timestamp {
|
||||
return nil
|
||||
}
|
||||
|
||||
type CheckPolicyRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Policy string `protobuf:"bytes,1,opt,name=policy,proto3" json:"policy,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *CheckPolicyRequest) Reset() {
|
||||
*x = CheckPolicyRequest{}
|
||||
mi := &file_headscale_v1_policy_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *CheckPolicyRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*CheckPolicyRequest) ProtoMessage() {}
|
||||
|
||||
func (x *CheckPolicyRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_headscale_v1_policy_proto_msgTypes[4]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use CheckPolicyRequest.ProtoReflect.Descriptor instead.
|
||||
func (*CheckPolicyRequest) Descriptor() ([]byte, []int) {
|
||||
return file_headscale_v1_policy_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *CheckPolicyRequest) GetPolicy() string {
|
||||
if x != nil {
|
||||
return x.Policy
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type CheckPolicyResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *CheckPolicyResponse) Reset() {
|
||||
*x = CheckPolicyResponse{}
|
||||
mi := &file_headscale_v1_policy_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *CheckPolicyResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*CheckPolicyResponse) ProtoMessage() {}
|
||||
|
||||
func (x *CheckPolicyResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_headscale_v1_policy_proto_msgTypes[5]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use CheckPolicyResponse.ProtoReflect.Descriptor instead.
|
||||
func (*CheckPolicyResponse) Descriptor() ([]byte, []int) {
|
||||
return file_headscale_v1_policy_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
var File_headscale_v1_policy_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_headscale_v1_policy_proto_rawDesc = "" +
|
||||
@@ -221,7 +301,10 @@ const file_headscale_v1_policy_proto_rawDesc = "" +
|
||||
"\x11GetPolicyResponse\x12\x16\n" +
|
||||
"\x06policy\x18\x01 \x01(\tR\x06policy\x129\n" +
|
||||
"\n" +
|
||||
"updated_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAtB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3"
|
||||
"updated_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\",\n" +
|
||||
"\x12CheckPolicyRequest\x12\x16\n" +
|
||||
"\x06policy\x18\x01 \x01(\tR\x06policy\"\x15\n" +
|
||||
"\x13CheckPolicyResponseB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_headscale_v1_policy_proto_rawDescOnce sync.Once
|
||||
@@ -235,17 +318,19 @@ func file_headscale_v1_policy_proto_rawDescGZIP() []byte {
|
||||
return file_headscale_v1_policy_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_headscale_v1_policy_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
|
||||
var file_headscale_v1_policy_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
|
||||
var file_headscale_v1_policy_proto_goTypes = []any{
|
||||
(*SetPolicyRequest)(nil), // 0: headscale.v1.SetPolicyRequest
|
||||
(*SetPolicyResponse)(nil), // 1: headscale.v1.SetPolicyResponse
|
||||
(*GetPolicyRequest)(nil), // 2: headscale.v1.GetPolicyRequest
|
||||
(*GetPolicyResponse)(nil), // 3: headscale.v1.GetPolicyResponse
|
||||
(*timestamppb.Timestamp)(nil), // 4: google.protobuf.Timestamp
|
||||
(*CheckPolicyRequest)(nil), // 4: headscale.v1.CheckPolicyRequest
|
||||
(*CheckPolicyResponse)(nil), // 5: headscale.v1.CheckPolicyResponse
|
||||
(*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp
|
||||
}
|
||||
var file_headscale_v1_policy_proto_depIdxs = []int32{
|
||||
4, // 0: headscale.v1.SetPolicyResponse.updated_at:type_name -> google.protobuf.Timestamp
|
||||
4, // 1: headscale.v1.GetPolicyResponse.updated_at:type_name -> google.protobuf.Timestamp
|
||||
6, // 0: headscale.v1.SetPolicyResponse.updated_at:type_name -> google.protobuf.Timestamp
|
||||
6, // 1: headscale.v1.GetPolicyResponse.updated_at:type_name -> google.protobuf.Timestamp
|
||||
2, // [2:2] is the sub-list for method output_type
|
||||
2, // [2:2] is the sub-list for method input_type
|
||||
2, // [2:2] is the sub-list for extension type_name
|
||||
@@ -264,7 +349,7 @@ func file_headscale_v1_policy_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_policy_proto_rawDesc), len(file_headscale_v1_policy_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 4,
|
||||
NumMessages: 6,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
|
||||
@@ -660,6 +660,38 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/policy/check": {
|
||||
"post": {
|
||||
"operationId": "HeadscaleService_CheckPolicy",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A successful response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1CheckPolicyResponse"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "An unexpected error response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rpcStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1CheckPolicyRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"HeadscaleService"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/preauthkey": {
|
||||
"get": {
|
||||
"operationId": "HeadscaleService_ListPreAuthKeys",
|
||||
@@ -1044,6 +1076,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1CheckPolicyRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"policy": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1CheckPolicyResponse": {
|
||||
"type": "object"
|
||||
},
|
||||
"v1CreateApiKeyRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -54,6 +54,7 @@ require (
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
pgregory.net/rapid v1.2.0
|
||||
tailscale.com v1.96.5
|
||||
zombiezen.com/go/postgrestest v1.0.1
|
||||
)
|
||||
@@ -96,11 +97,14 @@ require (
|
||||
atomicgo.dev/schedule v0.1.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
fyne.io/systray v1.11.1-0.20250812065214-4856ac3adc3c // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Kodeworks/golang-image-ico v0.0.0-20141118225523-73f0f4cfade9 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
|
||||
github.com/akutz/memconn v0.1.0 // indirect
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 // indirect
|
||||
@@ -111,6 +115,7 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssm v1.45.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
|
||||
@@ -135,6 +140,7 @@ require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/felixge/fgprof v0.9.5 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fogleman/gg v1.3.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/gaissmai/bart v0.26.1 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
@@ -145,6 +151,7 @@ require (
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
@@ -165,8 +172,10 @@ require (
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/jsimonetti/rtnetlink v1.4.2 // indirect
|
||||
github.com/kamstrup/intmap v0.5.2 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/klauspost/compress v1.18.3 // indirect
|
||||
github.com/lib/pq v1.11.1 // indirect
|
||||
github.com/lithammer/fuzzysearch v1.1.8 // indirect
|
||||
@@ -189,6 +198,7 @@ require (
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/opencontainers/runc v1.3.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/peterbourgon/ff/v3 v3.4.0 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
|
||||
github.com/pires/go-proxyproto v0.9.2 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
@@ -200,6 +210,7 @@ require (
|
||||
github.com/safchain/ethtool v0.7.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
@@ -210,6 +221,7 @@ require (
|
||||
github.com/tailscale/setec v0.0.0-20260115174028-19d190c5556d // indirect
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20251127225136-f19339b67368 // indirect
|
||||
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect
|
||||
github.com/toqueteos/webbrowser v1.2.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||
@@ -224,6 +236,7 @@ require (
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
|
||||
golang.org/x/image v0.27.0 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/term v0.42.0 // indirect
|
||||
@@ -233,10 +246,15 @@ require (
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
k8s.io/client-go v0.34.0 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
software.sslmate.com/src/go-pkcs12 v0.4.0 // indirect
|
||||
)
|
||||
|
||||
tool (
|
||||
golang.org/x/tools/cmd/stress
|
||||
golang.org/x/tools/cmd/stringer
|
||||
tailscale.com/cmd/tailscale
|
||||
tailscale.com/cmd/viewer
|
||||
tailscale.com/tstest/mts
|
||||
)
|
||||
|
||||
@@ -14,10 +14,14 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
|
||||
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
|
||||
fyne.io/systray v1.11.1-0.20250812065214-4856ac3adc3c h1:km4PIleGtbbF1oxmFQuO93CyNCldwuRTPB8WlzNWNZs=
|
||||
fyne.io/systray v1.11.1-0.20250812065214-4856ac3adc3c/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/Kodeworks/golang-image-ico v0.0.0-20141118225523-73f0f4cfade9 h1:1ltqoej5GtaWF8jaiA49HwsZD459jqm9YFz9ZtMFpQA=
|
||||
github.com/Kodeworks/golang-image-ico v0.0.0-20141118225523-73f0f4cfade9/go.mod h1:7uhhqiBaR4CpN0k9rMjOtjpcfGd6DG2m04zQxKnWQ0I=
|
||||
github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs=
|
||||
github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8=
|
||||
github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII=
|
||||
@@ -40,6 +44,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuW
|
||||
github.com/arl/statsviz v0.8.0 h1:O6GjjVxEDxcByAucOSl29HaGYLXsuwA3ujJw8H9E7/U=
|
||||
github.com/arl/statsviz v0.8.0/go.mod h1:XlrbiT7xYT03xaW9JMMfD8KFUhBOESJwfyNJu83PbB0=
|
||||
github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
||||
@@ -164,6 +170,8 @@ github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY=
|
||||
github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
@@ -212,6 +220,8 @@ github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0=
|
||||
github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
@@ -284,11 +294,15 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5GTEn90=
|
||||
github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM=
|
||||
github.com/kamstrup/intmap v0.5.2 h1:qnwBm1mh4XAnW9W9Ue9tZtTff8pS6+s6iKF6JRIV2Dk=
|
||||
github.com/kamstrup/intmap v0.5.2/go.mod h1:gWUVWHKzWj8xpJVFf5GC0O26bWmv3GqdnIX/LMT6Aq4=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
@@ -370,6 +384,8 @@ github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCy
|
||||
github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc=
|
||||
github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ=
|
||||
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
|
||||
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
@@ -430,6 +446,8 @@ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
@@ -487,6 +505,8 @@ github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e h1:IWllFTiDjjLIf2
|
||||
github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e/go.mod h1:d7u6HkTYKSv5m6MCKkOQlHwaShTMl3HjqSGW3XtVhXM=
|
||||
github.com/tink-crypto/tink-go/v2 v2.6.0 h1:+KHNBHhWH33Vn+igZWcsgdEPUxKwBMEe0QC60t388v4=
|
||||
github.com/tink-crypto/tink-go/v2 v2.6.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8=
|
||||
github.com/toqueteos/webbrowser v1.2.0 h1:tVP/gpK69Fx+qMJKsLE7TD8LuGWPnEV71wBN9rrstGQ=
|
||||
github.com/toqueteos/webbrowser v1.2.0/go.mod h1:XWoZq4cyp9WeUeak7w7LXRUQf1F1ATJMir8RTqb4ayM=
|
||||
github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg=
|
||||
github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE=
|
||||
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
|
||||
@@ -631,6 +651,9 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
@@ -647,6 +670,8 @@ honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
|
||||
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
|
||||
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
|
||||
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||
k8s.io/client-go v0.34.0 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo=
|
||||
k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
@@ -677,6 +702,8 @@ modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
|
||||
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||
tailscale.com v1.96.5 h1:gNkfA/KSZAl6jCH9cj8urq00HRWItDDTtGsyATI89jA=
|
||||
|
||||
@@ -276,6 +276,31 @@ func (h *Headscale) scheduledTasks(ctx context.Context) {
|
||||
extraRecordsUpdate = make(chan []tailcfg.DNSRecord)
|
||||
}
|
||||
|
||||
var (
|
||||
haProber *state.HAHealthProber
|
||||
haHealthChan <-chan time.Time
|
||||
)
|
||||
if h.cfg.Node.Routes.HA.ProbeInterval > 0 {
|
||||
haProber = state.NewHAHealthProber(
|
||||
h.state,
|
||||
h.cfg.Node.Routes.HA,
|
||||
h.cfg.ServerURL,
|
||||
h.mapBatcher.IsConnected,
|
||||
)
|
||||
|
||||
haTicker := time.NewTicker(h.cfg.Node.Routes.HA.ProbeInterval)
|
||||
defer haTicker.Stop()
|
||||
|
||||
haHealthChan = haTicker.C
|
||||
|
||||
log.Info().
|
||||
Dur("interval", h.cfg.Node.Routes.HA.ProbeInterval).
|
||||
Dur("timeout", h.cfg.Node.Routes.HA.ProbeTimeout).
|
||||
Msg("HA subnet router health probing enabled")
|
||||
} else {
|
||||
haHealthChan = make(<-chan time.Time)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -332,6 +357,9 @@ func (h *Headscale) scheduledTasks(ctx context.Context) {
|
||||
h.cfg.TailcfgDNSConfig.ExtraRecords = records
|
||||
|
||||
h.Change(change.ExtraRecords())
|
||||
|
||||
case <-haHealthChan:
|
||||
haProber.ProbeOnce(ctx, h.Change)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -460,6 +488,20 @@ func (h *Headscale) ensureUnixSocketIsAbsent() error {
|
||||
return os.Remove(h.cfg.UnixSocket)
|
||||
}
|
||||
|
||||
// securityHeaders sets baseline response headers on every HTTP response:
|
||||
// deny framing (clickjacking), forbid MIME-type sniffing, drop the Referer
|
||||
// header on outbound navigation. Cheap defense-in-depth for HTML surfaces.
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Content-Security-Policy", "frame-ancestors 'none'")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
r.Use(metrics.Collector(metrics.CollectorOpts{
|
||||
@@ -473,6 +515,7 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux {
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.RequestLogger(&zerologRequestLogger{}))
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(securityHeaders)
|
||||
|
||||
r.Post(ts2021UpgradePath, h.NoiseUpgradeHandler)
|
||||
|
||||
@@ -509,6 +552,10 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux {
|
||||
r.Use(h.httpAuthenticationMiddleware)
|
||||
r.HandleFunc("/v1/*", grpcMux.ServeHTTP)
|
||||
})
|
||||
// Ping response endpoint: receives HEAD from clients responding
|
||||
// to a PingRequest. The unguessable ping ID serves as authentication.
|
||||
r.Head("/machine/ping-response", h.PingResponseHandler)
|
||||
|
||||
r.Get("/favicon.ico", FaviconHandler)
|
||||
r.Get("/", BlankHandler)
|
||||
|
||||
@@ -1109,6 +1156,11 @@ func (h *Headscale) StartBatcherForTest(tb testing.TB) {
|
||||
tb.Cleanup(func() { h.mapBatcher.Close() })
|
||||
}
|
||||
|
||||
// MapBatcher returns the map response batcher (for test use).
|
||||
func (h *Headscale) MapBatcher() *mapper.Batcher {
|
||||
return h.mapBatcher
|
||||
}
|
||||
|
||||
// StartEphemeralGCForTest starts the ephemeral node garbage collector.
|
||||
// It registers a cleanup function on tb to stop the collector.
|
||||
// It panics when called outside of tests.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package hscontrol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSecurityHeaders(t *testing.T) {
|
||||
handler := securityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
h := rec.Result().Header
|
||||
assert.Equal(t, "DENY", h.Get("X-Frame-Options"))
|
||||
assert.Equal(t, "frame-ancestors 'none'", h.Get("Content-Security-Policy"))
|
||||
assert.Equal(t, "nosniff", h.Get("X-Content-Type-Options"))
|
||||
assert.Equal(t, "no-referrer", h.Get("Referrer-Policy"))
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
@@ -11,11 +11,46 @@
|
||||
--md-typeset-a-color: var(--md-primary-fg-color);
|
||||
--md-text-font: "Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
--md-code-font: "Roboto Mono", "SF Mono", Monaco, "Cascadia Code", Consolas, "Courier New", monospace;
|
||||
--hs-success: #059669;
|
||||
--hs-success-bg: #d1fae5;
|
||||
--hs-error: #dc2626;
|
||||
--hs-error-bg: #fee2e2;
|
||||
--hs-warning-text: #92400e;
|
||||
--hs-warning-bg: #fef3c7;
|
||||
--hs-warning-border: #f59e0b;
|
||||
--hs-border: #e5e7eb;
|
||||
--hs-bg: #ffffff;
|
||||
--hs-focus-ring: #4051b5;
|
||||
}
|
||||
|
||||
/* Base Typography */
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--md-default-fg-color: rgba(255, 255, 255, 0.87);
|
||||
--md-default-fg-color--light: rgba(255, 255, 255, 0.6);
|
||||
--md-default-fg-color--lighter: rgba(255, 255, 255, 0.38);
|
||||
--md-default-fg-color--lightest: rgba(255, 255, 255, 0.07);
|
||||
--md-code-fg-color: #c9d1d9;
|
||||
--md-code-bg-color: #1e1e1e;
|
||||
--md-primary-fg-color: #7b8fdb;
|
||||
--md-accent-fg-color: #8fa4ff;
|
||||
--md-typeset-a-color: var(--md-primary-fg-color);
|
||||
--hs-success: #34d399;
|
||||
--hs-success-bg: #064e3b;
|
||||
--hs-error: #f87171;
|
||||
--hs-error-bg: #450a0a;
|
||||
--hs-warning-text: #fbbf24;
|
||||
--hs-warning-bg: #451a03;
|
||||
--hs-warning-border: #d97706;
|
||||
--hs-border: #374151;
|
||||
--hs-bg: #111827;
|
||||
--hs-focus-ring: #7b8fdb;
|
||||
}
|
||||
}
|
||||
|
||||
/* Base Typography - 1rem (16px) avoids iOS auto-zoom on inputs */
|
||||
.md-typeset {
|
||||
font-size: 0.8rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
color: var(--md-default-fg-color);
|
||||
font-family: var(--md-text-font);
|
||||
@@ -76,16 +111,33 @@
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.md-typeset li {
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
/* Links - underline for accessibility (don't rely on color alone) */
|
||||
.md-typeset a {
|
||||
color: var(--md-typeset-a-color);
|
||||
text-decoration: none;
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 2px;
|
||||
word-break: break-word;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.md-typeset a:hover,
|
||||
.md-typeset a:focus {
|
||||
color: var(--md-accent-fg-color);
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
/* Focus styles - visible ring for keyboard navigation */
|
||||
.md-typeset a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible {
|
||||
outline: 2px solid var(--hs-focus-ring);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Code (inline) */
|
||||
@@ -118,6 +170,7 @@
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* Links in code */
|
||||
@@ -125,6 +178,36 @@
|
||||
color: currentcolor;
|
||||
}
|
||||
|
||||
/* Buttons - styled via CSS for hover/active/focus pseudo-classes */
|
||||
.md-typeset button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--md-text-font);
|
||||
line-height: 1;
|
||||
color: #ffffff;
|
||||
background-color: var(--md-primary-fg-color);
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
transition:
|
||||
background-color 150ms ease-out,
|
||||
box-shadow 150ms ease-out;
|
||||
}
|
||||
|
||||
.md-typeset button:hover {
|
||||
background-color: var(--md-accent-fg-color);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.md-typeset button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
.headscale-logo {
|
||||
display: block;
|
||||
@@ -138,6 +221,17 @@
|
||||
@media (max-width: 768px) {
|
||||
.headscale-logo {
|
||||
width: 200px;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
+4
-6
@@ -10,7 +10,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -336,11 +335,10 @@ func registrationDataFromRequest(
|
||||
req tailcfg.RegisterRequest,
|
||||
machineKey key.MachinePublic,
|
||||
) *types.RegistrationData {
|
||||
hostname := util.EnsureHostname(
|
||||
req.Hostinfo.View(),
|
||||
machineKey.String(),
|
||||
req.NodeKey.String(),
|
||||
)
|
||||
var hostname string
|
||||
if req.Hostinfo != nil {
|
||||
hostname = req.Hostinfo.Hostname
|
||||
}
|
||||
|
||||
regData := &types.RegistrationData{
|
||||
MachineKey: machineKey,
|
||||
|
||||
@@ -1119,3 +1119,72 @@ func TestReregistrationAppliesDefaultExpiry(t *testing.T) {
|
||||
assert.True(t, node2.Expiry().Get().After(firstExpiry),
|
||||
"re-registration expiry should be later than initial registration expiry")
|
||||
}
|
||||
|
||||
// TestReregistrationZeroExpiryStaysNil tests that when a user-owned node
|
||||
// re-registers with zero client expiry and node.expiry is disabled (0),
|
||||
// the node's expiry stays nil rather than being set to a pointer to zero
|
||||
// time. Regression test for the else branch introduced in commit 6337a3db
|
||||
// which assigned `®Req.Expiry` (pointer to time.Time{}) instead of nil,
|
||||
// causing the database row to hold `0001-01-01 00:00:00` instead of NULL.
|
||||
//
|
||||
// The same !regReq.Expiry.IsZero() gate at state.go:2221-2228 is shared by
|
||||
// the tags-only PreAuthKey path (createAndSaveNewNode also receives nil
|
||||
// when the client sends zero expiry), so this regression is covered for
|
||||
// tagged nodes by inspection.
|
||||
func TestReregistrationZeroExpiryStaysNil(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// node.expiry = 0 means "no default expiry"
|
||||
app := createTestAppWithNodeExpiry(t, 0)
|
||||
|
||||
user := app.state.CreateUserForTest("node-owner")
|
||||
|
||||
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
machineKey := key.NewMachine()
|
||||
nodeKey := key.NewNode()
|
||||
|
||||
// Initial registration with zero client expiry
|
||||
regReq := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key,
|
||||
},
|
||||
NodeKey: nodeKey.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "reregister-zero-expiry",
|
||||
},
|
||||
Expiry: time.Time{},
|
||||
}
|
||||
|
||||
resp, err := app.handleRegisterWithAuthKey(regReq, machineKey.Public())
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp.MachineAuthorized)
|
||||
|
||||
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
|
||||
require.True(t, found)
|
||||
assert.False(t, node.Expiry().Valid(),
|
||||
"initial registration with zero expiry and no default should leave expiry nil")
|
||||
|
||||
// Re-register with a new node key but same machine key + user
|
||||
nodeKey2 := key.NewNode()
|
||||
regReq2 := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key,
|
||||
},
|
||||
NodeKey: nodeKey2.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "reregister-zero-expiry",
|
||||
},
|
||||
Expiry: time.Time{}, // still zero
|
||||
}
|
||||
|
||||
resp2, err := app.handleRegisterWithAuthKey(regReq2, machineKey.Public())
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp2.MachineAuthorized)
|
||||
|
||||
node2, found := app.state.GetNodeByNodeKey(nodeKey2.Public())
|
||||
require.True(t, found)
|
||||
assert.False(t, node2.Expiry().Valid(),
|
||||
"re-registration with zero client expiry and no default should leave expiry nil, not pointer to zero time")
|
||||
}
|
||||
|
||||
+124
-9
@@ -816,10 +816,12 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
validate: func(t *testing.T, resp *tailcfg.RegisterResponse, app *Headscale) { //nolint:thelper //nolint:thelper
|
||||
assert.True(t, resp.MachineAuthorized)
|
||||
|
||||
// Node should be created with generated hostname
|
||||
// Raw hostname is preserved (empty in, empty stored), and
|
||||
// GivenName falls back to the literal "node" per SaaS.
|
||||
node, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
|
||||
assert.True(t, found)
|
||||
assert.NotEmpty(t, node.Hostname())
|
||||
assert.Empty(t, node.Hostname())
|
||||
assert.Equal(t, "node", node.GivenName())
|
||||
},
|
||||
},
|
||||
// TEST: Nil hostinfo is handled with defensive code
|
||||
@@ -854,12 +856,12 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
validate: func(t *testing.T, resp *tailcfg.RegisterResponse, app *Headscale) { //nolint:thelper //nolint:thelper
|
||||
assert.True(t, resp.MachineAuthorized)
|
||||
|
||||
// Node should be created with generated hostname from defensive code
|
||||
// With nil Hostinfo the raw hostname stays empty and GivenName
|
||||
// falls back to the literal "node" per the SaaS spec.
|
||||
node, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
|
||||
assert.True(t, found)
|
||||
assert.NotEmpty(t, node.Hostname())
|
||||
// Hostname should start with "node-" (generated from machine key)
|
||||
assert.True(t, strings.HasPrefix(node.Hostname(), "node-"))
|
||||
assert.Empty(t, node.Hostname())
|
||||
assert.Equal(t, "node", node.GivenName())
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2251,9 +2253,9 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
assert.True(t, found, "node should be registered despite nil hostinfo")
|
||||
|
||||
if found {
|
||||
// Should have some default hostname or handle nil gracefully
|
||||
hostname := node.Hostname()
|
||||
assert.NotEmpty(t, hostname, "should have some hostname even with nil hostinfo")
|
||||
// Raw hostname stays empty; GivenName falls back to "node".
|
||||
assert.Empty(t, node.Hostname())
|
||||
assert.Equal(t, "node", node.GivenName())
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -3986,3 +3988,116 @@ func TestTaggedNodeWithoutUserToDifferentUser(t *testing.T) {
|
||||
nodeAfterReauth.ID().Uint64(), nodeAfterReauth.Tags().AsSlice(),
|
||||
nodeAfterReauth.IsTagged(), nodeAfterReauth.UserID().Get())
|
||||
}
|
||||
|
||||
// TestHandleNodeFromPreAuthKey_OldUserNil_NoPanic asserts that
|
||||
// HandleNodeFromPreAuthKey does not panic when the in-memory NodeStore
|
||||
// holds a non-tagged node whose UserID points at a user but whose User
|
||||
// pointer is nil (orphan snapshot, e.g. a Preload("User") join missed
|
||||
// the row). Re-registering the same machine key under a different user
|
||||
// enters the "different user" branch and would otherwise crash at
|
||||
// oldUser.Name() in UserView.Name when the backing pointer is nil.
|
||||
func TestHandleNodeFromPreAuthKey_OldUserNil_NoPanic(t *testing.T) {
|
||||
app := createTestApp(t)
|
||||
|
||||
userA := app.state.CreateUserForTest("preauth-orphan-old")
|
||||
userB := app.state.CreateUserForTest("preauth-orphan-new")
|
||||
|
||||
machineKey := key.NewMachine()
|
||||
orphanNodeKey := key.NewNode()
|
||||
|
||||
userIDA := userA.ID
|
||||
orphan := types.Node{
|
||||
ID: 99001,
|
||||
MachineKey: machineKey.Public(),
|
||||
NodeKey: orphanNodeKey.Public(),
|
||||
Hostname: "preauth-orphan",
|
||||
GivenName: "preauth-orphan",
|
||||
UserID: &userIDA,
|
||||
User: nil,
|
||||
RegisterMethod: "authkey",
|
||||
}
|
||||
app.state.PutNodeInStoreForTest(orphan)
|
||||
|
||||
pakB, err := app.state.CreatePreAuthKey(userB.TypedID(), true, false, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
newNodeKey := key.NewNode()
|
||||
regReq := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pakB.Key,
|
||||
},
|
||||
NodeKey: newNodeKey.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "preauth-orphan-newuser",
|
||||
},
|
||||
Expiry: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
resp, err := app.handleRegisterWithAuthKey(regReq, machineKey.Public())
|
||||
require.NoError(t, err, "registration must not panic when old user is nil")
|
||||
require.NotNil(t, resp)
|
||||
require.True(t, resp.MachineAuthorized)
|
||||
|
||||
var registered types.NodeView
|
||||
|
||||
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
var found bool
|
||||
|
||||
registered, found = app.state.GetNodeByNodeKey(newNodeKey.Public())
|
||||
assert.True(c, found, "new node should be available in NodeStore")
|
||||
}, 1*time.Second, 50*time.Millisecond, "waiting for new node")
|
||||
|
||||
assert.NotEqual(t, types.NodeID(99001), registered.ID(), "new node, not orphan")
|
||||
assert.Equal(t, userB.ID, registered.UserID().Get(), "new node belongs to userB")
|
||||
}
|
||||
|
||||
// TestHandleNodeFromAuthPath_OldUserNil_NoPanic is the parallel guard
|
||||
// for the gRPC/OIDC entry point. Same orphan shape as
|
||||
// TestHandleNodeFromPreAuthKey_OldUserNil_NoPanic; HandleNodeFromAuthPath
|
||||
// has its own oldUser.Name() log line in the existingNodeOwnedByOtherUser
|
||||
// branch and panics independently of the noise registration path.
|
||||
func TestHandleNodeFromAuthPath_OldUserNil_NoPanic(t *testing.T) {
|
||||
app := createTestApp(t)
|
||||
|
||||
userA := app.state.CreateUserForTest("authpath-orphan-old")
|
||||
userB := app.state.CreateUserForTest("authpath-orphan-new")
|
||||
|
||||
machineKey := key.NewMachine()
|
||||
orphanNodeKey := key.NewNode()
|
||||
|
||||
userIDA := userA.ID
|
||||
orphan := types.Node{
|
||||
ID: 99002,
|
||||
MachineKey: machineKey.Public(),
|
||||
NodeKey: orphanNodeKey.Public(),
|
||||
Hostname: "authpath-orphan",
|
||||
GivenName: "authpath-orphan",
|
||||
UserID: &userIDA,
|
||||
User: nil,
|
||||
RegisterMethod: "oidc",
|
||||
}
|
||||
app.state.PutNodeInStoreForTest(orphan)
|
||||
|
||||
newNodeKey := key.NewNode()
|
||||
authID := types.MustAuthID()
|
||||
regEntry := types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||
MachineKey: machineKey.Public(),
|
||||
NodeKey: newNodeKey.Public(),
|
||||
Hostname: "authpath-orphan-newuser",
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "authpath-orphan-newuser",
|
||||
},
|
||||
})
|
||||
app.state.SetAuthCacheEntry(authID, regEntry)
|
||||
|
||||
node, _, err := app.state.HandleNodeFromAuthPath(
|
||||
authID,
|
||||
types.UserID(userB.ID),
|
||||
nil,
|
||||
"oidc",
|
||||
)
|
||||
require.NoError(t, err, "auth-path registration must not panic on nil old user")
|
||||
require.True(t, node.Valid())
|
||||
assert.NotEqual(t, types.NodeID(99002), node.ID(), "new node, not orphan")
|
||||
assert.Equal(t, userB.ID, node.UserID().Get(), "new node belongs to userB")
|
||||
}
|
||||
|
||||
@@ -379,7 +379,7 @@ func TestEphemeralGarbageCollectorConcurrentScheduleAndClose(t *testing.T) {
|
||||
stopScheduling := make(chan struct{})
|
||||
|
||||
// Track how many nodes have been scheduled
|
||||
var scheduledCount int64
|
||||
var scheduledCount atomic.Int64
|
||||
|
||||
// Launch goroutines that continuously schedule nodes
|
||||
for schedulerIndex := range numSchedulers {
|
||||
@@ -396,7 +396,7 @@ func TestEphemeralGarbageCollectorConcurrentScheduleAndClose(t *testing.T) {
|
||||
default:
|
||||
nodeID := types.NodeID(baseNodeID + j + 1) //nolint:gosec // safe conversion in test
|
||||
gc.Schedule(nodeID, 1*time.Hour) // Long expiry to ensure it doesn't trigger during test
|
||||
atomic.AddInt64(&scheduledCount, 1)
|
||||
scheduledCount.Add(1)
|
||||
|
||||
// Yield to other goroutines to introduce variability
|
||||
runtime.Gosched()
|
||||
@@ -410,7 +410,7 @@ func TestEphemeralGarbageCollectorConcurrentScheduleAndClose(t *testing.T) {
|
||||
defer wg.Done()
|
||||
|
||||
// Wait until enough nodes have been scheduled
|
||||
for atomic.LoadInt64(&scheduledCount) < int64(numSchedulers*closeAfterNodes) {
|
||||
for scheduledCount.Load() < int64(numSchedulers*closeAfterNodes) {
|
||||
runtime.Gosched()
|
||||
}
|
||||
|
||||
|
||||
+6
-87
@@ -5,11 +5,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -21,6 +19,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/util/dnsname"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -34,8 +33,6 @@ const (
|
||||
// ErrNodeNameNotUnique is returned when a node name is not unique.
|
||||
var ErrNodeNameNotUnique = errors.New("node name is not unique")
|
||||
|
||||
var invalidDNSRegex = regexp.MustCompile("[^a-z0-9-.]+")
|
||||
|
||||
var (
|
||||
ErrNodeNotFound = errors.New("node not found")
|
||||
ErrNodeRouteIsNotAvailable = errors.New("route is not available on node")
|
||||
@@ -291,7 +288,7 @@ func SetLastSeen(tx *gorm.DB, nodeID types.NodeID, lastSeen time.Time) error {
|
||||
func RenameNode(tx *gorm.DB,
|
||||
nodeID types.NodeID, newName string,
|
||||
) error {
|
||||
err := util.ValidateHostname(newName)
|
||||
err := dnsname.ValidLabel(newName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("renaming node: %w", err)
|
||||
}
|
||||
@@ -299,8 +296,7 @@ func RenameNode(tx *gorm.DB,
|
||||
// Check if the new name is unique
|
||||
var count int64
|
||||
|
||||
err = tx.Model(&types.Node{}).Where("given_name = ? AND id != ?", newName, nodeID).Count(&count).Error
|
||||
if err != nil {
|
||||
if err := tx.Model(&types.Node{}).Where("given_name = ? AND id != ?", newName, nodeID).Count(&count).Error; err != nil { //nolint:noinlineerr
|
||||
return fmt.Errorf("checking name uniqueness: %w", err)
|
||||
}
|
||||
|
||||
@@ -427,22 +423,11 @@ func RegisterNodeForTest(tx *gorm.DB, node types.Node, ipv4 *netip.Addr, ipv6 *n
|
||||
node.IPv4 = ipv4
|
||||
node.IPv6 = ipv6
|
||||
|
||||
var err error
|
||||
|
||||
node.Hostname, err = util.NormaliseHostname(node.Hostname)
|
||||
if err != nil {
|
||||
newHostname := util.InvalidString()
|
||||
log.Info().Err(err).Str(zf.InvalidHostname, node.Hostname).Str(zf.NewHostname, newHostname).Msgf("invalid hostname, replacing")
|
||||
node.Hostname = newHostname
|
||||
}
|
||||
|
||||
if node.GivenName == "" {
|
||||
givenName, err := EnsureUniqueGivenName(tx, node.Hostname)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ensuring unique given name: %w", err)
|
||||
node.GivenName = dnsname.SanitizeHostname(node.Hostname)
|
||||
if node.GivenName == "" {
|
||||
node.GivenName = "node"
|
||||
}
|
||||
|
||||
node.GivenName = givenName
|
||||
}
|
||||
|
||||
if err := tx.Save(&node).Error; err != nil { //nolint:noinlineerr
|
||||
@@ -484,72 +469,6 @@ func NodeSetMachineKey(
|
||||
}).Error
|
||||
}
|
||||
|
||||
func generateGivenName(suppliedName string, randomSuffix bool) (string, error) {
|
||||
// Strip invalid DNS characters for givenName
|
||||
suppliedName = strings.ToLower(suppliedName)
|
||||
suppliedName = invalidDNSRegex.ReplaceAllString(suppliedName, "")
|
||||
|
||||
if len(suppliedName) > util.LabelHostnameLength {
|
||||
return "", types.ErrHostnameTooLong
|
||||
}
|
||||
|
||||
if randomSuffix {
|
||||
// Trim if a hostname will be longer than 63 chars after adding the hash.
|
||||
trimmedHostnameLength := util.LabelHostnameLength - NodeGivenNameHashLength - NodeGivenNameTrimSize
|
||||
if len(suppliedName) > trimmedHostnameLength {
|
||||
suppliedName = suppliedName[:trimmedHostnameLength]
|
||||
}
|
||||
|
||||
suffix, err := util.GenerateRandomStringDNSSafe(NodeGivenNameHashLength)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
suppliedName += "-" + suffix
|
||||
}
|
||||
|
||||
return suppliedName, nil
|
||||
}
|
||||
|
||||
func isUniqueName(tx *gorm.DB, name string) (bool, error) {
|
||||
nodes := types.Nodes{}
|
||||
|
||||
err := tx.
|
||||
Where("given_name = ?", name).Find(&nodes).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return len(nodes) == 0, nil
|
||||
}
|
||||
|
||||
// EnsureUniqueGivenName generates a unique given name for a node based on its hostname.
|
||||
func EnsureUniqueGivenName(
|
||||
tx *gorm.DB,
|
||||
name string,
|
||||
) (string, error) {
|
||||
givenName, err := generateGivenName(name, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
unique, err := isUniqueName(tx, givenName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !unique {
|
||||
postfixedName, err := generateGivenName(name, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
givenName = postfixedName
|
||||
}
|
||||
|
||||
return givenName, nil
|
||||
}
|
||||
|
||||
// EphemeralGarbageCollector is a garbage collector that will delete nodes after
|
||||
// a certain amount of time.
|
||||
// It is used to delete ephemeral nodes that have disconnected and should be
|
||||
|
||||
+3
-395
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -227,122 +226,6 @@ func TestSetTags(t *testing.T) {
|
||||
assert.Equal(t, []string{"tag:bar", "tag:test", "tag:unknown"}, node.Tags)
|
||||
}
|
||||
|
||||
func TestHeadscale_generateGivenName(t *testing.T) {
|
||||
type args struct {
|
||||
suppliedName string
|
||||
randomSuffix bool
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *regexp.Regexp
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "simple node name generation",
|
||||
args: args{
|
||||
suppliedName: "testnode",
|
||||
randomSuffix: false,
|
||||
},
|
||||
want: regexp.MustCompile("^testnode$"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "UPPERCASE node name generation",
|
||||
args: args{
|
||||
suppliedName: "TestNode",
|
||||
randomSuffix: false,
|
||||
},
|
||||
want: regexp.MustCompile("^testnode$"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "node name with 53 chars",
|
||||
args: args{
|
||||
suppliedName: "testmaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaachine",
|
||||
randomSuffix: false,
|
||||
},
|
||||
want: regexp.MustCompile("^testmaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaachine$"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "node name with 63 chars",
|
||||
args: args{
|
||||
suppliedName: "nodeeeeeee12345678901234567890123456789012345678901234567890123",
|
||||
randomSuffix: false,
|
||||
},
|
||||
want: regexp.MustCompile("^nodeeeeeee12345678901234567890123456789012345678901234567890123$"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "node name with 64 chars",
|
||||
args: args{
|
||||
suppliedName: "nodeeeeeee123456789012345678901234567890123456789012345678901234",
|
||||
randomSuffix: false,
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "node name with 73 chars",
|
||||
args: args{
|
||||
suppliedName: "nodeeeeeee123456789012345678901234567890123456789012345678901234567890123",
|
||||
randomSuffix: false,
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "node name with random suffix",
|
||||
args: args{
|
||||
suppliedName: "test",
|
||||
randomSuffix: true,
|
||||
},
|
||||
want: regexp.MustCompile(fmt.Sprintf("^test-[a-z0-9]{%d}$", NodeGivenNameHashLength)),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "node name with 63 chars with random suffix",
|
||||
args: args{
|
||||
suppliedName: "nodeeee12345678901234567890123456789012345678901234567890123",
|
||||
randomSuffix: true,
|
||||
},
|
||||
want: regexp.MustCompile(fmt.Sprintf("^nodeeee1234567890123456789012345678901234567890123456-[a-z0-9]{%d}$", NodeGivenNameHashLength)),
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := generateGivenName(tt.args.suppliedName, tt.args.randomSuffix)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf(
|
||||
"Headscale.GenerateGivenName() error = %v, wantErr %v",
|
||||
err,
|
||||
tt.wantErr,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if tt.want != nil && !tt.want.MatchString(got) {
|
||||
t.Errorf(
|
||||
"Headscale.GenerateGivenName() = %v, does not match %v",
|
||||
tt.want,
|
||||
got,
|
||||
)
|
||||
}
|
||||
|
||||
if len(got) > util.LabelHostnameLength {
|
||||
t.Errorf(
|
||||
"Headscale.GenerateGivenName() = %v is larger than allowed DNS segment %d",
|
||||
got,
|
||||
util.LabelHostnameLength,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoApproveRoutes(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -633,7 +516,7 @@ func TestEphemeralGarbageCollectorLoads(t *testing.T) {
|
||||
|
||||
want := 1000
|
||||
|
||||
var deletedCount int64
|
||||
var deletedCount atomic.Int64
|
||||
|
||||
e := NewEphemeralGarbageCollector(func(ni types.NodeID) {
|
||||
mu.Lock()
|
||||
@@ -644,7 +527,7 @@ func TestEphemeralGarbageCollectorLoads(t *testing.T) {
|
||||
|
||||
got = append(got, ni)
|
||||
|
||||
atomic.AddInt64(&deletedCount, 1)
|
||||
deletedCount.Add(1)
|
||||
})
|
||||
go e.Start()
|
||||
|
||||
@@ -655,7 +538,7 @@ func TestEphemeralGarbageCollectorLoads(t *testing.T) {
|
||||
|
||||
// Wait for all deletions to complete
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
count := atomic.LoadInt64(&deletedCount)
|
||||
count := deletedCount.Load()
|
||||
assert.Equal(c, int64(want), count, "all nodes should be deleted")
|
||||
}, 10*time.Second, 50*time.Millisecond, "waiting for all deletions")
|
||||
|
||||
@@ -742,281 +625,6 @@ func TestListEphemeralNodes(t *testing.T) {
|
||||
assert.Equal(t, nodeEph.Hostname, ephemeralNodes[0].Hostname)
|
||||
}
|
||||
|
||||
func TestNodeNaming(t *testing.T) {
|
||||
db, err := newSQLiteTestDB()
|
||||
if err != nil {
|
||||
t.Fatalf("creating db: %s", err)
|
||||
}
|
||||
|
||||
user, err := db.CreateUser(types.User{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
user2, err := db.CreateUser(types.User{Name: "user2"})
|
||||
require.NoError(t, err)
|
||||
|
||||
node := types.Node{
|
||||
ID: 0,
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
NodeKey: key.NewNode().Public(),
|
||||
Hostname: "test",
|
||||
UserID: &user.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
|
||||
node2 := types.Node{
|
||||
ID: 0,
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
NodeKey: key.NewNode().Public(),
|
||||
Hostname: "test",
|
||||
UserID: &user2.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
|
||||
// Using non-ASCII characters in the hostname can
|
||||
// break your network, so they should be replaced when registering
|
||||
// a node.
|
||||
// https://github.com/juanfont/headscale/issues/2343
|
||||
nodeInvalidHostname := types.Node{
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
NodeKey: key.NewNode().Public(),
|
||||
Hostname: "我的电脑", //nolint:gosmopolitan // intentional i18n test data
|
||||
UserID: &user2.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
}
|
||||
|
||||
nodeShortHostname := types.Node{
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
NodeKey: key.NewNode().Public(),
|
||||
Hostname: "a",
|
||||
UserID: &user2.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
}
|
||||
|
||||
err = db.DB.Save(&node).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
err = db.DB.Save(&node2).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
err = db.DB.Transaction(func(tx *gorm.DB) error {
|
||||
_, err := RegisterNodeForTest(tx, node, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = RegisterNodeForTest(tx, node2, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = RegisterNodeForTest(tx, nodeInvalidHostname, new(mpp("100.64.0.66/32").Addr()), nil)
|
||||
_, err = RegisterNodeForTest(tx, nodeShortHostname, new(mpp("100.64.0.67/32").Addr()), nil)
|
||||
|
||||
return err
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
nodes, err := db.ListNodes()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Len(t, nodes, 4)
|
||||
|
||||
t.Logf("node1 %s %s", nodes[0].Hostname, nodes[0].GivenName)
|
||||
t.Logf("node2 %s %s", nodes[1].Hostname, nodes[1].GivenName)
|
||||
t.Logf("node3 %s %s", nodes[2].Hostname, nodes[2].GivenName)
|
||||
t.Logf("node4 %s %s", nodes[3].Hostname, nodes[3].GivenName)
|
||||
|
||||
assert.Equal(t, nodes[0].Hostname, nodes[0].GivenName)
|
||||
assert.NotEqual(t, nodes[1].Hostname, nodes[1].GivenName)
|
||||
assert.Equal(t, nodes[0].Hostname, nodes[1].Hostname)
|
||||
assert.NotEqual(t, nodes[0].Hostname, nodes[1].GivenName)
|
||||
assert.Contains(t, nodes[1].GivenName, nodes[0].Hostname)
|
||||
assert.Equal(t, nodes[0].GivenName, nodes[1].Hostname)
|
||||
assert.Len(t, nodes[0].Hostname, 4)
|
||||
assert.Len(t, nodes[1].Hostname, 4)
|
||||
assert.Len(t, nodes[0].GivenName, 4)
|
||||
assert.Len(t, nodes[1].GivenName, 13)
|
||||
assert.Contains(t, nodes[2].Hostname, "invalid-") // invalid chars
|
||||
assert.Contains(t, nodes[2].GivenName, "invalid-")
|
||||
assert.Contains(t, nodes[3].Hostname, "invalid-") // too short
|
||||
assert.Contains(t, nodes[3].GivenName, "invalid-")
|
||||
|
||||
// Nodes can be renamed to a unique name
|
||||
err = db.Write(func(tx *gorm.DB) error {
|
||||
return RenameNode(tx, nodes[0].ID, "newname")
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
nodes, err = db.ListNodes()
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, nodes, 4)
|
||||
assert.Equal(t, "test", nodes[0].Hostname)
|
||||
assert.Equal(t, "newname", nodes[0].GivenName)
|
||||
|
||||
// Nodes can reuse name that is no longer used
|
||||
err = db.Write(func(tx *gorm.DB) error {
|
||||
return RenameNode(tx, nodes[1].ID, "test")
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
nodes, err = db.ListNodes()
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, nodes, 4)
|
||||
assert.Equal(t, "test", nodes[0].Hostname)
|
||||
assert.Equal(t, "newname", nodes[0].GivenName)
|
||||
assert.Equal(t, "test", nodes[1].GivenName)
|
||||
|
||||
// Nodes cannot be renamed to used names
|
||||
err = db.Write(func(tx *gorm.DB) error {
|
||||
return RenameNode(tx, nodes[0].ID, "test")
|
||||
})
|
||||
require.ErrorContains(t, err, "name is not unique")
|
||||
|
||||
// Rename invalid chars
|
||||
err = db.Write(func(tx *gorm.DB) error {
|
||||
return RenameNode(tx, nodes[2].ID, "我的电脑") //nolint:gosmopolitan // intentional i18n test data
|
||||
})
|
||||
require.ErrorContains(t, err, "invalid characters")
|
||||
|
||||
// Rename too short
|
||||
err = db.Write(func(tx *gorm.DB) error {
|
||||
return RenameNode(tx, nodes[3].ID, "a")
|
||||
})
|
||||
require.ErrorContains(t, err, "at least 2 characters")
|
||||
|
||||
// Rename with emoji
|
||||
err = db.Write(func(tx *gorm.DB) error {
|
||||
return RenameNode(tx, nodes[0].ID, "hostname-with-💩")
|
||||
})
|
||||
require.ErrorContains(t, err, "invalid characters")
|
||||
|
||||
// Rename with only emoji
|
||||
err = db.Write(func(tx *gorm.DB) error {
|
||||
return RenameNode(tx, nodes[0].ID, "🚀")
|
||||
})
|
||||
assert.ErrorContains(t, err, "invalid characters")
|
||||
}
|
||||
|
||||
func TestRenameNodeComprehensive(t *testing.T) {
|
||||
db, err := newSQLiteTestDB()
|
||||
if err != nil {
|
||||
t.Fatalf("creating db: %s", err)
|
||||
}
|
||||
|
||||
user, err := db.CreateUser(types.User{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
node := types.Node{
|
||||
ID: 0,
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
NodeKey: key.NewNode().Public(),
|
||||
Hostname: "testnode",
|
||||
UserID: &user.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
|
||||
err = db.DB.Save(&node).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
err = db.DB.Transaction(func(tx *gorm.DB) error {
|
||||
_, err := RegisterNodeForTest(tx, node, nil, nil)
|
||||
return err
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
nodes, err := db.ListNodes()
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, nodes, 1)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
newName string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "uppercase_rejected",
|
||||
newName: "User2-Host",
|
||||
wantErr: "must be lowercase",
|
||||
},
|
||||
{
|
||||
name: "underscore_rejected",
|
||||
newName: "test_node",
|
||||
wantErr: "invalid characters",
|
||||
},
|
||||
{
|
||||
name: "at_sign_uppercase_rejected",
|
||||
newName: "Test@Host",
|
||||
wantErr: "must be lowercase",
|
||||
},
|
||||
{
|
||||
name: "at_sign_rejected",
|
||||
newName: "test@host",
|
||||
wantErr: "invalid characters",
|
||||
},
|
||||
{
|
||||
name: "chinese_chars_with_dash_rejected",
|
||||
newName: "server-北京-01", //nolint:gosmopolitan // intentional i18n test data
|
||||
wantErr: "invalid characters",
|
||||
},
|
||||
{
|
||||
name: "chinese_only_rejected",
|
||||
newName: "我的电脑", //nolint:gosmopolitan // intentional i18n test data
|
||||
wantErr: "invalid characters",
|
||||
},
|
||||
{
|
||||
name: "emoji_with_text_rejected",
|
||||
newName: "laptop-🚀",
|
||||
wantErr: "invalid characters",
|
||||
},
|
||||
{
|
||||
name: "mixed_chinese_emoji_rejected",
|
||||
newName: "测试💻机器", //nolint:gosmopolitan // intentional i18n test data
|
||||
wantErr: "invalid characters",
|
||||
},
|
||||
{
|
||||
name: "only_emojis_rejected",
|
||||
newName: "🎉🎊",
|
||||
wantErr: "invalid characters",
|
||||
},
|
||||
{
|
||||
name: "only_at_signs_rejected",
|
||||
newName: "@@@",
|
||||
wantErr: "invalid characters",
|
||||
},
|
||||
{
|
||||
name: "starts_with_dash_rejected",
|
||||
newName: "-test",
|
||||
wantErr: "cannot start or end with a hyphen",
|
||||
},
|
||||
{
|
||||
name: "ends_with_dash_rejected",
|
||||
newName: "test-",
|
||||
wantErr: "cannot start or end with a hyphen",
|
||||
},
|
||||
{
|
||||
name: "too_long_hostname_rejected",
|
||||
newName: "this-is-a-very-long-hostname-that-exceeds-sixty-three-characters-limit",
|
||||
wantErr: "must not exceed 63 characters",
|
||||
},
|
||||
{
|
||||
name: "too_short_hostname_rejected",
|
||||
newName: "a",
|
||||
wantErr: "at least 2 characters",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := db.Write(func(tx *gorm.DB) error {
|
||||
return RenameNode(tx, nodes[0].ID, tt.newName)
|
||||
})
|
||||
assert.ErrorContains(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPeers(t *testing.T) {
|
||||
// Setup test database
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/rs/zerolog"
|
||||
"zombiezen.com/go/postgrestest"
|
||||
)
|
||||
|
||||
@@ -20,7 +19,6 @@ func newSQLiteTestDB() (*HSDatabase, error) {
|
||||
}
|
||||
|
||||
log.Printf("database path: %s", tmpDir+"/headscale_test.db")
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
|
||||
db, err := NewHeadscaleDatabase(
|
||||
&types.Config{
|
||||
|
||||
@@ -28,7 +28,7 @@ func (hsdb *HSDatabase) CreateUser(user types.User) (*types.User, error) {
|
||||
// CreateUser creates a new User. Returns error if could not be created
|
||||
// or another user already exists.
|
||||
func CreateUser(tx *gorm.DB, user types.User) (*types.User, error) {
|
||||
err := util.ValidateHostname(user.Name)
|
||||
err := util.ValidateUsername(user.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -102,7 +102,7 @@ func RenameUser(tx *gorm.DB, uid types.UserID, newName string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = util.ValidateHostname(newName); err != nil { //nolint:noinlineerr
|
||||
if err = util.ValidateUsername(newName); err != nil { //nolint:noinlineerr
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ func TestDestroyUserErrors(t *testing.T) {
|
||||
user, err := db.CreateUser(types.User{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a tagged node with no user_id (the invariant).
|
||||
// Create a tagged node with no user_id (the rule for tagged nodes).
|
||||
node := types.Node{
|
||||
ID: 0,
|
||||
Hostname: "tagged-node",
|
||||
|
||||
+138
-4
@@ -1,16 +1,21 @@
|
||||
package hscontrol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/arl/statsviz"
|
||||
"github.com/juanfont/headscale/hscontrol/templates"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/types/change"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsweb"
|
||||
)
|
||||
|
||||
@@ -324,6 +329,41 @@ func (h *Headscale) debugHTTPServer() *http.Server {
|
||||
}
|
||||
}))
|
||||
|
||||
// Ping endpoint: sends a PingRequest to a node and waits for it to respond.
|
||||
// Supports POST (form submit) and GET with ?node= (clickable quick-ping links).
|
||||
debug.Handle("ping", "Ping a node to check connectivity", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
query string
|
||||
result *templates.PingResult
|
||||
)
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096) //nolint:mnd
|
||||
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, "bad form data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
query = r.FormValue("node")
|
||||
result = h.doPing(r.Context(), query)
|
||||
case http.MethodGet:
|
||||
// Support ?node= for auto-ping links from other debug pages.
|
||||
if q := r.URL.Query().Get("node"); q != "" {
|
||||
query = q
|
||||
result = h.doPing(r.Context(), query)
|
||||
}
|
||||
}
|
||||
|
||||
nodes := h.connectedNodesList()
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(templates.PingPage(query, result, nodes).Render()))
|
||||
}))
|
||||
|
||||
// statsviz.Register would mount handlers directly on the raw mux,
|
||||
// bypassing the access gate. Build the server by hand and wrap
|
||||
// each handler with protectedDebugHandler.
|
||||
@@ -339,7 +379,7 @@ func (h *Headscale) debugHTTPServer() *http.Server {
|
||||
|
||||
debugHTTPServer := &http.Server{
|
||||
Addr: h.cfg.MetricsAddr,
|
||||
Handler: debugMux,
|
||||
Handler: securityHeaders(debugMux),
|
||||
ReadTimeout: types.HTTPTimeout,
|
||||
WriteTimeout: 0,
|
||||
}
|
||||
@@ -395,13 +435,13 @@ func (h *Headscale) debugBatcher() string {
|
||||
}
|
||||
|
||||
if node.activeConnections > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Node %d:\t%s (%d connections)\n", node.id, status, node.activeConnections))
|
||||
fmt.Fprintf(&sb, "Node %d:\t%s (%d connections)\n", node.id, status, node.activeConnections)
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("Node %d:\t%s\n", node.id, status))
|
||||
fmt.Fprintf(&sb, "Node %d:\t%s\n", node.id, status)
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\nSummary: %d connected, %d total\n", connectedCount, totalNodes))
|
||||
fmt.Fprintf(&sb, "\nSummary: %d connected, %d total\n", connectedCount, totalNodes)
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@@ -436,3 +476,97 @@ func (h *Headscale) debugBatcherJSON() DebugBatcherInfo {
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// connectedNodesList returns a list of connected nodes for the ping page.
|
||||
func (h *Headscale) connectedNodesList() []templates.ConnectedNode {
|
||||
debugInfo := h.mapBatcher.Debug()
|
||||
|
||||
var nodes []templates.ConnectedNode
|
||||
|
||||
for nodeID, info := range debugInfo {
|
||||
if !info.Connected {
|
||||
continue
|
||||
}
|
||||
|
||||
nv, ok := h.state.GetNodeByID(nodeID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
cn := templates.ConnectedNode{
|
||||
ID: nodeID,
|
||||
Hostname: nv.Hostname(),
|
||||
}
|
||||
|
||||
for _, ip := range nv.IPs() {
|
||||
cn.IPs = append(cn.IPs, ip.String())
|
||||
}
|
||||
|
||||
nodes = append(nodes, cn)
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
const pingTimeout = 30 * time.Second
|
||||
|
||||
// doPing sends a PingRequest to the node identified by query and waits for a response.
|
||||
func (h *Headscale) doPing(ctx context.Context, query string) *templates.PingResult {
|
||||
if query == "" {
|
||||
return &templates.PingResult{
|
||||
Status: "error",
|
||||
Message: "No node specified.",
|
||||
}
|
||||
}
|
||||
|
||||
node, ok := h.state.ResolveNode(query)
|
||||
if !ok {
|
||||
return &templates.PingResult{
|
||||
Status: "error",
|
||||
Message: fmt.Sprintf("Node %q not found.", query),
|
||||
}
|
||||
}
|
||||
|
||||
nodeID := node.ID()
|
||||
|
||||
if !h.mapBatcher.IsConnected(nodeID) {
|
||||
return &templates.PingResult{
|
||||
Status: "error",
|
||||
NodeID: nodeID,
|
||||
Message: fmt.Sprintf("Node %d is not connected.", nodeID),
|
||||
}
|
||||
}
|
||||
|
||||
pingID, responseCh := h.state.RegisterPing(nodeID)
|
||||
defer h.state.CancelPing(pingID)
|
||||
|
||||
// The callback hits /machine/ping-response on the main TLS router,
|
||||
// not the noise chain, so URLIsNoise stays false. Operators running
|
||||
// server_url over plain HTTP are on their own — don't do that.
|
||||
callbackURL := h.cfg.ServerURL + "/machine/ping-response?id=" + pingID
|
||||
h.Change(change.PingNode(nodeID, &tailcfg.PingRequest{
|
||||
URL: callbackURL,
|
||||
Log: true,
|
||||
}))
|
||||
|
||||
select {
|
||||
case latency := <-responseCh:
|
||||
return &templates.PingResult{
|
||||
Status: "ok",
|
||||
Latency: latency,
|
||||
NodeID: nodeID,
|
||||
}
|
||||
case <-time.After(pingTimeout):
|
||||
return &templates.PingResult{
|
||||
Status: "timeout",
|
||||
NodeID: nodeID,
|
||||
Message: fmt.Sprintf("No response after %s.", pingTimeout),
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return &templates.PingResult{
|
||||
Status: "error",
|
||||
NodeID: nodeID,
|
||||
Message: "Request cancelled.",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"tailscale.com/types/views"
|
||||
|
||||
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/state"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
@@ -781,6 +782,35 @@ func (api headscaleV1APIServer) SetPolicy(
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// CheckPolicy validates the given policy against the server's live users
|
||||
// and nodes, running its `tests` block as a sandbox. Nothing is persisted
|
||||
// and the live PolicyManager is not touched. Works regardless of
|
||||
// policy.mode so operators can validate a policy file before storing it.
|
||||
func (api headscaleV1APIServer) CheckPolicy(
|
||||
_ context.Context,
|
||||
request *v1.CheckPolicyRequest,
|
||||
) (*v1.CheckPolicyResponse, error) {
|
||||
polB := []byte(request.GetPolicy())
|
||||
|
||||
users, err := api.h.state.ListAllUsers()
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "loading users: %s", err)
|
||||
}
|
||||
|
||||
nodes := api.h.state.ListNodes()
|
||||
|
||||
pm, err := policyv2.NewPolicyManager(polB, users, nodes)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
|
||||
if _, err := pm.SetPolicy(polB); err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
|
||||
return &v1.CheckPolicyResponse{}, nil
|
||||
}
|
||||
|
||||
// The following service calls are for testing and debugging
|
||||
func (api headscaleV1APIServer) DebugCreateNode(
|
||||
ctx context.Context,
|
||||
|
||||
@@ -596,7 +596,7 @@ func TestDeleteUser_TaggedNodeSurvives(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp.MachineAuthorized)
|
||||
|
||||
// Verify the registered node has nil UserID (enforced invariant).
|
||||
// Verify the registered node has nil UserID (enforced at registration).
|
||||
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
|
||||
require.True(t, found)
|
||||
require.True(t, node.IsTagged())
|
||||
|
||||
@@ -44,6 +44,54 @@ func httpError(w http.ResponseWriter, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// httpUserError logs an error and sends a styled HTML error page.
|
||||
// Use this for browser-facing error paths (OIDC, registration confirm)
|
||||
// where the user should see a branded page instead of plain text.
|
||||
// Technical details go to the server log; the HTML page only shows
|
||||
// an actionable message derived from the HTTP status code.
|
||||
func httpUserError(w http.ResponseWriter, err error) {
|
||||
code := http.StatusInternalServerError
|
||||
|
||||
if herr, ok := errors.AsType[HTTPError](err); ok {
|
||||
if herr.Code != 0 {
|
||||
code = herr.Code
|
||||
}
|
||||
|
||||
log.Error().Err(herr.Err).Int("code", code).Msgf("user msg: %s", herr.Msg)
|
||||
} else {
|
||||
log.Error().Err(err).Int("code", code).Msg("http internal server error")
|
||||
}
|
||||
|
||||
userMsg := userMessageForStatusCode(code)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
|
||||
page := templates.AuthError(templates.AuthErrorResult{
|
||||
Title: "Headscale - Error",
|
||||
Heading: http.StatusText(code),
|
||||
Message: userMsg,
|
||||
})
|
||||
|
||||
_, werr := w.Write([]byte(page.Render()))
|
||||
if werr != nil {
|
||||
log.Error().Err(werr).Msg("failed to write HTML error response")
|
||||
}
|
||||
}
|
||||
|
||||
func userMessageForStatusCode(code int) string {
|
||||
switch {
|
||||
case code == http.StatusUnauthorized || code == http.StatusForbidden:
|
||||
return "You are not authorized. Please contact your administrator."
|
||||
case code == http.StatusGone:
|
||||
return "Your session has expired. Please try again."
|
||||
case code >= 400 && code < 500:
|
||||
return "The request could not be processed. Please try again."
|
||||
default:
|
||||
return "Something went wrong. Please try again later."
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPError represents an error that is surfaced to the user via web.
|
||||
type HTTPError struct {
|
||||
Code int // HTTP response code to send to client; 0 means 500
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
var errTestUnexpected = errors.New("unexpected failure")
|
||||
|
||||
// TestHandleVerifyRequest_OversizedBodyRejected verifies that the
|
||||
// /verify handler refuses POST bodies larger than verifyBodyLimit.
|
||||
// The MaxBytesReader is applied in VerifyHandler, so we simulate
|
||||
@@ -55,3 +57,73 @@ func errorAsHTTPError(err error) (HTTPError, bool) {
|
||||
|
||||
return HTTPError{}, false
|
||||
}
|
||||
|
||||
func TestHttpUserError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantCode int
|
||||
wantContains string
|
||||
wantNotContain string
|
||||
}{
|
||||
{
|
||||
name: "forbidden_renders_authorization_message",
|
||||
err: NewHTTPError(http.StatusForbidden, "csrf token mismatch", nil),
|
||||
wantCode: http.StatusForbidden,
|
||||
wantContains: "You are not authorized. Please contact your administrator.",
|
||||
wantNotContain: "csrf token mismatch",
|
||||
},
|
||||
{
|
||||
name: "unauthorized_renders_authorization_message",
|
||||
err: NewHTTPError(http.StatusUnauthorized, "unauthorised domain", nil),
|
||||
wantCode: http.StatusUnauthorized,
|
||||
wantContains: "You are not authorized. Please contact your administrator.",
|
||||
wantNotContain: "unauthorised domain",
|
||||
},
|
||||
{
|
||||
name: "gone_renders_session_expired",
|
||||
err: NewHTTPError(http.StatusGone, "login session expired, try again", nil),
|
||||
wantCode: http.StatusGone,
|
||||
wantContains: "Your session has expired. Please try again.",
|
||||
wantNotContain: "login session expired",
|
||||
},
|
||||
{
|
||||
name: "bad_request_renders_generic_retry",
|
||||
err: NewHTTPError(http.StatusBadRequest, "state not found", nil),
|
||||
wantCode: http.StatusBadRequest,
|
||||
wantContains: "The request could not be processed. Please try again.",
|
||||
wantNotContain: "state not found",
|
||||
},
|
||||
{
|
||||
name: "plain_error_renders_500",
|
||||
err: errTestUnexpected,
|
||||
wantCode: http.StatusInternalServerError,
|
||||
wantContains: "Something went wrong. Please try again later.",
|
||||
},
|
||||
{
|
||||
name: "html_structure_present",
|
||||
err: NewHTTPError(http.StatusGone, "session expired", nil),
|
||||
wantCode: http.StatusGone,
|
||||
wantContains: "<!DOCTYPE html>",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
httpUserError(rec, tt.err)
|
||||
|
||||
assert.Equal(t, tt.wantCode, rec.Code)
|
||||
assert.Contains(t, rec.Header().Get("Content-Type"), "text/html")
|
||||
assert.Contains(t, rec.Body.String(), tt.wantContains)
|
||||
|
||||
if tt.wantNotContain != "" {
|
||||
assert.NotContains(t, rec.Body.String(), tt.wantNotContain)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/types/change"
|
||||
"github.com/puzpuzpuz/xsync/v4"
|
||||
"github.com/rs/zerolog"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
@@ -182,9 +181,6 @@ func benchBatcher(nodeCount, bufferSize int) (*Batcher, map[types.NodeID]chan *t
|
||||
// BenchmarkAddToBatch_Broadcast measures the cost of broadcasting a change
|
||||
// to all nodes via addToBatch (no worker processing, just queuing).
|
||||
func BenchmarkAddToBatch_Broadcast(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, nodeCount := range []int{10, 100, 1000} {
|
||||
b.Run(fmt.Sprintf("%dnodes", nodeCount), func(b *testing.B) {
|
||||
batcher, _ := benchBatcher(nodeCount, 10)
|
||||
@@ -213,9 +209,6 @@ func BenchmarkAddToBatch_Broadcast(b *testing.B) {
|
||||
// BenchmarkAddToBatch_Targeted measures the cost of adding a targeted change
|
||||
// to a single node.
|
||||
func BenchmarkAddToBatch_Targeted(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, nodeCount := range []int{10, 100, 1000} {
|
||||
b.Run(fmt.Sprintf("%dnodes", nodeCount), func(b *testing.B) {
|
||||
batcher, _ := benchBatcher(nodeCount, 10)
|
||||
@@ -251,9 +244,6 @@ func BenchmarkAddToBatch_Targeted(b *testing.B) {
|
||||
|
||||
// BenchmarkAddToBatch_FullUpdate measures the cost of a FullUpdate broadcast.
|
||||
func BenchmarkAddToBatch_FullUpdate(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, nodeCount := range []int{10, 100, 1000} {
|
||||
b.Run(fmt.Sprintf("%dnodes", nodeCount), func(b *testing.B) {
|
||||
batcher, _ := benchBatcher(nodeCount, 10)
|
||||
@@ -275,9 +265,6 @@ func BenchmarkAddToBatch_FullUpdate(b *testing.B) {
|
||||
// BenchmarkProcessBatchedChanges measures the cost of moving pending changes
|
||||
// to the work queue.
|
||||
func BenchmarkProcessBatchedChanges(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, nodeCount := range []int{10, 100, 1000} {
|
||||
b.Run(fmt.Sprintf("%dpending", nodeCount), func(b *testing.B) {
|
||||
batcher, _ := benchBatcher(nodeCount, 10)
|
||||
@@ -311,9 +298,6 @@ func BenchmarkProcessBatchedChanges(b *testing.B) {
|
||||
// BenchmarkBroadcastToN measures end-to-end broadcast: addToBatch + processBatchedChanges
|
||||
// to N nodes. Does NOT include worker processing (MapResponse generation).
|
||||
func BenchmarkBroadcastToN(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, nodeCount := range []int{10, 100, 1000} {
|
||||
b.Run(fmt.Sprintf("%dnodes", nodeCount), func(b *testing.B) {
|
||||
batcher, _ := benchBatcher(nodeCount, 10)
|
||||
@@ -339,9 +323,6 @@ func BenchmarkBroadcastToN(b *testing.B) {
|
||||
// BenchmarkMultiChannelBroadcast measures the cost of sending a MapResponse
|
||||
// to N nodes each with varying connection counts.
|
||||
func BenchmarkMultiChannelBroadcast(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, nodeCount := range []int{10, 100, 1000} {
|
||||
b.Run(fmt.Sprintf("%dnodes", nodeCount), func(b *testing.B) {
|
||||
batcher, _ := benchBatcher(nodeCount, b.N+1)
|
||||
@@ -387,9 +368,6 @@ func BenchmarkMultiChannelBroadcast(b *testing.B) {
|
||||
// BenchmarkConcurrentAddToBatch measures addToBatch throughput under
|
||||
// concurrent access from multiple goroutines.
|
||||
func BenchmarkConcurrentAddToBatch(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, nodeCount := range []int{10, 100, 1000} {
|
||||
b.Run(fmt.Sprintf("%dnodes", nodeCount), func(b *testing.B) {
|
||||
batcher, _ := benchBatcher(nodeCount, 10)
|
||||
@@ -440,9 +418,6 @@ func BenchmarkConcurrentAddToBatch(b *testing.B) {
|
||||
|
||||
// BenchmarkIsConnected measures the read throughput of IsConnected checks.
|
||||
func BenchmarkIsConnected(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, nodeCount := range []int{10, 100, 1000} {
|
||||
b.Run(fmt.Sprintf("%dnodes", nodeCount), func(b *testing.B) {
|
||||
batcher, _ := benchBatcher(nodeCount, 1)
|
||||
@@ -464,9 +439,6 @@ func BenchmarkIsConnected(b *testing.B) {
|
||||
|
||||
// BenchmarkConnectedMap measures the cost of building the full connected map.
|
||||
func BenchmarkConnectedMap(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, nodeCount := range []int{10, 100, 1000} {
|
||||
b.Run(fmt.Sprintf("%dnodes", nodeCount), func(b *testing.B) {
|
||||
batcher, channels := benchBatcher(nodeCount, 1)
|
||||
@@ -499,9 +471,6 @@ func BenchmarkConnectedMap(b *testing.B) {
|
||||
// BenchmarkConnectionChurn measures the cost of add/remove connection cycling
|
||||
// which simulates client reconnection patterns.
|
||||
func BenchmarkConnectionChurn(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, nodeCount := range []int{10, 100, 1000} {
|
||||
b.Run(fmt.Sprintf("%dnodes", nodeCount), func(b *testing.B) {
|
||||
batcher, channels := benchBatcher(nodeCount, 10)
|
||||
@@ -545,9 +514,6 @@ func BenchmarkConnectionChurn(b *testing.B) {
|
||||
// BenchmarkConcurrentSendAndChurn measures the combined cost of sends happening
|
||||
// concurrently with connection churn - the hot path in production.
|
||||
func BenchmarkConcurrentSendAndChurn(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, nodeCount := range []int{10, 100} {
|
||||
b.Run(fmt.Sprintf("%dnodes", nodeCount), func(b *testing.B) {
|
||||
batcher, channels := benchBatcher(nodeCount, 100)
|
||||
@@ -620,9 +586,6 @@ func BenchmarkAddNode(b *testing.B) {
|
||||
b.Skip("skipping full pipeline benchmark in short mode")
|
||||
}
|
||||
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, nodeCount := range []int{10, 100} {
|
||||
b.Run(fmt.Sprintf("%dnodes", nodeCount), func(b *testing.B) {
|
||||
testData, cleanup := setupBatcherWithTestData(b, NewBatcherAndMapper, 1, nodeCount, largeBufferSize)
|
||||
@@ -683,9 +646,6 @@ func BenchmarkFullPipeline(b *testing.B) {
|
||||
b.Skip("skipping full pipeline benchmark in short mode")
|
||||
}
|
||||
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, nodeCount := range []int{10, 100} {
|
||||
b.Run(fmt.Sprintf("%dnodes", nodeCount), func(b *testing.B) {
|
||||
testData, cleanup := setupBatcherWithTestData(b, NewBatcherAndMapper, 1, nodeCount, largeBufferSize)
|
||||
@@ -738,9 +698,6 @@ func BenchmarkMapResponseFromChange(b *testing.B) {
|
||||
b.Skip("skipping full pipeline benchmark in short mode")
|
||||
}
|
||||
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, nodeCount := range []int{10, 100} {
|
||||
b.Run(fmt.Sprintf("%dnodes", nodeCount), func(b *testing.B) {
|
||||
testData, cleanup := setupBatcherWithTestData(b, NewBatcherAndMapper, 1, nodeCount, largeBufferSize)
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
"github.com/juanfont/headscale/hscontrol/types/change"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"github.com/puzpuzpuz/xsync/v4"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -473,9 +472,6 @@ func TestProcessBatchedChanges_BundlesChangesPerNode(t *testing.T) {
|
||||
// could process bundles from tick N and tick N+1 concurrently for the same
|
||||
// node, causing out-of-order delivery and races on lastSentPeers.
|
||||
func TestWorkMu_PreventsInterTickRace(t *testing.T) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
mc := newMultiChannelNodeConn(1, nil)
|
||||
ch := make(chan *tailcfg.MapResponse, 100)
|
||||
entry := &connectionEntry{
|
||||
@@ -786,6 +782,7 @@ func TestBug3_CleanupOfflineNodes_TOCTOU(t *testing.T) {
|
||||
entry.lastUsed.Store(time.Now().Unix())
|
||||
mc.addConnection(entry)
|
||||
mc.markConnected()
|
||||
|
||||
lb.channels[targetNode] = newCh
|
||||
|
||||
// Now run cleanup. Node 3 is in the candidates list (old disconnect
|
||||
@@ -848,9 +845,6 @@ func TestBug3_CleanupOfflineNodes_TOCTOU(t *testing.T) {
|
||||
// BUG: batcher_lockfree.go worker() - no nil check after b.nodes.Load()
|
||||
// FIX: Add nil guard: `exists && nc != nil` in both sync and async paths.
|
||||
func TestBug5_WorkerPanicKillsWorkerPermanently(t *testing.T) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
lb := setupLightweightBatcher(t, 3, 10)
|
||||
defer lb.cleanup()
|
||||
|
||||
@@ -926,9 +920,6 @@ func TestBug5_WorkerPanicKillsWorkerPermanently(t *testing.T) {
|
||||
// BUG: batcher_lockfree.go:163-166 - Start() has no "already started" check
|
||||
// FIX: Add sync.Once or atomic.Bool to prevent multiple Start() calls.
|
||||
func TestBug6_StartCalledMultipleTimes_GoroutineLeak(t *testing.T) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
lb := setupLightweightBatcher(t, 3, 10)
|
||||
lb.b.workers = 2
|
||||
|
||||
@@ -1041,9 +1032,6 @@ func TestBug7_CleanupOfflineNodes_PendingChangesCleanedStructurally(t *testing.T
|
||||
// (timeouts happen here), then write-lock only to remove failed connections.
|
||||
// The lock is now held only for O(N) pointer copies, not for N*50ms I/O.
|
||||
func TestBug8_SerialTimeoutUnderWriteLock(t *testing.T) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
mc := newMultiChannelNodeConn(1, nil)
|
||||
|
||||
// Add 5 stale connections (unbuffered, no reader = will timeout at 50ms each)
|
||||
@@ -1147,9 +1135,6 @@ func TestScale1000_AddToBatch_Broadcast(t *testing.T) {
|
||||
t.Skip("skipping 1000-node test in short mode")
|
||||
}
|
||||
|
||||
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
lb := setupLightweightBatcher(t, 1000, 10)
|
||||
defer lb.cleanup()
|
||||
|
||||
@@ -1180,9 +1165,6 @@ func TestScale1000_ProcessBatchedWithConcurrentAdd(t *testing.T) {
|
||||
t.Skip("skipping 1000-node test in short mode")
|
||||
}
|
||||
|
||||
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
lb := setupLightweightBatcher(t, 1000, 10)
|
||||
defer lb.cleanup()
|
||||
|
||||
@@ -1235,9 +1217,6 @@ func TestScale1000_MultiChannelBroadcast(t *testing.T) {
|
||||
t.Skip("skipping 1000-node test in short mode")
|
||||
}
|
||||
|
||||
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
const (
|
||||
nodeCount = 1000
|
||||
bufferSize = 5
|
||||
@@ -1338,9 +1317,6 @@ func TestScale1000_ConnectionChurn(t *testing.T) {
|
||||
t.Skip("skipping 1000-node test in short mode")
|
||||
}
|
||||
|
||||
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
lb := setupLightweightBatcher(t, 1000, 20)
|
||||
defer lb.cleanup()
|
||||
|
||||
@@ -1440,9 +1416,6 @@ func TestScale1000_ConcurrentAddRemove(t *testing.T) {
|
||||
t.Skip("skipping 1000-node test in short mode")
|
||||
}
|
||||
|
||||
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
lb := setupLightweightBatcher(t, 1000, 10)
|
||||
defer lb.cleanup()
|
||||
|
||||
@@ -1484,9 +1457,6 @@ func TestScale1000_IsConnectedConsistency(t *testing.T) {
|
||||
t.Skip("skipping 1000-node test in short mode")
|
||||
}
|
||||
|
||||
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
lb := setupLightweightBatcher(t, 1000, 10)
|
||||
defer lb.cleanup()
|
||||
|
||||
@@ -1553,9 +1523,6 @@ func TestScale1000_BroadcastDuringNodeChurn(t *testing.T) {
|
||||
t.Skip("skipping 1000-node test in short mode")
|
||||
}
|
||||
|
||||
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
lb := setupLightweightBatcher(t, 1000, 10)
|
||||
defer lb.cleanup()
|
||||
|
||||
@@ -1640,9 +1607,6 @@ func TestScale1000_WorkChannelSaturation(t *testing.T) {
|
||||
t.Skip("skipping 1000-node test in short mode")
|
||||
}
|
||||
|
||||
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
// Create batcher with SMALL work channel to force saturation
|
||||
b := &Batcher{
|
||||
tick: time.NewTicker(10 * time.Millisecond),
|
||||
@@ -1718,9 +1682,6 @@ func TestScale1000_FullUpdate_AllNodesGetPending(t *testing.T) {
|
||||
t.Skip("skipping 1000-node test in short mode")
|
||||
}
|
||||
|
||||
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
lb := setupLightweightBatcher(t, 1000, 10)
|
||||
defer lb.cleanup()
|
||||
|
||||
@@ -1756,9 +1717,6 @@ func TestScale1000_AllToAll_FullPipeline(t *testing.T) {
|
||||
t.Skip("skipping 1000-node test with race detector (bcrypt setup too slow)")
|
||||
}
|
||||
|
||||
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
t.Logf("setting up 1000-node test environment (this may take a minute)...")
|
||||
|
||||
testData, cleanup := setupBatcherWithTestData(t, NewBatcherAndMapper, 1, 1000, 200)
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/types/change"
|
||||
"github.com/rs/zerolog"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
@@ -40,9 +39,6 @@ var (
|
||||
|
||||
// BenchmarkScale_IsConnected tests single-node lookup at increasing map sizes.
|
||||
func BenchmarkScale_IsConnected(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, n := range scaleCountsO1 {
|
||||
b.Run(strconv.Itoa(n), func(b *testing.B) {
|
||||
batcher, _ := benchBatcher(n, 1)
|
||||
@@ -65,9 +61,6 @@ func BenchmarkScale_IsConnected(b *testing.B) {
|
||||
// BenchmarkScale_AddToBatch_Targeted tests single-node targeted change at
|
||||
// increasing map sizes. The map size should not affect per-operation cost.
|
||||
func BenchmarkScale_AddToBatch_Targeted(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, n := range scaleCountsO1 {
|
||||
b.Run(strconv.Itoa(n), func(b *testing.B) {
|
||||
batcher, _ := benchBatcher(n, 10)
|
||||
@@ -104,9 +97,6 @@ func BenchmarkScale_AddToBatch_Targeted(b *testing.B) {
|
||||
// BenchmarkScale_ConnectionChurn tests add/remove connection cycle.
|
||||
// The map size should not affect per-operation cost for a single node.
|
||||
func BenchmarkScale_ConnectionChurn(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, n := range scaleCountsO1 {
|
||||
b.Run(strconv.Itoa(n), func(b *testing.B) {
|
||||
batcher, channels := benchBatcher(n, 10)
|
||||
@@ -152,9 +142,6 @@ func BenchmarkScale_ConnectionChurn(b *testing.B) {
|
||||
// BenchmarkScale_AddToBatch_Broadcast tests broadcasting a change to ALL nodes.
|
||||
// Cost should scale linearly with node count.
|
||||
func BenchmarkScale_AddToBatch_Broadcast(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, n := range scaleCountsLinear {
|
||||
b.Run(strconv.Itoa(n), func(b *testing.B) {
|
||||
batcher, _ := benchBatcher(n, 10)
|
||||
@@ -182,9 +169,6 @@ func BenchmarkScale_AddToBatch_Broadcast(b *testing.B) {
|
||||
|
||||
// BenchmarkScale_AddToBatch_FullUpdate tests FullUpdate broadcast cost.
|
||||
func BenchmarkScale_AddToBatch_FullUpdate(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, n := range scaleCountsLinear {
|
||||
b.Run(strconv.Itoa(n), func(b *testing.B) {
|
||||
batcher, _ := benchBatcher(n, 10)
|
||||
@@ -205,9 +189,6 @@ func BenchmarkScale_AddToBatch_FullUpdate(b *testing.B) {
|
||||
|
||||
// BenchmarkScale_ProcessBatchedChanges tests draining pending changes into work queue.
|
||||
func BenchmarkScale_ProcessBatchedChanges(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, n := range scaleCountsLinear {
|
||||
b.Run(strconv.Itoa(n), func(b *testing.B) {
|
||||
batcher, _ := benchBatcher(n, 10)
|
||||
@@ -238,9 +219,6 @@ func BenchmarkScale_ProcessBatchedChanges(b *testing.B) {
|
||||
|
||||
// BenchmarkScale_BroadcastToN tests end-to-end: addToBatch + processBatchedChanges.
|
||||
func BenchmarkScale_BroadcastToN(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, n := range scaleCountsLinear {
|
||||
b.Run(strconv.Itoa(n), func(b *testing.B) {
|
||||
batcher, _ := benchBatcher(n, 10)
|
||||
@@ -267,9 +245,6 @@ func BenchmarkScale_BroadcastToN(b *testing.B) {
|
||||
// This isolates the multiChannelNodeConn.send() cost.
|
||||
// Uses large buffered channels to avoid goroutine drain overhead.
|
||||
func BenchmarkScale_SendToAll(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, n := range scaleCountsLinear {
|
||||
b.Run(strconv.Itoa(n), func(b *testing.B) {
|
||||
// b.N+1 buffer so sends never block
|
||||
@@ -300,9 +275,6 @@ func BenchmarkScale_SendToAll(b *testing.B) {
|
||||
|
||||
// BenchmarkScale_ConnectedMap tests building the full connected/disconnected map.
|
||||
func BenchmarkScale_ConnectedMap(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, n := range scaleCountsHeavy {
|
||||
b.Run(strconv.Itoa(n), func(b *testing.B) {
|
||||
batcher, channels := benchBatcher(n, 1)
|
||||
@@ -335,9 +307,6 @@ func BenchmarkScale_ConnectedMap(b *testing.B) {
|
||||
// BenchmarkScale_ComputePeerDiff tests peer diff computation at scale.
|
||||
// Each node tracks N-1 peers, with 10% removed.
|
||||
func BenchmarkScale_ComputePeerDiff(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, n := range scaleCountsHeavy {
|
||||
b.Run(strconv.Itoa(n), func(b *testing.B) {
|
||||
mc := newMultiChannelNodeConn(1, nil)
|
||||
@@ -366,9 +335,6 @@ func BenchmarkScale_ComputePeerDiff(b *testing.B) {
|
||||
|
||||
// BenchmarkScale_UpdateSentPeers_Full tests full peer list update.
|
||||
func BenchmarkScale_UpdateSentPeers_Full(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, n := range scaleCountsHeavy {
|
||||
b.Run(strconv.Itoa(n), func(b *testing.B) {
|
||||
mc := newMultiChannelNodeConn(1, nil)
|
||||
@@ -391,9 +357,6 @@ func BenchmarkScale_UpdateSentPeers_Full(b *testing.B) {
|
||||
|
||||
// BenchmarkScale_UpdateSentPeers_Incremental tests incremental peer updates (10% new).
|
||||
func BenchmarkScale_UpdateSentPeers_Incremental(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, n := range scaleCountsHeavy {
|
||||
b.Run(strconv.Itoa(n), func(b *testing.B) {
|
||||
mc := newMultiChannelNodeConn(1, nil)
|
||||
@@ -428,9 +391,6 @@ func BenchmarkScale_UpdateSentPeers_Incremental(b *testing.B) {
|
||||
// ~1.6 connections on average (every 3rd node has 3 connections).
|
||||
// Uses large buffered channels to avoid goroutine drain overhead.
|
||||
func BenchmarkScale_MultiChannelBroadcast(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, n := range scaleCountsHeavy {
|
||||
b.Run(strconv.Itoa(n), func(b *testing.B) {
|
||||
// Use b.N+1 buffer so sends never block
|
||||
@@ -480,9 +440,6 @@ func BenchmarkScale_MultiChannelBroadcast(b *testing.B) {
|
||||
|
||||
// BenchmarkScale_ConcurrentAddToBatch tests parallel addToBatch throughput.
|
||||
func BenchmarkScale_ConcurrentAddToBatch(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, n := range scaleCountsConc {
|
||||
b.Run(strconv.Itoa(n), func(b *testing.B) {
|
||||
batcher, _ := benchBatcher(n, 10)
|
||||
@@ -529,9 +486,6 @@ func BenchmarkScale_ConcurrentAddToBatch(b *testing.B) {
|
||||
// sending to all nodes while 10% of connections are churning concurrently.
|
||||
// Uses large buffered channels to avoid goroutine drain overhead.
|
||||
func BenchmarkScale_ConcurrentSendAndChurn(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, n := range scaleCountsConc {
|
||||
b.Run(strconv.Itoa(n), func(b *testing.B) {
|
||||
batcher, channels := benchBatcher(n, b.N+1)
|
||||
@@ -602,9 +556,6 @@ func BenchmarkScale_ConcurrentSendAndChurn(b *testing.B) {
|
||||
// - 10% full updates (broadcast with full map)
|
||||
// All while 10% of connections are churning.
|
||||
func BenchmarkScale_MixedWorkload(b *testing.B) {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, n := range scaleCountsConc {
|
||||
b.Run(strconv.Itoa(n), func(b *testing.B) {
|
||||
batcher, channels := benchBatcher(n, 10)
|
||||
@@ -722,9 +673,6 @@ func BenchmarkScale_AddAllNodes(b *testing.B) {
|
||||
b.Skip("skipping full pipeline benchmark in short mode")
|
||||
}
|
||||
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, nodeCount := range []int{10, 50, 100, 200, 500} {
|
||||
b.Run(strconv.Itoa(nodeCount), func(b *testing.B) {
|
||||
testData, cleanup := setupBatcherWithTestData(b, NewBatcherAndMapper, 1, nodeCount, largeBufferSize)
|
||||
@@ -785,9 +733,6 @@ func BenchmarkScale_SingleAddNode(b *testing.B) {
|
||||
b.Skip("skipping full pipeline benchmark in short mode")
|
||||
}
|
||||
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, nodeCount := range []int{10, 50, 100, 200, 500, 1000} {
|
||||
b.Run(strconv.Itoa(nodeCount), func(b *testing.B) {
|
||||
testData, cleanup := setupBatcherWithTestData(b, NewBatcherAndMapper, 1, nodeCount, largeBufferSize)
|
||||
@@ -851,9 +796,6 @@ func BenchmarkScale_MapResponse_DERPMap(b *testing.B) {
|
||||
b.Skip("skipping full pipeline benchmark in short mode")
|
||||
}
|
||||
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, nodeCount := range []int{10, 50, 100, 200, 500} {
|
||||
b.Run(strconv.Itoa(nodeCount), func(b *testing.B) {
|
||||
testData, cleanup := setupBatcherWithTestData(b, NewBatcherAndMapper, 1, nodeCount, largeBufferSize)
|
||||
@@ -903,9 +845,6 @@ func BenchmarkScale_MapResponse_FullUpdate(b *testing.B) {
|
||||
b.Skip("skipping full pipeline benchmark in short mode")
|
||||
}
|
||||
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
for _, nodeCount := range []int{10, 50, 100, 200, 500} {
|
||||
b.Run(strconv.Itoa(nodeCount), func(b *testing.B) {
|
||||
testData, cleanup := setupBatcherWithTestData(b, NewBatcherAndMapper, 1, nodeCount, largeBufferSize)
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/juanfont/headscale/hscontrol/state"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/types/change"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -141,9 +140,9 @@ type node struct {
|
||||
ch chan *tailcfg.MapResponse
|
||||
|
||||
// Update tracking (all accessed atomically for thread safety)
|
||||
updateCount int64
|
||||
patchCount int64
|
||||
fullCount int64
|
||||
updateCount atomic.Int64
|
||||
patchCount atomic.Int64
|
||||
fullCount atomic.Int64
|
||||
maxPeersCount atomic.Int64
|
||||
lastPeerCount atomic.Int64
|
||||
stop chan struct{}
|
||||
@@ -404,14 +403,14 @@ func (n *node) start() {
|
||||
for {
|
||||
select {
|
||||
case data := <-n.ch:
|
||||
atomic.AddInt64(&n.updateCount, 1)
|
||||
n.updateCount.Add(1)
|
||||
|
||||
// Parse update and track detailed stats
|
||||
info := parseUpdateAndAnalyze(data)
|
||||
{
|
||||
// Track update types
|
||||
if info.IsFull {
|
||||
atomic.AddInt64(&n.fullCount, 1)
|
||||
n.fullCount.Add(1)
|
||||
n.lastPeerCount.Store(int64(info.PeerCount))
|
||||
// Update max peers seen using compare-and-swap for thread safety
|
||||
for {
|
||||
@@ -427,7 +426,7 @@ func (n *node) start() {
|
||||
}
|
||||
|
||||
if info.IsPatch {
|
||||
atomic.AddInt64(&n.patchCount, 1)
|
||||
n.patchCount.Add(1)
|
||||
// For patches, we track how many patch items using compare-and-swap
|
||||
for {
|
||||
current := n.maxPeersCount.Load()
|
||||
@@ -466,9 +465,9 @@ func (n *node) cleanup() NodeStats {
|
||||
}
|
||||
|
||||
return NodeStats{
|
||||
TotalUpdates: atomic.LoadInt64(&n.updateCount),
|
||||
PatchUpdates: atomic.LoadInt64(&n.patchCount),
|
||||
FullUpdates: atomic.LoadInt64(&n.fullCount),
|
||||
TotalUpdates: n.updateCount.Load(),
|
||||
PatchUpdates: n.patchCount.Load(),
|
||||
FullUpdates: n.fullCount.Load(),
|
||||
MaxPeersSeen: int(n.maxPeersCount.Load()),
|
||||
LastPeerCount: int(n.lastPeerCount.Load()),
|
||||
}
|
||||
@@ -509,7 +508,7 @@ func TestEnhancedNodeTracking(t *testing.T) {
|
||||
|
||||
// Wait for tracking goroutine to process the update
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
assert.GreaterOrEqual(c, atomic.LoadInt64(&testNode.updateCount), int64(1), "should have processed the update")
|
||||
assert.GreaterOrEqual(c, testNode.updateCount.Load(), int64(1), "should have processed the update")
|
||||
}, time.Second, 10*time.Millisecond, "waiting for update to be processed")
|
||||
|
||||
// Check stats
|
||||
@@ -553,7 +552,7 @@ func TestEnhancedTrackingWithBatcher(t *testing.T) {
|
||||
|
||||
// Wait for updates to be processed (at least 1 update received)
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
assert.GreaterOrEqual(c, atomic.LoadInt64(&testNode.updateCount), int64(1), "should have received updates")
|
||||
assert.GreaterOrEqual(c, testNode.updateCount.Load(), int64(1), "should have received updates")
|
||||
}, time.Second, 10*time.Millisecond, "waiting for updates to be processed")
|
||||
|
||||
// Check stats
|
||||
@@ -574,12 +573,6 @@ func TestEnhancedTrackingWithBatcher(t *testing.T) {
|
||||
// and ensure all nodes can see all other nodes. This is a critical test for mesh network
|
||||
// functionality where every node must be able to communicate with every other node.
|
||||
func TestBatcherScalabilityAllToAll(t *testing.T) {
|
||||
// Reduce verbose application logging for cleaner test output
|
||||
originalLevel := zerolog.GlobalLevel()
|
||||
defer zerolog.SetGlobalLevel(originalLevel)
|
||||
|
||||
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
||||
|
||||
// Test cases: different node counts to stress test the all-to-all connectivity
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -2141,8 +2134,8 @@ func TestNodeDeletedWhileChangesPending(t *testing.T) {
|
||||
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
// Node 1 and 2 should receive updates
|
||||
stats1 := NodeStats{TotalUpdates: atomic.LoadInt64(&node1.updateCount)}
|
||||
stats2 := NodeStats{TotalUpdates: atomic.LoadInt64(&node2.updateCount)}
|
||||
stats1 := NodeStats{TotalUpdates: node1.updateCount.Load()}
|
||||
stats2 := NodeStats{TotalUpdates: node2.updateCount.Load()}
|
||||
assert.Positive(c, stats1.TotalUpdates, "node1 should have received updates")
|
||||
assert.Positive(c, stats2.TotalUpdates, "node2 should have received updates")
|
||||
}, 5*time.Second, 100*time.Millisecond, "waiting for remaining nodes to receive updates")
|
||||
|
||||
@@ -277,6 +277,12 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) (
|
||||
return tailPeers, nil
|
||||
}
|
||||
|
||||
// WithPingRequest adds a PingRequest to the response.
|
||||
func (b *MapResponseBuilder) WithPingRequest(pr *tailcfg.PingRequest) *MapResponseBuilder {
|
||||
b.resp.PingRequest = pr
|
||||
return b
|
||||
}
|
||||
|
||||
// WithPeerChangedPatch adds peer change patches.
|
||||
func (b *MapResponseBuilder) WithPeerChangedPatch(changes []*tailcfg.PeerChange) *MapResponseBuilder {
|
||||
b.resp.PeersChangedPatch = changes
|
||||
|
||||
@@ -308,6 +308,10 @@ func (m *mapper) buildFromChange(
|
||||
builder.WithPeerChangedPatch(resp.PeerPatches)
|
||||
}
|
||||
|
||||
if resp.PingRequest != nil {
|
||||
builder.WithPingRequest(resp.PingRequest)
|
||||
}
|
||||
|
||||
return builder.Build()
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/juanfont/headscale/hscontrol/routes"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -209,25 +208,30 @@ func TestTailNode(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
primary := routes.New()
|
||||
cfg := &types.Config{
|
||||
BaseDomain: tt.baseDomain,
|
||||
TailcfgDNSConfig: tt.dnsConfig,
|
||||
RandomizeClientPort: false,
|
||||
Taildrop: types.TaildropConfig{Enabled: true},
|
||||
}
|
||||
_ = primary.SetRoutes(tt.node.ID, tt.node.SubnetRoutes()...)
|
||||
|
||||
// This is a hack to avoid having a second node to test the primary route.
|
||||
// This should be baked into the test case proper if it is extended in the future.
|
||||
_ = primary.SetRoutes(2, netip.MustParsePrefix("192.168.0.0/24"))
|
||||
// Stub primary-route lookup: tt.node owns its SubnetRoutes,
|
||||
// node ID 2 owns 192.168.0.0/24 (a hack carried over from
|
||||
// the original routes-package-driven version of this test —
|
||||
// avoids spinning up a second node just to validate that
|
||||
// other nodes' primaries don't leak into tt.node's TailNode
|
||||
// output).
|
||||
primaries := map[types.NodeID][]netip.Prefix{
|
||||
tt.node.ID: tt.node.SubnetRoutes(),
|
||||
2: {netip.MustParsePrefix("192.168.0.0/24")},
|
||||
}
|
||||
nv := tt.node.View()
|
||||
got, err := nv.TailNode(
|
||||
0,
|
||||
func(id types.NodeID) []netip.Prefix {
|
||||
// Route function returns primaries + exit routes
|
||||
// (matching the real caller contract).
|
||||
return slices.Concat(primary.PrimaryRoutes(id), nv.ExitRoutes())
|
||||
return slices.Concat(primaries[id], nv.ExitRoutes())
|
||||
},
|
||||
cfg,
|
||||
)
|
||||
|
||||
+28
-4
@@ -169,7 +169,7 @@ func (h *Headscale) NoiseUpgradeHandler(
|
||||
r.Post("/map", ns.PollNetMapHandler)
|
||||
|
||||
// SSH Check mode endpoint, consulted to validate if a given SSH connection should be accepted or rejected.
|
||||
r.Get("/ssh/action/from/{src_node_id}/to/{dst_node_id}", ns.SSHActionHandler)
|
||||
r.Get("/ssh/action/{src_node_id}/to/{dst_node_id}", ns.SSHActionHandler)
|
||||
|
||||
// Not implemented yet
|
||||
//
|
||||
@@ -295,6 +295,31 @@ func (ns *noiseServer) NotImplementedHandler(writer http.ResponseWriter, req *ht
|
||||
http.Error(writer, "Not implemented yet", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// PingResponseHandler handles HEAD requests from clients responding to a
|
||||
// PingRequest. The client calls this endpoint to prove connectivity.
|
||||
// The unguessable ping ID serves as authentication.
|
||||
func (h *Headscale) PingResponseHandler(
|
||||
writer http.ResponseWriter,
|
||||
req *http.Request,
|
||||
) {
|
||||
if req.Method != http.MethodHead {
|
||||
http.Error(writer, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
pingID := req.URL.Query().Get("id")
|
||||
if pingID == "" {
|
||||
http.Error(writer, "missing ping ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if h.state.CompletePing(pingID) {
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
http.Error(writer, "unknown or expired ping", http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func urlParam[T any](req *http.Request, key string) (T, error) {
|
||||
var zero T
|
||||
|
||||
@@ -394,7 +419,6 @@ func (ns *noiseServer) SSHActionHandler(
|
||||
reqLog := log.With().
|
||||
Uint64("src_node_id", srcNodeID.Uint64()).
|
||||
Uint64("dst_node_id", dstNodeID.Uint64()).
|
||||
Str("ssh_user", req.URL.Query().Get("ssh_user")).
|
||||
Str("local_user", req.URL.Query().Get("local_user")).
|
||||
Logger()
|
||||
|
||||
@@ -495,8 +519,8 @@ func (ns *noiseServer) sshActionHoldAndDelegate(
|
||||
) (*tailcfg.SSHAction, error) {
|
||||
holdURL, err := url.Parse(
|
||||
ns.headscale.cfg.ServerURL +
|
||||
"/machine/ssh/action/from/$SRC_NODE_ID/to/$DST_NODE_ID" +
|
||||
"?ssh_user=$SSH_USER&local_user=$LOCAL_USER",
|
||||
"/machine/ssh/action/$SRC_NODE_ID/to/$DST_NODE_ID" +
|
||||
"?local_user=$LOCAL_USER",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, NewHTTPError(
|
||||
|
||||
+39
-2
@@ -198,13 +198,50 @@ func TestRegistrationHandler_OversizedBody(t *testing.T) {
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
|
||||
// TestSSHActionRoute_OldPathReturns404 pins the wire-format shape of the
|
||||
// SSH check-action endpoint. Pre-alignment headscale served
|
||||
// /machine/ssh/action/from/{src}/to/{dst}?ssh_user=...; the current
|
||||
// endpoint is /machine/ssh/action/{src}/to/{dst}?local_user=.... If
|
||||
// someone re-adds the old route shape, this fails.
|
||||
func TestSSHActionRoute_OldPathReturns404(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Route("/machine", func(r chi.Router) {
|
||||
r.Get("/ssh/action/{src_node_id}/to/{dst_node_id}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
want int
|
||||
}{
|
||||
{"new", "/machine/ssh/action/1/to/2", http.StatusOK},
|
||||
{"old-with-from", "/machine/ssh/action/from/1/to/2", http.StatusNotFound},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, tc.path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, tc.want, rec.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// newSSHActionRequest builds an httptest request with the chi URL params
|
||||
// SSHActionHandler reads (src_node_id and dst_node_id), so the handler
|
||||
// can be exercised directly without going through the chi router.
|
||||
func newSSHActionRequest(t *testing.T, src, dst types.NodeID) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
url := fmt.Sprintf("/machine/ssh/action/from/%d/to/%d", src.Uint64(), dst.Uint64())
|
||||
url := fmt.Sprintf("/machine/ssh/action/%d/to/%d", src.Uint64(), dst.Uint64())
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
|
||||
rctx := chi.NewRouteContext()
|
||||
@@ -315,7 +352,7 @@ func TestSSHActionFollowUp_RejectsBindingMismatch(t *testing.T) {
|
||||
}
|
||||
|
||||
url := fmt.Sprintf(
|
||||
"/machine/ssh/action/from/%d/to/%d?auth_id=%s",
|
||||
"/machine/ssh/action/%d/to/%d?auth_id=%s",
|
||||
srcOther.ID.Uint64(), dstOther.ID.Uint64(), authID.String(),
|
||||
)
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
|
||||
+39
-48
@@ -155,21 +155,21 @@ func (a *AuthProviderOIDC) authHandler(
|
||||
) {
|
||||
authID, err := authIDFromRequest(req)
|
||||
if err != nil {
|
||||
httpError(writer, err)
|
||||
httpUserError(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Set the state and nonce cookies to protect against CSRF attacks
|
||||
state, err := setCSRFCookie(writer, req, "state")
|
||||
if err != nil {
|
||||
httpError(writer, err)
|
||||
httpUserError(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Set the state and nonce cookies to protect against CSRF attacks
|
||||
nonce, err := setCSRFCookie(writer, req, "nonce")
|
||||
if err != nil {
|
||||
httpError(writer, err)
|
||||
httpUserError(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
) {
|
||||
code, state, err := extractCodeAndStateParamFromRequest(req)
|
||||
if err != nil {
|
||||
httpError(writer, err)
|
||||
httpUserError(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -230,29 +230,29 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
|
||||
cookieState, err := req.Cookie(stateCookieName)
|
||||
if err != nil {
|
||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "state not found", err))
|
||||
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "state not found", err))
|
||||
return
|
||||
}
|
||||
|
||||
if state != cookieState.Value {
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "state did not match", nil))
|
||||
httpUserError(writer, NewHTTPError(http.StatusForbidden, "state did not match", nil))
|
||||
return
|
||||
}
|
||||
|
||||
oauth2Token, err := a.getOauth2Token(req.Context(), code, state)
|
||||
if err != nil {
|
||||
httpError(writer, err)
|
||||
httpUserError(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
idToken, err := a.extractIDToken(req.Context(), oauth2Token)
|
||||
if err != nil {
|
||||
httpError(writer, err)
|
||||
httpUserError(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
if idToken.Nonce == "" {
|
||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "nonce not found in IDToken", err))
|
||||
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "nonce not found in IDToken", err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -260,12 +260,12 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
|
||||
nonce, err := req.Cookie(nonceCookieName)
|
||||
if err != nil {
|
||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "nonce not found", err))
|
||||
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "nonce not found", err))
|
||||
return
|
||||
}
|
||||
|
||||
if idToken.Nonce != nonce.Value {
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "nonce did not match", nil))
|
||||
httpUserError(writer, NewHTTPError(http.StatusForbidden, "nonce did not match", nil))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
|
||||
var claims types.OIDCClaims
|
||||
if err := idToken.Claims(&claims); err != nil { //nolint:noinlineerr
|
||||
httpError(writer, fmt.Errorf("decoding ID token claims: %w", err))
|
||||
httpUserError(writer, fmt.Errorf("decoding ID token claims: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -310,26 +310,17 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
// against allowed emails, email domains, and groups.
|
||||
err = doOIDCAuthorization(a.cfg, &claims)
|
||||
if err != nil {
|
||||
httpError(writer, err)
|
||||
httpUserError(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
user, _, err := a.createOrUpdateUserFromClaim(&claims)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Caller().
|
||||
Msgf("could not create or update user")
|
||||
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
|
||||
_, werr := writer.Write([]byte("Could not create or update user"))
|
||||
if werr != nil {
|
||||
log.Error().
|
||||
Caller().
|
||||
Err(werr).
|
||||
Msg("Failed to write HTTP response")
|
||||
}
|
||||
httpUserError(writer, NewHTTPError(
|
||||
http.StatusInternalServerError,
|
||||
"could not create or update user",
|
||||
err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -341,7 +332,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
authInfo := a.getAuthInfoFromState(state)
|
||||
if authInfo == nil {
|
||||
log.Debug().Caller().Str("state", state).Msg("state not found in cache, login session may have expired")
|
||||
httpError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
||||
httpUserError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -367,7 +358,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
authReq, ok := a.h.state.GetAuthCacheEntry(authInfo.AuthID)
|
||||
if !ok {
|
||||
log.Debug().Caller().Str("auth_id", authInfo.AuthID.String()).Msg("auth session expired before authorization completed")
|
||||
httpError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
||||
httpUserError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -376,7 +367,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
log.Warn().Caller().
|
||||
Str("auth_id", authInfo.AuthID.String()).
|
||||
Msg("OIDC callback hit non-registration path with auth request that is not an SSH check binding")
|
||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "auth session is not for SSH check", nil))
|
||||
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "auth session is not for SSH check", nil))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -389,7 +380,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
Str("auth_id", authInfo.AuthID.String()).
|
||||
Uint64("src_node_id", binding.SrcNodeID.Uint64()).
|
||||
Msg("SSH check src node no longer exists")
|
||||
httpError(writer, NewHTTPError(http.StatusGone, "src node no longer exists", nil))
|
||||
httpUserError(writer, NewHTTPError(http.StatusGone, "src node no longer exists", nil))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -404,7 +395,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
Bool("src_is_tagged", srcNode.IsTagged()).
|
||||
Str("oidc_user", user.Username()).
|
||||
Msg("SSH check rejected: src node has no user owner")
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "src node has no user owner", nil))
|
||||
httpUserError(writer, NewHTTPError(http.StatusForbidden, "src node has no user owner", nil))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -417,7 +408,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
Uint("oidc_user_id", user.ID).
|
||||
Str("oidc_user", user.Username()).
|
||||
Msg("SSH check rejected: OIDC user is not the owner of src node")
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "OIDC user is not the owner of the SSH source node", nil))
|
||||
httpUserError(writer, NewHTTPError(http.StatusForbidden, "OIDC user is not the owner of the SSH source node", nil))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -680,7 +671,7 @@ func (a *AuthProviderOIDC) renderRegistrationConfirmInterstitial(
|
||||
authReq, ok := a.h.state.GetAuthCacheEntry(authID)
|
||||
if !ok {
|
||||
log.Debug().Caller().Str("auth_id", authID.String()).Msg("registration session expired before authorization completed")
|
||||
httpError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
||||
httpUserError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -689,14 +680,14 @@ func (a *AuthProviderOIDC) renderRegistrationConfirmInterstitial(
|
||||
log.Warn().Caller().
|
||||
Str("auth_id", authID.String()).
|
||||
Msg("OIDC callback hit registration path with auth request that is not a node registration")
|
||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "auth session is not for node registration", nil))
|
||||
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "auth session is not for node registration", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
csrf, err := util.GenerateRandomStringURLSafe(32)
|
||||
if err != nil {
|
||||
httpError(writer, fmt.Errorf("generating csrf token: %w", err))
|
||||
httpUserError(writer, fmt.Errorf("generating csrf token: %w", err))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -748,14 +739,14 @@ func (a *AuthProviderOIDC) RegisterConfirmHandler(
|
||||
req *http.Request,
|
||||
) {
|
||||
if req.Method != http.MethodPost {
|
||||
httpError(writer, errMethodNotAllowed)
|
||||
httpUserError(writer, errMethodNotAllowed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
authID, err := authIDFromRequest(req)
|
||||
if err != nil {
|
||||
httpError(writer, err)
|
||||
httpUserError(writer, err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -766,54 +757,54 @@ func (a *AuthProviderOIDC) RegisterConfirmHandler(
|
||||
req.Body = http.MaxBytesReader(writer, req.Body, 4*1024)
|
||||
|
||||
if err := req.ParseForm(); err != nil { //nolint:noinlineerr,gosec // body is bounded above
|
||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "invalid form", err))
|
||||
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "invalid form", err))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
formCSRF := req.PostFormValue(registerConfirmCSRFCookie) //nolint:gosec // body is bounded above
|
||||
if formCSRF == "" {
|
||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "missing csrf token", nil))
|
||||
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "missing csrf token", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
cookie, err := req.Cookie(registerConfirmCSRFCookie)
|
||||
if err != nil {
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "missing csrf cookie", err))
|
||||
httpUserError(writer, NewHTTPError(http.StatusForbidden, "missing csrf cookie", err))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if cookie.Value != formCSRF {
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "csrf token mismatch", nil))
|
||||
httpUserError(writer, NewHTTPError(http.StatusForbidden, "csrf token mismatch", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
authReq, ok := a.h.state.GetAuthCacheEntry(authID)
|
||||
if !ok {
|
||||
httpError(writer, NewHTTPError(http.StatusGone, "registration session expired", nil))
|
||||
httpUserError(writer, NewHTTPError(http.StatusGone, "registration session expired", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
pending := authReq.PendingConfirmation()
|
||||
if pending == nil {
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "registration not OIDC-authorized", nil))
|
||||
httpUserError(writer, NewHTTPError(http.StatusForbidden, "registration not OIDC-authorized", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if pending.CSRF != cookie.Value {
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "csrf token does not match cached registration", nil))
|
||||
httpUserError(writer, NewHTTPError(http.StatusForbidden, "csrf token does not match cached registration", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
user, err := a.h.state.GetUserByID(types.UserID(pending.UserID))
|
||||
if err != nil {
|
||||
httpError(writer, fmt.Errorf("looking up user: %w", err))
|
||||
httpUserError(writer, fmt.Errorf("looking up user: %w", err))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -821,12 +812,12 @@ func (a *AuthProviderOIDC) RegisterConfirmHandler(
|
||||
newNode, err := a.handleRegistration(user, authID, pending.NodeExpiry)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNodeNotFoundRegistrationCache) {
|
||||
httpError(writer, NewHTTPError(http.StatusGone, "registration session expired", err))
|
||||
httpUserError(writer, NewHTTPError(http.StatusGone, "registration session expired", err))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
httpError(writer, err)
|
||||
httpUserError(writer, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,6 +7,71 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAuthErrorTemplate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
result templates.AuthErrorResult
|
||||
}{
|
||||
{
|
||||
name: "bad_request",
|
||||
result: templates.AuthErrorResult{
|
||||
Title: "Headscale - Error",
|
||||
Heading: "Bad Request",
|
||||
Message: "The request could not be processed. Please try again.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "forbidden",
|
||||
result: templates.AuthErrorResult{
|
||||
Title: "Headscale - Error",
|
||||
Heading: "Forbidden",
|
||||
Message: "You are not authorized. Please contact your administrator.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gone_expired",
|
||||
result: templates.AuthErrorResult{
|
||||
Title: "Headscale - Error",
|
||||
Heading: "Gone",
|
||||
Message: "Your session has expired. Please try again.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "internal_server_error",
|
||||
result: templates.AuthErrorResult{
|
||||
Title: "Headscale - Error",
|
||||
Heading: "Internal Server Error",
|
||||
Message: "Something went wrong. Please try again later.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
html := templates.AuthError(tt.result).Render()
|
||||
|
||||
// Verify the HTML contains expected structural elements
|
||||
assert.Contains(t, html, "<!DOCTYPE html>")
|
||||
assert.Contains(t, html, "<title>"+tt.result.Title+"</title>")
|
||||
assert.Contains(t, html, tt.result.Heading)
|
||||
assert.Contains(t, html, tt.result.Message)
|
||||
|
||||
// Verify Material for MkDocs design system CSS is present
|
||||
assert.Contains(t, html, "Material for MkDocs")
|
||||
assert.Contains(t, html, "Roboto")
|
||||
assert.Contains(t, html, ".md-typeset")
|
||||
|
||||
// Verify SVG elements are present
|
||||
assert.Contains(t, html, "<svg")
|
||||
assert.Contains(t, html, "class=\"headscale-logo\"")
|
||||
assert.Contains(t, html, "id=\"error-icon\"")
|
||||
|
||||
// Verify no success checkbox icon
|
||||
assert.NotContains(t, html, "id=\"checkbox\"")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthSuccessTemplate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -53,6 +53,11 @@ func MatchFromFilterRule(rule tailcfg.FilterRule) Match {
|
||||
return MatchFromStrings(rule.SrcIPs, dests)
|
||||
}
|
||||
|
||||
// MatchFromStrings builds a Match from raw source and destination
|
||||
// strings. Unparseable entries are silently dropped (fail-open): the
|
||||
// resulting Match is narrower than the input described, but never
|
||||
// wider. Callers that need strict validation should pre-validate
|
||||
// their inputs via util.ParseIPSet.
|
||||
func MatchFromStrings(sources, destinations []string) Match {
|
||||
srcs := new(netipx.IPSetBuilder)
|
||||
dests := new(netipx.IPSetBuilder)
|
||||
@@ -96,18 +101,20 @@ func (m *Match) DestsOverlapsPrefixes(prefixes ...netip.Prefix) bool {
|
||||
return slices.ContainsFunc(prefixes, m.dests.OverlapsPrefix)
|
||||
}
|
||||
|
||||
// DestsIsTheInternet reports if the destination contains "the internet"
|
||||
// which is a IPSet that represents "autogroup:internet" and is special
|
||||
// cased for exit nodes.
|
||||
// This checks if dests is a superset of TheInternet(), which handles
|
||||
// merged filter rules where TheInternet is combined with other destinations.
|
||||
// DestsIsTheInternet reports whether the destination covers "the
|
||||
// internet" — the set represented by autogroup:internet, special-cased
|
||||
// for exit nodes. Returns true if either family's /0 is contained
|
||||
// (0.0.0.0/0 or ::/0), or if dests is a superset of TheInternet(). A
|
||||
// single-family /0 counts because operators may write it directly and
|
||||
// it still denotes the whole internet for that family.
|
||||
func (m *Match) DestsIsTheInternet() bool {
|
||||
if m.dests.ContainsPrefix(tsaddr.AllIPv4()) ||
|
||||
m.dests.ContainsPrefix(tsaddr.AllIPv6()) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if dests contains all prefixes of TheInternet (superset check)
|
||||
// Superset-of-TheInternet check handles merged filter rules
|
||||
// where the internet prefixes are combined with other dests.
|
||||
theInternet := util.TheInternet()
|
||||
for _, prefix := range theInternet.Prefixes() {
|
||||
if !m.dests.ContainsPrefix(prefix) {
|
||||
|
||||
@@ -1 +1,472 @@
|
||||
package matcher
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
func TestMatchFromStrings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
srcs []string
|
||||
dsts []string
|
||||
wantSrc netip.Addr
|
||||
wantDst netip.Addr
|
||||
srcIn bool
|
||||
dstIn bool
|
||||
}{
|
||||
{
|
||||
name: "basic CIDR match",
|
||||
srcs: []string{"10.0.0.0/8"},
|
||||
dsts: []string{"192.168.1.0/24"},
|
||||
wantSrc: netip.MustParseAddr("10.1.2.3"),
|
||||
wantDst: netip.MustParseAddr("192.168.1.100"),
|
||||
srcIn: true,
|
||||
dstIn: true,
|
||||
},
|
||||
{
|
||||
name: "basic CIDR no match",
|
||||
srcs: []string{"10.0.0.0/8"},
|
||||
dsts: []string{"192.168.1.0/24"},
|
||||
wantSrc: netip.MustParseAddr("172.16.0.1"),
|
||||
wantDst: netip.MustParseAddr("10.0.0.1"),
|
||||
srcIn: false,
|
||||
dstIn: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard matches everything",
|
||||
srcs: []string{"*"},
|
||||
dsts: []string{"*"},
|
||||
wantSrc: netip.MustParseAddr("8.8.8.8"),
|
||||
wantDst: netip.MustParseAddr("1.1.1.1"),
|
||||
srcIn: true,
|
||||
dstIn: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard matches IPv6",
|
||||
srcs: []string{"*"},
|
||||
dsts: []string{"*"},
|
||||
wantSrc: netip.MustParseAddr("2001:db8::1"),
|
||||
wantDst: netip.MustParseAddr("fd7a:115c:a1e0::1"),
|
||||
srcIn: true,
|
||||
dstIn: true,
|
||||
},
|
||||
{
|
||||
name: "single IP source",
|
||||
srcs: []string{"100.64.0.1"},
|
||||
dsts: []string{"10.0.0.0/8"},
|
||||
wantSrc: netip.MustParseAddr("100.64.0.1"),
|
||||
wantDst: netip.MustParseAddr("10.33.0.1"),
|
||||
srcIn: true,
|
||||
dstIn: true,
|
||||
},
|
||||
{
|
||||
name: "single IP source no match",
|
||||
srcs: []string{"100.64.0.1"},
|
||||
dsts: []string{"10.0.0.0/8"},
|
||||
wantSrc: netip.MustParseAddr("100.64.0.2"),
|
||||
wantDst: netip.MustParseAddr("10.33.0.1"),
|
||||
srcIn: false,
|
||||
dstIn: true,
|
||||
},
|
||||
{
|
||||
name: "multiple CIDRs",
|
||||
srcs: []string{"10.0.0.0/8", "172.16.0.0/12"},
|
||||
dsts: []string{"192.168.0.0/16", "100.64.0.0/10"},
|
||||
wantSrc: netip.MustParseAddr("172.20.0.1"),
|
||||
wantDst: netip.MustParseAddr("100.100.0.1"),
|
||||
srcIn: true,
|
||||
dstIn: true,
|
||||
},
|
||||
{
|
||||
name: "IPv6 CIDR",
|
||||
srcs: []string{"fd7a:115c:a1e0::/48"},
|
||||
dsts: []string{"2001:db8::/32"},
|
||||
wantSrc: netip.MustParseAddr("fd7a:115c:a1e0::1"),
|
||||
wantDst: netip.MustParseAddr("2001:db8::1"),
|
||||
srcIn: true,
|
||||
dstIn: true,
|
||||
},
|
||||
{
|
||||
name: "empty sources and destinations",
|
||||
srcs: []string{},
|
||||
dsts: []string{},
|
||||
wantSrc: netip.MustParseAddr("10.0.0.1"),
|
||||
wantDst: netip.MustParseAddr("10.0.0.1"),
|
||||
srcIn: false,
|
||||
dstIn: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := MatchFromStrings(tt.srcs, tt.dsts)
|
||||
assert.Equal(t, tt.srcIn, m.SrcsContainsIPs(tt.wantSrc),
|
||||
"SrcsContainsIPs(%s)", tt.wantSrc)
|
||||
assert.Equal(t, tt.dstIn, m.DestsContainsIP(tt.wantDst),
|
||||
"DestsContainsIP(%s)", tt.wantDst)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchFromFilterRule(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rule tailcfg.FilterRule
|
||||
checkSrc netip.Addr
|
||||
checkDst netip.Addr
|
||||
srcMatch bool
|
||||
dstMatch bool
|
||||
}{
|
||||
{
|
||||
name: "standard rule with port range",
|
||||
rule: tailcfg.FilterRule{
|
||||
SrcIPs: []string{"100.64.0.1", "fd7a:115c:a1e0::1"},
|
||||
DstPorts: []tailcfg.NetPortRange{
|
||||
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 0, Last: 65535}},
|
||||
},
|
||||
},
|
||||
checkSrc: netip.MustParseAddr("100.64.0.1"),
|
||||
checkDst: netip.MustParseAddr("10.33.0.50"),
|
||||
srcMatch: true,
|
||||
dstMatch: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard destination",
|
||||
rule: tailcfg.FilterRule{
|
||||
SrcIPs: []string{"10.0.0.0/8"},
|
||||
DstPorts: []tailcfg.NetPortRange{
|
||||
{IP: "*"},
|
||||
},
|
||||
},
|
||||
checkSrc: netip.MustParseAddr("10.1.1.1"),
|
||||
checkDst: netip.MustParseAddr("8.8.8.8"),
|
||||
srcMatch: true,
|
||||
dstMatch: true,
|
||||
},
|
||||
{
|
||||
name: "multiple DstPorts entries",
|
||||
rule: tailcfg.FilterRule{
|
||||
SrcIPs: []string{"100.64.0.1"},
|
||||
DstPorts: []tailcfg.NetPortRange{
|
||||
{IP: "10.33.0.0/16"},
|
||||
{IP: "192.168.1.0/24"},
|
||||
},
|
||||
},
|
||||
checkSrc: netip.MustParseAddr("100.64.0.1"),
|
||||
checkDst: netip.MustParseAddr("192.168.1.50"),
|
||||
srcMatch: true,
|
||||
dstMatch: true,
|
||||
},
|
||||
{
|
||||
name: "empty DstPorts",
|
||||
rule: tailcfg.FilterRule{
|
||||
SrcIPs: []string{"100.64.0.1"},
|
||||
DstPorts: nil,
|
||||
},
|
||||
checkSrc: netip.MustParseAddr("100.64.0.1"),
|
||||
checkDst: netip.MustParseAddr("10.0.0.1"),
|
||||
srcMatch: true,
|
||||
dstMatch: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := MatchFromFilterRule(tt.rule)
|
||||
assert.Equal(t, tt.srcMatch, m.SrcsContainsIPs(tt.checkSrc),
|
||||
"SrcsContainsIPs(%s)", tt.checkSrc)
|
||||
assert.Equal(t, tt.dstMatch, m.DestsContainsIP(tt.checkDst),
|
||||
"DestsContainsIP(%s)", tt.checkDst)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesFromFilterRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rules := []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"10.0.0.0/8"},
|
||||
DstPorts: []tailcfg.NetPortRange{{IP: "192.168.1.0/24"}},
|
||||
},
|
||||
{
|
||||
SrcIPs: []string{"172.16.0.0/12"},
|
||||
DstPorts: []tailcfg.NetPortRange{{IP: "10.33.0.0/16"}},
|
||||
},
|
||||
}
|
||||
|
||||
matches := MatchesFromFilterRules(rules)
|
||||
require.Len(t, matches, 2)
|
||||
|
||||
// First matcher: 10.0.0.0/8 -> 192.168.1.0/24
|
||||
assert.True(t, matches[0].SrcsContainsIPs(netip.MustParseAddr("10.1.2.3")))
|
||||
assert.False(t, matches[0].SrcsContainsIPs(netip.MustParseAddr("172.16.0.1")))
|
||||
assert.True(t, matches[0].DestsContainsIP(netip.MustParseAddr("192.168.1.100")))
|
||||
|
||||
// Second matcher: 172.16.0.0/12 -> 10.33.0.0/16
|
||||
assert.True(t, matches[1].SrcsContainsIPs(netip.MustParseAddr("172.20.0.1")))
|
||||
assert.True(t, matches[1].DestsContainsIP(netip.MustParseAddr("10.33.0.1")))
|
||||
assert.False(t, matches[1].DestsContainsIP(netip.MustParseAddr("192.168.1.1")))
|
||||
}
|
||||
|
||||
func TestSrcsOverlapsPrefixes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
srcs []string
|
||||
prefixes []netip.Prefix
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "exact match",
|
||||
srcs: []string{"10.33.0.0/16"},
|
||||
prefixes: []netip.Prefix{netip.MustParsePrefix("10.33.0.0/16")},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "parent contains child",
|
||||
srcs: []string{"10.0.0.0/8"},
|
||||
prefixes: []netip.Prefix{netip.MustParsePrefix("10.33.0.0/16")},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "child overlaps parent",
|
||||
srcs: []string{"10.33.0.0/16"},
|
||||
prefixes: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "no overlap",
|
||||
srcs: []string{"10.0.0.0/8"},
|
||||
prefixes: []netip.Prefix{netip.MustParsePrefix("192.168.1.0/24")},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "multiple prefixes one overlaps",
|
||||
srcs: []string{"10.0.0.0/8"},
|
||||
prefixes: []netip.Prefix{
|
||||
netip.MustParsePrefix("192.168.1.0/24"),
|
||||
netip.MustParsePrefix("10.33.0.0/16"),
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "IPv6 overlap",
|
||||
srcs: []string{"fd7a:115c:a1e0::/48"},
|
||||
prefixes: []netip.Prefix{netip.MustParsePrefix("fd7a:115c:a1e0:ab12::/64")},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "empty prefixes",
|
||||
srcs: []string{"10.0.0.0/8"},
|
||||
prefixes: []netip.Prefix{},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := MatchFromStrings(tt.srcs, nil)
|
||||
got := m.SrcsOverlapsPrefixes(tt.prefixes...)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDestsOverlapsPrefixes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dsts []string
|
||||
prefixes []netip.Prefix
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "exact match",
|
||||
dsts: []string{"10.33.0.0/16"},
|
||||
prefixes: []netip.Prefix{netip.MustParsePrefix("10.33.0.0/16")},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "parent contains child",
|
||||
dsts: []string{"10.0.0.0/8"},
|
||||
prefixes: []netip.Prefix{netip.MustParsePrefix("10.33.0.0/16")},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "no overlap",
|
||||
dsts: []string{"10.0.0.0/8"},
|
||||
prefixes: []netip.Prefix{netip.MustParsePrefix("192.168.0.0/16")},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard overlaps everything",
|
||||
dsts: []string{"*"},
|
||||
prefixes: []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"),
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard overlaps exit route",
|
||||
dsts: []string{"*"},
|
||||
prefixes: []netip.Prefix{netip.MustParsePrefix("::/0")},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := MatchFromStrings(nil, tt.dsts)
|
||||
got := m.DestsOverlapsPrefixes(tt.prefixes...)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDestsIsTheInternet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dsts []string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "all IPv4 is the internet",
|
||||
dsts: []string{"0.0.0.0/0"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "all IPv6 is the internet",
|
||||
dsts: []string{"::/0"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard is the internet",
|
||||
dsts: []string{"*"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "private range is not the internet",
|
||||
dsts: []string{"10.0.0.0/8"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "CGNAT range is not the internet",
|
||||
dsts: []string{"100.64.0.0/10"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "single public IP is not the internet",
|
||||
dsts: []string{"8.8.8.8"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty dests is not the internet",
|
||||
dsts: []string{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "multiple private ranges are not the internet",
|
||||
dsts: []string{
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "all IPv4 combined with subnet is the internet",
|
||||
dsts: []string{"0.0.0.0/0", "10.33.0.0/16"},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := MatchFromStrings(nil, tt.dsts)
|
||||
got := m.DestsIsTheInternet()
|
||||
assert.Equal(t, tt.want, got,
|
||||
"DestsIsTheInternet() for dsts=%v", tt.dsts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := MatchFromStrings(
|
||||
[]string{"10.0.0.0/8"},
|
||||
[]string{"192.168.1.0/24"},
|
||||
)
|
||||
|
||||
s := m.DebugString()
|
||||
assert.Contains(t, s, "Match:")
|
||||
assert.Contains(t, s, "Sources:")
|
||||
assert.Contains(t, s, "Destinations:")
|
||||
assert.Contains(t, s, "10.0.0.0/8")
|
||||
assert.Contains(t, s, "192.168.1.0/24")
|
||||
|
||||
// Sources appear before Destinations in the output.
|
||||
assert.Less(
|
||||
t,
|
||||
strings.Index(s, "Sources:"),
|
||||
strings.Index(s, "Destinations:"),
|
||||
"Sources section must precede Destinations",
|
||||
)
|
||||
}
|
||||
|
||||
func TestDebugString_Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := MatchFromStrings(nil, nil)
|
||||
|
||||
s := m.DebugString()
|
||||
assert.Contains(t, s, "Match:")
|
||||
assert.Contains(t, s, "Sources:")
|
||||
assert.Contains(t, s, "Destinations:")
|
||||
assert.NotContains(t, s, "/")
|
||||
}
|
||||
|
||||
// TestMatchFromStrings_MalformedFailsOpen asserts that unparseable
|
||||
// entries are silently dropped and do not crash or widen the Match.
|
||||
func TestMatchFromStrings_MalformedFailsOpen(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := MatchFromStrings(
|
||||
[]string{"not-a-cidr", "10.0.0.0/8"},
|
||||
[]string{"also-bogus", "192.168.1.0/24"},
|
||||
)
|
||||
|
||||
assert.True(t, m.SrcsContainsIPs(netip.MustParseAddr("10.1.2.3")),
|
||||
"valid src entry must still match")
|
||||
assert.False(t, m.SrcsContainsIPs(netip.MustParseAddr("1.1.1.1")),
|
||||
"malformed src entry must not widen the set")
|
||||
assert.True(t, m.DestsContainsIP(netip.MustParseAddr("192.168.1.10")),
|
||||
"valid dst entry must still match")
|
||||
assert.False(t, m.DestsContainsIP(netip.MustParseAddr("8.8.8.8")),
|
||||
"malformed dst entry must not widen the set")
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/juanfont/headscale/hscontrol/policy/matcher"
|
||||
@@ -768,6 +767,65 @@ func TestReduceNodes(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
// Subnet-to-subnet: routers must see each other when ACL
|
||||
// uses only subnet CIDRs. Issue #3157.
|
||||
{
|
||||
name: "subnet-to-subnet-routers-see-each-other-3157",
|
||||
args: args{
|
||||
nodes: []*types.Node{
|
||||
{
|
||||
ID: 1,
|
||||
IPv4: ap("100.64.0.1"),
|
||||
Hostname: "router-a",
|
||||
User: &types.User{Name: "router-a"},
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.88.8.0/24")},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.88.8.0/24")},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
IPv4: ap("100.64.0.2"),
|
||||
Hostname: "router-b",
|
||||
User: &types.User{Name: "router-b"},
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.99.9.0/24")},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.99.9.0/24")},
|
||||
},
|
||||
},
|
||||
rules: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"10.88.8.0/24"},
|
||||
DstPorts: []tailcfg.NetPortRange{
|
||||
{IP: "10.99.9.0/24", Ports: tailcfg.PortRangeAny},
|
||||
},
|
||||
},
|
||||
},
|
||||
node: &types.Node{
|
||||
ID: 1,
|
||||
IPv4: ap("100.64.0.1"),
|
||||
Hostname: "router-a",
|
||||
User: &types.User{Name: "router-a"},
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.88.8.0/24")},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.88.8.0/24")},
|
||||
},
|
||||
},
|
||||
want: []*types.Node{
|
||||
{
|
||||
ID: 2,
|
||||
IPv4: ap("100.64.0.2"),
|
||||
Hostname: "router-b",
|
||||
User: &types.User{Name: "router-b"},
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.99.9.0/24")},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.99.9.0/24")},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -893,11 +951,13 @@ func TestReduceNodesFromPolicy(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
node: n(1, "100.64.0.1", "mobile", "mobile"),
|
||||
// autogroup:internet does not generate packet filters - it's handled
|
||||
// by exit node routing via AllowedIPs, not by packet filtering.
|
||||
// Only server is visible through the mobile -> server:80 rule.
|
||||
// autogroup:internet emits no client packet filter, but it
|
||||
// must still produce a matcher: Node.CanAccess uses
|
||||
// matcher.DestsIsTheInternet() + IsExitNode() to surface
|
||||
// exit-node peers (juanfont/headscale#3212).
|
||||
want: types.Nodes{
|
||||
n(2, "100.64.0.2", "server", "server"),
|
||||
n(3, "100.64.0.3", "exit", "server", "0.0.0.0/0", "::/0"),
|
||||
},
|
||||
wantMatchers: 1,
|
||||
},
|
||||
@@ -1205,11 +1265,11 @@ func TestSSHPolicyRules(t *testing.T) {
|
||||
},
|
||||
Action: &tailcfg.SSHAction{
|
||||
Accept: false,
|
||||
SessionDuration: 24 * time.Hour,
|
||||
HoldAndDelegate: "unused-url/machine/ssh/action/from/$SRC_NODE_ID/to/$DST_NODE_ID?ssh_user=$SSH_USER&local_user=$LOCAL_USER",
|
||||
AllowAgentForwarding: true,
|
||||
AllowLocalPortForwarding: true,
|
||||
AllowRemotePortForwarding: true,
|
||||
SessionDuration: 0,
|
||||
HoldAndDelegate: "unused-url/machine/ssh/action/$SRC_NODE_ID/to/$DST_NODE_ID?local_user=$LOCAL_USER",
|
||||
AllowAgentForwarding: false,
|
||||
AllowLocalPortForwarding: false,
|
||||
AllowRemotePortForwarding: false,
|
||||
},
|
||||
},
|
||||
}},
|
||||
@@ -2230,6 +2290,127 @@ func TestReduceRoutes(t *testing.T) {
|
||||
netip.MustParsePrefix("192.168.1.0/14"),
|
||||
},
|
||||
},
|
||||
// Subnet-to-subnet tests for issue #3157.
|
||||
// When an ACL references subnet CIDRs as both source and destination,
|
||||
// the subnet routers for those subnets must receive routes to each
|
||||
// other's subnets.
|
||||
{
|
||||
name: "subnet-to-subnet-src-router-gets-dst-route-3157",
|
||||
args: args{
|
||||
node: &types.Node{
|
||||
ID: 1,
|
||||
IPv4: ap("100.64.0.1"),
|
||||
User: &types.User{Name: "router-a"},
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.88.8.0/24"),
|
||||
},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.88.8.0/24"),
|
||||
},
|
||||
},
|
||||
routes: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.99.9.0/24"),
|
||||
},
|
||||
rules: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"10.88.8.0/24"},
|
||||
DstPorts: []tailcfg.NetPortRange{
|
||||
{IP: "10.99.9.0/24", Ports: tailcfg.PortRangeAny},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.99.9.0/24"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "subnet-to-subnet-dst-router-gets-src-route-3157",
|
||||
args: args{
|
||||
node: &types.Node{
|
||||
ID: 2,
|
||||
IPv4: ap("100.64.0.2"),
|
||||
User: &types.User{Name: "router-b"},
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.99.9.0/24"),
|
||||
},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.99.9.0/24"),
|
||||
},
|
||||
},
|
||||
routes: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.88.8.0/24"),
|
||||
},
|
||||
rules: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"10.88.8.0/24"},
|
||||
DstPorts: []tailcfg.NetPortRange{
|
||||
{IP: "10.99.9.0/24", Ports: tailcfg.PortRangeAny},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.88.8.0/24"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "subnet-to-subnet-regular-node-no-route-leak-3157",
|
||||
args: args{
|
||||
node: &types.Node{
|
||||
ID: 3,
|
||||
IPv4: ap("100.64.0.3"),
|
||||
User: &types.User{Name: "regular-node"},
|
||||
},
|
||||
routes: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.88.8.0/24"),
|
||||
netip.MustParsePrefix("10.99.9.0/24"),
|
||||
},
|
||||
rules: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"10.88.8.0/24"},
|
||||
DstPorts: []tailcfg.NetPortRange{
|
||||
{IP: "10.99.9.0/24", Ports: tailcfg.PortRangeAny},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "subnet-to-subnet-unrelated-router-no-route-leak-3157",
|
||||
args: args{
|
||||
node: &types.Node{
|
||||
ID: 4,
|
||||
IPv4: ap("100.64.0.4"),
|
||||
User: &types.User{Name: "router-c"},
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: []netip.Prefix{
|
||||
netip.MustParsePrefix("172.16.0.0/24"),
|
||||
},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("172.16.0.0/24"),
|
||||
},
|
||||
},
|
||||
routes: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.88.8.0/24"),
|
||||
},
|
||||
rules: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"10.88.8.0/24"},
|
||||
DstPorts: []tailcfg.NetPortRange{
|
||||
{IP: "10.99.9.0/24", Ports: tailcfg.PortRangeAny},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Package policyutil contains pure functions that transform compiled
|
||||
// policy rules for a specific node. The headline function is
|
||||
// ReduceFilterRules, which filters global rules down to those relevant
|
||||
// to one node.
|
||||
//
|
||||
// A node's SubnetRoutes (approved, non-exit) participate in rule
|
||||
// matching so subnet routers receive filter rules for destinations
|
||||
// their subnets cover — the fix for issue #3169.
|
||||
package policyutil
|
||||
@@ -2,10 +2,11 @@ package policyutil
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"go4.org/netipx"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
@@ -17,6 +18,8 @@ import (
|
||||
// to this function. Use PolicyManager.FilterForNode() instead, which handles both cases.
|
||||
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
|
||||
@@ -33,65 +36,65 @@ func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcf
|
||||
// record if the rule is actually relevant for the given node.
|
||||
var dests []tailcfg.NetPortRange
|
||||
|
||||
DEST_LOOP:
|
||||
for _, dest := range rule.DstPorts {
|
||||
expanded, err := util.ParseIPSet(dest.IP, nil)
|
||||
// Fail closed, if we can't parse it, then we should not allow
|
||||
// access.
|
||||
// Fail closed: unparseable dests are dropped.
|
||||
if err != nil {
|
||||
continue DEST_LOOP
|
||||
continue
|
||||
}
|
||||
|
||||
if node.InIPSet(expanded) {
|
||||
dests = append(dests, dest)
|
||||
continue DEST_LOOP
|
||||
continue
|
||||
}
|
||||
|
||||
// If the node exposes routes, ensure they are not removed
|
||||
// when the filters are reduced. Exit routes (0.0.0.0/0, ::/0)
|
||||
// are skipped here because exit nodes handle traffic via
|
||||
// AllowedIPs/routing, not packet filter rules. This matches
|
||||
// Tailscale SaaS behavior where exit nodes do not receive
|
||||
// filter rules for destinations that only overlap via exit routes.
|
||||
if node.Hostinfo().Valid() {
|
||||
routableIPs := node.Hostinfo().RoutableIPs()
|
||||
if routableIPs.Len() > 0 {
|
||||
for _, routableIP := range routableIPs.All() {
|
||||
if tsaddr.IsExitRoute(routableIP) {
|
||||
continue
|
||||
}
|
||||
if expanded.OverlapsPrefix(routableIP) {
|
||||
dests = append(dests, dest)
|
||||
continue DEST_LOOP
|
||||
}
|
||||
}
|
||||
}
|
||||
// If the node has approved subnet routes, preserve
|
||||
// filter rules targeting those routes. SubnetRoutes()
|
||||
// returns only approved, non-exit routes — matching
|
||||
// Tailscale SaaS behavior, which does not generate
|
||||
// filter rules for advertised-but-unapproved routes.
|
||||
// Exit routes (0.0.0.0/0, ::/0) are excluded by
|
||||
// SubnetRoutes() and handled separately via
|
||||
// AllowedIPs/routing.
|
||||
if slices.ContainsFunc(subnetRoutes, expanded.OverlapsPrefix) {
|
||||
dests = append(dests, dest)
|
||||
continue
|
||||
}
|
||||
|
||||
// Also check approved subnet routes - nodes should have access
|
||||
// to subnets they're approved to route traffic for.
|
||||
subnetRoutes := node.SubnetRoutes()
|
||||
|
||||
for _, subnetRoute := range subnetRoutes {
|
||||
if expanded.OverlapsPrefix(subnetRoute) {
|
||||
dests = append(dests, dest)
|
||||
continue DEST_LOOP
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
if len(dests) > 0 {
|
||||
ret = append(ret, tailcfg.FilterRule{
|
||||
SrcIPs: rule.SrcIPs,
|
||||
DstPorts: dests,
|
||||
IPProto: rule.IPProto,
|
||||
})
|
||||
// Struct-copy preserves any unknown future FilterRule
|
||||
// fields.
|
||||
out := rule
|
||||
out.DstPorts = dests
|
||||
ret = append(ret, out)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -104,6 +107,7 @@ func reduceCapGrantRule(
|
||||
var capGrants []tailcfg.CapGrant
|
||||
|
||||
nodeIPs := node.IPs()
|
||||
subnetRoutes := node.SubnetRoutes()
|
||||
|
||||
for _, cg := range rule.CapGrant {
|
||||
// Collect the node's IPs that fall within any of this
|
||||
@@ -113,8 +117,9 @@ func reduceCapGrantRule(
|
||||
|
||||
for _, dst := range cg.Dsts {
|
||||
if dst.IsSingleIP() {
|
||||
// Already a specific IP — keep it if it matches.
|
||||
if dst.Addr() == nodeIPs[0] || (len(nodeIPs) > 1 && dst.Addr() == nodeIPs[1]) {
|
||||
// Already a specific IP — keep it if it matches
|
||||
// any of the node's IPs.
|
||||
if slices.Contains(nodeIPs, dst.Addr()) {
|
||||
matchingDsts = append(matchingDsts, dst)
|
||||
}
|
||||
} else {
|
||||
@@ -127,28 +132,30 @@ func reduceCapGrantRule(
|
||||
}
|
||||
}
|
||||
|
||||
// Also check routable IPs (subnet routes) — nodes that
|
||||
// advertise routes should receive CapGrant rules for
|
||||
// destinations that overlap their routes.
|
||||
if node.Hostinfo().Valid() {
|
||||
routableIPs := node.Hostinfo().RoutableIPs()
|
||||
if routableIPs.Len() > 0 {
|
||||
for _, dst := range cg.Dsts {
|
||||
for _, routableIP := range routableIPs.All() {
|
||||
if tsaddr.IsExitRoute(routableIP) {
|
||||
continue
|
||||
}
|
||||
|
||||
if dst.Overlaps(routableIP) {
|
||||
// For route overlaps, keep the original prefix.
|
||||
matchingDsts = append(matchingDsts, dst)
|
||||
}
|
||||
}
|
||||
// Asymmetric on purpose: the IP-match loop above narrows broad
|
||||
// prefixes to node-specific /32 or /128 so peers receive only
|
||||
// the minimum routing surface. The route-match loop below
|
||||
// preserves the original prefix so the subnet-serving node
|
||||
// receives the full CapGrant scope. SubnetRoutes() excludes
|
||||
// both unapproved and exit routes, matching Tailscale SaaS
|
||||
// behavior.
|
||||
for _, dst := range cg.Dsts {
|
||||
for _, subnetRoute := range subnetRoutes {
|
||||
if dst.Overlaps(subnetRoute) {
|
||||
// For route overlaps, keep the original prefix.
|
||||
matchingDsts = append(matchingDsts, dst)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(matchingDsts) > 0 {
|
||||
// A Dst can be appended twice when a broad prefix both
|
||||
// contains a node IP and overlaps one of its approved
|
||||
// subnet routes. Sort + Compact dedups; netip.Prefix is
|
||||
// comparable so Compact works with ==.
|
||||
slices.SortFunc(matchingDsts, netip.Prefix.Compare)
|
||||
matchingDsts = slices.Compact(matchingDsts)
|
||||
|
||||
capGrants = append(capGrants, tailcfg.CapGrant{
|
||||
Dsts: matchingDsts,
|
||||
CapMap: cg.CapMap,
|
||||
|
||||
@@ -196,6 +196,9 @@ func TestReduceFilterRules(t *testing.T) {
|
||||
netip.MustParsePrefix("10.33.0.0/16"),
|
||||
},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.33.0.0/16"),
|
||||
},
|
||||
},
|
||||
peers: types.Nodes{
|
||||
&types.Node{
|
||||
@@ -518,6 +521,7 @@ func TestReduceFilterRules(t *testing.T) {
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("8.0.0.0/16"), netip.MustParsePrefix("16.0.0.0/16")},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("8.0.0.0/16"), netip.MustParsePrefix("16.0.0.0/16")},
|
||||
},
|
||||
peers: types.Nodes{
|
||||
&types.Node{
|
||||
@@ -601,6 +605,7 @@ func TestReduceFilterRules(t *testing.T) {
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("8.0.0.0/8"), netip.MustParsePrefix("16.0.0.0/8")},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("8.0.0.0/8"), netip.MustParsePrefix("16.0.0.0/8")},
|
||||
},
|
||||
peers: types.Nodes{
|
||||
&types.Node{
|
||||
@@ -676,7 +681,8 @@ func TestReduceFilterRules(t *testing.T) {
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("172.16.0.0/24")},
|
||||
},
|
||||
Tags: []string{"tag:access-servers"},
|
||||
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("172.16.0.0/24")},
|
||||
Tags: []string{"tag:access-servers"},
|
||||
},
|
||||
peers: types.Nodes{
|
||||
&types.Node{
|
||||
@@ -759,7 +765,9 @@ func TestReduceFilterRules(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
for idx, pmf := range policy.PolicyManagerFuncsForTest([]byte(tt.pol)) {
|
||||
for idx, pmf := range policy.PolicyManagerFuncsForTest(
|
||||
[]byte(tt.pol),
|
||||
) {
|
||||
t.Run(fmt.Sprintf("%s-index%d", tt.name, idx), func(t *testing.T) {
|
||||
var (
|
||||
pm policy.PolicyManager
|
||||
@@ -781,3 +789,494 @@ func TestReduceFilterRules(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReduceFilterRulesPartialApproval verifies that ReduceFilterRules
|
||||
// only preserves filter rules for routes that are both advertised
|
||||
// (RoutableIPs) AND approved (ApprovedRoutes), matching Tailscale
|
||||
// SaaS behavior. Advertised-but-unapproved routes do not cause rule
|
||||
// preservation: SaaS never generates filter rules for unapproved
|
||||
// routes, and headscale consults node.SubnetRoutes() (which filters
|
||||
// by approval) rather than Hostinfo.RoutableIPs() (which does not).
|
||||
func TestReduceFilterRulesPartialApproval(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
node *types.Node
|
||||
rules []tailcfg.FilterRule
|
||||
wantCount int
|
||||
wantRoutes []string
|
||||
}{
|
||||
{
|
||||
name: "approved-route-included",
|
||||
node: &types.Node{
|
||||
IPv4: ap("100.64.0.1"),
|
||||
IPv6: ap("fd7a:115c:a1e0::1"),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.33.0.0/16"),
|
||||
},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.33.0.0/16"),
|
||||
},
|
||||
},
|
||||
rules: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"*"},
|
||||
DstPorts: []tailcfg.NetPortRange{
|
||||
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCount: 1,
|
||||
wantRoutes: []string{"10.33.0.0/16"},
|
||||
},
|
||||
{
|
||||
name: "unapproved-route-excluded",
|
||||
node: &types.Node{
|
||||
IPv4: ap("100.64.0.1"),
|
||||
IPv6: ap("fd7a:115c:a1e0::1"),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.33.0.0/16"),
|
||||
// Advertised but NOT approved:
|
||||
netip.MustParsePrefix("172.16.0.0/24"),
|
||||
},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{
|
||||
// Only 10.33.0.0/16 approved
|
||||
netip.MustParsePrefix("10.33.0.0/16"),
|
||||
},
|
||||
},
|
||||
rules: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"*"},
|
||||
DstPorts: []tailcfg.NetPortRange{
|
||||
// Targets the unapproved route
|
||||
{IP: "172.16.0.0/24", Ports: tailcfg.PortRangeAny},
|
||||
},
|
||||
},
|
||||
},
|
||||
// SubnetRoutes() does NOT contain 172.16.0.0/24
|
||||
// (only approved routes), and the ACL dst does not
|
||||
// overlap the node's own IPs, so the rule is
|
||||
// dropped. This matches Tailscale SaaS behavior.
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "neither-advertised-nor-approved-excluded",
|
||||
node: &types.Node{
|
||||
IPv4: ap("100.64.0.1"),
|
||||
IPv6: ap("fd7a:115c:a1e0::1"),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.33.0.0/16"),
|
||||
},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.33.0.0/16"),
|
||||
},
|
||||
},
|
||||
rules: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"*"},
|
||||
DstPorts: []tailcfg.NetPortRange{
|
||||
// Not advertised, not approved
|
||||
{IP: "192.168.0.0/16", Ports: tailcfg.PortRangeAny},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCount: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := policyutil.ReduceFilterRules(
|
||||
tt.node.View(), tt.rules,
|
||||
)
|
||||
require.Len(t, got, tt.wantCount,
|
||||
"rule count mismatch")
|
||||
|
||||
if tt.wantCount > 0 {
|
||||
var gotRoutes []string
|
||||
for _, dp := range got[0].DstPorts {
|
||||
gotRoutes = append(gotRoutes, dp.IP)
|
||||
}
|
||||
|
||||
require.Equal(t, tt.wantRoutes, gotRoutes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReduceFilterRulesCapGrant tests the CapGrant branch of
|
||||
// ReduceFilterRules, which was previously untested. All existing
|
||||
// test cases use ACL-only policies with DstPorts rules.
|
||||
func TestReduceFilterRulesCapGrant(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
node *types.Node
|
||||
rules []tailcfg.FilterRule
|
||||
want []tailcfg.FilterRule
|
||||
}{
|
||||
{
|
||||
name: "capgrant-matches-node-ip-narrowed",
|
||||
node: &types.Node{
|
||||
IPv4: ap("100.64.0.1"),
|
||||
IPv6: ap("fd7a:115c:a1e0::1"),
|
||||
},
|
||||
rules: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"10.0.0.0/8"},
|
||||
CapGrant: []tailcfg.CapGrant{
|
||||
{
|
||||
Dsts: []netip.Prefix{
|
||||
// Broad IPv4 prefix containing node IP
|
||||
netip.MustParsePrefix("100.64.0.0/10"),
|
||||
},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
"tailscale.com/cap/drive-sharer": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"10.0.0.0/8"},
|
||||
CapGrant: []tailcfg.CapGrant{
|
||||
{
|
||||
Dsts: []netip.Prefix{
|
||||
// Only IPv4 narrowed (IPv6 not in /10)
|
||||
netip.MustParsePrefix("100.64.0.1/32"),
|
||||
},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
"tailscale.com/cap/drive-sharer": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "capgrant-no-match-filtered-out",
|
||||
node: &types.Node{
|
||||
IPv4: ap("100.64.0.1"),
|
||||
IPv6: ap("fd7a:115c:a1e0::1"),
|
||||
},
|
||||
rules: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"10.0.0.0/8"},
|
||||
CapGrant: []tailcfg.CapGrant{
|
||||
{
|
||||
Dsts: []netip.Prefix{
|
||||
// Different IP — doesn't match this node
|
||||
netip.MustParsePrefix("100.64.0.99/32"),
|
||||
},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
"tailscale.com/cap/drive-sharer": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: []tailcfg.FilterRule{},
|
||||
},
|
||||
{
|
||||
name: "capgrant-with-subnet-route-overlap",
|
||||
node: &types.Node{
|
||||
IPv4: ap("100.64.0.1"),
|
||||
IPv6: ap("fd7a:115c:a1e0::1"),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.33.0.0/16"),
|
||||
},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.33.0.0/16"),
|
||||
},
|
||||
},
|
||||
rules: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"*"},
|
||||
CapGrant: []tailcfg.CapGrant{
|
||||
{
|
||||
Dsts: []netip.Prefix{
|
||||
// Subnet route overlap
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
"tailscale.com/cap/relay-target": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"*"},
|
||||
CapGrant: []tailcfg.CapGrant{
|
||||
{
|
||||
Dsts: []netip.Prefix{
|
||||
// 10.0.0.0/8 doesn't contain node's
|
||||
// CGNAT IP (100.64.0.1), so no IP
|
||||
// narrowing. Only route overlap kept
|
||||
// as original prefix.
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
"tailscale.com/cap/relay-target": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "capgrant-exit-route-skipped",
|
||||
node: &types.Node{
|
||||
IPv4: ap("100.64.0.1"),
|
||||
IPv6: ap("fd7a:115c:a1e0::1"),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: tsaddr.ExitRoutes(),
|
||||
},
|
||||
ApprovedRoutes: tsaddr.ExitRoutes(),
|
||||
},
|
||||
rules: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"*"},
|
||||
CapGrant: []tailcfg.CapGrant{
|
||||
{
|
||||
Dsts: []netip.Prefix{
|
||||
// 0.0.0.0/0 overlaps the exit route
|
||||
// but exit routes should be skipped
|
||||
netip.MustParsePrefix("0.0.0.0/0"),
|
||||
},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
"tailscale.com/cap/drive-sharer": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"*"},
|
||||
CapGrant: []tailcfg.CapGrant{
|
||||
{
|
||||
Dsts: []netip.Prefix{
|
||||
// Node IP narrowed (0.0.0.0/0
|
||||
// contains the node's IP)
|
||||
netip.MustParsePrefix("100.64.0.1/32"),
|
||||
},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
"tailscale.com/cap/drive-sharer": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed-dstports-and-capgrant-rules",
|
||||
node: &types.Node{
|
||||
IPv4: ap("100.64.0.1"),
|
||||
IPv6: ap("fd7a:115c:a1e0::1"),
|
||||
},
|
||||
rules: []tailcfg.FilterRule{
|
||||
{
|
||||
// DstPorts rule that matches
|
||||
SrcIPs: []string{"10.0.0.0/8"},
|
||||
DstPorts: []tailcfg.NetPortRange{
|
||||
{IP: "100.64.0.1", Ports: tailcfg.PortRangeAny},
|
||||
},
|
||||
},
|
||||
{
|
||||
// CapGrant rule that matches
|
||||
SrcIPs: []string{"10.0.0.0/8"},
|
||||
CapGrant: []tailcfg.CapGrant{
|
||||
{
|
||||
Dsts: []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.0.1/32"),
|
||||
},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
"tailscale.com/cap/drive-sharer": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// CapGrant rule that doesn't match
|
||||
SrcIPs: []string{"10.0.0.0/8"},
|
||||
CapGrant: []tailcfg.CapGrant{
|
||||
{
|
||||
Dsts: []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.0.99/32"),
|
||||
},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
"tailscale.com/cap/drive-sharer": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"10.0.0.0/8"},
|
||||
DstPorts: []tailcfg.NetPortRange{
|
||||
{IP: "100.64.0.1", Ports: tailcfg.PortRangeAny},
|
||||
},
|
||||
},
|
||||
{
|
||||
SrcIPs: []string{"10.0.0.0/8"},
|
||||
CapGrant: []tailcfg.CapGrant{
|
||||
{
|
||||
Dsts: []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.0.1/32"),
|
||||
},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
"tailscale.com/cap/drive-sharer": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Third rule filtered out — doesn't match node
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "capgrant-ipv4-only-node",
|
||||
node: &types.Node{
|
||||
IPv4: ap("100.64.0.1"),
|
||||
// IPv6 is nil, single-IP node
|
||||
},
|
||||
rules: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"10.0.0.0/8"},
|
||||
CapGrant: []tailcfg.CapGrant{
|
||||
{
|
||||
Dsts: []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.0.1/32"),
|
||||
},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
"tailscale.com/cap/drive-sharer": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"10.0.0.0/8"},
|
||||
CapGrant: []tailcfg.CapGrant{
|
||||
{
|
||||
Dsts: []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.0.1/32"),
|
||||
},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
"tailscale.com/cap/drive-sharer": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "capgrant-zero-ip-node-no-panic",
|
||||
node: &types.Node{
|
||||
// Both IPv4 and IPv6 are nil, zero IPs.
|
||||
// Must not panic.
|
||||
},
|
||||
rules: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"10.0.0.0/8"},
|
||||
CapGrant: []tailcfg.CapGrant{
|
||||
{
|
||||
Dsts: []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.0.1/32"),
|
||||
},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
"tailscale.com/cap/drive-sharer": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: []tailcfg.FilterRule{},
|
||||
},
|
||||
{
|
||||
// A broad prefix that both contains the node IP (/32 narrow)
|
||||
// and overlaps one of its approved subnet routes (route
|
||||
// preserved) would otherwise be emitted twice.
|
||||
name: "capgrant-ip-and-route-overlap-dedup",
|
||||
node: &types.Node{
|
||||
IPv4: ap("100.64.0.1"),
|
||||
IPv6: ap("fd7a:115c:a1e0::1"),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.0.0/24"),
|
||||
},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.0.0/24"),
|
||||
},
|
||||
},
|
||||
rules: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"*"},
|
||||
CapGrant: []tailcfg.CapGrant{
|
||||
{
|
||||
Dsts: []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.0.0/24"),
|
||||
},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
"tailscale.com/cap/relay-target": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"*"},
|
||||
CapGrant: []tailcfg.CapGrant{
|
||||
{
|
||||
Dsts: []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.0.1/32"),
|
||||
netip.MustParsePrefix("100.64.0.0/24"),
|
||||
},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
"tailscale.com/cap/relay-target": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := policyutil.ReduceFilterRules(tt.node.View(), tt.rules)
|
||||
|
||||
require.Len(t, got, len(tt.want),
|
||||
"rule count mismatch")
|
||||
|
||||
for i := range tt.want {
|
||||
require.Equal(t, tt.want[i].SrcIPs, got[i].SrcIPs,
|
||||
"rule[%d] SrcIPs", i)
|
||||
require.Len(t, got[i].DstPorts, len(tt.want[i].DstPorts),
|
||||
"rule[%d] DstPorts count", i)
|
||||
require.Len(t, got[i].CapGrant, len(tt.want[i].CapGrant),
|
||||
"rule[%d] CapGrant count", i)
|
||||
|
||||
for j := range tt.want[i].CapGrant {
|
||||
require.ElementsMatch(t,
|
||||
tt.want[i].CapGrant[j].Dsts,
|
||||
got[i].CapGrant[j].Dsts,
|
||||
"rule[%d].CapGrant[%d] Dsts", i, j,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,740 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"github.com/rs/zerolog/log"
|
||||
"go4.org/netipx"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
|
||||
// grantCategory classifies a grant by what per-node work it needs.
|
||||
type grantCategory int
|
||||
|
||||
const (
|
||||
// grantCategoryRegular requires no per-node work. The pre-compiled
|
||||
// rules are complete and only need ReduceFilterRules.
|
||||
grantCategoryRegular grantCategory = iota
|
||||
|
||||
// grantCategorySelf has autogroup:self destinations that must be
|
||||
// expanded per-node to same-user untagged device IPs.
|
||||
grantCategorySelf
|
||||
|
||||
// grantCategoryVia has Via tags that route rules to specific
|
||||
// nodes based on their tags and advertised routes.
|
||||
grantCategoryVia
|
||||
)
|
||||
|
||||
// compiledGrant is a grant with its sources already resolved to IP
|
||||
// addresses. The expensive work (alias → IP resolution) is done once
|
||||
// here. Extracting rules for a specific node reads from pre-resolved
|
||||
// data without re-resolving.
|
||||
type compiledGrant struct {
|
||||
category grantCategory
|
||||
|
||||
// srcIPStrings is the final SrcIPs for non-self rules, with
|
||||
// nonWildcardSrcs appended to match Tailscale SaaS behavior.
|
||||
srcIPStrings []string
|
||||
|
||||
hasWildcard bool
|
||||
hasDangerAll bool
|
||||
|
||||
// rules are the pre-compiled filter rules for non-self, non-via
|
||||
// destinations. For regular grants this is the complete output.
|
||||
// For self grants with mixed destinations (self + other), this
|
||||
// is the non-self portion only.
|
||||
rules []tailcfg.FilterRule
|
||||
|
||||
// self is non-nil when the grant has autogroup:self destinations.
|
||||
self *selfGrantData
|
||||
|
||||
// via is non-nil when the grant has Via tags.
|
||||
via *viaGrantData
|
||||
}
|
||||
|
||||
// selfGrantData holds data needed for per-node autogroup:self
|
||||
// compilation. Sources are already resolved.
|
||||
type selfGrantData struct {
|
||||
resolvedSrcs []ResolvedAddresses
|
||||
internetProtocols []ProtocolPort
|
||||
app tailcfg.PeerCapMap
|
||||
}
|
||||
|
||||
// viaGrantData holds data needed for per-node via-grant compilation.
|
||||
// Sources are already resolved into srcIPStrings.
|
||||
type viaGrantData struct {
|
||||
viaTags []Tag
|
||||
destinations Aliases
|
||||
internetProtocols []ProtocolPort
|
||||
srcIPStrings []string
|
||||
}
|
||||
|
||||
// userNodeIndex maps user IDs to their untagged nodes. Built once per
|
||||
// policy or node-set change and read from many goroutines under
|
||||
// PolicyManager.mu; readers must hold the lock (or the snapshot
|
||||
// returned to them).
|
||||
type userNodeIndex map[uint][]types.NodeView
|
||||
|
||||
func buildUserNodeIndex(
|
||||
nodes views.Slice[types.NodeView],
|
||||
) userNodeIndex {
|
||||
idx := make(userNodeIndex)
|
||||
|
||||
for _, n := range nodes.All() {
|
||||
if !n.IsTagged() && n.User().Valid() {
|
||||
uid := n.User().ID()
|
||||
idx[uid] = append(idx[uid], n)
|
||||
}
|
||||
}
|
||||
|
||||
return idx
|
||||
}
|
||||
|
||||
// compileGrants resolves all policy grants into compiledGrant structs.
|
||||
// Source resolution and non-self destination resolution happens once
|
||||
// here. This is the single resolution path that replaces the
|
||||
// duplicated work in compileFilterRules and compileGrantWithAutogroupSelf.
|
||||
func (pol *Policy) compileGrants(
|
||||
users types.Users,
|
||||
nodes views.Slice[types.NodeView],
|
||||
) []compiledGrant {
|
||||
if pol == nil || (pol.ACLs == nil && pol.Grants == nil) {
|
||||
return nil
|
||||
}
|
||||
|
||||
grants := pol.Grants
|
||||
for _, acl := range pol.ACLs {
|
||||
grants = append(grants, aclToGrants(acl)...)
|
||||
}
|
||||
|
||||
compiled := make([]compiledGrant, 0, len(grants))
|
||||
|
||||
for _, grant := range grants {
|
||||
cg, err := pol.compileOneGrant(grant, users, nodes)
|
||||
if err != nil {
|
||||
log.Trace().Err(err).Msg("compiling grant")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if cg != nil {
|
||||
compiled = append(compiled, *cg)
|
||||
}
|
||||
}
|
||||
|
||||
return compiled
|
||||
}
|
||||
|
||||
// compileOneGrant resolves a single grant into a compiledGrant.
|
||||
// All source resolution happens here. Non-self, non-via destination
|
||||
// resolution also happens here. Per-node data (self dests, via
|
||||
// matching) is stored for deferred compilation.
|
||||
//
|
||||
//nolint:gocyclo,cyclop
|
||||
func (pol *Policy) compileOneGrant(
|
||||
grant Grant,
|
||||
users types.Users,
|
||||
nodes views.Slice[types.NodeView],
|
||||
) (*compiledGrant, error) {
|
||||
// Via grants: resolve sources, store deferred data.
|
||||
if len(grant.Via) > 0 {
|
||||
return pol.compileOneViaGrant(grant, users, nodes)
|
||||
}
|
||||
|
||||
// Split destinations into self vs other.
|
||||
var autogroupSelfDests, otherDests []Alias
|
||||
|
||||
for _, dest := range grant.Destinations {
|
||||
if ag, ok := dest.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
|
||||
autogroupSelfDests = append(autogroupSelfDests, dest)
|
||||
} else {
|
||||
otherDests = append(otherDests, dest)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve sources per-alias, tracking non-wildcard sources
|
||||
// separately so we can preserve their IPs alongside the
|
||||
// wildcard CGNAT ranges (matching Tailscale SaaS behavior).
|
||||
resolvedSrcs, nonWildcardSrcs, err := resolveSources(
|
||||
pol, grant.Sources, users, nodes,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Literally empty src=[] or dst=[] produces no rules.
|
||||
if len(grant.Sources) == 0 || len(grant.Destinations) == 0 {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
|
||||
if len(resolvedSrcs) == 0 && grant.App == nil {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
|
||||
hasWildcard := sourcesHaveWildcard(grant.Sources)
|
||||
hasDangerAll := sourcesHaveDangerAll(grant.Sources)
|
||||
srcIPStrings := buildSrcIPStrings(
|
||||
resolvedSrcs, nonWildcardSrcs,
|
||||
hasWildcard, hasDangerAll, nodes,
|
||||
)
|
||||
|
||||
cg := &compiledGrant{
|
||||
srcIPStrings: srcIPStrings,
|
||||
hasWildcard: hasWildcard,
|
||||
hasDangerAll: hasDangerAll,
|
||||
}
|
||||
|
||||
// Compile non-self destination rules (done once, shared).
|
||||
if len(otherDests) > 0 {
|
||||
cg.rules = pol.compileOtherDests(
|
||||
users, nodes, grant, otherDests,
|
||||
resolvedSrcs, srcIPStrings,
|
||||
)
|
||||
}
|
||||
|
||||
// Classify and store deferred self data.
|
||||
switch {
|
||||
case len(autogroupSelfDests) > 0:
|
||||
cg.category = grantCategorySelf
|
||||
cg.self = &selfGrantData{
|
||||
resolvedSrcs: resolvedSrcs,
|
||||
internetProtocols: grant.InternetProtocols,
|
||||
app: grant.App,
|
||||
}
|
||||
default:
|
||||
cg.category = grantCategoryRegular
|
||||
}
|
||||
|
||||
return cg, nil
|
||||
}
|
||||
|
||||
// compileOneViaGrant resolves sources for a via grant and stores the
|
||||
// deferred per-node data. The actual via-node matching and route
|
||||
// intersection happens in compileViaForNode.
|
||||
func (pol *Policy) compileOneViaGrant(
|
||||
grant Grant,
|
||||
users types.Users,
|
||||
nodes views.Slice[types.NodeView],
|
||||
) (*compiledGrant, error) {
|
||||
if len(grant.InternetProtocols) == 0 {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
|
||||
resolvedSrcs, _, err := resolveSources(
|
||||
pol, grant.Sources, users, nodes,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(resolvedSrcs) == 0 {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
|
||||
// Build merged SrcIPs.
|
||||
var srcIPs netipx.IPSetBuilder
|
||||
|
||||
for _, ips := range resolvedSrcs {
|
||||
for _, pref := range ips.Prefixes() {
|
||||
srcIPs.AddPrefix(pref)
|
||||
}
|
||||
}
|
||||
|
||||
srcResolved, err := newResolved(&srcIPs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if srcResolved.Empty() {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
|
||||
hasWildcard := sourcesHaveWildcard(grant.Sources)
|
||||
hasDangerAll := sourcesHaveDangerAll(grant.Sources)
|
||||
|
||||
return &compiledGrant{
|
||||
category: grantCategoryVia,
|
||||
via: &viaGrantData{
|
||||
viaTags: grant.Via,
|
||||
destinations: grant.Destinations,
|
||||
internetProtocols: grant.InternetProtocols,
|
||||
srcIPStrings: srcIPsWithRoutes(
|
||||
srcResolved, hasWildcard, hasDangerAll, nodes,
|
||||
),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveSources resolves grant sources per-alias, returning the
|
||||
// resolved addresses and a separate slice of non-wildcard sources.
|
||||
// This is the canonical source-resolution path. Its output lands in
|
||||
// compiledGrant.srcIPStrings (among other places) and callers on the
|
||||
// hot path should prefer reading that over calling Resolve again.
|
||||
func resolveSources(
|
||||
pol *Policy,
|
||||
sources Aliases,
|
||||
users types.Users,
|
||||
nodes views.Slice[types.NodeView],
|
||||
) ([]ResolvedAddresses, []ResolvedAddresses, error) {
|
||||
var all, nonWild []ResolvedAddresses
|
||||
|
||||
for i, src := range sources {
|
||||
if ag, ok := src.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
|
||||
return nil, nil, errSelfInSources
|
||||
}
|
||||
|
||||
ips, err := src.Resolve(pol, users, nodes)
|
||||
if err != nil {
|
||||
log.Trace().Caller().Err(err).
|
||||
Msg("resolving source ips")
|
||||
}
|
||||
|
||||
if ips != nil {
|
||||
all = append(all, ips)
|
||||
|
||||
if _, isWildcard := sources[i].(Asterix); !isWildcard {
|
||||
nonWild = append(nonWild, ips)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return all, nonWild, nil
|
||||
}
|
||||
|
||||
// buildSrcIPStrings builds the final SrcIPs string slice from
|
||||
// resolved sources, preserving non-wildcard IPs alongside wildcard
|
||||
// CGNAT ranges to match Tailscale SaaS behavior.
|
||||
func buildSrcIPStrings(
|
||||
resolvedSrcs, nonWildcardSrcs []ResolvedAddresses,
|
||||
hasWildcard, hasDangerAll bool,
|
||||
nodes views.Slice[types.NodeView],
|
||||
) []string {
|
||||
var merged netipx.IPSetBuilder
|
||||
|
||||
for _, ips := range resolvedSrcs {
|
||||
for _, pref := range ips.Prefixes() {
|
||||
merged.AddPrefix(pref)
|
||||
}
|
||||
}
|
||||
|
||||
srcResolved, err := newResolved(&merged)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if srcResolved.Empty() {
|
||||
return nil
|
||||
}
|
||||
|
||||
srcIPStrs := srcIPsWithRoutes(
|
||||
srcResolved, hasWildcard, hasDangerAll, nodes,
|
||||
)
|
||||
|
||||
// When sources include a wildcard (*) alongside explicit
|
||||
// sources (tags, groups, etc.), Tailscale preserves the
|
||||
// individual IPs from non-wildcard sources alongside the
|
||||
// merged CGNAT ranges rather than absorbing them.
|
||||
if hasWildcard && len(nonWildcardSrcs) > 0 {
|
||||
seen := make(map[string]bool, len(srcIPStrs))
|
||||
for _, s := range srcIPStrs {
|
||||
seen[s] = true
|
||||
}
|
||||
|
||||
for _, ips := range nonWildcardSrcs {
|
||||
for _, s := range ips.Strings() {
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
srcIPStrs = append(srcIPStrs, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return srcIPStrs
|
||||
}
|
||||
|
||||
// compileOtherDests compiles filter rules for non-self, non-via
|
||||
// destinations. This produces both DstPorts rules (from
|
||||
// InternetProtocols) and CapGrant rules (from App).
|
||||
func (pol *Policy) compileOtherDests(
|
||||
users types.Users,
|
||||
nodes views.Slice[types.NodeView],
|
||||
grant Grant,
|
||||
otherDests Aliases,
|
||||
resolvedSrcs []ResolvedAddresses,
|
||||
srcIPStrings []string,
|
||||
) []tailcfg.FilterRule {
|
||||
var rules []tailcfg.FilterRule
|
||||
|
||||
// DstPorts rules from InternetProtocols.
|
||||
for _, ipp := range grant.InternetProtocols {
|
||||
destPorts := pol.destinationsToNetPortRange(
|
||||
users, nodes, otherDests, ipp.Ports,
|
||||
)
|
||||
|
||||
if len(destPorts) > 0 && len(srcIPStrings) > 0 {
|
||||
rules = append(rules, tailcfg.FilterRule{
|
||||
SrcIPs: srcIPStrings,
|
||||
DstPorts: destPorts,
|
||||
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CapGrant rules from App.
|
||||
if grant.App != nil {
|
||||
capSrcIPStrs := srcIPStrings
|
||||
|
||||
// When sources resolved to empty but App is set,
|
||||
// Tailscale still produces the CapGrant rule with
|
||||
// empty SrcIPs.
|
||||
if capSrcIPStrs == nil {
|
||||
capSrcIPStrs = []string{}
|
||||
}
|
||||
|
||||
var (
|
||||
capGrants []tailcfg.CapGrant
|
||||
dstIPStrings []string
|
||||
)
|
||||
|
||||
for _, dst := range otherDests {
|
||||
ips, err := dst.Resolve(pol, users, nodes)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
capGrants = append(capGrants, tailcfg.CapGrant{
|
||||
Dsts: ips.Prefixes(),
|
||||
CapMap: grant.App,
|
||||
})
|
||||
|
||||
dstIPStrings = append(dstIPStrings, ips.Strings()...)
|
||||
}
|
||||
|
||||
if len(capGrants) > 0 {
|
||||
srcPrefixes := make([]netip.Prefix, 0, len(resolvedSrcs)*2)
|
||||
for _, ips := range resolvedSrcs {
|
||||
srcPrefixes = append(
|
||||
srcPrefixes, ips.Prefixes()...,
|
||||
)
|
||||
}
|
||||
|
||||
rules = append(rules, tailcfg.FilterRule{
|
||||
SrcIPs: capSrcIPStrs,
|
||||
CapGrant: capGrants,
|
||||
})
|
||||
|
||||
dstsHaveWildcard := sourcesHaveWildcard(otherDests)
|
||||
if dstsHaveWildcard {
|
||||
dstIPStrings = append(
|
||||
dstIPStrings,
|
||||
approvedSubnetRoutes(nodes)...,
|
||||
)
|
||||
}
|
||||
|
||||
rules = append(
|
||||
rules,
|
||||
companionCapGrantRules(
|
||||
dstIPStrings, srcPrefixes, grant.App,
|
||||
)...,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
// hasPerNodeGrants reports whether any compiled grant requires
|
||||
// per-node filter compilation (via grants or autogroup:self).
|
||||
func hasPerNodeGrants(grants []compiledGrant) bool {
|
||||
for i := range grants {
|
||||
if grants[i].category != grantCategoryRegular {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// globalFilterRules extracts global filter rules from compiled
|
||||
// grants. Via grants produce no global rules (they are per-node
|
||||
// only); regular grants contribute their full pre-compiled ruleset;
|
||||
// self grants contribute their non-self portion.
|
||||
func globalFilterRules(grants []compiledGrant) []tailcfg.FilterRule {
|
||||
var rules []tailcfg.FilterRule
|
||||
|
||||
for i := range grants {
|
||||
if grants[i].category == grantCategoryVia {
|
||||
continue
|
||||
}
|
||||
|
||||
rules = append(rules, grants[i].rules...)
|
||||
}
|
||||
|
||||
return mergeFilterRules(rules)
|
||||
}
|
||||
|
||||
// filterRulesForNode produces unreduced filter rules for a specific
|
||||
// node by combining pre-compiled global rules with per-node self and
|
||||
// via rules. Regular grants emit their pre-compiled rules as-is.
|
||||
// Self grants add autogroup:self expansion. Via grants add
|
||||
// tag-matched, route-intersected rules.
|
||||
func filterRulesForNode(
|
||||
grants []compiledGrant,
|
||||
node types.NodeView,
|
||||
userIdx userNodeIndex,
|
||||
) []tailcfg.FilterRule {
|
||||
var rules []tailcfg.FilterRule
|
||||
|
||||
for i := range grants {
|
||||
cg := &grants[i]
|
||||
|
||||
// Pre-compiled rules apply to all grant categories
|
||||
// (empty for via-only grants).
|
||||
rules = append(rules, cg.rules...)
|
||||
|
||||
switch cg.category {
|
||||
case grantCategoryRegular:
|
||||
// Nothing more to do.
|
||||
|
||||
case grantCategorySelf:
|
||||
rules = append(
|
||||
rules,
|
||||
compileAutogroupSelf(cg, node, userIdx)...,
|
||||
)
|
||||
|
||||
case grantCategoryVia:
|
||||
rules = append(
|
||||
rules,
|
||||
compileViaForNode(cg, node)...,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return mergeFilterRules(rules)
|
||||
}
|
||||
|
||||
// compileAutogroupSelf produces filter rules for autogroup:self
|
||||
// destinations for a specific node. Only called for grants with
|
||||
// self destinations and only produces rules for untagged nodes.
|
||||
func compileAutogroupSelf(
|
||||
cg *compiledGrant,
|
||||
node types.NodeView,
|
||||
userIdx userNodeIndex,
|
||||
) []tailcfg.FilterRule {
|
||||
if node.IsTagged() || cg.self == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !node.User().Valid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
sameUserNodes := userIdx[node.User().ID()]
|
||||
if len(sameUserNodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var rules []tailcfg.FilterRule
|
||||
|
||||
// Filter sources to only same-user untagged devices.
|
||||
srcResolved := filterSourcesToSameUser(
|
||||
cg.self.resolvedSrcs, sameUserNodes,
|
||||
)
|
||||
if srcResolved == nil || srcResolved.Empty() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DstPorts rules from InternetProtocols.
|
||||
for _, ipp := range cg.self.internetProtocols {
|
||||
var destPorts []tailcfg.NetPortRange
|
||||
|
||||
for _, n := range sameUserNodes {
|
||||
for _, port := range ipp.Ports {
|
||||
for _, ip := range n.IPs() {
|
||||
destPorts = append(
|
||||
destPorts,
|
||||
tailcfg.NetPortRange{
|
||||
IP: ip.String(),
|
||||
Ports: port,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(destPorts) > 0 {
|
||||
rules = append(rules, tailcfg.FilterRule{
|
||||
SrcIPs: srcResolved.Strings(),
|
||||
DstPorts: destPorts,
|
||||
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CapGrant rules from App.
|
||||
if cg.self.app != nil {
|
||||
var (
|
||||
capGrants []tailcfg.CapGrant
|
||||
dstIPStrings []string
|
||||
)
|
||||
|
||||
for _, n := range sameUserNodes {
|
||||
var dsts []netip.Prefix
|
||||
for _, ip := range n.IPs() {
|
||||
dsts = append(
|
||||
dsts,
|
||||
netip.PrefixFrom(ip, ip.BitLen()),
|
||||
)
|
||||
dstIPStrings = append(
|
||||
dstIPStrings, ip.String(),
|
||||
)
|
||||
}
|
||||
|
||||
capGrants = append(capGrants, tailcfg.CapGrant{
|
||||
Dsts: dsts,
|
||||
CapMap: cg.self.app,
|
||||
})
|
||||
}
|
||||
|
||||
if len(capGrants) > 0 {
|
||||
rules = append(rules, tailcfg.FilterRule{
|
||||
SrcIPs: srcResolved.Strings(),
|
||||
CapGrant: capGrants,
|
||||
})
|
||||
|
||||
rules = append(
|
||||
rules,
|
||||
companionCapGrantRules(
|
||||
dstIPStrings,
|
||||
srcResolved.Prefixes(),
|
||||
cg.self.app,
|
||||
)...,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
// filterSourcesToSameUser intersects resolved source addresses with
|
||||
// same-user untagged device IPs, returning only the addresses that
|
||||
// belong to those devices.
|
||||
func filterSourcesToSameUser(
|
||||
resolvedSrcs []ResolvedAddresses,
|
||||
sameUserNodes []types.NodeView,
|
||||
) ResolvedAddresses {
|
||||
var srcIPs netipx.IPSetBuilder
|
||||
|
||||
for _, ips := range resolvedSrcs {
|
||||
for _, n := range sameUserNodes {
|
||||
if slices.ContainsFunc(n.IPs(), ips.Contains) {
|
||||
n.AppendToIPSet(&srcIPs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
srcResolved, err := newResolved(&srcIPs)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return srcResolved
|
||||
}
|
||||
|
||||
// compileViaForNode produces via-grant filter rules for a specific
|
||||
// node. Only produces rules when the node matches one of the via
|
||||
// tags and advertises routes that match the grant destinations.
|
||||
func compileViaForNode(
|
||||
cg *compiledGrant,
|
||||
node types.NodeView,
|
||||
) []tailcfg.FilterRule {
|
||||
if cg.via == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if node matches any via tag.
|
||||
matchesVia := false
|
||||
|
||||
for _, viaTag := range cg.via.viaTags {
|
||||
if node.HasTag(string(viaTag)) {
|
||||
matchesVia = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !matchesVia {
|
||||
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.
|
||||
nodeSubnetRoutes := node.SubnetRoutes()
|
||||
|
||||
var viaDstPrefixes []netip.Prefix
|
||||
|
||||
for _, dst := range cg.via.destinations {
|
||||
switch d := dst.(type) {
|
||||
case *Prefix:
|
||||
dstPrefix := netip.Prefix(*d)
|
||||
if slices.Contains(nodeSubnetRoutes, dstPrefix) {
|
||||
viaDstPrefixes = append(
|
||||
viaDstPrefixes, dstPrefix,
|
||||
)
|
||||
}
|
||||
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()...,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(viaDstPrefixes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build rules using pre-resolved srcIPStrings.
|
||||
var rules []tailcfg.FilterRule
|
||||
|
||||
for _, ipp := range cg.via.internetProtocols {
|
||||
var destPorts []tailcfg.NetPortRange
|
||||
|
||||
for _, prefix := range viaDstPrefixes {
|
||||
for _, port := range ipp.Ports {
|
||||
destPorts = append(
|
||||
destPorts,
|
||||
tailcfg.NetPortRange{
|
||||
IP: prefix.String(),
|
||||
Ports: port,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if len(destPorts) > 0 {
|
||||
rules = append(rules, tailcfg.FilterRule{
|
||||
SrcIPs: cg.via.srcIPStrings,
|
||||
DstPorts: destPorts,
|
||||
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
+41
-616
@@ -1,6 +1,7 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
@@ -62,7 +63,7 @@ func companionCapGrantRules(
|
||||
}
|
||||
|
||||
slices.SortFunc(pairs, func(a, b pair) int {
|
||||
return strings.Compare(string(a.original), string(b.original))
|
||||
return cmp.Compare(a.original, b.original)
|
||||
})
|
||||
|
||||
companions := make([]tailcfg.FilterRule, 0, len(pairs))
|
||||
@@ -137,92 +138,11 @@ func (pol *Policy) compileFilterRules(
|
||||
users types.Users,
|
||||
nodes views.Slice[types.NodeView],
|
||||
) ([]tailcfg.FilterRule, error) {
|
||||
if pol == nil || (pol.ACLs == nil && len(pol.Grants) == 0) {
|
||||
if pol == nil || (pol.ACLs == nil && pol.Grants == nil) {
|
||||
return tailcfg.FilterAllowAll, nil
|
||||
}
|
||||
|
||||
var rules []tailcfg.FilterRule
|
||||
|
||||
grants := pol.Grants
|
||||
for _, acl := range pol.ACLs {
|
||||
grants = append(grants, aclToGrants(acl)...)
|
||||
}
|
||||
|
||||
for _, grant := range grants {
|
||||
// Via grants are compiled per-node in compileViaGrant,
|
||||
// not in the global filter set.
|
||||
if len(grant.Via) > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
srcIPs, err := grant.Sources.Resolve(pol, users, nodes)
|
||||
if err != nil {
|
||||
log.Trace().Caller().Err(err).Msgf("resolving source ips")
|
||||
}
|
||||
|
||||
if srcIPs.Empty() {
|
||||
continue
|
||||
}
|
||||
|
||||
hasWildcard := sourcesHaveWildcard(grant.Sources)
|
||||
hasDangerAll := sourcesHaveDangerAll(grant.Sources)
|
||||
|
||||
for _, ipp := range grant.InternetProtocols {
|
||||
destPorts := pol.destinationsToNetPortRange(users, nodes, grant.Destinations, ipp.Ports)
|
||||
|
||||
if len(destPorts) > 0 {
|
||||
rules = append(rules, tailcfg.FilterRule{
|
||||
SrcIPs: srcIPsWithRoutes(srcIPs, hasWildcard, hasDangerAll, nodes),
|
||||
DstPorts: destPorts,
|
||||
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if grant.App != nil {
|
||||
var (
|
||||
capGrants []tailcfg.CapGrant
|
||||
dstIPStrings []string
|
||||
)
|
||||
|
||||
for _, dst := range grant.Destinations {
|
||||
ips, err := dst.Resolve(pol, users, nodes)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
dstPrefixes := ips.Prefixes()
|
||||
capGrants = append(capGrants, tailcfg.CapGrant{
|
||||
Dsts: dstPrefixes,
|
||||
CapMap: grant.App,
|
||||
})
|
||||
|
||||
dstIPStrings = append(dstIPStrings, ips.Strings()...)
|
||||
}
|
||||
|
||||
srcIPStrs := srcIPsWithRoutes(srcIPs, hasWildcard, hasDangerAll, nodes)
|
||||
rules = append(rules, tailcfg.FilterRule{
|
||||
SrcIPs: srcIPStrs,
|
||||
CapGrant: capGrants,
|
||||
})
|
||||
|
||||
// Companion rules use reversed direction: SrcIPs are
|
||||
// destination IPs and CapGrant Dsts are source IPs.
|
||||
// When destinations include a wildcard, add subnet
|
||||
// routes to companion SrcIPs (same as main rule).
|
||||
dstsHaveWildcard := sourcesHaveWildcard(grant.Destinations)
|
||||
if dstsHaveWildcard {
|
||||
dstIPStrings = append(dstIPStrings, approvedSubnetRoutes(nodes)...)
|
||||
}
|
||||
|
||||
rules = append(
|
||||
rules,
|
||||
companionCapGrantRules(dstIPStrings, srcIPs.Prefixes(), grant.App)...,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return mergeFilterRules(rules), nil
|
||||
return globalFilterRules(pol.compileGrants(users, nodes)), nil
|
||||
}
|
||||
|
||||
func (pol *Policy) destinationsToNetPortRange(
|
||||
@@ -246,12 +166,6 @@ func (pol *Policy) destinationsToNetPortRange(
|
||||
continue
|
||||
}
|
||||
|
||||
// autogroup:internet does not generate packet filters - it's handled
|
||||
// by exit node routing via AllowedIPs, not by packet filtering.
|
||||
if ag, isAutoGroup := dest.(*AutoGroup); isAutoGroup && ag.Is(AutoGroupInternet) {
|
||||
continue
|
||||
}
|
||||
|
||||
ips, err := dest.Resolve(pol, users, nodes)
|
||||
if err != nil {
|
||||
log.Trace().Caller().Err(err).Msgf("resolving destination ips")
|
||||
@@ -293,509 +207,10 @@ func (pol *Policy) compileFilterRulesForNode(
|
||||
return tailcfg.FilterAllowAll, nil
|
||||
}
|
||||
|
||||
var rules []tailcfg.FilterRule
|
||||
grants := pol.compileGrants(users, nodes)
|
||||
userIdx := buildUserNodeIndex(nodes)
|
||||
|
||||
grants := pol.Grants
|
||||
for _, acl := range pol.ACLs {
|
||||
grants = append(grants, aclToGrants(acl)...)
|
||||
}
|
||||
|
||||
for _, grant := range grants {
|
||||
res, err := pol.compileGrantWithAutogroupSelf(grant, users, node, nodes)
|
||||
if err != nil {
|
||||
log.Trace().Err(err).Msgf("compiling ACL")
|
||||
continue
|
||||
}
|
||||
|
||||
rules = append(rules, res...)
|
||||
}
|
||||
|
||||
return mergeFilterRules(rules), nil
|
||||
}
|
||||
|
||||
// compileViaGrant compiles a grant with a "via" field. Via grants
|
||||
// produce filter rules ONLY on nodes matching a via tag that actually
|
||||
// advertise (and have approved) the destination subnets. All other
|
||||
// nodes receive no rules. App-only via grants (no ip field) produce
|
||||
// no packet filter rules.
|
||||
func (pol *Policy) compileViaGrant(
|
||||
grant Grant,
|
||||
users types.Users,
|
||||
node types.NodeView,
|
||||
nodes views.Slice[types.NodeView],
|
||||
) ([]tailcfg.FilterRule, error) {
|
||||
// Check if the current node matches any of the via tags.
|
||||
matchesVia := false
|
||||
|
||||
for _, viaTag := range grant.Via {
|
||||
if node.HasTag(string(viaTag)) {
|
||||
matchesVia = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !matchesVia {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// App-only via grants produce no packet filter rules.
|
||||
if len(grant.InternetProtocols) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Find which grant destination subnets/exit routes this node actually advertises.
|
||||
nodeSubnetRoutes := node.SubnetRoutes()
|
||||
nodeExitRoutes := node.ExitRoutes()
|
||||
|
||||
if len(nodeSubnetRoutes) == 0 && len(nodeExitRoutes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Collect destination prefixes that match the node's approved routes.
|
||||
var viaDstPrefixes []netip.Prefix
|
||||
|
||||
for _, dst := range grant.Destinations {
|
||||
switch d := dst.(type) {
|
||||
case *Prefix:
|
||||
dstPrefix := netip.Prefix(*d)
|
||||
if slices.Contains(nodeSubnetRoutes, dstPrefix) {
|
||||
viaDstPrefixes = append(viaDstPrefixes, dstPrefix)
|
||||
}
|
||||
case *AutoGroup:
|
||||
// autogroup:internet via grants do NOT produce PacketFilter rules
|
||||
// on the exit node. Tailscale SaaS handles exit traffic forwarding
|
||||
// through the client's exit node selection mechanism (AllowedIPs +
|
||||
// ExitNodeOption), not through PacketFilter rules. Verified by
|
||||
// golden captures GRANT-V14 through GRANT-V36.
|
||||
}
|
||||
}
|
||||
|
||||
if len(viaDstPrefixes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Resolve source IPs.
|
||||
var resolvedSrcs []ResolvedAddresses
|
||||
|
||||
for _, src := range grant.Sources {
|
||||
if ag, ok := src.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
|
||||
return nil, errSelfInSources
|
||||
}
|
||||
|
||||
ips, err := src.Resolve(pol, users, nodes)
|
||||
if err != nil {
|
||||
log.Trace().Caller().Err(err).Msgf("resolving source ips")
|
||||
}
|
||||
|
||||
if ips != nil {
|
||||
resolvedSrcs = append(resolvedSrcs, ips)
|
||||
}
|
||||
}
|
||||
|
||||
if len(resolvedSrcs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Build merged SrcIPs from all sources.
|
||||
var srcIPs netipx.IPSetBuilder
|
||||
|
||||
for _, ips := range resolvedSrcs {
|
||||
for _, pref := range ips.Prefixes() {
|
||||
srcIPs.AddPrefix(pref)
|
||||
}
|
||||
}
|
||||
|
||||
srcResolved, err := newResolved(&srcIPs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if srcResolved.Empty() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
hasWildcard := sourcesHaveWildcard(grant.Sources)
|
||||
hasDangerAll := sourcesHaveDangerAll(grant.Sources)
|
||||
srcIPStrs := srcIPsWithRoutes(srcResolved, hasWildcard, hasDangerAll, nodes)
|
||||
|
||||
// Build DstPorts from the matching via prefixes.
|
||||
var rules []tailcfg.FilterRule
|
||||
|
||||
for _, ipp := range grant.InternetProtocols {
|
||||
var destPorts []tailcfg.NetPortRange
|
||||
|
||||
for _, prefix := range viaDstPrefixes {
|
||||
for _, port := range ipp.Ports {
|
||||
destPorts = append(destPorts, tailcfg.NetPortRange{
|
||||
IP: prefix.String(),
|
||||
Ports: port,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(destPorts) > 0 {
|
||||
rules = append(rules, tailcfg.FilterRule{
|
||||
SrcIPs: srcIPStrs,
|
||||
DstPorts: destPorts,
|
||||
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// compileGrantWithAutogroupSelf compiles a single Grant rule, handling
|
||||
// autogroup:self per-node while supporting all other alias types normally.
|
||||
// It returns a slice of filter rules because when an Grant has both autogroup:self
|
||||
// and other destinations, they need to be split into separate rules with different
|
||||
// source filtering logic.
|
||||
//
|
||||
//nolint:gocyclo,cyclop // complex ACL compilation logic
|
||||
func (pol *Policy) compileGrantWithAutogroupSelf(
|
||||
grant Grant,
|
||||
users types.Users,
|
||||
node types.NodeView,
|
||||
nodes views.Slice[types.NodeView],
|
||||
) ([]tailcfg.FilterRule, error) {
|
||||
// Handle via route grants — filter rules only go to the node
|
||||
// matching the via tag that actually advertises the destination subnets.
|
||||
if len(grant.Via) > 0 {
|
||||
return pol.compileViaGrant(grant, users, node, nodes)
|
||||
}
|
||||
|
||||
var (
|
||||
autogroupSelfDests []Alias
|
||||
otherDests []Alias
|
||||
)
|
||||
|
||||
for _, dest := range grant.Destinations {
|
||||
if ag, ok := dest.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
|
||||
autogroupSelfDests = append(autogroupSelfDests, dest)
|
||||
} else {
|
||||
otherDests = append(otherDests, dest)
|
||||
}
|
||||
}
|
||||
|
||||
var rules []tailcfg.FilterRule
|
||||
|
||||
var resolvedSrcs []ResolvedAddresses
|
||||
// Track non-wildcard source IPs separately. When the grant has a
|
||||
// wildcard (*) source plus explicit sources (tags, groups, etc.),
|
||||
// Tailscale preserves the explicit IPs alongside the wildcard
|
||||
// CGNAT ranges rather than merging them into the IPSet.
|
||||
var nonWildcardSrcs []ResolvedAddresses
|
||||
|
||||
for i, src := range grant.Sources {
|
||||
if ag, ok := src.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
|
||||
return nil, errSelfInSources
|
||||
}
|
||||
|
||||
ips, err := src.Resolve(pol, users, nodes)
|
||||
if err != nil {
|
||||
log.Trace().Caller().Err(err).Msgf("resolving source ips")
|
||||
}
|
||||
|
||||
if ips != nil {
|
||||
resolvedSrcs = append(resolvedSrcs, ips)
|
||||
if _, isWildcard := grant.Sources[i].(Asterix); !isWildcard {
|
||||
nonWildcardSrcs = append(nonWildcardSrcs, ips)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When the grant has literally empty src=[] or dst=[], produce no rules
|
||||
// at all — Tailscale returns null for these. This is distinct from sources
|
||||
// that resolve to empty (e.g., group:empty) where Tailscale still produces
|
||||
// CapGrant rules with empty SrcIPs.
|
||||
if len(grant.Sources) == 0 || len(grant.Destinations) == 0 {
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
if len(resolvedSrcs) == 0 && grant.App == nil {
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
hasWildcard := sourcesHaveWildcard(grant.Sources)
|
||||
hasDangerAll := sourcesHaveDangerAll(grant.Sources)
|
||||
|
||||
for _, ipp := range grant.InternetProtocols {
|
||||
// Handle non-self destinations first to match Tailscale's
|
||||
// rule ordering in the FilterRule wire format.
|
||||
if len(otherDests) > 0 {
|
||||
var srcIPs netipx.IPSetBuilder
|
||||
|
||||
for _, ips := range resolvedSrcs {
|
||||
for _, pref := range ips.Prefixes() {
|
||||
srcIPs.AddPrefix(pref)
|
||||
}
|
||||
}
|
||||
|
||||
srcResolved, err := newResolved(&srcIPs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !srcResolved.Empty() {
|
||||
destPorts := pol.destinationsToNetPortRange(users, nodes, otherDests, ipp.Ports)
|
||||
|
||||
if len(destPorts) > 0 {
|
||||
srcIPStrs := srcIPsWithRoutes(srcResolved, hasWildcard, hasDangerAll, nodes)
|
||||
|
||||
// When sources include a wildcard (*) alongside
|
||||
// explicit sources (tags, groups, etc.), Tailscale
|
||||
// preserves the individual IPs from non-wildcard
|
||||
// sources alongside the merged wildcard CGNAT
|
||||
// ranges rather than absorbing them.
|
||||
if hasWildcard && len(nonWildcardSrcs) > 0 {
|
||||
seen := make(map[string]bool, len(srcIPStrs))
|
||||
for _, s := range srcIPStrs {
|
||||
seen[s] = true
|
||||
}
|
||||
|
||||
for _, ips := range nonWildcardSrcs {
|
||||
for _, s := range ips.Strings() {
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
srcIPStrs = append(srcIPStrs, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rules = append(rules, tailcfg.FilterRule{
|
||||
SrcIPs: srcIPStrs,
|
||||
DstPorts: destPorts,
|
||||
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle autogroup:self destinations (if any)
|
||||
// Tagged nodes don't participate in autogroup:self (identity is tag-based, not user-based)
|
||||
if len(autogroupSelfDests) > 0 && !node.IsTagged() {
|
||||
// Pre-filter to same-user untagged devices once - reuse for both sources and destinations
|
||||
sameUserNodes := make([]types.NodeView, 0)
|
||||
|
||||
for _, n := range nodes.All() {
|
||||
if !n.IsTagged() && n.User().ID() == node.User().ID() {
|
||||
sameUserNodes = append(sameUserNodes, n)
|
||||
}
|
||||
}
|
||||
|
||||
if len(sameUserNodes) > 0 {
|
||||
// Filter sources to only same-user untagged devices
|
||||
var srcIPs netipx.IPSetBuilder
|
||||
|
||||
for _, ips := range resolvedSrcs {
|
||||
for _, n := range sameUserNodes {
|
||||
// Check if any of this node's IPs are in the source set
|
||||
if slices.ContainsFunc(n.IPs(), ips.Contains) {
|
||||
n.AppendToIPSet(&srcIPs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
srcResolved, err := newResolved(&srcIPs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !srcResolved.Empty() {
|
||||
var destPorts []tailcfg.NetPortRange
|
||||
|
||||
for _, n := range sameUserNodes {
|
||||
for _, port := range ipp.Ports {
|
||||
for _, ip := range n.IPs() {
|
||||
destPorts = append(destPorts, tailcfg.NetPortRange{
|
||||
IP: ip.String(),
|
||||
Ports: port,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(destPorts) > 0 {
|
||||
rules = append(rules, tailcfg.FilterRule{
|
||||
SrcIPs: srcResolved.Strings(),
|
||||
DstPorts: destPorts,
|
||||
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle app grants (CapGrant rules) — these are separate from
|
||||
// InternetProtocols and produce FilterRules with CapGrant instead
|
||||
// of DstPorts. A grant with both ip and app fields produces rules
|
||||
// for each independently.
|
||||
if grant.App != nil {
|
||||
// Handle non-self destinations for CapGrant
|
||||
if len(otherDests) > 0 {
|
||||
var srcIPStrs []string
|
||||
|
||||
if len(resolvedSrcs) > 0 {
|
||||
var srcIPs netipx.IPSetBuilder
|
||||
|
||||
for _, ips := range resolvedSrcs {
|
||||
for _, pref := range ips.Prefixes() {
|
||||
srcIPs.AddPrefix(pref)
|
||||
}
|
||||
}
|
||||
|
||||
srcResolved, err := newResolved(&srcIPs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !srcResolved.Empty() {
|
||||
srcIPStrs = srcIPsWithRoutes(srcResolved, hasWildcard, hasDangerAll, nodes)
|
||||
|
||||
if hasWildcard && len(nonWildcardSrcs) > 0 {
|
||||
seen := make(map[string]bool, len(srcIPStrs))
|
||||
for _, s := range srcIPStrs {
|
||||
seen[s] = true
|
||||
}
|
||||
|
||||
for _, ips := range nonWildcardSrcs {
|
||||
for _, s := range ips.Strings() {
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
srcIPStrs = append(srcIPStrs, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
capGrants []tailcfg.CapGrant
|
||||
dstIPStrings []string
|
||||
)
|
||||
|
||||
for _, dst := range otherDests {
|
||||
ips, err := dst.Resolve(pol, users, nodes)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
capGrants = append(capGrants, tailcfg.CapGrant{
|
||||
Dsts: ips.Prefixes(),
|
||||
CapMap: grant.App,
|
||||
})
|
||||
|
||||
dstIPStrings = append(dstIPStrings, ips.Strings()...)
|
||||
}
|
||||
|
||||
if len(capGrants) > 0 {
|
||||
// When sources resolved to empty (e.g. empty group),
|
||||
// Tailscale still produces the CapGrant rule with
|
||||
// empty SrcIPs.
|
||||
if srcIPStrs == nil {
|
||||
srcIPStrs = []string{}
|
||||
}
|
||||
|
||||
// Collect source prefixes for reversed companion rules.
|
||||
var srcPrefixes []netip.Prefix
|
||||
for _, ips := range resolvedSrcs {
|
||||
srcPrefixes = append(srcPrefixes, ips.Prefixes()...)
|
||||
}
|
||||
|
||||
rules = append(rules, tailcfg.FilterRule{
|
||||
SrcIPs: srcIPStrs,
|
||||
CapGrant: capGrants,
|
||||
})
|
||||
|
||||
// Companion rules use reversed direction: companion
|
||||
// SrcIPs are the destination IPs. When destinations
|
||||
// include a wildcard, add subnet routes to companion
|
||||
// SrcIPs to match main rule behavior.
|
||||
dstsHaveWildcard := sourcesHaveWildcard(otherDests)
|
||||
if dstsHaveWildcard {
|
||||
dstIPStrings = append(dstIPStrings, approvedSubnetRoutes(nodes)...)
|
||||
}
|
||||
|
||||
rules = append(
|
||||
rules,
|
||||
companionCapGrantRules(dstIPStrings, srcPrefixes, grant.App)...,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle autogroup:self destinations for CapGrant
|
||||
if len(autogroupSelfDests) > 0 && !node.IsTagged() {
|
||||
sameUserNodes := make([]types.NodeView, 0)
|
||||
|
||||
for _, n := range nodes.All() {
|
||||
if !n.IsTagged() && n.User().ID() == node.User().ID() {
|
||||
sameUserNodes = append(sameUserNodes, n)
|
||||
}
|
||||
}
|
||||
|
||||
if len(sameUserNodes) > 0 {
|
||||
var srcIPs netipx.IPSetBuilder
|
||||
|
||||
for _, ips := range resolvedSrcs {
|
||||
for _, n := range sameUserNodes {
|
||||
if slices.ContainsFunc(n.IPs(), ips.Contains) {
|
||||
n.AppendToIPSet(&srcIPs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
srcResolved, err := newResolved(&srcIPs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !srcResolved.Empty() {
|
||||
var (
|
||||
capGrants []tailcfg.CapGrant
|
||||
dstIPStrings []string
|
||||
)
|
||||
|
||||
for _, n := range sameUserNodes {
|
||||
var dsts []netip.Prefix
|
||||
for _, ip := range n.IPs() {
|
||||
dsts = append(
|
||||
dsts,
|
||||
netip.PrefixFrom(ip, ip.BitLen()),
|
||||
)
|
||||
dstIPStrings = append(dstIPStrings, ip.String())
|
||||
}
|
||||
|
||||
capGrants = append(capGrants, tailcfg.CapGrant{
|
||||
Dsts: dsts,
|
||||
CapMap: grant.App,
|
||||
})
|
||||
}
|
||||
|
||||
if len(capGrants) > 0 {
|
||||
srcIPStrs := srcResolved.Strings()
|
||||
rules = append(rules, tailcfg.FilterRule{
|
||||
SrcIPs: srcIPStrs,
|
||||
CapGrant: capGrants,
|
||||
})
|
||||
rules = append(
|
||||
rules,
|
||||
companionCapGrantRules(
|
||||
dstIPStrings,
|
||||
srcResolved.Prefixes(),
|
||||
grant.App,
|
||||
)...,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rules, nil
|
||||
return filterRulesForNode(grants, node, userIdx), nil
|
||||
}
|
||||
|
||||
var sshAccept = tailcfg.SSHAction{
|
||||
@@ -809,6 +224,8 @@ var sshAccept = tailcfg.SSHAction{
|
||||
// checkPeriodFromRule extracts the check period duration from an SSH rule.
|
||||
// Returns SSHCheckPeriodDefault if no checkPeriod is configured,
|
||||
// 0 if checkPeriod is "always", or the configured duration otherwise.
|
||||
// This is used server-side by SSHCheckParams to resolve the real period
|
||||
// when the client calls back; the wire format always sends 0.
|
||||
func checkPeriodFromRule(rule SSH) time.Duration {
|
||||
switch {
|
||||
case rule.CheckPeriod == nil:
|
||||
@@ -820,13 +237,13 @@ func checkPeriodFromRule(rule SSH) time.Duration {
|
||||
}
|
||||
}
|
||||
|
||||
func sshCheck(baseURL string, duration time.Duration) tailcfg.SSHAction {
|
||||
holdURL := baseURL + "/machine/ssh/action/from/$SRC_NODE_ID/to/$DST_NODE_ID?ssh_user=$SSH_USER&local_user=$LOCAL_USER"
|
||||
func sshCheck(baseURL string, _ time.Duration) tailcfg.SSHAction {
|
||||
holdURL := baseURL + "/machine/ssh/action/$SRC_NODE_ID/to/$DST_NODE_ID?local_user=$LOCAL_USER"
|
||||
|
||||
return tailcfg.SSHAction{
|
||||
Reject: false,
|
||||
Accept: false,
|
||||
SessionDuration: duration,
|
||||
SessionDuration: 0,
|
||||
// Replaced in the client:
|
||||
// * $SRC_NODE_IP (URL escaped)
|
||||
// * $SRC_NODE_ID (Node.ID as int64 string)
|
||||
@@ -835,9 +252,9 @@ func sshCheck(baseURL string, duration time.Duration) tailcfg.SSHAction {
|
||||
// * $SSH_USER (URL escaped, ssh user requested)
|
||||
// * $LOCAL_USER (URL escaped, local user mapped)
|
||||
HoldAndDelegate: holdURL,
|
||||
AllowAgentForwarding: true,
|
||||
AllowLocalPortForwarding: true,
|
||||
AllowRemotePortForwarding: true,
|
||||
AllowAgentForwarding: false,
|
||||
AllowLocalPortForwarding: false,
|
||||
AllowRemotePortForwarding: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -994,9 +411,20 @@ func (pol *Policy) compileSSHPolicy(
|
||||
appendRules(taggedPrincipals, 0, false)
|
||||
}
|
||||
} else {
|
||||
if principals := resolvedAddrsToPrincipals(srcIPs); len(principals) > 0 {
|
||||
// Merge user and tagged principals into a
|
||||
// single list. Tagged principals preserve
|
||||
// per-tag duplication (a node with N tags
|
||||
// appears N times, matching SaaS behavior).
|
||||
var allPrincipals []*tailcfg.SSHPrincipal
|
||||
for _, uid := range userIDs {
|
||||
allPrincipals = append(allPrincipals, principalsByUser[uid]...)
|
||||
}
|
||||
|
||||
allPrincipals = append(allPrincipals, taggedPrincipals...)
|
||||
|
||||
if len(allPrincipals) > 0 {
|
||||
rules = append(rules, &tailcfg.SSHRule{
|
||||
Principals: principals,
|
||||
Principals: allPrincipals,
|
||||
SSHUsers: baseUserMap,
|
||||
Action: &action,
|
||||
AcceptEnv: acceptEnv,
|
||||
@@ -1137,9 +565,7 @@ func groupSourcesByUser(
|
||||
) ([]uint, map[uint][]*tailcfg.SSHPrincipal, []*tailcfg.SSHPrincipal) {
|
||||
userIPSets := make(map[uint]*netipx.IPSetBuilder)
|
||||
|
||||
var taggedIPSet netipx.IPSetBuilder
|
||||
|
||||
hasTagged := false
|
||||
var taggedPrincipals []*tailcfg.SSHPrincipal
|
||||
|
||||
for _, n := range nodes.All() {
|
||||
if !slices.ContainsFunc(n.IPs(), srcIPs.Contains) {
|
||||
@@ -1147,9 +573,17 @@ func groupSourcesByUser(
|
||||
}
|
||||
|
||||
if n.IsTagged() {
|
||||
n.AppendToIPSet(&taggedIPSet)
|
||||
|
||||
hasTagged = true
|
||||
// Tailscale SaaS resolves autogroup:tagged by
|
||||
// iterating tag membership lists. A node with N
|
||||
// tags produces N copies of its IPs in the
|
||||
// principal list. Match that behavior so the SSH
|
||||
// wire format is identical.
|
||||
for range n.Tags().Len() {
|
||||
for _, ip := range n.IPs() {
|
||||
taggedPrincipals = append(taggedPrincipals,
|
||||
&tailcfg.SSHPrincipal{NodeIP: ip.String()})
|
||||
}
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
@@ -1185,16 +619,7 @@ func groupSourcesByUser(
|
||||
|
||||
slices.Sort(userIDs)
|
||||
|
||||
var tagged []*tailcfg.SSHPrincipal
|
||||
|
||||
if hasTagged {
|
||||
taggedSet, err := taggedIPSet.IPSet()
|
||||
if err == nil && taggedSet != nil {
|
||||
tagged = ipSetToPrincipals(taggedSet)
|
||||
}
|
||||
}
|
||||
|
||||
return userIDs, principalsByUser, tagged
|
||||
return userIDs, principalsByUser, taggedPrincipals
|
||||
}
|
||||
|
||||
func ipSetToPrefixStringList(ips *netipx.IPSet) []string {
|
||||
|
||||
@@ -1071,7 +1071,7 @@ func TestCompileSSHPolicy_CheckAction(t *testing.T) {
|
||||
assert.False(t, rule.Action.Reject)
|
||||
assert.NotEmpty(t, rule.Action.HoldAndDelegate)
|
||||
assert.Contains(t, rule.Action.HoldAndDelegate, "/machine/ssh/action/")
|
||||
assert.Equal(t, 24*time.Hour, rule.Action.SessionDuration)
|
||||
assert.Equal(t, time.Duration(0), rule.Action.SessionDuration)
|
||||
|
||||
// Verify check params are NOT encoded in the URL (looked up server-side).
|
||||
assert.NotContains(t, rule.Action.HoldAndDelegate, "check_explicit")
|
||||
@@ -2632,25 +2632,24 @@ func TestCompileSSHPolicy_CheckPeriodVariants(t *testing.T) {
|
||||
|
||||
nodes := types.Nodes{&node}
|
||||
|
||||
// SaaS always sends SessionDuration=0 in the wire format
|
||||
// regardless of checkPeriod. The check period is resolved
|
||||
// server-side, not embedded in the SSHAction.
|
||||
tests := []struct {
|
||||
name string
|
||||
checkPeriod *SSHCheckPeriod
|
||||
wantDuration time.Duration
|
||||
name string
|
||||
checkPeriod *SSHCheckPeriod
|
||||
}{
|
||||
{
|
||||
name: "nil period defaults to 12h",
|
||||
checkPeriod: nil,
|
||||
wantDuration: SSHCheckPeriodDefault,
|
||||
name: "nil period",
|
||||
checkPeriod: nil,
|
||||
},
|
||||
{
|
||||
name: "always period uses 0",
|
||||
checkPeriod: &SSHCheckPeriod{Always: true},
|
||||
wantDuration: 0,
|
||||
name: "always period",
|
||||
checkPeriod: &SSHCheckPeriod{Always: true},
|
||||
},
|
||||
{
|
||||
name: "explicit 2h",
|
||||
checkPeriod: &SSHCheckPeriod{Duration: 2 * time.Hour},
|
||||
wantDuration: 2 * time.Hour,
|
||||
name: "explicit 2h",
|
||||
checkPeriod: &SSHCheckPeriod{Duration: 2 * time.Hour},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2682,7 +2681,7 @@ func TestCompileSSHPolicy_CheckPeriodVariants(t *testing.T) {
|
||||
require.Len(t, sshPolicy.Rules, 1)
|
||||
|
||||
rule := sshPolicy.Rules[0]
|
||||
assert.Equal(t, tt.wantDuration, rule.Action.SessionDuration)
|
||||
assert.Equal(t, time.Duration(0), rule.Action.SessionDuration)
|
||||
// Check params must NOT be in the URL; they are
|
||||
// resolved server-side via SSHCheckParams.
|
||||
assert.NotContains(t, rule.Action.HoldAndDelegate, "check_explicit")
|
||||
@@ -3538,11 +3537,26 @@ func TestFilterAllowAllFix(t *testing.T) {
|
||||
wantFilterAllow: true,
|
||||
},
|
||||
{
|
||||
name: "nil ACLs and empty grants returns FilterAllowAll",
|
||||
name: "nil ACLs and empty grants denies all",
|
||||
pol: &Policy{
|
||||
Grants: []Grant{},
|
||||
},
|
||||
wantFilterAllow: true,
|
||||
wantFilterAllow: false,
|
||||
},
|
||||
{
|
||||
name: "empty ACLs and nil grants denies all",
|
||||
pol: &Policy{
|
||||
ACLs: []ACL{},
|
||||
},
|
||||
wantFilterAllow: false,
|
||||
},
|
||||
{
|
||||
name: "empty ACLs and empty grants denies all",
|
||||
pol: &Policy{
|
||||
ACLs: []ACL{},
|
||||
Grants: []Grant{},
|
||||
},
|
||||
wantFilterAllow: false,
|
||||
},
|
||||
{
|
||||
name: "both ACLs and grants should not return FilterAllowAll",
|
||||
@@ -3654,6 +3668,27 @@ func TestCompileViaGrant(t *testing.T) {
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
|
||||
// Expected rule for autogroup:internet on a via-tagged exit
|
||||
// advertiser: SrcIPs scoped to the grant source, DstPorts
|
||||
// enumerating util.TheInternet() prefixes.
|
||||
internetDstPorts := make(
|
||||
[]tailcfg.NetPortRange, 0, len(util.TheInternet().Prefixes()),
|
||||
)
|
||||
|
||||
for _, p := range util.TheInternet().Prefixes() {
|
||||
internetDstPorts = append(internetDstPorts, tailcfg.NetPortRange{
|
||||
IP: p.String(),
|
||||
Ports: tailcfg.PortRangeAny,
|
||||
})
|
||||
}
|
||||
|
||||
internetWant := []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"100.64.0.10"},
|
||||
DstPorts: internetDstPorts,
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
grant Grant
|
||||
@@ -3710,11 +3745,12 @@ func TestCompileViaGrant(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
// autogroup:internet via grants do NOT produce PacketFilter rules
|
||||
// on exit nodes. Tailscale SaaS handles exit traffic forwarding
|
||||
// through the client's exit node mechanism, not PacketFilter.
|
||||
// Verified by golden captures GRANT-V14 through GRANT-V36.
|
||||
name: "autogroup:internet with exit routes produces no rules",
|
||||
// autogroup:internet on a via-tagged exit advertiser
|
||||
// produces a rule with DstPorts enumerating
|
||||
// util.TheInternet(). The matchers derived from this
|
||||
// rule let Node.CanAccess surface the exit node to
|
||||
// grant sources via DestsIsTheInternet.
|
||||
name: "autogroup:internet with exit routes produces TheInternet rule",
|
||||
grant: Grant{
|
||||
Sources: Aliases{up("testuser@")},
|
||||
Destinations: Aliases{agp(string(AutoGroupInternet))},
|
||||
@@ -3724,7 +3760,7 @@ func TestCompileViaGrant(t *testing.T) {
|
||||
node: exitNode,
|
||||
nodes: types.Nodes{exitNode, srcNode},
|
||||
pol: &Policy{},
|
||||
want: nil,
|
||||
want: internetWant,
|
||||
},
|
||||
{
|
||||
name: "autogroup:internet without exit routes returns nil",
|
||||
@@ -3832,7 +3868,7 @@ func TestCompileViaGrant(t *testing.T) {
|
||||
nodeView := tt.node.View()
|
||||
nodesSlice := tt.nodes.ViewSlice()
|
||||
|
||||
got, err := tt.pol.compileViaGrant(tt.grant, users, nodeView, nodesSlice)
|
||||
cg, err := tt.pol.compileOneGrant(tt.grant, users, nodesSlice)
|
||||
|
||||
if tt.wantErr != nil {
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
@@ -3842,6 +3878,11 @@ func TestCompileViaGrant(t *testing.T) {
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
var got []tailcfg.FilterRule
|
||||
if cg != nil {
|
||||
got = compileViaForNode(cg, nodeView)
|
||||
}
|
||||
|
||||
if tt.name == "wildcard sources include subnet routes in SrcIPs" {
|
||||
// Wildcard resolves to CGNAT ranges; just check the route is appended.
|
||||
require.Len(t, got, 1)
|
||||
@@ -3997,9 +4038,10 @@ func TestCompileGrantWithAutogroupSelf_GrantPaths(t *testing.T) {
|
||||
|
||||
nodeView := tt.node.View()
|
||||
nodesSlice := allNodes.ViewSlice()
|
||||
userIdx := buildUserNodeIndex(nodesSlice)
|
||||
|
||||
got, err := tt.pol.compileGrantWithAutogroupSelf(
|
||||
tt.grant, users, nodeView, nodesSlice,
|
||||
cg, err := tt.pol.compileOneGrant(
|
||||
tt.grant, users, nodesSlice,
|
||||
)
|
||||
|
||||
if tt.wantErr != nil {
|
||||
@@ -4010,6 +4052,13 @@ func TestCompileGrantWithAutogroupSelf_GrantPaths(t *testing.T) {
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
var got []tailcfg.FilterRule
|
||||
if cg != nil {
|
||||
got = append(got, cg.rules...)
|
||||
got = append(got, compileAutogroupSelf(cg, nodeView, userIdx)...)
|
||||
got = mergeFilterRules(got)
|
||||
}
|
||||
|
||||
switch tt.name {
|
||||
case "autogroup:self destination for untagged node produces same-user devices":
|
||||
// Should produce rules; sources and destinations should only
|
||||
@@ -4074,6 +4123,14 @@ func TestDestinationsToNetPortRange_AutogroupInternet(t *testing.T) {
|
||||
pol := &Policy{}
|
||||
ports := []tailcfg.PortRange{tailcfg.PortRangeAny}
|
||||
|
||||
// autogroup:internet must surface as DstPorts (not be skipped at
|
||||
// compile time). The matcher derived from these FilterRules is
|
||||
// what makes Node.CanAccess return true for exit-node peers via
|
||||
// DestsIsTheInternet (#3212). The wire format is currently the
|
||||
// canonical CIDR breakdown of util.TheInternet(); aligning it to
|
||||
// the SaaS range form is tracked separately.
|
||||
internetPrefixCount := len(util.TheInternet().Prefixes())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dests Aliases
|
||||
@@ -4081,9 +4138,9 @@ func TestDestinationsToNetPortRange_AutogroupInternet(t *testing.T) {
|
||||
wantStar bool
|
||||
}{
|
||||
{
|
||||
name: "autogroup:internet produces no DstPorts",
|
||||
name: "autogroup:internet produces TheInternet DstPorts",
|
||||
dests: Aliases{agp(string(AutoGroupInternet))},
|
||||
wantLen: 0,
|
||||
wantLen: internetPrefixCount,
|
||||
},
|
||||
{
|
||||
name: "wildcard produces DstPorts with star",
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// Tests pinned against tscap captures for juanfont/headscale#3212.
|
||||
//
|
||||
// The captures were taken on 2026-04-28 against a live Tailscale SaaS
|
||||
// tailnet. They reproduce the literal #3212 setup: an ACL granting
|
||||
// access to autogroup:internet:* combined with autoApprovers.exitNode
|
||||
// approving exit routes on tagged exit nodes. SaaS surfaces those exit
|
||||
// nodes as peers in the ACL source's netmap with 0.0.0.0/0 and ::/0 in
|
||||
// AllowedIPs. Headscale must do the same — that is the user-visible UX
|
||||
// driving `tailscale exit-node list`.
|
||||
//
|
||||
// Captures live under testdata/issue_3212/ rather than testdata/
|
||||
// routes_results/ so the broader TestRoutesCompat / *PeerAllowedIPs /
|
||||
// *ReduceRoutes machinery does not pull them in. Those tests assume a
|
||||
// PacketFilterRules wire format (CIDR prefix per dest entry) that
|
||||
// differs from what SaaS emits for autogroup:internet (range form per
|
||||
// IPSet range — e.g. "0.0.0.0-9.255.255.255"). Aligning that wire
|
||||
// format is tracked separately; the #3212 fix is about peer
|
||||
// visibility, not packet-filter encoding.
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/types/testcapture"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/net/tsaddr"
|
||||
)
|
||||
|
||||
// TestIssue3212AutogroupInternetExitVisibility loads the b17/b18
|
||||
// SaaS captures and asserts headscale's BuildPeerMap surfaces every
|
||||
// exit-route advertiser to every ACL-source node — matching the peer
|
||||
// list in the captured netmap.
|
||||
//
|
||||
// The bug fixed by this PR (#3212) was that headscale skipped
|
||||
// autogroup:internet during FilterRule compilation, which silently
|
||||
// dropped the matchers that Node.CanAccess reads via DestsIsTheInternet.
|
||||
// The captures pin the SaaS-equivalent expectation as a regression
|
||||
// guard so the same skip cannot sneak back in unnoticed.
|
||||
func TestIssue3212AutogroupInternetExitVisibility(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files := []string{
|
||||
"routes-b17-autogroup-internet-with-exit-autoapprover",
|
||||
"routes-b18-autogroup-internet-wildcard-src-with-exit-autoapprover",
|
||||
}
|
||||
|
||||
for _, testID := range files {
|
||||
path := filepath.Join(
|
||||
"testdata", "issue_3212", testID+".hujson",
|
||||
)
|
||||
tf := loadRoutesTestFile(t, path)
|
||||
|
||||
t.Run(tf.TestID, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
users, nodes := buildRoutesUsersAndNodes(t, tf.Topology)
|
||||
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
|
||||
|
||||
pm, err := NewPolicyManager(
|
||||
policyJSON, users, nodes.ViewSlice(),
|
||||
)
|
||||
require.NoErrorf(t, err,
|
||||
"%s: failed to create PolicyManager", tf.TestID,
|
||||
)
|
||||
|
||||
peerMap := pm.BuildPeerMap(nodes.ViewSlice())
|
||||
|
||||
expected := expectedExitPeerVisibility(t, tf, nodes)
|
||||
require.NotEmptyf(t, expected,
|
||||
"%s: capture exposes no source→exit relationships — "+
|
||||
"the test is meaningless if SaaS itself never "+
|
||||
"surfaced an exit node to a source",
|
||||
tf.TestID,
|
||||
)
|
||||
|
||||
for srcName, exitNames := range expected {
|
||||
srcNode := findNodeByGivenName(nodes, srcName)
|
||||
require.NotNilf(t, srcNode,
|
||||
"%s: src node %q missing from topology",
|
||||
tf.TestID, srcName,
|
||||
)
|
||||
|
||||
peerIDs := make(
|
||||
map[types.NodeID]struct{},
|
||||
len(peerMap[srcNode.ID]),
|
||||
)
|
||||
for _, p := range peerMap[srcNode.ID] {
|
||||
peerIDs[p.ID()] = struct{}{}
|
||||
}
|
||||
|
||||
for _, exitName := range exitNames {
|
||||
exitNode := findNodeByGivenName(nodes, exitName)
|
||||
require.NotNilf(t, exitNode,
|
||||
"%s: exit node %q missing from topology",
|
||||
tf.TestID, exitName,
|
||||
)
|
||||
|
||||
_, found := peerIDs[exitNode.ID]
|
||||
assert.Truef(t, found,
|
||||
"%s: source %q must see exit node %q "+
|
||||
"as a peer via the autogroup:internet "+
|
||||
"ACL — Tailscale SaaS does (#3212)",
|
||||
tf.TestID, srcName, exitName,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// expectedExitPeerVisibility extracts (source-node, exit-node) pairs
|
||||
// the capture's netmaps witness. A node is treated as an exit-route
|
||||
// advertiser when its ApprovedRoutes contain 0.0.0.0/0 or ::/0;
|
||||
// a (source, exit) pair is recorded when the source's captured netmap
|
||||
// lists the advertiser as a peer with 0.0.0.0/0 or ::/0 in AllowedIPs.
|
||||
func expectedExitPeerVisibility(
|
||||
t *testing.T,
|
||||
tf *testcapture.Capture,
|
||||
nodes types.Nodes,
|
||||
) map[string][]string {
|
||||
t.Helper()
|
||||
|
||||
v4Exit := tsaddr.AllIPv4()
|
||||
v6Exit := tsaddr.AllIPv6()
|
||||
|
||||
exitAdvertisers := make(map[string]bool)
|
||||
|
||||
for _, n := range nodes {
|
||||
if slices.Contains(n.ApprovedRoutes, v4Exit) ||
|
||||
slices.Contains(n.ApprovedRoutes, v6Exit) {
|
||||
exitAdvertisers[n.GivenName] = true
|
||||
}
|
||||
}
|
||||
|
||||
expected := make(map[string][]string)
|
||||
|
||||
for srcName, capture := range tf.Captures {
|
||||
if capture.Netmap == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var seen []string
|
||||
|
||||
for _, peer := range capture.Netmap.Peers {
|
||||
peerName := strings.Split(peer.Name(), ".")[0]
|
||||
|
||||
if !exitAdvertisers[peerName] {
|
||||
continue
|
||||
}
|
||||
|
||||
peerAllowed := peer.AllowedIPs().AsSlice()
|
||||
if !slices.Contains(peerAllowed, v4Exit) &&
|
||||
!slices.Contains(peerAllowed, v6Exit) {
|
||||
continue
|
||||
}
|
||||
|
||||
seen = append(seen, peerName)
|
||||
}
|
||||
|
||||
if len(seen) > 0 {
|
||||
expected[srcName] = seen
|
||||
}
|
||||
}
|
||||
|
||||
return expected
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// A via grant scoping autogroup:internet to a tag must surface only
|
||||
// the matching exit node to the source — not strip every exit node
|
||||
// from the source's view.
|
||||
//
|
||||
// Spec: https://tailscale.com/docs/features/access-control/grants/grants-via#route-users-through-exit-nodes-based-on-location
|
||||
package v2
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// TestIssue3233ViaInternetExitVisibility loads a policy where alice's
|
||||
// only access to autogroup:internet is via tag:exit1. Alice sees her
|
||||
// tag:exit1 exit node as a peer with 0.0.0.0/0 + ::/0 in AllowedIPs,
|
||||
// and does not see bob's tag:exit2 exit node.
|
||||
func TestIssue3233ViaInternetExitVisibility(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
users := types.Users{
|
||||
{Model: gorm.Model{ID: 1}, Name: "alice", Email: "alice@headscale.net"},
|
||||
{Model: gorm.Model{ID: 2}, Name: "bob", Email: "bob@headscale.net"},
|
||||
}
|
||||
|
||||
exitRoutes := []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()}
|
||||
|
||||
aliceLaptop := node("alice-laptop", "100.64.0.10", "fd7a:115c:a1e0::a", users[0])
|
||||
aliceLaptop.ID = 1
|
||||
|
||||
aliceExit := node("alice-exit", "100.64.0.11", "fd7a:115c:a1e0::b", users[0])
|
||||
aliceExit.ID = 2
|
||||
aliceExit.Tags = []string{"tag:exit1"}
|
||||
aliceExit.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: exitRoutes}
|
||||
aliceExit.ApprovedRoutes = exitRoutes
|
||||
|
||||
bobExit := node("bob-exit", "100.64.0.21", "fd7a:115c:a1e0::15", users[1])
|
||||
bobExit.ID = 3
|
||||
bobExit.Tags = []string{"tag:exit2"}
|
||||
bobExit.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: exitRoutes}
|
||||
bobExit.ApprovedRoutes = exitRoutes
|
||||
|
||||
nodes := types.Nodes{aliceLaptop, aliceExit, bobExit}
|
||||
|
||||
policy := `{
|
||||
"tagOwners": {
|
||||
"tag:exit1": ["alice@headscale.net"],
|
||||
"tag:exit2": ["bob@headscale.net"]
|
||||
},
|
||||
"grants": [
|
||||
{
|
||||
"src": ["alice@headscale.net"],
|
||||
"dst": ["autogroup:internet"],
|
||||
"via": ["tag:exit1"],
|
||||
"ip": ["*"]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice())
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("BuildPeerMap_includes_via_tagged_exit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
peerMap := pm.BuildPeerMap(nodes.ViewSlice())
|
||||
|
||||
require.True(t,
|
||||
slices.ContainsFunc(peerMap[aliceLaptop.ID], func(n types.NodeView) bool {
|
||||
return n.ID() == aliceExit.ID
|
||||
}),
|
||||
"alice must see her tag:exit1 exit node as a peer")
|
||||
|
||||
require.False(t,
|
||||
slices.ContainsFunc(peerMap[aliceLaptop.ID], func(n types.NodeView) bool {
|
||||
return n.ID() == bobExit.ID
|
||||
}),
|
||||
"alice must not see bob's tag:exit2 exit node — via grant scopes to tag:exit1")
|
||||
})
|
||||
|
||||
t.Run("ViaRoutesForPeer_includes_exit_for_matching_tag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := pm.ViaRoutesForPeer(aliceLaptop.View(), aliceExit.View())
|
||||
require.Contains(t, result.Include, tsaddr.AllIPv4(),
|
||||
"alice viewing tag:exit1 exit must Include 0.0.0.0/0 — drives AllowedIPs in state.RoutesForPeer")
|
||||
require.Contains(t, result.Include, tsaddr.AllIPv6(),
|
||||
"alice viewing tag:exit1 exit must Include ::/0 — drives AllowedIPs in state.RoutesForPeer")
|
||||
})
|
||||
|
||||
t.Run("ViaRoutesForPeer_excludes_exit_for_other_tag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := pm.ViaRoutesForPeer(aliceLaptop.View(), bobExit.View())
|
||||
require.Contains(t, result.Exclude, tsaddr.AllIPv4(),
|
||||
"alice viewing tag:exit2 exit must Exclude 0.0.0.0/0 — strips it from AllowedIPs")
|
||||
require.Contains(t, result.Exclude, tsaddr.AllIPv6(),
|
||||
"alice viewing tag:exit2 exit must Exclude ::/0 — strips it from AllowedIPs")
|
||||
})
|
||||
}
|
||||
+356
-137
@@ -20,6 +20,7 @@ import (
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/deephash"
|
||||
"tailscale.com/util/multierr"
|
||||
)
|
||||
|
||||
// ErrInvalidTagOwner is returned when a tag owner is not an Alias type.
|
||||
@@ -46,16 +47,22 @@ type PolicyManager struct {
|
||||
// Lazy map of SSH policies
|
||||
sshPolicyMap map[types.NodeID]*tailcfg.SSHPolicy
|
||||
|
||||
// Lazy map of per-node compiled filter rules (unreduced, for autogroup:self)
|
||||
compiledFilterRulesMap map[types.NodeID][]tailcfg.FilterRule
|
||||
// Lazy map of per-node filter rules (reduced, for packet filters)
|
||||
filterRulesMap map[types.NodeID][]tailcfg.FilterRule
|
||||
usesAutogroupSelf bool
|
||||
// compiledGrants are the grants with sources pre-resolved.
|
||||
// The single source of truth for filter compilation. Both
|
||||
// global and per-node filter rules are derived from these.
|
||||
compiledGrants []compiledGrant
|
||||
userNodeIdx userNodeIndex
|
||||
|
||||
// needsPerNodeFilter is true when filter rules must be compiled
|
||||
// per-node rather than globally. This is required when the policy
|
||||
// uses autogroup:self (node-relative destinations) or via grants
|
||||
// (per-router filter rules for steered traffic).
|
||||
// Lazy map of per-node filter rules (reduced, for packet filters)
|
||||
filterRulesMap map[types.NodeID][]tailcfg.FilterRule
|
||||
|
||||
// Lazy map of per-node matchers derived from UNREDUCED filter
|
||||
// rules. Only populated on the slow path when needsPerNodeFilter
|
||||
// is true; the fast path returns pm.matchers directly.
|
||||
matchersForNodeMap map[types.NodeID][]matcher.Match
|
||||
|
||||
// needsPerNodeFilter is true when any compiled grant requires
|
||||
// per-node work (autogroup:self or via grants).
|
||||
needsPerNodeFilter bool
|
||||
}
|
||||
|
||||
@@ -67,6 +74,91 @@ type filterAndPolicy struct {
|
||||
Policy *Policy
|
||||
}
|
||||
|
||||
// validateUserReferences surfaces ambiguous user@ tokens at policy load so
|
||||
// duplicate DB rows fail loudly instead of silently dropping rules (#3160).
|
||||
// Missing-user tokens stay tolerant (#2863). Empty users → no-op for
|
||||
// syntax-only checks.
|
||||
func validateUserReferences(pol *Policy, users types.Users) error {
|
||||
if pol == nil || len(users) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var errs []error
|
||||
|
||||
check := func(u *Username) {
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, err := u.resolveUser(users)
|
||||
if err != nil && errors.Is(err, ErrMultipleUsersFound) {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
checkAlias := func(a Alias) {
|
||||
if u, ok := a.(*Username); ok {
|
||||
check(u)
|
||||
}
|
||||
}
|
||||
|
||||
checkOwner := func(o Owner) {
|
||||
if u, ok := o.(*Username); ok {
|
||||
check(u)
|
||||
}
|
||||
}
|
||||
|
||||
checkAutoApprover := func(aa AutoApprover) {
|
||||
if u, ok := aa.(*Username); ok {
|
||||
check(u)
|
||||
}
|
||||
}
|
||||
|
||||
for _, usernames := range pol.Groups {
|
||||
for i := range usernames {
|
||||
check(&usernames[i])
|
||||
}
|
||||
}
|
||||
|
||||
for _, owners := range pol.TagOwners {
|
||||
for _, o := range owners {
|
||||
checkOwner(o)
|
||||
}
|
||||
}
|
||||
|
||||
for _, approvers := range pol.AutoApprovers.Routes {
|
||||
for _, aa := range approvers {
|
||||
checkAutoApprover(aa)
|
||||
}
|
||||
}
|
||||
|
||||
for _, aa := range pol.AutoApprovers.ExitNode {
|
||||
checkAutoApprover(aa)
|
||||
}
|
||||
|
||||
for _, acl := range pol.ACLs {
|
||||
for _, src := range acl.Sources {
|
||||
checkAlias(src)
|
||||
}
|
||||
|
||||
for _, dst := range acl.Destinations {
|
||||
checkAlias(dst.Alias)
|
||||
}
|
||||
}
|
||||
|
||||
for _, ssh := range pol.SSHs {
|
||||
for _, src := range ssh.Sources {
|
||||
checkAlias(src)
|
||||
}
|
||||
|
||||
for _, dst := range ssh.Destinations {
|
||||
checkAlias(dst)
|
||||
}
|
||||
}
|
||||
|
||||
return multierr.New(errs...)
|
||||
}
|
||||
|
||||
// NewPolicyManager creates a new PolicyManager from a policy file and a list of users and nodes.
|
||||
// It returns an error if the policy file is invalid.
|
||||
// The policy manager will update the filter rules based on the users and nodes.
|
||||
@@ -76,15 +168,18 @@ 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,
|
||||
nodes: nodes,
|
||||
sshPolicyMap: make(map[types.NodeID]*tailcfg.SSHPolicy, nodes.Len()),
|
||||
compiledFilterRulesMap: make(map[types.NodeID][]tailcfg.FilterRule, nodes.Len()),
|
||||
filterRulesMap: make(map[types.NodeID][]tailcfg.FilterRule, nodes.Len()),
|
||||
usesAutogroupSelf: policy.usesAutogroupSelf(),
|
||||
needsPerNodeFilter: policy.usesAutogroupSelf() || policy.hasViaGrants(),
|
||||
pol: policy,
|
||||
users: users,
|
||||
nodes: nodes,
|
||||
sshPolicyMap: make(map[types.NodeID]*tailcfg.SSHPolicy, nodes.Len()),
|
||||
filterRulesMap: make(map[types.NodeID][]tailcfg.FilterRule, nodes.Len()),
|
||||
matchersForNodeMap: make(map[types.NodeID][]matcher.Match, nodes.Len()),
|
||||
}
|
||||
|
||||
_, err = pm.updateLocked()
|
||||
@@ -98,18 +193,17 @@ func NewPolicyManager(b []byte, users []types.User, nodes views.Slice[types.Node
|
||||
// updateLocked updates the filter rules based on the current policy and nodes.
|
||||
// It must be called with the lock held.
|
||||
func (pm *PolicyManager) updateLocked() (bool, error) {
|
||||
// Check if policy uses autogroup:self or via grants
|
||||
pm.usesAutogroupSelf = pm.pol.usesAutogroupSelf()
|
||||
pm.needsPerNodeFilter = pm.usesAutogroupSelf || pm.pol.hasViaGrants()
|
||||
// Compile all grants once. Both global and per-node filter
|
||||
// rules are derived from these compiled grants.
|
||||
pm.compiledGrants = pm.pol.compileGrants(pm.users, pm.nodes)
|
||||
pm.userNodeIdx = buildUserNodeIndex(pm.nodes)
|
||||
pm.needsPerNodeFilter = hasPerNodeGrants(pm.compiledGrants)
|
||||
|
||||
var filter []tailcfg.FilterRule
|
||||
|
||||
var err error
|
||||
|
||||
// Standard compilation for all policies
|
||||
filter, err = pm.pol.compileFilterRules(pm.users, pm.nodes)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("compiling filter rules: %w", err)
|
||||
if pm.pol == nil || (pm.pol.ACLs == nil && pm.pol.Grants == nil) {
|
||||
filter = tailcfg.FilterAllowAll
|
||||
} else {
|
||||
filter = globalFilterRules(pm.compiledGrants)
|
||||
}
|
||||
|
||||
// Hash both the compiled filter AND the policy content together.
|
||||
@@ -209,8 +303,8 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
|
||||
// policies for nodes that have changed. Particularly if the only difference is
|
||||
// that nodes has been added or removed.
|
||||
clear(pm.sshPolicyMap)
|
||||
clear(pm.compiledFilterRulesMap)
|
||||
clear(pm.filterRulesMap)
|
||||
clear(pm.matchersForNodeMap)
|
||||
}
|
||||
|
||||
// If nothing changed, no need to update nodes
|
||||
@@ -231,6 +325,11 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// SSHPolicy returns the tailcfg.SSHPolicy for node, compiling and
|
||||
// caching on first access. Rules use SessionDuration = 0 (no
|
||||
// auto-approval) and emit check URLs of the form
|
||||
// /machine/ssh/action/{src}/to/{dst}?local_user={local_user} per the
|
||||
// SaaS wire format. Cache is invalidated on policy reload.
|
||||
func (pm *PolicyManager) SSHPolicy(baseURL string, node types.NodeView) (*tailcfg.SSHPolicy, error) {
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
@@ -251,9 +350,12 @@ func (pm *PolicyManager) SSHPolicy(baseURL string, node types.NodeView) (*tailcf
|
||||
|
||||
// SSHCheckParams resolves the SSH check period for a source-destination
|
||||
// node pair by looking up the current policy. This avoids trusting URL
|
||||
// parameters that a client could tamper with.
|
||||
// It returns the check period duration and whether a matching check
|
||||
// rule was found.
|
||||
// parameters that a client could tamper with. First-match wins across
|
||||
// the policy's SSH rules.
|
||||
//
|
||||
// Returns (duration, true) when a matching rule is found and
|
||||
// (0, false) when none is. A (0, true) return means the matched rule
|
||||
// uses a zero check period (re-check every session).
|
||||
func (pm *PolicyManager) SSHCheckParams(
|
||||
srcNodeID, dstNodeID types.NodeID,
|
||||
) (time.Duration, bool) {
|
||||
@@ -340,6 +442,20 @@ func (pm *PolicyManager) SetPolicy(polB []byte) (bool, error) {
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
err = validateUserReferences(pol, pm.users)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("validating policy user references: %w", err)
|
||||
}
|
||||
|
||||
// SetPolicy is the user-write boundary. Tests evaluate against a
|
||||
// sandbox compiled from the new policy + current users/nodes; if
|
||||
// they fail, return without mutating the live PolicyManager so the
|
||||
// failed write does not knock the running config offline.
|
||||
err = evaluateTests(pol, pm.users, pm.nodes)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Log policy metadata for debugging
|
||||
log.Debug().
|
||||
Int("policy.bytes", len(polB)).
|
||||
@@ -348,6 +464,7 @@ func (pm *PolicyManager) SetPolicy(polB []byte) (bool, error) {
|
||||
Int("hosts.count", len(pol.Hosts)).
|
||||
Int("tagOwners.count", len(pol.TagOwners)).
|
||||
Int("autoApprovers.routes.count", len(pol.AutoApprovers.Routes)).
|
||||
Int("tests.count", len(pol.Tests)).
|
||||
Msg("Policy parsed successfully")
|
||||
|
||||
pm.pol = pol
|
||||
@@ -414,16 +531,8 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ
|
||||
// but peer relationships require the full bidirectional access rules.
|
||||
nodeMatchers := make(map[types.NodeID][]matcher.Match, nodes.Len())
|
||||
for _, node := range nodes.All() {
|
||||
filter, err := pm.compileFilterRulesForNodeLocked(node)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Include all nodes in nodeMatchers, even those with empty filters.
|
||||
// Empty filters result in empty matchers where CanAccess() returns false,
|
||||
// but the node still needs to be in the map so hasFilterX is true.
|
||||
// This ensures symmetric visibility works correctly: if node A can access
|
||||
// node B, both should see each other regardless of B's filter rules.
|
||||
nodeMatchers[node.ID()] = matcher.MatchesFromFilterRules(filter)
|
||||
unreduced := pm.filterRulesForNodeLocked(node)
|
||||
nodeMatchers[node.ID()] = matcher.MatchesFromFilterRules(unreduced)
|
||||
}
|
||||
|
||||
// Check each node pair for peer relationships.
|
||||
@@ -464,81 +573,59 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ
|
||||
return ret
|
||||
}
|
||||
|
||||
// compileFilterRulesForNodeLocked returns the unreduced compiled filter rules for a node
|
||||
// when using autogroup:self. This is used by BuildPeerMap to determine peer relationships.
|
||||
// For packet filters sent to nodes, use filterForNodeLocked which returns reduced rules.
|
||||
func (pm *PolicyManager) compileFilterRulesForNodeLocked(node types.NodeView) ([]tailcfg.FilterRule, error) {
|
||||
if pm == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Check if we have cached compiled rules
|
||||
if rules, ok := pm.compiledFilterRulesMap[node.ID()]; ok {
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// Compile per-node rules with autogroup:self expanded
|
||||
rules, err := pm.pol.compileFilterRulesForNode(pm.users, node, pm.nodes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compiling filter rules for node: %w", err)
|
||||
}
|
||||
|
||||
// Cache the unreduced compiled rules
|
||||
pm.compiledFilterRulesMap[node.ID()] = rules
|
||||
|
||||
return rules, nil
|
||||
// filterRulesForNodeLocked returns the unreduced compiled filter rules
|
||||
// for a node, combining pre-compiled global rules with per-node self
|
||||
// and via rules from the stored compiled grants.
|
||||
func (pm *PolicyManager) filterRulesForNodeLocked(
|
||||
node types.NodeView,
|
||||
) []tailcfg.FilterRule {
|
||||
return filterRulesForNode(
|
||||
pm.compiledGrants, node, pm.userNodeIdx,
|
||||
)
|
||||
}
|
||||
|
||||
// filterForNodeLocked returns the filter rules for a specific node, already reduced
|
||||
// to only include rules relevant to that node.
|
||||
// This is a lock-free version of FilterForNode for internal use when the lock is already held.
|
||||
// BuildPeerMap already holds the lock, so we need a version that doesn't re-acquire it.
|
||||
func (pm *PolicyManager) filterForNodeLocked(node types.NodeView) ([]tailcfg.FilterRule, error) {
|
||||
// filterForNodeLocked returns the filter rules for a specific node,
|
||||
// already reduced to only include rules relevant to that node.
|
||||
//
|
||||
// Fast path (!needsPerNodeFilter): reduces global filter per-node.
|
||||
// Slow path (needsPerNodeFilter): combines global + self + via rules
|
||||
// from the stored compiled grants, then reduces.
|
||||
//
|
||||
// Both paths derive from the same compiledGrants, ensuring there is
|
||||
// no divergence between global and per-node filter output.
|
||||
//
|
||||
// Lock-free version for internal use when the lock is already held.
|
||||
func (pm *PolicyManager) filterForNodeLocked(
|
||||
node types.NodeView,
|
||||
) []tailcfg.FilterRule {
|
||||
if pm == nil {
|
||||
return nil, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
if !pm.needsPerNodeFilter {
|
||||
// For global filters, reduce to only rules relevant to this node.
|
||||
// Cache the reduced filter per node for efficiency.
|
||||
if rules, ok := pm.filterRulesMap[node.ID()]; ok {
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// Use policyutil.ReduceFilterRules for global filter reduction.
|
||||
reducedFilter := policyutil.ReduceFilterRules(node, pm.filter)
|
||||
|
||||
pm.filterRulesMap[node.ID()] = reducedFilter
|
||||
|
||||
return reducedFilter, nil
|
||||
}
|
||||
|
||||
// Per-node compilation is needed when the policy uses autogroup:self
|
||||
// (node-relative destinations) or via grants (per-router filter rules).
|
||||
// Check if we have cached reduced rules for this node.
|
||||
if rules, ok := pm.filterRulesMap[node.ID()]; ok {
|
||||
return rules, nil
|
||||
return rules
|
||||
}
|
||||
|
||||
// Get unreduced compiled rules
|
||||
compiledRules, err := pm.compileFilterRulesForNodeLocked(node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var unreduced []tailcfg.FilterRule
|
||||
if !pm.needsPerNodeFilter {
|
||||
unreduced = pm.filter
|
||||
} else {
|
||||
unreduced = pm.filterRulesForNodeLocked(node)
|
||||
}
|
||||
|
||||
// Reduce the compiled rules to only destinations relevant to this node
|
||||
reducedFilter := policyutil.ReduceFilterRules(node, compiledRules)
|
||||
reduced := policyutil.ReduceFilterRules(node, unreduced)
|
||||
pm.filterRulesMap[node.ID()] = reduced
|
||||
|
||||
// Cache the reduced filter
|
||||
pm.filterRulesMap[node.ID()] = reducedFilter
|
||||
|
||||
return reducedFilter, nil
|
||||
return reduced
|
||||
}
|
||||
|
||||
// FilterForNode returns the filter rules for a specific node, already reduced
|
||||
// to only include rules relevant to that node.
|
||||
// If the policy uses autogroup:self, this returns node-specific compiled rules.
|
||||
// Otherwise, it returns the global filter reduced for this node.
|
||||
//
|
||||
// Cache is invalidated by updateLocked on policy reload, node-set
|
||||
// change, or tag-state change.
|
||||
func (pm *PolicyManager) FilterForNode(node types.NodeView) ([]tailcfg.FilterRule, error) {
|
||||
if pm == nil {
|
||||
return nil, nil
|
||||
@@ -547,7 +634,7 @@ func (pm *PolicyManager) FilterForNode(node types.NodeView) ([]tailcfg.FilterRul
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
return pm.filterForNodeLocked(node)
|
||||
return pm.filterForNodeLocked(node), nil
|
||||
}
|
||||
|
||||
// MatchersForNode returns the matchers for peer relationship determination for a specific node.
|
||||
@@ -556,6 +643,10 @@ func (pm *PolicyManager) FilterForNode(node types.NodeView) ([]tailcfg.FilterRul
|
||||
//
|
||||
// For global policies: returns the global matchers (same for all nodes)
|
||||
// For autogroup:self: returns node-specific matchers from unreduced compiled rules.
|
||||
//
|
||||
// Per-node results are cached and invalidated on policy/node updates
|
||||
// so BuildPeerMap's O(N²) slow path avoids recomputing matchers for
|
||||
// every pair.
|
||||
func (pm *PolicyManager) MatchersForNode(node types.NodeView) ([]matcher.Match, error) {
|
||||
if pm == nil {
|
||||
return nil, nil
|
||||
@@ -571,14 +662,17 @@ func (pm *PolicyManager) MatchersForNode(node types.NodeView) ([]matcher.Match,
|
||||
return pm.matchers, nil
|
||||
}
|
||||
|
||||
// For autogroup:self or via grants, get unreduced compiled rules and create matchers
|
||||
compiledRules, err := pm.compileFilterRulesForNodeLocked(node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if cached, ok := pm.matchersForNodeMap[node.ID()]; ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
// Create matchers from unreduced rules for peer relationship determination
|
||||
return matcher.MatchesFromFilterRules(compiledRules), nil
|
||||
// For autogroup:self or via grants, derive matchers from
|
||||
// the stored compiled grants for this specific node.
|
||||
unreduced := pm.filterRulesForNodeLocked(node)
|
||||
matchers := matcher.MatchesFromFilterRules(unreduced)
|
||||
pm.matchersForNodeMap[node.ID()] = matchers
|
||||
|
||||
return matchers, nil
|
||||
}
|
||||
|
||||
// SetUsers updates the users in the policy manager and updates the filter rules.
|
||||
@@ -649,8 +743,8 @@ func (pm *PolicyManager) SetNodes(nodes views.Slice[types.NodeView]) (bool, erro
|
||||
if !needsUpdate {
|
||||
// This ensures fresh filter rules are generated for all nodes
|
||||
clear(pm.sshPolicyMap)
|
||||
clear(pm.compiledFilterRulesMap)
|
||||
clear(pm.filterRulesMap)
|
||||
clear(pm.matchersForNodeMap)
|
||||
}
|
||||
// Always return true when nodes changed, even if filter hash didn't change
|
||||
// (can happen with autogroup:self or when nodes are added but don't affect rules)
|
||||
@@ -799,6 +893,9 @@ func (pm *PolicyManager) NodeCanApproveRoute(node types.NodeView, route netip.Pr
|
||||
return false
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
// If the route to-be-approved is an exit route, then we need to check
|
||||
// if the node is in allowed to approve it. This is treated differently
|
||||
// than the auto-approvers, as the auto-approvers are not allowed to
|
||||
@@ -810,16 +907,9 @@ func (pm *PolicyManager) NodeCanApproveRoute(node types.NodeView, route netip.Pr
|
||||
return false
|
||||
}
|
||||
|
||||
if slices.ContainsFunc(node.IPs(), pm.exitSet.Contains) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
return slices.ContainsFunc(node.IPs(), pm.exitSet.Contains)
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
// The fast path is that a node requests to approve a prefix
|
||||
// where there is an exact entry, e.g. 10.0.0.0/8, then
|
||||
// check and return quickly
|
||||
@@ -852,6 +942,10 @@ func (pm *PolicyManager) NodeCanApproveRoute(node types.NodeView, route netip.Pr
|
||||
// For each via grant where the viewer matches the source, it checks whether the
|
||||
// peer advertises any of the grant's destination prefixes. If the peer has the
|
||||
// via tag, those prefixes go into Include; otherwise into Exclude.
|
||||
//
|
||||
// Performance note: this holds pm.mu for its full duration. Hot
|
||||
// callers should memoise by (policy-hash, viewer-id) rather than
|
||||
// invoking this per-pair.
|
||||
func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.ViaRouteResult {
|
||||
var result types.ViaRouteResult
|
||||
|
||||
@@ -872,28 +966,33 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via
|
||||
grants = append(grants, aclToGrants(acl)...)
|
||||
}
|
||||
|
||||
for _, grant := range grants {
|
||||
if len(grant.Via) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if viewer matches any grant source.
|
||||
viewerMatches := false
|
||||
// Resolve each grant's sources against the viewer once. The three
|
||||
// passes below reuse this result instead of calling src.Resolve
|
||||
// per grant per pass.
|
||||
viewerIPs := viewer.IPs()
|
||||
viewerMatchesGrant := make([]bool, len(grants))
|
||||
|
||||
for i, grant := range grants {
|
||||
for _, src := range grant.Sources {
|
||||
ips, err := src.Resolve(pm.pol, pm.users, pm.nodes)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if ips != nil && slices.ContainsFunc(viewer.IPs(), ips.Contains) {
|
||||
viewerMatches = true
|
||||
if ips != nil && slices.ContainsFunc(viewerIPs, ips.Contains) {
|
||||
viewerMatchesGrant[i] = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !viewerMatches {
|
||||
for i, grant := range grants {
|
||||
if len(grant.Via) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if !viewerMatchesGrant[i] {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -910,11 +1009,16 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via
|
||||
matchedPrefixes = append(matchedPrefixes, dstPrefix)
|
||||
}
|
||||
case *AutoGroup:
|
||||
// autogroup:internet via grants do NOT affect AllowedIPs or
|
||||
// route steering for exit nodes. Tailscale SaaS handles exit
|
||||
// traffic forwarding through the client's exit node selection
|
||||
// mechanism, not through AllowedIPs. Verified by golden
|
||||
// captures GRANT-V14 through GRANT-V36.
|
||||
// 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()...,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -940,6 +1044,113 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via
|
||||
}
|
||||
}
|
||||
|
||||
// Detect prefixes that should fall back to HA primary election
|
||||
// rather than per-viewer via steering. Two conditions trigger this:
|
||||
//
|
||||
// 1. Multi-router via: a via grant's tag matches multiple peers
|
||||
// advertising the same prefix.
|
||||
// 2. Regular grant overlap: a non-via grant also covers the same
|
||||
// prefix for this viewer.
|
||||
//
|
||||
// When neither condition is met, per-viewer via steering applies.
|
||||
if len(result.Include) > 0 || len(result.Exclude) > 0 {
|
||||
// Multi-router via election: when a via grant's tag matches
|
||||
// multiple peers advertising the same prefix, only the
|
||||
// lowest-ID peer (the via-group primary) keeps the prefix in
|
||||
// Include. The others move to Exclude. This mirrors HA
|
||||
// primary election scoped to the via tag group.
|
||||
//
|
||||
// Unlike the global PrimaryRoutes election (routes/primary.go),
|
||||
// which picks one primary across ALL advertisers of a prefix,
|
||||
// this election is scoped to the via tag. Two via grants with
|
||||
// different tags (e.g., tag:ha-a vs tag:ha-b) each elect their
|
||||
// own winner independently.
|
||||
//
|
||||
// Only process via grants where the viewer matches the source,
|
||||
// otherwise grants for other viewer groups would incorrectly
|
||||
// demote the peer.
|
||||
for i, grant := range grants {
|
||||
if len(grant.Via) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if !viewerMatchesGrant[i] {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, dst := range grant.Destinations {
|
||||
d, ok := dst.(*Prefix)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
dstPrefix := netip.Prefix(*d)
|
||||
if !slices.Contains(result.Include, dstPrefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the lowest-ID peer with this via tag that
|
||||
// advertises this prefix — the via-group primary.
|
||||
var viaPrimaryID types.NodeID
|
||||
|
||||
for _, viaTag := range grant.Via {
|
||||
for _, node := range pm.nodes.All() {
|
||||
if node.HasTag(string(viaTag)) &&
|
||||
slices.Contains(node.SubnetRoutes(), dstPrefix) {
|
||||
if viaPrimaryID == 0 || node.ID() < viaPrimaryID {
|
||||
viaPrimaryID = node.ID()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the current peer is not the via-group primary,
|
||||
// demote the prefix from Include to Exclude.
|
||||
if viaPrimaryID != 0 && peer.ID() != viaPrimaryID {
|
||||
result.Include = slices.DeleteFunc(result.Include, func(p netip.Prefix) bool {
|
||||
return p == dstPrefix
|
||||
})
|
||||
if !slices.Contains(result.Exclude, dstPrefix) {
|
||||
result.Exclude = append(result.Exclude, dstPrefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for regular (non-via) grants covering the same prefix.
|
||||
// When a regular grant also covers a prefix that a via grant
|
||||
// included, defer to global HA primary election (UsePrimary).
|
||||
// When a regular grant covers a prefix that a via grant excluded
|
||||
// (peer lacks via tag), remove the exclusion so RoutesForPeer
|
||||
// can apply normal ReduceRoutes + primary logic.
|
||||
for i, grant := range grants {
|
||||
if len(grant.Via) > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if !viewerMatchesGrant[i] {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, dst := range grant.Destinations {
|
||||
if d, ok := dst.(*Prefix); ok {
|
||||
dstPrefix := netip.Prefix(*d)
|
||||
if slices.Contains(result.Include, dstPrefix) &&
|
||||
!slices.Contains(result.UsePrimary, dstPrefix) {
|
||||
result.UsePrimary = append(result.UsePrimary, dstPrefix)
|
||||
}
|
||||
|
||||
// A regular grant overrides a via exclusion: the
|
||||
// peer doesn't need the via tag if the viewer has
|
||||
// direct (non-via) access to the prefix.
|
||||
result.Exclude = slices.DeleteFunc(result.Exclude, func(p netip.Prefix) bool {
|
||||
return p == dstPrefix
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1151,13 +1362,13 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S
|
||||
// If we found the user and they're affected, clear this cache entry
|
||||
if found {
|
||||
if _, affected := affectedUsers[nodeUserID]; affected {
|
||||
delete(pm.compiledFilterRulesMap, nodeID)
|
||||
delete(pm.filterRulesMap, nodeID)
|
||||
delete(pm.matchersForNodeMap, nodeID)
|
||||
}
|
||||
} else {
|
||||
// Node not found in either old or new list, clear it
|
||||
delete(pm.compiledFilterRulesMap, nodeID)
|
||||
delete(pm.filterRulesMap, nodeID)
|
||||
delete(pm.matchersForNodeMap, nodeID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1171,13 +1382,14 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S
|
||||
|
||||
// invalidateNodeCache invalidates cache entries based on what changed.
|
||||
func (pm *PolicyManager) invalidateNodeCache(newNodes views.Slice[types.NodeView]) {
|
||||
if pm.usesAutogroupSelf {
|
||||
// For autogroup:self, a node's filter depends on its peers (same user).
|
||||
// When any node in a user changes, all nodes for that user need invalidation.
|
||||
if pm.needsPerNodeFilter {
|
||||
// For autogroup:self or via grants, a node's filter depends
|
||||
// on its peers. When any node changes, invalidate affected
|
||||
// users' caches.
|
||||
pm.invalidateAutogroupSelfCache(pm.nodes, newNodes)
|
||||
} else {
|
||||
// For global policies, a node's filter depends only on its own properties.
|
||||
// Only invalidate nodes whose properties actually changed.
|
||||
// For global policies, a node's filter depends only on its
|
||||
// own properties. Only invalidate changed nodes.
|
||||
pm.invalidateGlobalPolicyCache(newNodes)
|
||||
}
|
||||
}
|
||||
@@ -1205,6 +1417,7 @@ func (pm *PolicyManager) invalidateGlobalPolicyCache(newNodes views.Slice[types.
|
||||
|
||||
if newNode.HasNetworkChanges(oldNode) {
|
||||
delete(pm.filterRulesMap, nodeID)
|
||||
delete(pm.matchersForNodeMap, nodeID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1214,6 +1427,12 @@ func (pm *PolicyManager) invalidateGlobalPolicyCache(newNodes views.Slice[types.
|
||||
delete(pm.filterRulesMap, nodeID)
|
||||
}
|
||||
}
|
||||
|
||||
for nodeID := range pm.matchersForNodeMap {
|
||||
if _, exists := newNodeMap[nodeID]; !exists {
|
||||
delete(pm.matchersForNodeMap, nodeID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flattenTags flattens the TagOwners by resolving nested tags and detecting cycles.
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
@@ -44,6 +45,20 @@ func TestPolicyManager(t *testing.T) {
|
||||
wantFilter: tailcfg.FilterAllowAll,
|
||||
wantMatchers: matcher.MatchesFromFilterRules(tailcfg.FilterAllowAll),
|
||||
},
|
||||
{
|
||||
name: "empty-acls-denies-all",
|
||||
pol: `{"acls": []}`,
|
||||
nodes: types.Nodes{},
|
||||
wantFilter: nil,
|
||||
wantMatchers: matcher.MatchesFromFilterRules(nil),
|
||||
},
|
||||
{
|
||||
name: "empty-grants-denies-all",
|
||||
pol: `{"grants": []}`,
|
||||
nodes: types.Nodes{},
|
||||
wantFilter: nil,
|
||||
wantMatchers: matcher.MatchesFromFilterRules(nil),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -355,9 +370,8 @@ func TestInvalidateGlobalPolicyCache(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pm := &PolicyManager{
|
||||
nodes: tt.oldNodes.ViewSlice(),
|
||||
filterRulesMap: tt.initialCache,
|
||||
usesAutogroupSelf: false,
|
||||
nodes: tt.oldNodes.ViewSlice(),
|
||||
filterRulesMap: tt.initialCache,
|
||||
}
|
||||
|
||||
pm.invalidateGlobalPolicyCache(tt.newNodes.ViewSlice())
|
||||
@@ -399,7 +413,7 @@ func TestAutogroupSelfReducedVsUnreducedRules(t *testing.T) {
|
||||
|
||||
pm, err := NewPolicyManager([]byte(policyStr), users, nodes.ViewSlice())
|
||||
require.NoError(t, err)
|
||||
require.True(t, pm.usesAutogroupSelf, "policy should use autogroup:self")
|
||||
require.True(t, pm.needsPerNodeFilter, "policy should need per-node filter")
|
||||
|
||||
// Test FilterForNode returns reduced rules
|
||||
// For node1: should have rules where node1 is in destinations (its own IP)
|
||||
@@ -572,7 +586,7 @@ func TestAutogroupSelfPolicyUpdateTriggersMapResponse(t *testing.T) {
|
||||
|
||||
pm, err := NewPolicyManager([]byte(initialPolicy), users, nodes.ViewSlice())
|
||||
require.NoError(t, err)
|
||||
require.True(t, pm.usesAutogroupSelf, "policy should use autogroup:self")
|
||||
require.True(t, pm.needsPerNodeFilter, "policy should need per-node filter")
|
||||
|
||||
// Get initial filter rules for test-1 (should be cached)
|
||||
rules1, err := pm.FilterForNode(test1Node.View())
|
||||
@@ -1626,10 +1640,14 @@ func TestViaRoutesForPeer(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
result := pm.ViaRoutesForPeer(nodes[0].View(), nodes[1].View())
|
||||
// Include should have only the subnet route.
|
||||
// autogroup:internet does not produce via route effects.
|
||||
// Include contains the subnet route plus the peer's approved
|
||||
// exit routes — the peer holds tag:router and advertises exit
|
||||
// routes, so autogroup:internet steering applies alongside the
|
||||
// explicit prefix.
|
||||
require.Contains(t, result.Include, mp("10.0.0.0/24"))
|
||||
require.Len(t, result.Include, 1)
|
||||
require.Contains(t, result.Include, mp("0.0.0.0/0"))
|
||||
require.Contains(t, result.Include, mp("::/0"))
|
||||
require.Len(t, result.Include, 3)
|
||||
require.Empty(t, result.Exclude)
|
||||
})
|
||||
|
||||
@@ -1699,17 +1717,20 @@ func TestViaRoutesForPeer(t *testing.T) {
|
||||
pm, err := NewPolicyManager([]byte(pol), users, nodes.ViewSlice())
|
||||
require.NoError(t, err)
|
||||
|
||||
// autogroup:internet via grants do NOT affect AllowedIPs or
|
||||
// route steering. Tailscale SaaS handles exit traffic through
|
||||
// the client's exit node mechanism, not ViaRoutesForPeer.
|
||||
// Verified by golden captures GRANT-V14 through GRANT-V36.
|
||||
// autogroup:internet via grants surface the peer's approved
|
||||
// exit routes when the peer carries the via tag, and exclude
|
||||
// them when it does not — restricting which exit nodes the
|
||||
// viewer may use, per Tailscale's grants-via spec for
|
||||
// autogroup:internet.
|
||||
resultExit := pm.ViaRoutesForPeer(nodes[0].View(), nodes[1].View())
|
||||
require.Empty(t, resultExit.Include)
|
||||
require.Contains(t, resultExit.Include, mp("0.0.0.0/0"))
|
||||
require.Contains(t, resultExit.Include, mp("::/0"))
|
||||
require.Empty(t, resultExit.Exclude)
|
||||
|
||||
resultOther := pm.ViaRoutesForPeer(nodes[0].View(), nodes[2].View())
|
||||
require.Empty(t, resultOther.Include)
|
||||
require.Empty(t, resultOther.Exclude)
|
||||
require.Contains(t, resultOther.Exclude, mp("0.0.0.0/0"))
|
||||
require.Contains(t, resultOther.Exclude, mp("::/0"))
|
||||
})
|
||||
|
||||
t.Run("via_routes_survive_reduce_routes", func(t *testing.T) {
|
||||
@@ -1793,3 +1814,211 @@ func TestViaRoutesForPeer(t *testing.T) {
|
||||
"state.RoutesForPeer adds via routes after ReduceRoutes to fix this")
|
||||
})
|
||||
}
|
||||
|
||||
// TestBuildPeerMap_AutogroupInternetMakesExitNodeVisible reproduces
|
||||
// juanfont/headscale#3212. An ACL that grants access only via
|
||||
// `autogroup:internet` must keep the exit node visible to the source
|
||||
// in BuildPeerMap so the Tailscale client surfaces it in
|
||||
// `tailscale exit-node list`. Authoritative SaaS captures
|
||||
// (tscap routes-b17/b18, 2026-04-28) confirm SaaS includes the exit
|
||||
// node in the source's Peers with 0.0.0.0/0 and ::/0 in AllowedIPs.
|
||||
func TestBuildPeerMap_AutogroupInternetMakesExitNodeVisible(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
users := types.Users{
|
||||
{Model: gorm.Model{ID: 1}, Name: "alice", Email: "alice@headscale.net"},
|
||||
}
|
||||
|
||||
aliceNode := node("alice-laptop", "100.64.0.10", "fd7a:115c:a1e0::a", users[0])
|
||||
aliceNode.ID = 1
|
||||
|
||||
exitRoutes := []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()}
|
||||
exitNode := node("alice-exit", "100.64.0.1", "fd7a:115c:a1e0::1", users[0])
|
||||
exitNode.ID = 2
|
||||
exitNode.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: exitRoutes}
|
||||
exitNode.ApprovedRoutes = exitRoutes
|
||||
|
||||
nodes := types.Nodes{aliceNode, exitNode}
|
||||
|
||||
policy := `{
|
||||
"acls": [
|
||||
{"action": "accept", "src": ["alice@headscale.net"], "dst": ["autogroup:internet:*"]}
|
||||
]
|
||||
}`
|
||||
|
||||
pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice())
|
||||
require.NoError(t, err)
|
||||
|
||||
peerMap := pm.BuildPeerMap(nodes.ViewSlice())
|
||||
|
||||
require.True(t,
|
||||
slices.ContainsFunc(peerMap[aliceNode.ID], func(n types.NodeView) bool {
|
||||
return n.ID() == exitNode.ID
|
||||
}),
|
||||
"alice should see the exit node as a peer when an ACL grants autogroup:internet (#3212)")
|
||||
|
||||
_, matchers := pm.Filter()
|
||||
require.True(t, aliceNode.View().CanAccess(matchers, exitNode.View()),
|
||||
"alice.CanAccess(exit) should be true via DestsIsTheInternet()+IsExitNode() (#3212)")
|
||||
}
|
||||
|
||||
// Reproduction for #3160: ambiguous user@ used to silently drop rules.
|
||||
func TestNewPolicyManager_DuplicateUsername(t *testing.T) {
|
||||
users := types.Users{
|
||||
{Model: gorm.Model{ID: 2}, Name: "yala"},
|
||||
{Model: gorm.Model{ID: 7}, Name: "yala", Email: "yala@yala.yala"},
|
||||
}
|
||||
|
||||
polB := []byte(`{
|
||||
"groups": {"group:admins": ["yala@"]},
|
||||
"tagOwners": {"tag:ssh": ["group:admins"]},
|
||||
"acls": [{"action":"accept","src":["*"],"dst":["*:*"]}],
|
||||
"ssh": [
|
||||
{"action":"accept","src":["group:admins"],"dst":["tag:ssh"],"users":["root"]}
|
||||
]
|
||||
}`)
|
||||
|
||||
_, err := NewPolicyManager(polB, users, types.Nodes{}.ViewSlice())
|
||||
require.Error(t, err, "NewPolicyManager must reject policy with ambiguous username")
|
||||
require.ErrorIs(t, err, ErrMultipleUsersFound)
|
||||
require.Contains(t, err.Error(), "yala@",
|
||||
"error must name the offending token")
|
||||
}
|
||||
|
||||
// Missing-user tokens stay tolerant per #2863; only multi-match blocks load.
|
||||
func TestNewPolicyManager_UnknownUsernameTolerant(t *testing.T) {
|
||||
users := types.Users{
|
||||
{Model: gorm.Model{ID: 1}, Name: "alice"},
|
||||
}
|
||||
|
||||
polB := []byte(`{
|
||||
"acls": [{"action":"accept","src":["ghost@"],"dst":["*:*"]}]
|
||||
}`)
|
||||
|
||||
_, err := NewPolicyManager(polB, users, types.Nodes{}.ViewSlice())
|
||||
require.NoError(t, err, "missing-user references must not block policy load (#2863)")
|
||||
}
|
||||
|
||||
// Rejected SetPolicy must keep the previous policy intact.
|
||||
func TestSetPolicy_DuplicateUsername(t *testing.T) {
|
||||
users := types.Users{
|
||||
{Model: gorm.Model{ID: 2}, Name: "yala"},
|
||||
{Model: gorm.Model{ID: 7}, Name: "yala", Email: "yala@yala.yala"},
|
||||
}
|
||||
|
||||
good := []byte(`{
|
||||
"acls": [{"action":"accept","src":["*"],"dst":["*:*"]}]
|
||||
}`)
|
||||
|
||||
pm, err := NewPolicyManager(good, users, types.Nodes{}.ViewSlice())
|
||||
require.NoError(t, err)
|
||||
|
||||
bad := []byte(`{
|
||||
"groups": {"group:admins": ["yala@"]},
|
||||
"acls": [{"action":"accept","src":["group:admins"],"dst":["*:*"]}]
|
||||
}`)
|
||||
|
||||
_, err = pm.SetPolicy(bad)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrMultipleUsersFound)
|
||||
|
||||
filter, _ := pm.Filter()
|
||||
require.NotNil(t, filter, "filter must remain populated after rejected SetPolicy")
|
||||
}
|
||||
|
||||
// Empty users → syntax-only check, used by `headscale policy check`.
|
||||
func TestValidateUserReferences_EmptyUsersTolerant(t *testing.T) {
|
||||
polB := []byte(`{
|
||||
"groups": {"group:admins": ["yala@"]},
|
||||
"tagOwners": {"tag:ssh": ["group:admins"]},
|
||||
"acls": [{"action":"accept","src":["yala@"],"dst":["*:*"]}],
|
||||
"ssh": [
|
||||
{"action":"accept","src":["yala@"],"dst":["tag:ssh"],"users":["root"]}
|
||||
]
|
||||
}`)
|
||||
|
||||
_, err := NewPolicyManager(polB, nil, types.Nodes{}.ViewSlice())
|
||||
require.NoError(t, err, "nil users must skip user-reference validation")
|
||||
|
||||
_, err = NewPolicyManager(polB, types.Users{}, types.Nodes{}.ViewSlice())
|
||||
require.NoError(t, err, "empty users must skip user-reference validation")
|
||||
}
|
||||
|
||||
// One case per AST site so a dropped walk fails the matching subtest.
|
||||
func TestValidateUserReferences_AllSites(t *testing.T) {
|
||||
users := types.Users{
|
||||
{Model: gorm.Model{ID: 1}, Name: "alice"},
|
||||
{Model: gorm.Model{ID: 2}, Name: "dup"},
|
||||
{Model: gorm.Model{ID: 3}, Name: "dup"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pol string
|
||||
}{
|
||||
{
|
||||
name: "groups",
|
||||
pol: `{
|
||||
"groups": {"group:admins": ["dup@"]},
|
||||
"acls": [{"action":"accept","src":["group:admins"],"dst":["*:*"]}]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "tagOwners",
|
||||
pol: `{
|
||||
"tagOwners": {"tag:ssh": ["dup@"]},
|
||||
"acls": [{"action":"accept","src":["*"],"dst":["*:*"]}]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "autoApprovers.routes",
|
||||
pol: `{
|
||||
"autoApprovers": {"routes": {"10.0.0.0/8": ["dup@"]}},
|
||||
"acls": [{"action":"accept","src":["*"],"dst":["*:*"]}]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "autoApprovers.exitNode",
|
||||
pol: `{
|
||||
"autoApprovers": {"exitNode": ["dup@"]},
|
||||
"acls": [{"action":"accept","src":["*"],"dst":["*:*"]}]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "acls.src",
|
||||
pol: `{
|
||||
"acls": [{"action":"accept","src":["dup@"],"dst":["*:*"]}]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "acls.dst",
|
||||
pol: `{
|
||||
"acls": [{"action":"accept","src":["alice@"],"dst":["dup@:*"]}]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "ssh.src",
|
||||
pol: `{
|
||||
"tagOwners": {"tag:ssh": ["alice@"]},
|
||||
"acls": [{"action":"accept","src":["*"],"dst":["*:*"]}],
|
||||
"ssh": [{"action":"accept","src":["dup@"],"dst":["tag:ssh"],"users":["root"]}]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
// ErrSSHUserDestRequiresSameUser forces src==dst when dst is a user.
|
||||
name: "ssh.dst",
|
||||
pol: `{
|
||||
"acls": [{"action":"accept","src":["*"],"dst":["*:*"]}],
|
||||
"ssh": [{"action":"accept","src":["dup@"],"dst":["dup@"],"users":["root"]}]
|
||||
}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := NewPolicyManager([]byte(tt.pol), users, types.Nodes{}.ViewSlice())
|
||||
require.Error(t, err, "site %q must surface duplicate-user errors", tt.name)
|
||||
require.ErrorIs(t, err, ErrMultipleUsersFound)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// Compatibility tests for the policy `tests` block, replaying captures
|
||||
// recorded against a real Tailscale SaaS tailnet. The runner mirrors the
|
||||
// pattern in tailscale_grants_compat_test.go: a single Glob over a
|
||||
// testdata directory, one t.Run per file. Each capture is one of:
|
||||
//
|
||||
// - APIResponseCode != 200 — the policy was rejected by the SaaS, the
|
||||
// captured Message is the byte-exact body the user saw, and headscale
|
||||
// must reject the same input with an error string that contains the
|
||||
// same body (substring match, allowing wrapping like "test(s)
|
||||
// failed:\n…").
|
||||
// - APIResponseCode == 200 — the SaaS accepted the policy (its `tests`
|
||||
// block passed); headscale's RunTests must also pass.
|
||||
//
|
||||
// Captures live in testdata/policytest_results/*.hujson. Scenarios in
|
||||
// knownPolicyTesterDivergences are skipped with their tracking note —
|
||||
// these are real Tailscale ↔ headscale divergences uncovered by the
|
||||
// captures that need engine-level fixes in follow-up PRs.
|
||||
//
|
||||
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/types/testcapture"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// knownPolicyTesterDivergences lists scenarios where headscale's evaluator
|
||||
// disagrees with Tailscale SaaS on whether the policy should be accepted.
|
||||
// Each entry is a real bug to fix in a follow-up; documenting them here
|
||||
// keeps the compat suite green and the divergence list visible.
|
||||
var knownPolicyTesterDivergences = map[string]string{} //nolint:gosec // strings here are human-readable notes, not credentials
|
||||
|
||||
// policyTesterCompatUsers / policyTesterCompatNodes mirror the small
|
||||
// shared topology used to record the captures. When more captures land
|
||||
// we'll also exercise an autogroup-heavy second topology — for now this
|
||||
// minimal one is enough to make the runner go.
|
||||
func policyTesterCompatUsers() types.Users {
|
||||
return types.Users{
|
||||
{Model: gorm.Model{ID: 1}, Name: "odin", Email: "odin@example.com"},
|
||||
{Model: gorm.Model{ID: 2}, Name: "thor", Email: "thor@example.org"},
|
||||
{Model: gorm.Model{ID: 3}, Name: "freya", Email: "freya@example.com"},
|
||||
}
|
||||
}
|
||||
|
||||
func policyTesterCompatNodes(users types.Users) types.Nodes {
|
||||
return types.Nodes{
|
||||
{
|
||||
ID: 1,
|
||||
GivenName: "bulbasaur",
|
||||
User: &users[0],
|
||||
UserID: &users[0].ID,
|
||||
IPv4: ptrAddr("100.90.199.68"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::2d01:c747"),
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
GivenName: "ivysaur",
|
||||
User: &users[1],
|
||||
UserID: &users[1].ID,
|
||||
IPv4: ptrAddr("100.110.121.96"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::1737:7960"),
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
},
|
||||
{
|
||||
ID: 3,
|
||||
GivenName: "venusaur",
|
||||
User: &users[2],
|
||||
UserID: &users[2].ID,
|
||||
IPv4: ptrAddr("100.103.90.82"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::9e37:5a52"),
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
},
|
||||
{
|
||||
ID: 4,
|
||||
GivenName: "beedrill",
|
||||
IPv4: ptrAddr("100.108.74.26"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::b901:4a87"),
|
||||
Tags: []string{"tag:server"},
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
},
|
||||
{
|
||||
ID: 5,
|
||||
GivenName: "kakuna",
|
||||
IPv4: ptrAddr("100.103.8.15"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::5b37:80f"),
|
||||
Tags: []string{"tag:client"},
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestPolicyTesterCompat replays every capture under
|
||||
// testdata/policytest_results/ against the engine. With no captures the
|
||||
// test is a no-op — committed early so the layout/wiring lands before
|
||||
// the bulk import.
|
||||
func TestPolicyTesterCompat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files, err := filepath.Glob(filepath.Join("testdata", "policytest_results", "*.hujson"))
|
||||
require.NoError(t, err, "failed to glob test files")
|
||||
|
||||
if len(files) == 0 {
|
||||
t.Skip("no policytest captures yet")
|
||||
}
|
||||
|
||||
users := policyTesterCompatUsers()
|
||||
nodes := policyTesterCompatNodes(users)
|
||||
|
||||
for _, file := range files {
|
||||
c, err := testcapture.Read(file)
|
||||
require.NoError(t, err, "reading %s", file)
|
||||
|
||||
t.Run(c.TestID, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if reason, skip := knownPolicyTesterDivergences[c.TestID]; skip {
|
||||
t.Skip(reason)
|
||||
}
|
||||
|
||||
policyJSON := []byte(c.Input.FullPolicy)
|
||||
|
||||
pm, parseErr := NewPolicyManager(policyJSON, users, nodes.ViewSlice())
|
||||
|
||||
// Tailscale validates and runs tests as one POST step:
|
||||
// either failure mode produces the same 400. Headscale
|
||||
// splits structural validation (parse) from test
|
||||
// evaluation (SetPolicy). For the compat assertion, the
|
||||
// two are equivalent — whichever surfaces first carries
|
||||
// the captured body.
|
||||
if c.Input.APIResponseCode == 200 {
|
||||
require.NoError(t, parseErr, "tailscale accepted this policy; headscale must parse it")
|
||||
|
||||
_, setErr := pm.SetPolicy(policyJSON)
|
||||
require.NoError(t, setErr, "tailscale accepted this policy; headscale tests should pass")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var got error
|
||||
|
||||
switch {
|
||||
case parseErr != nil:
|
||||
got = parseErr
|
||||
default:
|
||||
_, setErr := pm.SetPolicy(policyJSON)
|
||||
got = setErr
|
||||
}
|
||||
|
||||
require.Error(t, got, "tailscale rejected; headscale must reject too")
|
||||
|
||||
if c.Input.APIResponseBody == nil || c.Input.APIResponseBody.Message == "" {
|
||||
return
|
||||
}
|
||||
|
||||
want := c.Input.APIResponseBody.Message
|
||||
if !strings.Contains(got.Error(), want) {
|
||||
t.Errorf("error body mismatch\n tailscale wants: %q\n headscale got: %q", want, got.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,23 @@
|
||||
// This file implements a data-driven test runner for ACL compatibility tests.
|
||||
// It loads JSON golden files from testdata/acl_results/ACL-*.json and compares
|
||||
// headscale's ACL engine output against the expected packet filter rules.
|
||||
// It loads HuJSON golden files from testdata/acl_results/acl-*.hujson and
|
||||
// compares headscale's ACL engine output against the expected packet filter
|
||||
// rules captured from Tailscale SaaS by the tscap tool.
|
||||
//
|
||||
// The JSON files were converted from the original inline Go struct test cases
|
||||
// in tailscale_acl_compat_test.go. Each file contains:
|
||||
// - A full policy (groups, tagOwners, hosts, acls)
|
||||
// - Expected packet_filter_rules per node (5 nodes)
|
||||
// - Or an error response for invalid policies
|
||||
// Each file is a testcapture.Capture containing:
|
||||
// - The full policy that was POSTed to the Tailscale SaaS API
|
||||
// - The 8-node topology used for the capture run
|
||||
// - Expected packet_filter_rules per node (or error metadata for
|
||||
// scenarios that the SaaS rejected)
|
||||
//
|
||||
// Test data source: testdata/acl_results/ACL-*.json
|
||||
// Original source: Tailscale SaaS API captures + headscale-generated expansions
|
||||
// Test data source: testdata/acl_results/acl-*.hujson
|
||||
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -25,9 +26,8 @@ import (
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/juanfont/headscale/hscontrol/policy/policyutil"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/juanfont/headscale/hscontrol/types/testcapture"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tailscale/hujson"
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
@@ -40,55 +40,56 @@ func ptrAddr(s string) *netip.Addr {
|
||||
}
|
||||
|
||||
// setupACLCompatUsers returns the 3 test users for ACL compatibility tests.
|
||||
// Email addresses use @example.com domain, matching the converted Tailscale
|
||||
// policy format (Tailscale uses @passkey and @dalby.cc).
|
||||
// Names and emails match the anonymized identifiers tscap writes into the
|
||||
// capture files (see github.com/kradalby/tscap/anonymize): users get
|
||||
// norse-god names and nodes get original-151 pokémon names.
|
||||
func setupACLCompatUsers() types.Users {
|
||||
return types.Users{
|
||||
{Model: gorm.Model{ID: 1}, Name: "kratail2tid", Email: "kratail2tid@example.com"},
|
||||
{Model: gorm.Model{ID: 2}, Name: "kristoffer", Email: "kristoffer@example.com"},
|
||||
{Model: gorm.Model{ID: 3}, Name: "monitorpasskeykradalby", Email: "monitorpasskeykradalby@example.com"},
|
||||
{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"},
|
||||
}
|
||||
}
|
||||
|
||||
// setupACLCompatNodes returns the 8 test nodes for ACL compatibility tests.
|
||||
// Uses the same topology as the grants compat tests.
|
||||
// Node GivenNames match tscap's anonymized pokémon naming.
|
||||
func setupACLCompatNodes(users types.Users) types.Nodes {
|
||||
return types.Nodes{
|
||||
{
|
||||
ID: 1, GivenName: "user1",
|
||||
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: "user-kris",
|
||||
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: "user-mon",
|
||||
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: "tagged-server",
|
||||
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: "tagged-prod",
|
||||
ID: 5, GivenName: "kakuna",
|
||||
IPv4: ptrAddr("100.103.8.15"), IPv6: ptrAddr("fd7a:115c:a1e0::5b37:80f"),
|
||||
Tags: []string{"tag:prod"}, Hostinfo: &tailcfg.Hostinfo{},
|
||||
},
|
||||
{
|
||||
ID: 6, GivenName: "tagged-client",
|
||||
ID: 6, GivenName: "weedle",
|
||||
IPv4: ptrAddr("100.83.200.69"), IPv6: ptrAddr("fd7a:115c:a1e0::c537:c845"),
|
||||
Tags: []string{"tag:client"}, Hostinfo: &tailcfg.Hostinfo{},
|
||||
},
|
||||
{
|
||||
ID: 7, GivenName: "subnet-router",
|
||||
ID: 7, GivenName: "squirtle",
|
||||
IPv4: ptrAddr("100.92.142.61"), IPv6: ptrAddr("fd7a:115c:a1e0::3e37:8e3d"),
|
||||
Tags: []string{"tag:router"},
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
@@ -97,7 +98,7 @@ func setupACLCompatNodes(users types.Users) types.Nodes {
|
||||
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.33.0.0/16")},
|
||||
},
|
||||
{
|
||||
ID: 8, GivenName: "exit-node",
|
||||
ID: 8, GivenName: "charmander",
|
||||
IPv4: ptrAddr("100.85.66.106"), IPv6: ptrAddr("fd7a:115c:a1e0::7c37:426a"),
|
||||
Tags: []string{"tag:exit"}, Hostinfo: &tailcfg.Hostinfo{},
|
||||
},
|
||||
@@ -140,40 +141,21 @@ func cmpOptions() []cmp.Option {
|
||||
|
||||
return a.Bits() < b.Bits()
|
||||
}),
|
||||
// Compare json.RawMessage semantically rather than by exact
|
||||
// bytes to handle indentation differences between the policy
|
||||
// source and the golden capture data.
|
||||
cmp.Comparer(func(a, b json.RawMessage) bool {
|
||||
var va, vb any
|
||||
|
||||
err := json.Unmarshal(a, &va)
|
||||
if err != nil {
|
||||
return string(a) == string(b)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(b, &vb)
|
||||
if err != nil {
|
||||
return string(a) == string(b)
|
||||
}
|
||||
|
||||
ja, _ := json.Marshal(va)
|
||||
jb, _ := json.Marshal(vb)
|
||||
|
||||
return string(ja) == string(jb)
|
||||
}),
|
||||
// Compare tailcfg.RawMessage semantically (it's a string type
|
||||
// containing JSON) to handle indentation differences.
|
||||
// containing JSON) to handle indentation differences. Both
|
||||
// sides must be valid JSON — golden data parse failures are
|
||||
// always errors.
|
||||
cmp.Comparer(func(a, b tailcfg.RawMessage) bool {
|
||||
var va, vb any
|
||||
|
||||
err := json.Unmarshal([]byte(a), &va)
|
||||
if err != nil {
|
||||
return a == b
|
||||
panic(fmt.Sprintf("golden RawMessage A unparseable: %v", err))
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(b), &vb)
|
||||
if err != nil {
|
||||
return a == b
|
||||
panic(fmt.Sprintf("golden RawMessage B unparseable: %v", err))
|
||||
}
|
||||
|
||||
ja, _ := json.Marshal(va)
|
||||
@@ -184,69 +166,94 @@ func cmpOptions() []cmp.Option {
|
||||
}
|
||||
}
|
||||
|
||||
// aclTestFile represents the JSON structure of a captured ACL test file.
|
||||
type aclTestFile struct {
|
||||
TestID string `json:"test_id"`
|
||||
Source string `json:"source"` // "tailscale_saas" or "headscale_adapted"
|
||||
Error bool `json:"error"`
|
||||
HeadscaleDiffers bool `json:"headscale_differs"`
|
||||
ParentTest string `json:"parent_test"`
|
||||
Input struct {
|
||||
FullPolicy json.RawMessage `json:"full_policy"`
|
||||
APIResponseCode int `json:"api_response_code"`
|
||||
APIResponseBody *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"api_response_body"`
|
||||
} `json:"input"`
|
||||
Topology struct {
|
||||
Nodes map[string]struct {
|
||||
Hostname string `json:"hostname"`
|
||||
Tags []string `json:"tags"`
|
||||
IPv4 string `json:"ipv4"`
|
||||
IPv6 string `json:"ipv6"`
|
||||
User string `json:"user"`
|
||||
RoutableIPs []string `json:"routable_ips"`
|
||||
ApprovedRoutes []string `json:"approved_routes"`
|
||||
} `json:"nodes"`
|
||||
} `json:"topology"`
|
||||
Captures map[string]struct {
|
||||
PacketFilterRules json.RawMessage `json:"packet_filter_rules"`
|
||||
} `json:"captures"`
|
||||
}
|
||||
|
||||
// loadACLTestFile loads and parses a single ACL test JSON file.
|
||||
func loadACLTestFile(t *testing.T, path string) aclTestFile {
|
||||
// buildACLUsersAndNodes constructs users and nodes from an ACL
|
||||
// golden file's topology. This ensures the test creates the same
|
||||
// nodes that were present during the Tailscale SaaS capture.
|
||||
func buildACLUsersAndNodes(
|
||||
t *testing.T,
|
||||
tf *testcapture.Capture,
|
||||
) (types.Users, types.Nodes) {
|
||||
t.Helper()
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
require.NoError(t, err, "failed to read test file %s", path)
|
||||
users := setupACLCompatUsers()
|
||||
nodes := make(types.Nodes, 0, len(tf.Topology.Nodes))
|
||||
autoID := 1
|
||||
|
||||
ast, err := hujson.Parse(content)
|
||||
require.NoError(t, err, "failed to parse HuJSON in %s", path)
|
||||
ast.Standardize()
|
||||
for name, nodeDef := range tf.Topology.Nodes {
|
||||
node := &types.Node{
|
||||
ID: types.NodeID(autoID), //nolint:gosec
|
||||
GivenName: name,
|
||||
IPv4: ptrAddr(nodeDef.IPv4),
|
||||
IPv6: ptrAddr(nodeDef.IPv6),
|
||||
Tags: nodeDef.Tags,
|
||||
}
|
||||
autoID++
|
||||
|
||||
var tf aclTestFile
|
||||
hostinfo := &tailcfg.Hostinfo{}
|
||||
|
||||
err = json.Unmarshal(ast.Pack(), &tf)
|
||||
require.NoError(t, err, "failed to unmarshal test file %s", path)
|
||||
if len(nodeDef.RoutableIPs) > 0 {
|
||||
routableIPs := make(
|
||||
[]netip.Prefix, 0, len(nodeDef.RoutableIPs),
|
||||
)
|
||||
|
||||
return tf
|
||||
for _, r := range nodeDef.RoutableIPs {
|
||||
routableIPs = append(
|
||||
routableIPs, netip.MustParsePrefix(r),
|
||||
)
|
||||
}
|
||||
|
||||
hostinfo.RoutableIPs = routableIPs
|
||||
}
|
||||
|
||||
node.Hostinfo = hostinfo
|
||||
|
||||
if len(nodeDef.ApprovedRoutes) > 0 {
|
||||
approved := make(
|
||||
[]netip.Prefix, 0, len(nodeDef.ApprovedRoutes),
|
||||
)
|
||||
|
||||
for _, r := range nodeDef.ApprovedRoutes {
|
||||
approved = append(
|
||||
approved, netip.MustParsePrefix(r),
|
||||
)
|
||||
}
|
||||
|
||||
node.ApprovedRoutes = approved
|
||||
} else {
|
||||
node.ApprovedRoutes = []netip.Prefix{}
|
||||
}
|
||||
|
||||
// Assign user — untagged nodes get user1
|
||||
if len(nodeDef.Tags) == 0 {
|
||||
if nodeDef.User != "" {
|
||||
for i := range users {
|
||||
if users[i].Name == nodeDef.User {
|
||||
node.User = &users[i]
|
||||
node.UserID = &users[i].ID
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
node.User = &users[0]
|
||||
node.UserID = &users[0].ID
|
||||
}
|
||||
}
|
||||
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
|
||||
return users, nodes
|
||||
}
|
||||
|
||||
// aclSkipReasons documents WHY tests are expected to fail and WHAT needs to be
|
||||
// implemented to fix them. Tests are grouped by root cause.
|
||||
//
|
||||
// Impact summary:
|
||||
//
|
||||
// SRCIPS_FORMAT - tests: SrcIPs use adapted format (100.64.0.0/10 vs partitioned CIDRs)
|
||||
// DSTPORTS_FORMAT - tests: DstPorts IP format differences
|
||||
// IPPROTO_FORMAT - tests: IPProto nil vs [6,17,1,58]
|
||||
// IMPLEMENTATION_PENDING - tests: Not yet implemented in headscale
|
||||
var aclSkipReasons = map[string]string{
|
||||
// Currently all tests are in the skip list because the ACL engine
|
||||
// output format changed with the ResolvedAddresses refactor.
|
||||
// Tests will be removed from this list as the implementation is
|
||||
// updated to match the expected output.
|
||||
// loadACLTestFile loads and parses a single ACL capture HuJSON file.
|
||||
func loadACLTestFile(t *testing.T, path string) *testcapture.Capture {
|
||||
t.Helper()
|
||||
|
||||
c, err := testcapture.Read(path)
|
||||
require.NoError(t, err, "failed to read test file %s", path)
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// TestACLCompat is a data-driven test that loads all ACL-*.json test files
|
||||
@@ -260,65 +267,55 @@ func TestACLCompat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files, err := filepath.Glob(
|
||||
filepath.Join("testdata", "acl_results", "ACL-*.hujson"),
|
||||
filepath.Join("testdata", "acl_results", "acl-*.hujson"),
|
||||
)
|
||||
require.NoError(t, err, "failed to glob test files")
|
||||
require.NotEmpty(
|
||||
t,
|
||||
files,
|
||||
"no ACL-*.hujson test files found in testdata/acl_results/",
|
||||
"no acl-*.hujson test files found in testdata/acl_results/",
|
||||
)
|
||||
|
||||
t.Logf("Loaded %d ACL test files", len(files))
|
||||
|
||||
users := setupACLCompatUsers()
|
||||
nodes := setupACLCompatNodes(users)
|
||||
|
||||
for _, file := range files {
|
||||
tf := loadACLTestFile(t, file)
|
||||
|
||||
t.Run(tf.TestID, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Check skip list
|
||||
if reason, ok := aclSkipReasons[tf.TestID]; ok {
|
||||
t.Skipf(
|
||||
"TODO: %s — see aclSkipReasons for details",
|
||||
reason,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if tf.Error {
|
||||
testACLError(t, tf)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Build nodes per-scenario from this file's topology.
|
||||
// tscap uses clean-slate mode, so each scenario has
|
||||
// different node IPs; using a shared topology would
|
||||
// cause IP mismatches in filter rule comparisons.
|
||||
users, nodes := buildACLUsersAndNodes(t, tf)
|
||||
require.NotEmpty(t, nodes, "%s: topology is empty", tf.TestID)
|
||||
|
||||
testACLSuccess(t, tf, users, nodes)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// testACLError verifies that an invalid policy produces the expected error.
|
||||
func testACLError(t *testing.T, tf aclTestFile) {
|
||||
func testACLError(t *testing.T, tf *testcapture.Capture) {
|
||||
t.Helper()
|
||||
|
||||
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
|
||||
|
||||
pol, err := unmarshalPolicy(policyJSON)
|
||||
if err != nil {
|
||||
// Parse-time error — valid for some error tests
|
||||
// Parse-time error.
|
||||
if tf.Input.APIResponseBody != nil {
|
||||
wantMsg := tf.Input.APIResponseBody.Message
|
||||
if wantMsg != "" {
|
||||
assert.Contains(
|
||||
t,
|
||||
err.Error(),
|
||||
wantMsg,
|
||||
"%s: error message should contain expected substring",
|
||||
tf.TestID,
|
||||
assertACLErrorContains(
|
||||
t, err, wantMsg, tf.TestID,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -331,64 +328,52 @@ func testACLError(t *testing.T, tf aclTestFile) {
|
||||
if tf.Input.APIResponseBody != nil {
|
||||
wantMsg := tf.Input.APIResponseBody.Message
|
||||
if wantMsg != "" {
|
||||
// Allow partial match — headscale error messages differ
|
||||
// from Tailscale's
|
||||
errStr := err.Error()
|
||||
if !strings.Contains(errStr, wantMsg) {
|
||||
// Try matching key parts
|
||||
matched := false
|
||||
|
||||
for _, part := range []string{
|
||||
"autogroup:self",
|
||||
"not valid on the src",
|
||||
"port range",
|
||||
"tag not found",
|
||||
"undefined",
|
||||
} {
|
||||
if strings.Contains(wantMsg, part) &&
|
||||
strings.Contains(errStr, part) {
|
||||
matched = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !matched {
|
||||
t.Logf(
|
||||
"%s: error message difference\n want (tailscale): %q\n got (headscale): %q",
|
||||
tf.TestID,
|
||||
wantMsg,
|
||||
errStr,
|
||||
)
|
||||
}
|
||||
}
|
||||
assertACLErrorContains(
|
||||
t, err, wantMsg, tf.TestID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// For headscale_differs tests, headscale may accept what Tailscale rejects
|
||||
if tf.HeadscaleDiffers {
|
||||
t.Logf(
|
||||
"%s: headscale accepts this policy (Tailscale rejects it)",
|
||||
tf.TestID,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
t.Errorf(
|
||||
"%s: expected error but policy parsed and validated successfully",
|
||||
tf.TestID,
|
||||
)
|
||||
}
|
||||
|
||||
// assertACLErrorContains requires that headscale's error contains the
|
||||
// Tailscale SaaS error message exactly. Divergence means an emitter
|
||||
// needs to be aligned, not papered over with a translation table.
|
||||
func assertACLErrorContains(
|
||||
t *testing.T,
|
||||
err error,
|
||||
wantMsg string,
|
||||
testID string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, wantMsg) {
|
||||
return
|
||||
}
|
||||
|
||||
t.Errorf(
|
||||
"%s: error message mismatch\n"+
|
||||
" want (tailscale): %q\n"+
|
||||
" got (headscale): %q",
|
||||
testID,
|
||||
wantMsg,
|
||||
errStr,
|
||||
)
|
||||
}
|
||||
|
||||
// testACLSuccess verifies that a valid policy produces the expected
|
||||
// packet filter rules for each node.
|
||||
func testACLSuccess(
|
||||
t *testing.T,
|
||||
tf aclTestFile,
|
||||
tf *testcapture.Capture,
|
||||
users types.Users,
|
||||
nodes types.Nodes,
|
||||
) {
|
||||
@@ -415,9 +400,6 @@ func testACLSuccess(
|
||||
|
||||
for nodeName, capture := range tf.Captures {
|
||||
t.Run(nodeName, func(t *testing.T) {
|
||||
captureIsNull := len(capture.PacketFilterRules) == 0 ||
|
||||
string(capture.PacketFilterRules) == "null" //nolint:goconst
|
||||
|
||||
node := findNodeByGivenName(nodes, nodeName)
|
||||
if node == nil {
|
||||
t.Skipf(
|
||||
@@ -447,21 +429,7 @@ func testACLSuccess(
|
||||
compiledRules,
|
||||
)
|
||||
|
||||
// Parse expected rules from JSON
|
||||
var wantRules []tailcfg.FilterRule
|
||||
if !captureIsNull {
|
||||
err = json.Unmarshal(
|
||||
capture.PacketFilterRules,
|
||||
&wantRules,
|
||||
)
|
||||
require.NoError(
|
||||
t,
|
||||
err,
|
||||
"%s/%s: failed to unmarshal expected rules",
|
||||
tf.TestID,
|
||||
nodeName,
|
||||
)
|
||||
}
|
||||
wantRules := capture.PacketFilterRules
|
||||
|
||||
// Compare
|
||||
opts := append(
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
// This file is "generated" by Claude.
|
||||
// It contains a data-driven test that reads 237 GRANT-*.json test files
|
||||
// captured from Tailscale SaaS. Each file contains:
|
||||
// - A policy with grants (and optionally ACLs)
|
||||
// - The expected packet_filter_rules for each of 8 test nodes
|
||||
// This file implements a data-driven test runner for grant compatibility
|
||||
// tests. It loads HuJSON golden files from testdata/grant_results/grant-*.hujson
|
||||
// and via-grant-*.hujson, captured from Tailscale SaaS by tscap, and compares
|
||||
// headscale's grants engine output against the captured packet filter rules.
|
||||
//
|
||||
// Each file is a testcapture.Capture containing:
|
||||
// - A full policy with grants (and optionally ACLs)
|
||||
// - The expected packet_filter_rules for each of 8-15 test nodes
|
||||
// - Or an error response for invalid policies
|
||||
//
|
||||
// The test loads each JSON file, applies the policy through headscale's
|
||||
// grants engine, and compares the output against Tailscale's actual behavior.
|
||||
// Tests known to fail due to unimplemented features or known differences are
|
||||
// skipped with a TODO comment explaining the root cause. As headscale's grants
|
||||
// implementation improves, tests should be removed from the skip list.
|
||||
//
|
||||
// Tests that are known to fail due to unimplemented features or known
|
||||
// differences are skipped with a TODO comment explaining the root cause.
|
||||
// As headscale's grants implementation improves, tests should be removed
|
||||
// from the skip list.
|
||||
//
|
||||
// Test data source: testdata/grant_results/GRANT-*.json
|
||||
// Captured from: Tailscale SaaS API + tailscale debug localapi
|
||||
// Test data source: testdata/grant_results/{grant,via-grant}-*.hujson
|
||||
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -30,54 +27,33 @@ import (
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/juanfont/headscale/hscontrol/policy/policyutil"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/types/testcapture"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tailscale/hujson"
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// grantTestFile represents the JSON structure of a captured grant test file.
|
||||
type grantTestFile struct {
|
||||
TestID string `json:"test_id"`
|
||||
Error bool `json:"error"`
|
||||
Input struct {
|
||||
FullPolicy json.RawMessage `json:"full_policy"`
|
||||
APIResponseCode int `json:"api_response_code"`
|
||||
APIResponseBody *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"api_response_body"`
|
||||
} `json:"input"`
|
||||
Topology struct {
|
||||
Nodes map[string]struct {
|
||||
Hostname string `json:"hostname"`
|
||||
Tags []string `json:"tags"`
|
||||
IPv4 string `json:"ipv4"`
|
||||
IPv6 string `json:"ipv6"`
|
||||
} `json:"nodes"`
|
||||
} `json:"topology"`
|
||||
Captures map[string]struct {
|
||||
PacketFilterRules json.RawMessage `json:"packet_filter_rules"`
|
||||
} `json:"captures"`
|
||||
}
|
||||
|
||||
// setupGrantsCompatUsers returns the 3 test users for grants compatibility tests.
|
||||
// Email addresses use @example.com domain, matching the converted Tailscale policy format.
|
||||
// Users get norse-god names; nodes get original-151 pokémon names — matching
|
||||
// the anonymized identifiers tscap writes into the capture files
|
||||
// (see github.com/kradalby/tscap/anonymize).
|
||||
func setupGrantsCompatUsers() types.Users {
|
||||
return types.Users{
|
||||
{Model: gorm.Model{ID: 1}, Name: "kratail2tid", Email: "kratail2tid@example.com"},
|
||||
{Model: gorm.Model{ID: 2}, Name: "kristoffer", Email: "kristoffer@example.com"},
|
||||
{Model: gorm.Model{ID: 3}, Name: "monitorpasskeykradalby", Email: "monitorpasskeykradalby@example.com"},
|
||||
{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"},
|
||||
}
|
||||
}
|
||||
|
||||
// setupGrantsCompatNodes returns the 8 test nodes for grants compatibility tests.
|
||||
// setupGrantsCompatNodes returns the 15 test nodes for grants compatibility tests.
|
||||
// The node configuration matches the Tailscale test environment:
|
||||
// - 3 user-owned nodes (user1, user-kris, user-mon)
|
||||
// - 5 tagged nodes (tagged-server, tagged-prod, tagged-client, subnet-router, exit-node)
|
||||
// - 3 user-owned nodes (bulbasaur, ivysaur, venusaur)
|
||||
// - 12 tagged nodes (beedrill, kakuna, weedle, squirtle, charmander,
|
||||
// pidgey, pidgeotto, rattata, raticate, spearow, fearow, blastoise)
|
||||
func setupGrantsCompatNodes(users types.Users) types.Nodes {
|
||||
nodeUser1 := &types.Node{
|
||||
nodeBulbasaur := &types.Node{
|
||||
ID: 1,
|
||||
GivenName: "user1",
|
||||
GivenName: "bulbasaur",
|
||||
User: &users[0],
|
||||
UserID: &users[0].ID,
|
||||
IPv4: ptrAddr("100.90.199.68"),
|
||||
@@ -85,9 +61,9 @@ func setupGrantsCompatNodes(users types.Users) types.Nodes {
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
|
||||
nodeUserKris := &types.Node{
|
||||
nodeIvysaur := &types.Node{
|
||||
ID: 2,
|
||||
GivenName: "user-kris",
|
||||
GivenName: "ivysaur",
|
||||
User: &users[1],
|
||||
UserID: &users[1].ID,
|
||||
IPv4: ptrAddr("100.110.121.96"),
|
||||
@@ -95,9 +71,9 @@ func setupGrantsCompatNodes(users types.Users) types.Nodes {
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
|
||||
nodeUserMon := &types.Node{
|
||||
nodeVenusaur := &types.Node{
|
||||
ID: 3,
|
||||
GivenName: "user-mon",
|
||||
GivenName: "venusaur",
|
||||
User: &users[2],
|
||||
UserID: &users[2].ID,
|
||||
IPv4: ptrAddr("100.103.90.82"),
|
||||
@@ -105,36 +81,36 @@ func setupGrantsCompatNodes(users types.Users) types.Nodes {
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
|
||||
nodeTaggedServer := &types.Node{
|
||||
nodeBeedrill := &types.Node{
|
||||
ID: 4,
|
||||
GivenName: "tagged-server",
|
||||
GivenName: "beedrill",
|
||||
IPv4: ptrAddr("100.108.74.26"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::b901:4a87"),
|
||||
Tags: []string{"tag:server"},
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
|
||||
nodeTaggedProd := &types.Node{
|
||||
nodeKakuna := &types.Node{
|
||||
ID: 5,
|
||||
GivenName: "tagged-prod",
|
||||
GivenName: "kakuna",
|
||||
IPv4: ptrAddr("100.103.8.15"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::5b37:80f"),
|
||||
Tags: []string{"tag:prod"},
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
|
||||
nodeTaggedClient := &types.Node{
|
||||
nodeWeedle := &types.Node{
|
||||
ID: 6,
|
||||
GivenName: "tagged-client",
|
||||
GivenName: "weedle",
|
||||
IPv4: ptrAddr("100.83.200.69"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::c537:c845"),
|
||||
Tags: []string{"tag:client"},
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
|
||||
nodeSubnetRouter := &types.Node{
|
||||
nodeSquirtle := &types.Node{
|
||||
ID: 7,
|
||||
GivenName: "subnet-router",
|
||||
GivenName: "squirtle",
|
||||
IPv4: ptrAddr("100.92.142.61"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::3e37:8e3d"),
|
||||
Tags: []string{"tag:router"},
|
||||
@@ -144,9 +120,9 @@ func setupGrantsCompatNodes(users types.Users) types.Nodes {
|
||||
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.33.0.0/16")},
|
||||
}
|
||||
|
||||
nodeExitNode := &types.Node{
|
||||
nodeCharmander := &types.Node{
|
||||
ID: 8,
|
||||
GivenName: "exit-node",
|
||||
GivenName: "charmander",
|
||||
IPv4: ptrAddr("100.85.66.106"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::7c37:426a"),
|
||||
Tags: []string{"tag:exit"},
|
||||
@@ -164,9 +140,9 @@ func setupGrantsCompatNodes(users types.Users) types.Nodes {
|
||||
|
||||
// --- New nodes for expanded via grant topology ---
|
||||
|
||||
nodeExitA := &types.Node{
|
||||
nodePidgey := &types.Node{
|
||||
ID: 9,
|
||||
GivenName: "exit-a",
|
||||
GivenName: "pidgey",
|
||||
IPv4: ptrAddr("100.124.195.93"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::7837:c35d"),
|
||||
Tags: []string{"tag:exit-a"},
|
||||
@@ -182,9 +158,9 @@ func setupGrantsCompatNodes(users types.Users) types.Nodes {
|
||||
},
|
||||
}
|
||||
|
||||
nodeExitB := &types.Node{
|
||||
nodePidgeotto := &types.Node{
|
||||
ID: 10,
|
||||
GivenName: "exit-b",
|
||||
GivenName: "pidgeotto",
|
||||
IPv4: ptrAddr("100.116.18.24"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::ff37:1218"),
|
||||
Tags: []string{"tag:exit-b"},
|
||||
@@ -200,27 +176,27 @@ func setupGrantsCompatNodes(users types.Users) types.Nodes {
|
||||
},
|
||||
}
|
||||
|
||||
nodeGroupA := &types.Node{
|
||||
nodeRattata := &types.Node{
|
||||
ID: 11,
|
||||
GivenName: "group-a-client",
|
||||
GivenName: "rattata",
|
||||
IPv4: ptrAddr("100.107.162.14"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::a237:a20e"),
|
||||
Tags: []string{"tag:group-a"},
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
|
||||
nodeGroupB := &types.Node{
|
||||
nodeRaticate := &types.Node{
|
||||
ID: 12,
|
||||
GivenName: "group-b-client",
|
||||
GivenName: "raticate",
|
||||
IPv4: ptrAddr("100.77.135.18"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::4b37:8712"),
|
||||
Tags: []string{"tag:group-b"},
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
|
||||
nodeRouterA := &types.Node{
|
||||
nodeSpearow := &types.Node{
|
||||
ID: 13,
|
||||
GivenName: "router-a",
|
||||
GivenName: "spearow",
|
||||
IPv4: ptrAddr("100.109.43.124"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::a537:2b7c"),
|
||||
Tags: []string{"tag:router-a"},
|
||||
@@ -230,9 +206,9 @@ func setupGrantsCompatNodes(users types.Users) types.Nodes {
|
||||
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.44.0.0/16")},
|
||||
}
|
||||
|
||||
nodeRouterB := &types.Node{
|
||||
nodeFearow := &types.Node{
|
||||
ID: 14,
|
||||
GivenName: "router-b",
|
||||
GivenName: "fearow",
|
||||
IPv4: ptrAddr("100.65.172.123"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::5a37:ac7c"),
|
||||
Tags: []string{"tag:router-b"},
|
||||
@@ -242,9 +218,9 @@ func setupGrantsCompatNodes(users types.Users) types.Nodes {
|
||||
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.55.0.0/16")},
|
||||
}
|
||||
|
||||
nodeMultiExitRouter := &types.Node{
|
||||
nodeBlastoise := &types.Node{
|
||||
ID: 15,
|
||||
GivenName: "multi-exit-router",
|
||||
GivenName: "blastoise",
|
||||
IPv4: ptrAddr("100.105.127.107"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::9537:7f6b"),
|
||||
Tags: []string{"tag:exit", "tag:router"},
|
||||
@@ -263,21 +239,21 @@ func setupGrantsCompatNodes(users types.Users) types.Nodes {
|
||||
}
|
||||
|
||||
return types.Nodes{
|
||||
nodeUser1,
|
||||
nodeUserKris,
|
||||
nodeUserMon,
|
||||
nodeTaggedServer,
|
||||
nodeTaggedProd,
|
||||
nodeTaggedClient,
|
||||
nodeSubnetRouter,
|
||||
nodeExitNode,
|
||||
nodeExitA,
|
||||
nodeExitB,
|
||||
nodeGroupA,
|
||||
nodeGroupB,
|
||||
nodeRouterA,
|
||||
nodeRouterB,
|
||||
nodeMultiExitRouter,
|
||||
nodeBulbasaur,
|
||||
nodeIvysaur,
|
||||
nodeVenusaur,
|
||||
nodeBeedrill,
|
||||
nodeKakuna,
|
||||
nodeWeedle,
|
||||
nodeSquirtle,
|
||||
nodeCharmander,
|
||||
nodePidgey,
|
||||
nodePidgeotto,
|
||||
nodeRattata,
|
||||
nodeRaticate,
|
||||
nodeSpearow,
|
||||
nodeFearow,
|
||||
nodeBlastoise,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,41 +268,87 @@ func findGrantsNode(nodes types.Nodes, name string) *types.Node {
|
||||
return nil
|
||||
}
|
||||
|
||||
// convertPolicyUserEmails converts Tailscale SaaS user email formats to
|
||||
// headscale-compatible @example.com format in the raw policy JSON.
|
||||
//
|
||||
// Tailscale uses provider-specific email formats:
|
||||
// - kratail2tid@passkey (passkey auth)
|
||||
// - kristoffer@dalby.cc (email auth)
|
||||
// - monitorpasskeykradalby@passkey (passkey auth)
|
||||
//
|
||||
// Headscale resolves users by Email field, so we convert all to @example.com.
|
||||
func convertPolicyUserEmails(policyJSON []byte) []byte {
|
||||
s := string(policyJSON)
|
||||
s = strings.ReplaceAll(s, "kratail2tid@passkey", "kratail2tid@example.com")
|
||||
s = strings.ReplaceAll(s, "kristoffer@dalby.cc", "kristoffer@example.com")
|
||||
s = strings.ReplaceAll(s, "monitorpasskeykradalby@passkey", "monitorpasskeykradalby@example.com")
|
||||
// buildGrantsNodesFromCapture constructs types.Nodes from a capture's
|
||||
// topology section. Each scenario in tscap uses clean-slate mode, so
|
||||
// node IPs differ between scenarios; this builds the node set with
|
||||
// the IPs that were actually present during that capture.
|
||||
func buildGrantsNodesFromCapture(
|
||||
users types.Users,
|
||||
tf *testcapture.Capture,
|
||||
) types.Nodes {
|
||||
nodes := make(types.Nodes, 0, len(tf.Topology.Nodes))
|
||||
autoID := 1
|
||||
|
||||
return []byte(s)
|
||||
for _, nodeDef := range tf.Topology.Nodes {
|
||||
node := &types.Node{
|
||||
ID: types.NodeID(autoID), //nolint:gosec
|
||||
GivenName: nodeDef.Hostname,
|
||||
IPv4: ptrAddr(nodeDef.IPv4),
|
||||
IPv6: ptrAddr(nodeDef.IPv6),
|
||||
Tags: nodeDef.Tags,
|
||||
}
|
||||
autoID++
|
||||
|
||||
hostinfo := &tailcfg.Hostinfo{}
|
||||
|
||||
if len(nodeDef.RoutableIPs) > 0 {
|
||||
routableIPs := make([]netip.Prefix, 0, len(nodeDef.RoutableIPs))
|
||||
for _, r := range nodeDef.RoutableIPs {
|
||||
routableIPs = append(routableIPs, netip.MustParsePrefix(r))
|
||||
}
|
||||
|
||||
hostinfo.RoutableIPs = routableIPs
|
||||
}
|
||||
|
||||
node.Hostinfo = hostinfo
|
||||
|
||||
if len(nodeDef.ApprovedRoutes) > 0 {
|
||||
approved := make([]netip.Prefix, 0, len(nodeDef.ApprovedRoutes))
|
||||
for _, r := range nodeDef.ApprovedRoutes {
|
||||
approved = append(approved, netip.MustParsePrefix(r))
|
||||
}
|
||||
|
||||
node.ApprovedRoutes = approved
|
||||
} else {
|
||||
node.ApprovedRoutes = []netip.Prefix{}
|
||||
}
|
||||
|
||||
// Assign user — untagged nodes look up by User field.
|
||||
if len(nodeDef.Tags) == 0 && nodeDef.User != "" {
|
||||
for i := range users {
|
||||
if users[i].Name == nodeDef.User {
|
||||
node.User = &users[i]
|
||||
node.UserID = &users[i].ID
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
// loadGrantTestFile loads and parses a single grant test JSON file.
|
||||
func loadGrantTestFile(t *testing.T, path string) grantTestFile {
|
||||
// convertPolicyUserEmails used to map SaaS-side emails to @example.com.
|
||||
// tscap now anonymizes the policy JSON at write time (kratail2tid -> odin,
|
||||
// kristoffer -> thor, monitorpasskeykradalby -> freya), so the captured
|
||||
// FullPolicy is already in its final form and this is a passthrough that
|
||||
// just adapts the captured string value to the []byte that the policy
|
||||
// parser expects.
|
||||
func convertPolicyUserEmails(policyJSON string) []byte {
|
||||
return []byte(policyJSON)
|
||||
}
|
||||
|
||||
// loadGrantTestFile loads and parses a single grant capture HuJSON file.
|
||||
func loadGrantTestFile(t *testing.T, path string) *testcapture.Capture {
|
||||
t.Helper()
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
c, err := testcapture.Read(path)
|
||||
require.NoError(t, err, "failed to read test file %s", path)
|
||||
|
||||
ast, err := hujson.Parse(content)
|
||||
require.NoError(t, err, "failed to parse HuJSON in %s", path)
|
||||
ast.Standardize()
|
||||
|
||||
var tf grantTestFile
|
||||
|
||||
err = json.Unmarshal(ast.Pack(), &tf)
|
||||
require.NoError(t, err, "failed to unmarshal test file %s", path)
|
||||
|
||||
return tf
|
||||
return c
|
||||
}
|
||||
|
||||
// Skip categories document WHY tests are expected to differ from Tailscale SaaS.
|
||||
@@ -341,8 +363,8 @@ var grantSkipReasons = map[string]string{
|
||||
// Tailscale SaaS policies can use user:*@passkey as a wildcard matching
|
||||
// all passkey-authenticated users. headscale does not support passkey
|
||||
// authentication and has no equivalent for this wildcard pattern.
|
||||
"GRANT-K20": "USER_PASSKEY_WILDCARD: src=user:*@passkey not supported in headscale",
|
||||
"GRANT-K21": "USER_PASSKEY_WILDCARD: dst=user:*@passkey not supported in headscale",
|
||||
"grant-k20": "USER_PASSKEY_WILDCARD: src=user:*@passkey not supported in headscale",
|
||||
"grant-k21": "USER_PASSKEY_WILDCARD: dst=user:*@passkey not supported in headscale",
|
||||
}
|
||||
|
||||
// TestGrantsCompat is a data-driven test that loads all GRANT-*.json
|
||||
@@ -354,22 +376,21 @@ var grantSkipReasons = map[string]string{
|
||||
// - For success cases: expected packet_filter_rules per node
|
||||
// - For error cases: expected error message
|
||||
//
|
||||
// The test converts Tailscale user email formats (@passkey, @dalby.cc) to
|
||||
// headscale format (@example.com) and runs the policy through unmarshalPolicy,
|
||||
// The test converts Tailscale user email formats to headscale format
|
||||
// (@example.com, @example.org) and runs the policy through unmarshalPolicy,
|
||||
// validate, compileFilterRulesForNode, and ReduceFilterRules.
|
||||
//
|
||||
// 2 tests are skipped for user:*@passkey wildcard (not supported in headscale).
|
||||
func TestGrantsCompat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files, err := filepath.Glob(filepath.Join("testdata", "grant_results", "GRANT-*.hujson"))
|
||||
files, err := filepath.Glob(filepath.Join("testdata", "grant_results", "*-*.hujson"))
|
||||
require.NoError(t, err, "failed to glob test files")
|
||||
require.NotEmpty(t, files, "no GRANT-*.hujson test files found in testdata/grant_results/")
|
||||
require.NotEmpty(t, files, "no grant test files found in testdata/grant_results/")
|
||||
|
||||
t.Logf("Loaded %d grant test files", len(files))
|
||||
|
||||
users := setupGrantsCompatUsers()
|
||||
allNodes := setupGrantsCompatNodes(users)
|
||||
|
||||
for _, file := range files {
|
||||
tf := loadGrantTestFile(t, file)
|
||||
@@ -383,17 +404,13 @@ func TestGrantsCompat(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine which node set to use based on the test's topology.
|
||||
// Tests captured with the expanded 15-node topology (V26+) have
|
||||
// nodes like exit-a, group-a-client, etc. Tests from the original
|
||||
// 8-node topology should only use the first 8 nodes to avoid
|
||||
// resolving extra IPs from nodes that weren't present during capture.
|
||||
nodes := allNodes
|
||||
if _, hasNewNodes := tf.Captures["exit-a"]; !hasNewNodes {
|
||||
nodes = allNodes[:8]
|
||||
}
|
||||
// Build nodes per-scenario from this file's topology.
|
||||
// tscap uses clean-slate mode, so each scenario has
|
||||
// different node IPs.
|
||||
nodes := buildGrantsNodesFromCapture(users, tf)
|
||||
|
||||
// Convert Tailscale user emails to headscale @example.com format
|
||||
// Use the captured full policy as is (anonymization
|
||||
// in tscap already rewrote SaaS emails).
|
||||
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
|
||||
|
||||
if tf.Input.APIResponseCode == 400 || tf.Error {
|
||||
@@ -407,7 +424,7 @@ func TestGrantsCompat(t *testing.T) {
|
||||
}
|
||||
|
||||
// testGrantError verifies that an invalid policy produces the expected error.
|
||||
func testGrantError(t *testing.T, policyJSON []byte, tf grantTestFile) {
|
||||
func testGrantError(t *testing.T, policyJSON []byte, tf *testcapture.Capture) {
|
||||
t.Helper()
|
||||
|
||||
wantMsg := ""
|
||||
@@ -439,91 +456,28 @@ func testGrantError(t *testing.T, policyJSON []byte, tf grantTestFile) {
|
||||
tf.TestID, wantMsg)
|
||||
}
|
||||
|
||||
// grantErrorMessageMap maps Tailscale error messages to their headscale equivalents
|
||||
// where the wording differs but the meaning is the same.
|
||||
var grantErrorMessageMap = map[string]string{
|
||||
// Tailscale says "ip and app can not both be empty",
|
||||
// headscale says "grants must specify either 'ip' or 'app' field"
|
||||
"ip and app can not both be empty": "grants must specify either",
|
||||
// Tailscale says "via can only be a tag",
|
||||
// headscale rejects at unmarshal time via Tag.UnmarshalJSON: "tag must start with 'tag:'"
|
||||
"via can only be a tag": "tag must start with",
|
||||
}
|
||||
|
||||
// assertGrantErrorContains checks that an error message contains the expected
|
||||
// Tailscale error message (or its headscale equivalent).
|
||||
// assertGrantErrorContains requires that headscale's error contains
|
||||
// the Tailscale SaaS error message exactly. Divergence means an
|
||||
// emitter needs to be aligned, not papered over with a translation
|
||||
// table.
|
||||
func assertGrantErrorContains(t *testing.T, err error, wantMsg string, testID string) {
|
||||
t.Helper()
|
||||
|
||||
errStr := err.Error()
|
||||
|
||||
// First try direct substring match
|
||||
if strings.Contains(errStr, wantMsg) {
|
||||
return
|
||||
}
|
||||
|
||||
// Try mapped equivalent
|
||||
if mapped, ok := grantErrorMessageMap[wantMsg]; ok {
|
||||
if strings.Contains(errStr, mapped) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Try matching key parts of the error message
|
||||
// Extract the most distinctive part of the Tailscale message
|
||||
keyParts := extractErrorKeyParts(wantMsg)
|
||||
for _, part := range keyParts {
|
||||
if strings.Contains(errStr, part) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
t.Errorf("%s: error message mismatch\n tailscale wants: %q\n headscale got: %q",
|
||||
testID, wantMsg, errStr)
|
||||
}
|
||||
|
||||
// extractErrorKeyParts extracts distinctive substrings from an error message
|
||||
// that should appear in any equivalent error message.
|
||||
func extractErrorKeyParts(msg string) []string {
|
||||
var parts []string
|
||||
|
||||
// Common patterns to extract
|
||||
if strings.Contains(msg, "tag:") {
|
||||
// Extract tag references like tag:nonexistent
|
||||
for word := range strings.FieldsSeq(msg) {
|
||||
word = strings.Trim(word, `"'`)
|
||||
if strings.HasPrefix(word, "tag:") {
|
||||
parts = append(parts, word)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(msg, "autogroup:") {
|
||||
for word := range strings.FieldsSeq(msg) {
|
||||
word = strings.Trim(word, `"'`)
|
||||
if strings.HasPrefix(word, "autogroup:") {
|
||||
parts = append(parts, word)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(msg, "capability name") {
|
||||
parts = append(parts, "capability")
|
||||
}
|
||||
|
||||
if strings.Contains(msg, "port range") {
|
||||
parts = append(parts, "port")
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
// testGrantSuccess verifies that a valid policy produces the expected
|
||||
// packet filter rules for each node.
|
||||
func testGrantSuccess(
|
||||
t *testing.T,
|
||||
policyJSON []byte,
|
||||
tf grantTestFile,
|
||||
tf *testcapture.Capture,
|
||||
users types.Users,
|
||||
nodes types.Nodes,
|
||||
) {
|
||||
@@ -537,37 +491,9 @@ func testGrantSuccess(
|
||||
|
||||
for nodeName, capture := range tf.Captures {
|
||||
t.Run(nodeName, func(t *testing.T) {
|
||||
// Check if this node was offline during capture.
|
||||
// tagged-prod was frequently offline (132 of 188 success tests).
|
||||
// When offline, packet_filter_rules is null and topology shows
|
||||
// hostname="unknown" with empty tags.
|
||||
captureIsNull := len(capture.PacketFilterRules) == 0 ||
|
||||
string(capture.PacketFilterRules) == "null"
|
||||
|
||||
if captureIsNull {
|
||||
topoNode, exists := tf.Topology.Nodes[nodeName]
|
||||
if exists && (topoNode.Hostname == "unknown" || topoNode.Hostname == "") {
|
||||
t.Skipf(
|
||||
"node %s was offline during Tailscale capture (hostname=%q)",
|
||||
nodeName,
|
||||
topoNode.Hostname,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
// Node was online but has null/empty rules — means Tailscale
|
||||
// produced no rules. headscale should also produce no rules.
|
||||
}
|
||||
|
||||
node := findGrantsNode(nodes, nodeName)
|
||||
if node == nil {
|
||||
t.Skipf(
|
||||
"node %s not found in test setup (may be a test-specific node)",
|
||||
nodeName,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
require.NotNilf(t, node,
|
||||
"golden node %s not found in test setup", nodeName)
|
||||
|
||||
// Compile headscale filter rules for this node
|
||||
gotRules, err := pol.compileFilterRulesForNode(
|
||||
@@ -585,21 +511,7 @@ func testGrantSuccess(
|
||||
|
||||
gotRules = policyutil.ReduceFilterRules(node.View(), gotRules)
|
||||
|
||||
// Unmarshal Tailscale expected rules from JSON capture
|
||||
var wantRules []tailcfg.FilterRule
|
||||
if !captureIsNull {
|
||||
err = json.Unmarshal(
|
||||
[]byte(capture.PacketFilterRules),
|
||||
&wantRules,
|
||||
)
|
||||
require.NoError(
|
||||
t,
|
||||
err,
|
||||
"%s/%s: failed to unmarshal expected rules from JSON",
|
||||
tf.TestID,
|
||||
nodeName,
|
||||
)
|
||||
}
|
||||
wantRules := capture.PacketFilterRules
|
||||
|
||||
// Compare headscale output against Tailscale expected output.
|
||||
// The diff labels show (-tailscale +headscale) to make clear
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,22 @@
|
||||
// This file is "generated" by Claude.
|
||||
// It contains a data-driven test that reads SSH-*.json test files captured
|
||||
// from Tailscale SaaS. Each file contains:
|
||||
// - The SSH section of the policy
|
||||
// - The expected SSHPolicy rules for each of 5 test nodes
|
||||
// This file implements a data-driven test runner for SSH compatibility tests.
|
||||
// It loads HuJSON golden files from testdata/ssh_results/ssh-*.hujson, captured
|
||||
// from Tailscale SaaS by tscap, and compares headscale's SSH policy compilation
|
||||
// against the captured SSH rules.
|
||||
//
|
||||
// The test loads each JSON file, constructs a full policy from the SSH section,
|
||||
// applies it through headscale's SSH policy compilation, and compares the output
|
||||
// against Tailscale's actual behavior.
|
||||
// Each file is a testcapture.Capture containing:
|
||||
// - The full policy that was POSTed to Tailscale SaaS (we use tf.Input.FullPolicy
|
||||
// directly instead of reconstructing it from a sub-section)
|
||||
// - The expected SSH rules for each of the 8 test nodes (in tf.Captures[name].SSHRules)
|
||||
//
|
||||
// Tests that are known to fail due to unimplemented features or known
|
||||
// differences are skipped with a TODO comment explaining the root cause.
|
||||
// As headscale's SSH implementation improves, tests should be removed
|
||||
// from the skip list.
|
||||
// Tests known to fail due to unimplemented features or known differences are
|
||||
// skipped with a TODO comment explaining the root cause.
|
||||
//
|
||||
// Test data source: testdata/ssh_results/SSH-*.json
|
||||
// Captured from: Tailscale SaaS API + tailscale debug localapi
|
||||
// Test data source: testdata/ssh_results/ssh-*.hujson
|
||||
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -28,63 +24,55 @@ import (
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/types/testcapture"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tailscale/hujson"
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// sshTestFile represents the JSON structure of a captured SSH test file.
|
||||
type sshTestFile struct {
|
||||
TestID string `json:"test_id"`
|
||||
PolicyFile string `json:"policy_file"`
|
||||
SSHSection json.RawMessage `json:"ssh_section"`
|
||||
Nodes map[string]sshNodeCapture `json:"nodes"`
|
||||
}
|
||||
|
||||
// sshNodeCapture represents the expected SSH rules for a single node.
|
||||
type sshNodeCapture struct {
|
||||
Rules json.RawMessage `json:"rules"`
|
||||
}
|
||||
|
||||
// setupSSHDataCompatUsers returns the 3 test users for SSH data-driven
|
||||
// compatibility tests. The user configuration matches the Tailscale test
|
||||
// environment with email domains preserved for localpart matching:
|
||||
// - kratail2tid@example.com (converted from @passkey)
|
||||
// - kristoffer@dalby.cc (kept as-is — different domain for localpart exclusion)
|
||||
// - monitorpasskeykradalby@example.com (converted from @passkey)
|
||||
// compatibility tests. Users get norse-god names; nodes get original-151
|
||||
// pokémon names — matching the anonymized identifiers tscap writes into
|
||||
// the capture files (see github.com/kradalby/tscap/anonymize).
|
||||
//
|
||||
// odin and freya live on @example.com; thor lives on @example.org so
|
||||
// that "localpart:*@example.com" resolves to exactly two users
|
||||
// (matching SaaS output) and the "user on a different email domain"
|
||||
// case stays covered by scenarios like ssh-d1 that use
|
||||
// "localpart:*@example.org".
|
||||
func setupSSHDataCompatUsers() types.Users {
|
||||
return types.Users{
|
||||
{
|
||||
Model: gorm.Model{ID: 1},
|
||||
Name: "kratail2tid",
|
||||
Email: "kratail2tid@example.com",
|
||||
Name: "odin",
|
||||
Email: "odin@example.com",
|
||||
},
|
||||
{
|
||||
Model: gorm.Model{ID: 2},
|
||||
Name: "kristoffer",
|
||||
Email: "kristoffer@dalby.cc",
|
||||
Name: "thor",
|
||||
Email: "thor@example.org",
|
||||
},
|
||||
{
|
||||
Model: gorm.Model{ID: 3},
|
||||
Name: "monitorpasskeykradalby",
|
||||
Email: "monitorpasskeykradalby@example.com",
|
||||
Name: "freya",
|
||||
Email: "freya@example.com",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// setupSSHDataCompatNodes returns the 5 test nodes for SSH data-driven
|
||||
// compatibility tests. Node GivenNames match the keys in the JSON files:
|
||||
// - user1 (owned by kratail2tid)
|
||||
// - user-kris (owned by kristoffer)
|
||||
// - user-mon (owned by monitorpasskeykradalby)
|
||||
// - tagged-server (tag:server)
|
||||
// - tagged-prod (tag:prod)
|
||||
// setupSSHDataCompatNodes returns the test nodes for SSH data-driven
|
||||
// compatibility tests. Node GivenNames match the anonymized pokémon names:
|
||||
// - bulbasaur (owned by odin)
|
||||
// - ivysaur (owned by thor)
|
||||
// - venusaur (owned by freya)
|
||||
// - beedrill (tag:server)
|
||||
// - kakuna (tag:prod)
|
||||
func setupSSHDataCompatNodes(users types.Users) types.Nodes {
|
||||
return types.Nodes{
|
||||
&types.Node{
|
||||
ID: 1,
|
||||
GivenName: "user1",
|
||||
GivenName: "bulbasaur",
|
||||
User: &users[0],
|
||||
UserID: &users[0].ID,
|
||||
IPv4: ptrAddr("100.90.199.68"),
|
||||
@@ -93,7 +81,7 @@ func setupSSHDataCompatNodes(users types.Users) types.Nodes {
|
||||
},
|
||||
&types.Node{
|
||||
ID: 2,
|
||||
GivenName: "user-kris",
|
||||
GivenName: "ivysaur",
|
||||
User: &users[1],
|
||||
UserID: &users[1].ID,
|
||||
IPv4: ptrAddr("100.110.121.96"),
|
||||
@@ -102,7 +90,7 @@ func setupSSHDataCompatNodes(users types.Users) types.Nodes {
|
||||
},
|
||||
&types.Node{
|
||||
ID: 3,
|
||||
GivenName: "user-mon",
|
||||
GivenName: "venusaur",
|
||||
User: &users[2],
|
||||
UserID: &users[2].ID,
|
||||
IPv4: ptrAddr("100.103.90.82"),
|
||||
@@ -111,7 +99,7 @@ func setupSSHDataCompatNodes(users types.Users) types.Nodes {
|
||||
},
|
||||
&types.Node{
|
||||
ID: 4,
|
||||
GivenName: "tagged-server",
|
||||
GivenName: "beedrill",
|
||||
IPv4: ptrAddr("100.108.74.26"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::b901:4a87"),
|
||||
Tags: []string{"tag:server"},
|
||||
@@ -119,7 +107,7 @@ func setupSSHDataCompatNodes(users types.Users) types.Nodes {
|
||||
},
|
||||
&types.Node{
|
||||
ID: 5,
|
||||
GivenName: "tagged-prod",
|
||||
GivenName: "kakuna",
|
||||
IPv4: ptrAddr("100.103.8.15"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::5b37:80f"),
|
||||
Tags: []string{"tag:prod"},
|
||||
@@ -128,122 +116,75 @@ func setupSSHDataCompatNodes(users types.Users) types.Nodes {
|
||||
}
|
||||
}
|
||||
|
||||
// convertSSHPolicyEmails converts Tailscale SaaS email domains to
|
||||
// headscale-compatible format in the raw policy JSON.
|
||||
//
|
||||
// Tailscale uses provider-specific email formats:
|
||||
// - kratail2tid@passkey (passkey auth)
|
||||
// - kristoffer@dalby.cc (email auth — kept as-is)
|
||||
// - monitorpasskeykradalby@passkey (passkey auth)
|
||||
//
|
||||
// The @passkey domain is converted to @example.com. The @dalby.cc domain
|
||||
// is kept as-is to preserve localpart matching semantics (kristoffer should
|
||||
// NOT match localpart:*@example.com, just as it doesn't match
|
||||
// localpart:*@passkey in Tailscale SaaS).
|
||||
func convertSSHPolicyEmails(s string) string {
|
||||
s = strings.ReplaceAll(s, "@passkey", "@example.com")
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// constructSSHFullPolicy builds a complete headscale policy from the
|
||||
// ssh_section captured from Tailscale SaaS.
|
||||
//
|
||||
// The base policy includes:
|
||||
// - groups matching the Tailscale test environment
|
||||
// - tagOwners for tag:server and tag:prod
|
||||
// - A permissive ACL allowing all traffic (matches the grants wildcard
|
||||
// in the original Tailscale policy)
|
||||
// - The SSH section from the test file
|
||||
func constructSSHFullPolicy(sshSection json.RawMessage) string {
|
||||
// Base policy template with groups, tagOwners, and ACLs
|
||||
// User references match the converted email addresses.
|
||||
const basePolicyPrefix = `{
|
||||
"groups": {
|
||||
"group:admins": ["kratail2tid@example.com"],
|
||||
"group:developers": ["kristoffer@dalby.cc", "kratail2tid@example.com"],
|
||||
"group:empty": []
|
||||
},
|
||||
"tagOwners": {
|
||||
"tag:server": ["kratail2tid@example.com"],
|
||||
"tag:prod": ["kratail2tid@example.com"]
|
||||
},
|
||||
"acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}]`
|
||||
|
||||
// Handle null or empty SSH section
|
||||
if len(sshSection) == 0 || string(sshSection) == "null" {
|
||||
// No SSH section at all (like SSH-E4)
|
||||
return basePolicyPrefix + "\n}"
|
||||
}
|
||||
|
||||
sshStr := string(sshSection)
|
||||
|
||||
// Convert Tailscale email domains
|
||||
sshStr = convertSSHPolicyEmails(sshStr)
|
||||
|
||||
return basePolicyPrefix + `,
|
||||
"ssh": ` + sshStr + "\n}"
|
||||
}
|
||||
|
||||
// loadSSHTestFile loads and parses a single SSH test JSON file.
|
||||
func loadSSHTestFile(t *testing.T, path string) sshTestFile {
|
||||
// loadSSHTestFile loads and parses a single SSH capture HuJSON file.
|
||||
func loadSSHTestFile(t *testing.T, path string) *testcapture.Capture {
|
||||
t.Helper()
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
c, err := testcapture.Read(path)
|
||||
require.NoError(t, err, "failed to read test file %s", path)
|
||||
|
||||
ast, err := hujson.Parse(content)
|
||||
require.NoError(t, err, "failed to parse HuJSON in %s", path)
|
||||
ast.Standardize()
|
||||
|
||||
var tf sshTestFile
|
||||
|
||||
err = json.Unmarshal(ast.Pack(), &tf)
|
||||
require.NoError(t, err, "failed to unmarshal test file %s", path)
|
||||
|
||||
return tf
|
||||
return c
|
||||
}
|
||||
|
||||
// sshSkipReasons documents why each skipped test fails and what needs to be
|
||||
// fixed. Tests are grouped by root cause to identify high-impact changes.
|
||||
//
|
||||
// 37 of 39 tests are expected to pass.
|
||||
var sshSkipReasons = map[string]string{
|
||||
// user:*@passkey wildcard pattern not supported in headscale.
|
||||
// USER_PASSKEY_WILDCARD (2 tests)
|
||||
//
|
||||
// headscale does not support passkey authentication and has no
|
||||
// equivalent for this wildcard pattern.
|
||||
"SSH-B5": "user:*@passkey wildcard not supported in headscale",
|
||||
"SSH-D10": "user:*@passkey wildcard not supported in headscale",
|
||||
// equivalent for the user:*@passkey wildcard pattern.
|
||||
"ssh-b5": "user:*@passkey wildcard not supported in headscale",
|
||||
"ssh-d10": "user:*@passkey wildcard not supported in headscale",
|
||||
|
||||
// DOMAIN_NOT_ASSOCIATED (4 tests)
|
||||
//
|
||||
// SaaS validates that email domains in user:*@domain and
|
||||
// localpart:*@domain expressions are configured tailnet domains.
|
||||
// headscale has no concept of "associated tailnet domains" — it
|
||||
// only has users with email addresses. These policies are
|
||||
// legitimately rejected by SaaS but not by headscale.
|
||||
"ssh-b4": "domain validation: headscale has no 'associated tailnet domains' concept",
|
||||
"ssh-d1": "domain validation: headscale has no 'associated tailnet domains' concept",
|
||||
"ssh-e1": "domain validation: headscale has no 'associated tailnet domains' concept",
|
||||
"ssh-e2": "domain validation: headscale has no 'associated tailnet domains' concept",
|
||||
}
|
||||
|
||||
// TestSSHDataCompat is a data-driven test that loads all SSH-*.json test files
|
||||
// captured from Tailscale SaaS and compares headscale's SSH policy compilation
|
||||
// against the real Tailscale behavior.
|
||||
// TestSSHDataCompat is a data-driven test that loads all ssh-*.hujson test
|
||||
// files captured from Tailscale SaaS and compares headscale's SSH policy
|
||||
// compilation against the real Tailscale behavior.
|
||||
//
|
||||
// Each JSON file contains:
|
||||
// - The SSH section of the policy
|
||||
// - Expected SSH rules per node (5 nodes)
|
||||
// Each capture file contains:
|
||||
// - The full policy that was POSTed to the SaaS API (Input.FullPolicy)
|
||||
// - Expected SSH rules per node (Captures[name].SSHRules)
|
||||
//
|
||||
// The test constructs a full headscale policy from the SSH section, converts
|
||||
// Tailscale user email formats to headscale format, and runs the policy
|
||||
// through unmarshalPolicy and compileSSHPolicy.
|
||||
// The test converts Tailscale user email formats to headscale format and runs
|
||||
// the captured policy through unmarshalPolicy and compileSSHPolicy.
|
||||
func TestSSHDataCompat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files, err := filepath.Glob(
|
||||
filepath.Join("testdata", "ssh_results", "SSH-*.hujson"),
|
||||
filepath.Join("testdata", "ssh_results", "ssh-*.hujson"),
|
||||
)
|
||||
require.NoError(t, err, "failed to glob test files")
|
||||
require.NotEmpty(
|
||||
t,
|
||||
files,
|
||||
"no SSH-*.hujson test files found in testdata/ssh_results/",
|
||||
"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()
|
||||
nodes := setupSSHDataCompatNodes(users)
|
||||
|
||||
for _, file := range files {
|
||||
tf := loadSSHTestFile(t, file)
|
||||
@@ -261,8 +202,21 @@ func TestSSHDataCompat(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
// Construct full policy from SSH section
|
||||
policyJSON := constructSSHFullPolicy(tf.SSHSection)
|
||||
// SaaS rejected this policy — verify headscale also rejects it.
|
||||
if tf.Error {
|
||||
testSSHError(t, tf)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Build nodes per-scenario from this file's topology.
|
||||
// tscap uses clean-slate mode, so each scenario has
|
||||
// different node IPs.
|
||||
nodes := buildGrantsNodesFromCapture(users, tf)
|
||||
|
||||
// Use the captured full policy as is. Anonymization in
|
||||
// tscap already rewrites SaaS emails to @example.com.
|
||||
policyJSON := tf.Input.FullPolicy
|
||||
|
||||
pol, err := unmarshalPolicy([]byte(policyJSON))
|
||||
require.NoError(
|
||||
@@ -273,19 +227,15 @@ func TestSSHDataCompat(t *testing.T) {
|
||||
policyJSON,
|
||||
)
|
||||
|
||||
for nodeName, capture := range tf.Nodes {
|
||||
for nodeName, capture := range tf.Captures {
|
||||
t.Run(nodeName, func(t *testing.T) {
|
||||
node := findNodeByGivenName(nodes, nodeName)
|
||||
require.NotNilf(
|
||||
t,
|
||||
node,
|
||||
"node %s not found in test setup",
|
||||
nodeName,
|
||||
)
|
||||
require.NotNilf(t, node,
|
||||
"golden node %s not found in test setup", nodeName)
|
||||
|
||||
// Compile headscale SSH policy for this node
|
||||
gotSSH, err := pol.compileSSHPolicy(
|
||||
"unused-server-url",
|
||||
"https://unused",
|
||||
users,
|
||||
node.View(),
|
||||
nodes.ViewSlice(),
|
||||
@@ -298,24 +248,10 @@ func TestSSHDataCompat(t *testing.T) {
|
||||
nodeName,
|
||||
)
|
||||
|
||||
// Parse expected rules from JSON capture
|
||||
var wantRules []*tailcfg.SSHRule
|
||||
if len(capture.Rules) > 0 &&
|
||||
string(capture.Rules) != "null" {
|
||||
err = json.Unmarshal(capture.Rules, &wantRules)
|
||||
require.NoError(
|
||||
t,
|
||||
err,
|
||||
"%s/%s: failed to unmarshal expected rules",
|
||||
tf.TestID,
|
||||
nodeName,
|
||||
)
|
||||
}
|
||||
|
||||
// Build expected SSHPolicy from the rules
|
||||
// Build expected SSHPolicy from the typed rules.
|
||||
var wantSSH *tailcfg.SSHPolicy
|
||||
if len(wantRules) > 0 {
|
||||
wantSSH = &tailcfg.SSHPolicy{Rules: wantRules}
|
||||
if len(capture.SSHRules) > 0 {
|
||||
wantSSH = &tailcfg.SSHPolicy{Rules: capture.SSHRules}
|
||||
}
|
||||
|
||||
// Normalize: treat empty-rules SSHPolicy as nil
|
||||
@@ -327,6 +263,7 @@ func TestSSHDataCompat(t *testing.T) {
|
||||
// EquateEmpty treats nil and empty slices as equal.
|
||||
// Sort principals within rules (order doesn't matter).
|
||||
// Do NOT sort rules — order matters (first-match-wins).
|
||||
//
|
||||
opts := cmp.Options{
|
||||
cmpopts.SortSlices(func(a, b *tailcfg.SSHPrincipal) bool {
|
||||
return a.NodeIP < b.NodeIP
|
||||
@@ -341,8 +278,128 @@ func TestSSHDataCompat(t *testing.T) {
|
||||
diff,
|
||||
)
|
||||
}
|
||||
|
||||
// Separate presence check: the fields ignored by
|
||||
// the diff above must still be populated on matching
|
||||
// rules. This catches regressions where headscale
|
||||
// would silently drop the HoldAndDelegate URL or
|
||||
// flip Accept to false while we are not looking.
|
||||
if wantSSH != nil && gotSSH != nil {
|
||||
for i, wantRule := range wantSSH.Rules {
|
||||
if i >= len(gotSSH.Rules) {
|
||||
break
|
||||
}
|
||||
|
||||
gotRule := gotSSH.Rules[i]
|
||||
if wantRule.Action == nil || gotRule.Action == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
wantIsCheck := wantRule.Action.HoldAndDelegate != ""
|
||||
gotIsCheck := gotRule.Action.HoldAndDelegate != ""
|
||||
|
||||
assert.Equalf(t, wantIsCheck, gotIsCheck,
|
||||
"%s/%s rule %d: HoldAndDelegate presence mismatch",
|
||||
tf.TestID, nodeName, i,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// sshErrorMessageMap maps Tailscale SaaS error substrings to headscale
|
||||
// equivalents where the wording differs but the meaning is the same.
|
||||
var sshErrorMessageMap = map[string]string{}
|
||||
|
||||
// testSSHError verifies that an invalid policy produces the expected error.
|
||||
func testSSHError(t *testing.T, tf *testcapture.Capture) {
|
||||
t.Helper()
|
||||
|
||||
policyJSON := []byte(tf.Input.FullPolicy)
|
||||
|
||||
pol, err := unmarshalPolicy(policyJSON)
|
||||
if err != nil {
|
||||
// Parse-time error.
|
||||
if tf.Input.APIResponseBody != nil {
|
||||
wantMsg := tf.Input.APIResponseBody.Message
|
||||
if wantMsg != "" {
|
||||
assertSSHErrorContains(t, err, wantMsg, tf.TestID)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err = pol.validate()
|
||||
if err != nil {
|
||||
if tf.Input.APIResponseBody != nil {
|
||||
wantMsg := tf.Input.APIResponseBody.Message
|
||||
if wantMsg != "" {
|
||||
assertSSHErrorContains(t, err, wantMsg, tf.TestID)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
t.Errorf(
|
||||
"%s: expected error but policy parsed and validated successfully",
|
||||
tf.TestID,
|
||||
)
|
||||
}
|
||||
|
||||
// assertSSHErrorContains checks that an error message matches the
|
||||
// expected Tailscale SaaS message, using progressive fallbacks:
|
||||
// 1. Direct substring match
|
||||
// 2. Mapped equivalent from sshErrorMessageMap
|
||||
// 3. Key-part extraction (tags, autogroups)
|
||||
// 4. t.Errorf on no match (strict)
|
||||
func assertSSHErrorContains(
|
||||
t *testing.T,
|
||||
err error,
|
||||
wantMsg string,
|
||||
testID string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
errStr := err.Error()
|
||||
|
||||
// 1. Direct substring match.
|
||||
if strings.Contains(errStr, wantMsg) {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Mapped equivalent.
|
||||
for tsKey, hsKey := range sshErrorMessageMap {
|
||||
if strings.Contains(wantMsg, tsKey) &&
|
||||
strings.Contains(errStr, hsKey) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Key-part extraction.
|
||||
for _, part := range []string{
|
||||
"autogroup:",
|
||||
"tag:",
|
||||
"undefined",
|
||||
"not valid",
|
||||
} {
|
||||
if strings.Contains(wantMsg, part) &&
|
||||
strings.Contains(errStr, part) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. No match — strict failure.
|
||||
t.Errorf(
|
||||
"%s: error message mismatch\n"+
|
||||
" want (tailscale): %q\n"+
|
||||
" got (headscale): %q",
|
||||
testID,
|
||||
wantMsg,
|
||||
errStr,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
|
||||
// Tailscale's policy file `tests` block validates a policy against operator
|
||||
// assertions: from a given src, named dst:port pairs must be accepted, and
|
||||
// (optionally) other dst:port pairs must be denied. They run at user-write
|
||||
// boundaries — `headscale policy set`, file-mode reload after a change,
|
||||
// `headscale policy check` — and reject the write if any assertion fails.
|
||||
// Boot-time reload of an already-stored policy does not run them, so a
|
||||
// stale referenced entity (e.g. a deleted user) cannot lock the server out.
|
||||
//
|
||||
// The tests evaluate against the compiled global filter rules, which fold in
|
||||
// both `acls` and `grants`, so the `tests` block validates the whole policy.
|
||||
|
||||
// errPolicyTestsFailed wraps the rendered failure body so callers can
|
||||
// type-assert when they need to react differently to test failures vs. parse
|
||||
// errors. The Error() prefix is "test(s) failed", the same string Tailscale
|
||||
// SaaS returns in the api_response_body.message — see
|
||||
// hscontrol/policy/v2/testdata/policytest_results/.
|
||||
var (
|
||||
errPolicyTestsFailed = errors.New("test(s) failed")
|
||||
errTestDestinationNoIP = errors.New("destination resolved to no IP addresses")
|
||||
)
|
||||
|
||||
// PolicyTest is one entry in the policy's `tests` block.
|
||||
type PolicyTest struct {
|
||||
// Src is a single source alias (user, group, tag, host, autogroup, or IP).
|
||||
// Tailscale only supports a single src per test entry.
|
||||
Src string `json:"src"`
|
||||
|
||||
// Proto restricts the test to one protocol. Empty matches the default
|
||||
// set the client applies when proto is omitted (TCP/UDP/ICMP).
|
||||
Proto Protocol `json:"proto,omitempty"`
|
||||
|
||||
// Accept lists destinations in `host:port` form that must be reachable
|
||||
// from Src. A test fails if any entry is denied by the compiled filter.
|
||||
Accept []string `json:"accept,omitempty"`
|
||||
|
||||
// Deny lists destinations in `host:port` form that must NOT be reachable
|
||||
// from Src. A test fails if any entry is allowed by the compiled filter.
|
||||
Deny []string `json:"deny,omitempty"`
|
||||
}
|
||||
|
||||
// PolicyTestResult is the outcome of a single PolicyTest.
|
||||
type PolicyTestResult struct {
|
||||
Src string `json:"src"`
|
||||
Proto Protocol `json:"proto,omitempty"`
|
||||
Passed bool `json:"passed"`
|
||||
|
||||
// Errors are non-assertion problems: src failed to resolve, dst was
|
||||
// malformed, etc. These cause the test to fail.
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
|
||||
// AcceptOK / AcceptFail / DenyOK / DenyFail partition the per-dst
|
||||
// outcomes for diagnostics.
|
||||
AcceptOK []string `json:"accept_ok,omitempty"`
|
||||
AcceptFail []string `json:"accept_fail,omitempty"`
|
||||
DenyOK []string `json:"deny_ok,omitempty"`
|
||||
DenyFail []string `json:"deny_fail,omitempty"`
|
||||
}
|
||||
|
||||
// PolicyTestResults aggregates a run.
|
||||
type PolicyTestResults struct {
|
||||
AllPassed bool `json:"all_passed"`
|
||||
Results []PolicyTestResult `json:"results"`
|
||||
}
|
||||
|
||||
// Errors renders the per-test failure breakdown joined by newlines.
|
||||
// Tailscale SaaS itself only returns the literal "test(s) failed" — we
|
||||
// keep the per-test detail because it is significantly more useful in
|
||||
// CLI / config-reload paths where the user does not have a separate
|
||||
// audit endpoint to consult.
|
||||
func (r PolicyTestResults) Errors() string {
|
||||
if r.AllPassed {
|
||||
return ""
|
||||
}
|
||||
|
||||
var lines []string
|
||||
|
||||
for _, res := range r.Results {
|
||||
if res.Passed {
|
||||
continue
|
||||
}
|
||||
|
||||
protoSuffix := ""
|
||||
if res.Proto != "" {
|
||||
protoSuffix = fmt.Sprintf(" (%s)", res.Proto)
|
||||
}
|
||||
|
||||
for _, e := range res.Errors {
|
||||
lines = append(lines, fmt.Sprintf("%s%s: %s", res.Src, protoSuffix, e))
|
||||
}
|
||||
|
||||
for _, dst := range res.AcceptFail {
|
||||
lines = append(lines, fmt.Sprintf("%s -> %s%s: expected ALLOWED, got DENIED", res.Src, dst, protoSuffix))
|
||||
}
|
||||
|
||||
for _, dst := range res.DenyFail {
|
||||
lines = append(lines, fmt.Sprintf("%s -> %s%s: expected DENIED, got ALLOWED", res.Src, dst, protoSuffix))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// RunTests evaluates the policy's own `tests` block against the live compiled
|
||||
// filter and returns a wrapped error when any test fails. Callers that need
|
||||
// the per-test breakdown can call runPolicyTests directly.
|
||||
func (pm *PolicyManager) RunTests() error {
|
||||
if pm == nil || pm.pol == nil || len(pm.pol.Tests) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
results := runPolicyTests(pm.pol, pm.filter, pm.users, pm.nodes)
|
||||
if results.AllPassed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w:\n%s", errPolicyTestsFailed, results.Errors())
|
||||
}
|
||||
|
||||
// evaluateTests runs the `tests` block against a fresh compilation of pol.
|
||||
// It is the user-write sandbox: the live PolicyManager state is left
|
||||
// untouched, so a failing test rejects the write without side effects.
|
||||
func evaluateTests(pol *Policy, users []types.User, nodes views.Slice[types.NodeView]) error {
|
||||
if pol == nil || len(pol.Tests) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
grants := pol.compileGrants(users, nodes)
|
||||
|
||||
var filter []tailcfg.FilterRule
|
||||
if pol.ACLs == nil && pol.Grants == nil {
|
||||
filter = tailcfg.FilterAllowAll
|
||||
} else {
|
||||
filter = globalFilterRules(grants)
|
||||
}
|
||||
|
||||
results := runPolicyTests(pol, filter, users, nodes)
|
||||
if results.AllPassed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w:\n%s", errPolicyTestsFailed, results.Errors())
|
||||
}
|
||||
|
||||
// runPolicyTests is the pure evaluation function: given a policy, the
|
||||
// compiled filter rules derived from it, and the active users/nodes, run
|
||||
// every test and return the aggregated outcome. It does not lock anything
|
||||
// or mutate any input.
|
||||
func runPolicyTests(pol *Policy, filter []tailcfg.FilterRule, users []types.User, nodes views.Slice[types.NodeView]) PolicyTestResults {
|
||||
results := PolicyTestResults{
|
||||
AllPassed: true,
|
||||
Results: make([]PolicyTestResult, 0, len(pol.Tests)),
|
||||
}
|
||||
|
||||
for _, test := range pol.Tests {
|
||||
res := runPolicyTest(test, pol, filter, users, nodes)
|
||||
if !res.Passed {
|
||||
results.AllPassed = false
|
||||
}
|
||||
|
||||
results.Results = append(results.Results, res)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// runPolicyTest evaluates one PolicyTest.
|
||||
func runPolicyTest(test PolicyTest, pol *Policy, filter []tailcfg.FilterRule, users []types.User, nodes views.Slice[types.NodeView]) PolicyTestResult {
|
||||
res := PolicyTestResult{
|
||||
Src: test.Src,
|
||||
Proto: test.Proto,
|
||||
Passed: true,
|
||||
}
|
||||
|
||||
srcPrefixes, err := resolveTestSource(test.Src, pol, users, nodes)
|
||||
if err != nil {
|
||||
res.Passed = false
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("failed to resolve source %q: %v", test.Src, err))
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
if len(srcPrefixes) == 0 {
|
||||
res.Passed = false
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("source %q resolved to no IP addresses", test.Src))
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
for _, dst := range test.Accept {
|
||||
allowed, err := evalReachability(srcPrefixes, dst, test.Proto, pol, filter, users, nodes)
|
||||
if err != nil {
|
||||
res.Passed = false
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("error testing %q: %v", dst, err))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if allowed {
|
||||
res.AcceptOK = append(res.AcceptOK, dst)
|
||||
} else {
|
||||
res.Passed = false
|
||||
res.AcceptFail = append(res.AcceptFail, dst)
|
||||
}
|
||||
}
|
||||
|
||||
for _, dst := range test.Deny {
|
||||
allowed, err := evalReachability(srcPrefixes, dst, test.Proto, pol, filter, users, nodes)
|
||||
if err != nil {
|
||||
res.Passed = false
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("error testing %q: %v", dst, err))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
res.DenyOK = append(res.DenyOK, dst)
|
||||
} else {
|
||||
res.Passed = false
|
||||
res.DenyFail = append(res.DenyFail, dst)
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// resolveTestSource resolves the Src alias of a PolicyTest into a slice of
|
||||
// netip.Prefix. parseAlias + Alias.Resolve cover every alias type the rest
|
||||
// of the policy engine supports, so tests inherit alias semantics for free.
|
||||
func resolveTestSource(src string, pol *Policy, users []types.User, nodes views.Slice[types.NodeView]) ([]netip.Prefix, error) {
|
||||
alias, err := parseAlias(src)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid alias: %w", err)
|
||||
}
|
||||
|
||||
addrs, err := alias.Resolve(pol, users, nodes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolving: %w", err)
|
||||
}
|
||||
|
||||
if addrs == nil || addrs.Empty() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return addrs.Prefixes(), nil
|
||||
}
|
||||
|
||||
// evalReachability reports whether traffic from any srcPrefix to dst (in
|
||||
// `host:port` form) is allowed by filter for the requested protocol.
|
||||
//
|
||||
// Empty proto means the default set the client applies when proto is
|
||||
// omitted (TCP/UDP/ICMP) — we accept a rule whose IPProto list contains
|
||||
// any of those, or rules with no IPProto restriction at all.
|
||||
func evalReachability(srcPrefixes []netip.Prefix, dst string, proto Protocol, pol *Policy, filter []tailcfg.FilterRule, users []types.User, nodes views.Slice[types.NodeView]) (bool, error) {
|
||||
awp, err := parseDestinationAlias(dst)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid destination %q: %w", dst, err)
|
||||
}
|
||||
|
||||
dstAddrs, err := awp.Resolve(pol, users, nodes)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("resolving destination: %w", err)
|
||||
}
|
||||
|
||||
if dstAddrs == nil || dstAddrs.Empty() {
|
||||
return false, fmt.Errorf("%w: %q", errTestDestinationNoIP, dst)
|
||||
}
|
||||
|
||||
dstPrefixes := dstAddrs.Prefixes()
|
||||
|
||||
// Tailscale's tests semantics: ALL src prefixes must reach the dst for
|
||||
// the test to consider it allowed. A partial allow is a fail.
|
||||
for _, src := range srcPrefixes {
|
||||
if !srcReachesDst(src, dstPrefixes, awp.Ports, proto, filter) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// parseDestinationAlias is a thin wrapper over AliasWithPorts.UnmarshalJSON
|
||||
// so callers can hand it a bare `"host:port"` string without re-implementing
|
||||
// the parse logic.
|
||||
func parseDestinationAlias(dst string) (*AliasWithPorts, error) {
|
||||
var awp AliasWithPorts
|
||||
|
||||
// AliasWithPorts.UnmarshalJSON expects a quoted JSON string, so wrap.
|
||||
err := awp.UnmarshalJSON([]byte(`"` + dst + `"`))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &awp, nil
|
||||
}
|
||||
|
||||
// srcReachesDst walks the compiled filter rules and reports whether
|
||||
// traffic from src to any prefix in dstPrefixes on at least one of ports
|
||||
// (or any port when ports is empty) is allowed under proto.
|
||||
//
|
||||
// An empty test proto means the Tailscale client default set
|
||||
// {TCP, UDP, ICMP, ICMPv6} — the protocols the client tries when proto
|
||||
// is omitted. The captured Tailscale matches show these four IANA
|
||||
// numbers explicitly when no proto is set, so a rule restricted to any
|
||||
// of them satisfies an empty-proto test.
|
||||
func srcReachesDst(src netip.Prefix, dstPrefixes []netip.Prefix, ports []tailcfg.PortRange, proto Protocol, filter []tailcfg.FilterRule) bool {
|
||||
requestedProtos := proto.toIANAProtocolNumbers()
|
||||
if len(requestedProtos) == 0 {
|
||||
requestedProtos = []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}
|
||||
}
|
||||
|
||||
for _, rule := range filter {
|
||||
if !ruleMatchesSource(rule, src) {
|
||||
continue
|
||||
}
|
||||
|
||||
if !ruleMatchesProto(rule, requestedProtos) {
|
||||
continue
|
||||
}
|
||||
|
||||
if ruleAllowsAnyDest(rule, dstPrefixes, ports) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ruleMatchesSource reports whether the rule's source list contains src.
|
||||
// SrcIPs may be CIDR, single addresses, IP ranges (`a-b`), or `*`; we use
|
||||
// util.ParseIPSet to cover all of those uniformly. Unparseable entries
|
||||
// are skipped (the rule compiler emits well-formed strings, so this is
|
||||
// defence-in-depth, not error handling).
|
||||
func ruleMatchesSource(rule tailcfg.FilterRule, src netip.Prefix) bool {
|
||||
for _, raw := range rule.SrcIPs {
|
||||
set, err := util.ParseIPSet(raw, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if set.OverlapsPrefix(src) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ruleMatchesProto reports whether the rule permits any of requestedProtos.
|
||||
// An unset rule.IPProto means "any protocol" and matches everything.
|
||||
// requestedProtos is the per-test protocol set: a single proto for an
|
||||
// explicit test.Proto, or the default set when test.Proto is empty.
|
||||
func ruleMatchesProto(rule tailcfg.FilterRule, requestedProtos []int) bool {
|
||||
if len(rule.IPProto) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, ruleProto := range rule.IPProto {
|
||||
if slices.Contains(requestedProtos, ruleProto) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ruleAllowsAnyDest reports whether at least one destination prefix in
|
||||
// dstPrefixes is allowed by at least one of the rule's DstPorts entries
|
||||
// for at least one of ports (or any port when ports is empty).
|
||||
func ruleAllowsAnyDest(rule tailcfg.FilterRule, dstPrefixes []netip.Prefix, ports []tailcfg.PortRange) bool {
|
||||
for _, dp := range rule.DstPorts {
|
||||
if !destEntryMatchesPrefixes(dp, dstPrefixes) {
|
||||
continue
|
||||
}
|
||||
|
||||
if portsAllowed(ports, dp.Ports) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// destEntryMatchesPrefixes reports whether the rule's NetPortRange.IP
|
||||
// (CIDR, single IP, IP range, or "*") covers any prefix in dstPrefixes.
|
||||
func destEntryMatchesPrefixes(dp tailcfg.NetPortRange, dstPrefixes []netip.Prefix) bool {
|
||||
set, err := util.ParseIPSet(dp.IP, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return slices.ContainsFunc(dstPrefixes, set.OverlapsPrefix)
|
||||
}
|
||||
|
||||
// portsAllowed reports whether at least one requested port is contained
|
||||
// in allowed. Empty requested means "any port".
|
||||
func portsAllowed(requested []tailcfg.PortRange, allowed tailcfg.PortRange) bool {
|
||||
if len(requested) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, r := range requested {
|
||||
if r.First >= allowed.First && r.Last <= allowed.Last {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// policyTestUsers/policyTestNodes are reused across the test cases below to
|
||||
// keep each table row focussed on the policy + tests under exercise.
|
||||
func policyTestUsers() types.Users {
|
||||
return types.Users{
|
||||
{Model: gorm.Model{ID: 1}, Name: "alice", Email: "alice@headscale.net"},
|
||||
{Model: gorm.Model{ID: 2}, Name: "bob", Email: "bob@headscale.net"},
|
||||
}
|
||||
}
|
||||
|
||||
func policyTestNodes(users types.Users) types.Nodes {
|
||||
nodes := types.Nodes{
|
||||
// alice's user-owned laptop
|
||||
{
|
||||
ID: 1,
|
||||
Hostname: "alice-laptop",
|
||||
IPv4: ap("100.64.0.1"),
|
||||
IPv6: ap("fd7a:115c:a1e0::1"),
|
||||
User: &users[0],
|
||||
UserID: &users[0].ID,
|
||||
},
|
||||
// bob's user-owned laptop
|
||||
{
|
||||
ID: 2,
|
||||
Hostname: "bob-laptop",
|
||||
IPv4: ap("100.64.0.2"),
|
||||
IPv6: ap("fd7a:115c:a1e0::2"),
|
||||
User: &users[1],
|
||||
UserID: &users[1].ID,
|
||||
},
|
||||
// tagged server (created via tagged preauth key from alice)
|
||||
{
|
||||
ID: 3,
|
||||
Hostname: "server",
|
||||
IPv4: ap("100.64.0.3"),
|
||||
IPv6: ap("fd7a:115c:a1e0::3"),
|
||||
User: &users[0],
|
||||
UserID: &users[0].ID,
|
||||
Tags: []string{"tag:server"},
|
||||
},
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
// TestRunTests covers the engine's per-test outcome reporting. Each row
|
||||
// constructs a PolicyManager (which also runs SetPolicy's sandbox) and
|
||||
// checks the resulting RunTests behaviour. SetPolicy gating is exercised
|
||||
// separately in TestSetPolicyRejectsFailingTests.
|
||||
func TestRunTests(t *testing.T) {
|
||||
users := policyTestUsers()
|
||||
nodes := policyTestNodes(users)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
policy string
|
||||
wantPass bool
|
||||
wantErrSub []string // substrings expected in the rendered error
|
||||
wantNoErrIs error // sentinel the error must wrap
|
||||
}{
|
||||
{
|
||||
name: "all-pass-user-to-tag",
|
||||
policy: `{
|
||||
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
||||
"acls": [{
|
||||
"action": "accept",
|
||||
"src": ["alice@headscale.net"],
|
||||
"dst": ["tag:server:22"]
|
||||
}],
|
||||
"tests": [{
|
||||
"src": "alice@headscale.net",
|
||||
"accept": ["tag:server:22"]
|
||||
}]
|
||||
}`,
|
||||
wantPass: true,
|
||||
},
|
||||
{
|
||||
name: "accept-fail-blocked-by-policy",
|
||||
policy: `{
|
||||
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
||||
"acls": [{
|
||||
"action": "accept",
|
||||
"src": ["alice@headscale.net"],
|
||||
"dst": ["tag:server:22"]
|
||||
}],
|
||||
"tests": [{
|
||||
"src": "bob@headscale.net",
|
||||
"accept": ["tag:server:22"]
|
||||
}]
|
||||
}`,
|
||||
wantPass: false,
|
||||
wantErrSub: []string{"bob@headscale.net", "tag:server:22", "expected ALLOWED"},
|
||||
wantNoErrIs: errPolicyTestsFailed,
|
||||
},
|
||||
{
|
||||
name: "deny-fail-policy-allows-traffic",
|
||||
policy: `{
|
||||
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
||||
"acls": [{
|
||||
"action": "accept",
|
||||
"src": ["alice@headscale.net"],
|
||||
"dst": ["tag:server:22"]
|
||||
}],
|
||||
"tests": [{
|
||||
"src": "alice@headscale.net",
|
||||
"deny": ["tag:server:22"]
|
||||
}]
|
||||
}`,
|
||||
wantPass: false,
|
||||
wantErrSub: []string{"alice@headscale.net", "tag:server:22", "expected DENIED"},
|
||||
wantNoErrIs: errPolicyTestsFailed,
|
||||
},
|
||||
{
|
||||
name: "unknown-src-user",
|
||||
policy: `{
|
||||
"acls": [{
|
||||
"action": "accept",
|
||||
"src": ["*"],
|
||||
"dst": ["*:*"]
|
||||
}],
|
||||
"tests": [{
|
||||
"src": "ghost@headscale.net",
|
||||
"accept": ["alice-laptop:22"]
|
||||
}]
|
||||
}`,
|
||||
wantPass: false,
|
||||
wantErrSub: []string{"ghost@headscale.net", "failed to resolve source"},
|
||||
wantNoErrIs: errPolicyTestsFailed,
|
||||
},
|
||||
// "malformed-dst-missing-port" used to live here; structural
|
||||
// shape errors are now caught at parse by validateTests, so
|
||||
// RunTests no longer sees them. The parse-side behaviour is
|
||||
// covered by TestUnmarshalPolicy/tests-* in types_test.go.
|
||||
{
|
||||
name: "wildcard-src-passes",
|
||||
policy: `{
|
||||
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
||||
"acls": [{
|
||||
"action": "accept",
|
||||
"src": ["*"],
|
||||
"dst": ["tag:server:80"]
|
||||
}],
|
||||
"tests": [{
|
||||
"src": "alice@headscale.net",
|
||||
"accept": ["tag:server:80"]
|
||||
}]
|
||||
}`,
|
||||
wantPass: true,
|
||||
},
|
||||
{
|
||||
name: "proto-restrict-tcp-only",
|
||||
policy: `{
|
||||
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
||||
"acls": [{
|
||||
"action": "accept",
|
||||
"proto": "tcp",
|
||||
"src": ["alice@headscale.net"],
|
||||
"dst": ["tag:server:22"]
|
||||
}],
|
||||
"tests": [
|
||||
{
|
||||
"src": "alice@headscale.net",
|
||||
"proto": "tcp",
|
||||
"accept": ["tag:server:22"]
|
||||
},
|
||||
{
|
||||
"src": "alice@headscale.net",
|
||||
"proto": "udp",
|
||||
"deny": ["tag:server:22"]
|
||||
}
|
||||
]
|
||||
}`,
|
||||
wantPass: true,
|
||||
},
|
||||
{
|
||||
name: "grants-only-policy-evaluated",
|
||||
policy: `{
|
||||
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
||||
"grants": [{
|
||||
"src": ["alice@headscale.net"],
|
||||
"dst": ["tag:server"],
|
||||
"ip": ["22"]
|
||||
}],
|
||||
"tests": [{
|
||||
"src": "alice@headscale.net",
|
||||
"accept": ["tag:server:22"]
|
||||
}]
|
||||
}`,
|
||||
wantPass: true,
|
||||
},
|
||||
{
|
||||
name: "mixed-pass-and-fail-reports-failure",
|
||||
policy: `{
|
||||
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
||||
"acls": [{
|
||||
"action": "accept",
|
||||
"src": ["alice@headscale.net"],
|
||||
"dst": ["tag:server:22"]
|
||||
}],
|
||||
"tests": [
|
||||
{
|
||||
"src": "alice@headscale.net",
|
||||
"accept": ["tag:server:22"]
|
||||
},
|
||||
{
|
||||
"src": "bob@headscale.net",
|
||||
"accept": ["tag:server:22"]
|
||||
}
|
||||
]
|
||||
}`,
|
||||
wantPass: false,
|
||||
wantErrSub: []string{"bob@headscale.net", "expected ALLOWED"},
|
||||
wantNoErrIs: errPolicyTestsFailed,
|
||||
},
|
||||
{
|
||||
name: "no-tests-block-is-no-op",
|
||||
policy: `{
|
||||
"acls": [{
|
||||
"action": "accept",
|
||||
"src": ["*"],
|
||||
"dst": ["*:*"]
|
||||
}]
|
||||
}`,
|
||||
wantPass: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pm, err := NewPolicyManager([]byte(tt.policy), users, nodes.ViewSlice())
|
||||
require.NoError(t, err, "policy must parse and compile")
|
||||
|
||||
runErr := pm.RunTests()
|
||||
if tt.wantPass {
|
||||
require.NoError(t, runErr, "tests should pass")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
require.Error(t, runErr, "tests should fail")
|
||||
require.ErrorIs(t, runErr, tt.wantNoErrIs, "error should wrap errPolicyTestsFailed")
|
||||
|
||||
for _, sub := range tt.wantErrSub {
|
||||
assert.Contains(t, runErr.Error(), sub, "rendered error should mention %q", sub)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetPolicyRejectsFailingTests asserts that SetPolicy is the user-write
|
||||
// boundary: a policy whose tests fail must be rejected without mutating the
|
||||
// live PolicyManager. NewPolicyManager (boot path) does not run tests.
|
||||
func TestSetPolicyRejectsFailingTests(t *testing.T) {
|
||||
users := policyTestUsers()
|
||||
nodes := policyTestNodes(users)
|
||||
|
||||
good := `{
|
||||
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
||||
"acls": [{
|
||||
"action": "accept",
|
||||
"src": ["alice@headscale.net"],
|
||||
"dst": ["tag:server:22"]
|
||||
}],
|
||||
"tests": [{
|
||||
"src": "alice@headscale.net",
|
||||
"accept": ["tag:server:22"]
|
||||
}]
|
||||
}`
|
||||
|
||||
bad := `{
|
||||
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
||||
"acls": [{
|
||||
"action": "accept",
|
||||
"src": ["alice@headscale.net"],
|
||||
"dst": ["tag:server:22"]
|
||||
}],
|
||||
"tests": [{
|
||||
"src": "bob@headscale.net",
|
||||
"accept": ["tag:server:22"]
|
||||
}]
|
||||
}`
|
||||
|
||||
pm, err := NewPolicyManager([]byte(good), users, nodes.ViewSlice())
|
||||
require.NoError(t, err)
|
||||
|
||||
beforeFilter, _ := pm.Filter()
|
||||
|
||||
changed, err := pm.SetPolicy([]byte(bad))
|
||||
require.Error(t, err, "SetPolicy must reject a policy whose tests fail")
|
||||
require.False(t, changed, "SetPolicy must report no change when rejected")
|
||||
require.ErrorIs(t, err, errPolicyTestsFailed)
|
||||
require.Contains(t, err.Error(), "expected ALLOWED")
|
||||
|
||||
afterFilter, _ := pm.Filter()
|
||||
require.Len(t, afterFilter, len(beforeFilter), "live filter must not change after a rejected SetPolicy")
|
||||
}
|
||||
|
||||
// TestNewPolicyManagerSkipsTests asserts the boot path does not evaluate
|
||||
// tests, so a stale stored policy referencing a now-deleted user does not
|
||||
// stop the server from booting.
|
||||
func TestNewPolicyManagerSkipsTests(t *testing.T) {
|
||||
users := policyTestUsers()
|
||||
nodes := policyTestNodes(users)
|
||||
|
||||
// Tests reference "ghost@headscale.net" which doesn't exist. Boot
|
||||
// must not error.
|
||||
stale := `{
|
||||
"acls": [{
|
||||
"action": "accept",
|
||||
"src": ["*"],
|
||||
"dst": ["*:*"]
|
||||
}],
|
||||
"tests": [{
|
||||
"src": "ghost@headscale.net",
|
||||
"accept": ["alice-laptop:22"]
|
||||
}]
|
||||
}`
|
||||
|
||||
pm, err := NewPolicyManager([]byte(stale), users, nodes.ViewSlice())
|
||||
require.NoError(t, err, "boot must not run tests")
|
||||
require.NotNil(t, pm)
|
||||
|
||||
// And a subsequent SetPolicy of the same body must reject — that's
|
||||
// the user-write path.
|
||||
_, err = pm.SetPolicy([]byte(stale))
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, errPolicyTestsFailed)
|
||||
}
|
||||
|
||||
// TestRunTestsEmptyProtoMatchesDefaultProtocols captures the bug where a
|
||||
// test entry with no `proto` field fails to match a filter rule whose
|
||||
// IPProto is restricted to a default protocol (TCP, UDP, ICMP, ICMPv6).
|
||||
// Tailscale's client default set is {6, 17, 1, 58} when proto is omitted,
|
||||
// so a TCP-only rule must satisfy an empty-proto test.
|
||||
//
|
||||
// The capture
|
||||
// testdata/policytest_results/policytest-allpass-acls-and-grants-mixed.hujson
|
||||
// is the captured signal for this same bug (api_response_code 200, two
|
||||
// passing tests including `tag:client → webserver:80` with no proto over
|
||||
// a `ip: tcp:80` grant).
|
||||
func TestRunTestsEmptyProtoMatchesDefaultProtocols(t *testing.T) {
|
||||
users := types.Users{
|
||||
{Model: gorm.Model{ID: 1}, Name: "odin", Email: "odin@example.com"},
|
||||
}
|
||||
nodes := types.Nodes{
|
||||
{
|
||||
ID: 1,
|
||||
Hostname: "client",
|
||||
IPv4: ap("100.64.0.10"),
|
||||
IPv6: ap("fd7a:115c:a1e0::a"),
|
||||
Tags: []string{"tag:client"},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Hostname: "webserver",
|
||||
IPv4: ap("100.64.0.16"),
|
||||
IPv6: ap("fd7a:115c:a1e0::10"),
|
||||
Tags: []string{"tag:server"},
|
||||
},
|
||||
}
|
||||
|
||||
policy := `{
|
||||
"tagOwners": {
|
||||
"tag:client": ["odin@example.com"],
|
||||
"tag:server": ["odin@example.com"]
|
||||
},
|
||||
"hosts": {
|
||||
"webserver": "100.64.0.16"
|
||||
},
|
||||
"grants": [
|
||||
{"src": ["tag:client"], "dst": ["webserver"], "ip": ["tcp:80"]}
|
||||
],
|
||||
"tests": [
|
||||
{"src": "tag:client", "accept": ["webserver:80"]}
|
||||
]
|
||||
}`
|
||||
|
||||
pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice())
|
||||
require.NoError(t, err, "policy must parse and compile")
|
||||
|
||||
require.NoError(t, pm.RunTests(),
|
||||
"empty-proto test must match a tcp-only grant rule (TCP is in the client default set)")
|
||||
}
|
||||
|
||||
// TestPolicyTestResultsErrorsRendering checks the multi-line render layout
|
||||
// since the body becomes the user-facing error.
|
||||
func TestPolicyTestResultsErrorsRendering(t *testing.T) {
|
||||
results := PolicyTestResults{
|
||||
AllPassed: false,
|
||||
Results: []PolicyTestResult{
|
||||
{
|
||||
Src: "alice@headscale.net",
|
||||
AcceptFail: []string{"tag:server:22"},
|
||||
},
|
||||
{
|
||||
Src: "bob@headscale.net",
|
||||
Proto: "tcp",
|
||||
DenyFail: []string{"tag:server:443"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
rendered := results.Errors()
|
||||
for _, sub := range []string{
|
||||
"alice@headscale.net -> tag:server:22: expected ALLOWED, got DENIED",
|
||||
"bob@headscale.net -> tag:server:443 (tcp): expected DENIED, got ALLOWED",
|
||||
} {
|
||||
assert.Contains(t, rendered, sub)
|
||||
}
|
||||
|
||||
// Lines should be newline-separated, not space-joined.
|
||||
assert.Equal(t, 2, strings.Count(rendered, "\n")+1, "expected one line per failing assertion")
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
// ACL-A01
|
||||
//
|
||||
// ACL: accept: src=['autogroup:member'] dst=['*:*']
|
||||
//
|
||||
// Expected: Rules on 8 of 8 nodes
|
||||
{
|
||||
"test_id": "ACL-A01",
|
||||
"input": {
|
||||
"full_policy": {
|
||||
"groups": {
|
||||
"group:admins": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"group:developers": [
|
||||
"kristoffer@dalby.cc",
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"group:monitors": [
|
||||
"monitorpasskeykradalby@passkey"
|
||||
],
|
||||
"group:empty": []
|
||||
},
|
||||
"tagOwners": {
|
||||
"tag:server": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:prod": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:client": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:router": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:exit": [
|
||||
"kratail2tid@passkey"
|
||||
]
|
||||
},
|
||||
"hosts": {
|
||||
"webserver": "100.108.74.26",
|
||||
"prodbox": "100.103.8.15",
|
||||
"internal": "10.0.0.0/8",
|
||||
"subnet24": "192.168.1.0/24"
|
||||
},
|
||||
"autoApprovers": {
|
||||
"routes": {
|
||||
"10.33.0.0/16": [
|
||||
"tag:router"
|
||||
],
|
||||
"0.0.0.0/0": [
|
||||
"tag:exit"
|
||||
],
|
||||
"::/0": [
|
||||
"tag:exit"
|
||||
]
|
||||
}
|
||||
},
|
||||
"acls": [
|
||||
{
|
||||
"action": "accept",
|
||||
"src": [
|
||||
"autogroup:member"
|
||||
],
|
||||
"dst": [
|
||||
"*:*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"captures": {
|
||||
"exit-node": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"100.103.90.82",
|
||||
"100.110.121.96",
|
||||
"100.90.199.68",
|
||||
"fd7a:115c:a1e0::1737:7960",
|
||||
"fd7a:115c:a1e0::2d01:c747",
|
||||
"fd7a:115c:a1e0::9e37:5a52"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "*",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"subnet-router": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"100.103.90.82",
|
||||
"100.110.121.96",
|
||||
"100.90.199.68",
|
||||
"fd7a:115c:a1e0::1737:7960",
|
||||
"fd7a:115c:a1e0::2d01:c747",
|
||||
"fd7a:115c:a1e0::9e37:5a52"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "*",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"tagged-client": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"100.103.90.82",
|
||||
"100.110.121.96",
|
||||
"100.90.199.68",
|
||||
"fd7a:115c:a1e0::1737:7960",
|
||||
"fd7a:115c:a1e0::2d01:c747",
|
||||
"fd7a:115c:a1e0::9e37:5a52"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "*",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"tagged-prod": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"100.103.90.82",
|
||||
"100.110.121.96",
|
||||
"100.90.199.68",
|
||||
"fd7a:115c:a1e0::1737:7960",
|
||||
"fd7a:115c:a1e0::2d01:c747",
|
||||
"fd7a:115c:a1e0::9e37:5a52"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "*",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"tagged-server": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"100.103.90.82",
|
||||
"100.110.121.96",
|
||||
"100.90.199.68",
|
||||
"fd7a:115c:a1e0::1737:7960",
|
||||
"fd7a:115c:a1e0::2d01:c747",
|
||||
"fd7a:115c:a1e0::9e37:5a52"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "*",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"user-kris": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"100.103.90.82",
|
||||
"100.110.121.96",
|
||||
"100.90.199.68",
|
||||
"fd7a:115c:a1e0::1737:7960",
|
||||
"fd7a:115c:a1e0::2d01:c747",
|
||||
"fd7a:115c:a1e0::9e37:5a52"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "*",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"user-mon": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"100.103.90.82",
|
||||
"100.110.121.96",
|
||||
"100.90.199.68",
|
||||
"fd7a:115c:a1e0::1737:7960",
|
||||
"fd7a:115c:a1e0::2d01:c747",
|
||||
"fd7a:115c:a1e0::9e37:5a52"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "*",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"user1": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"100.103.90.82",
|
||||
"100.110.121.96",
|
||||
"100.90.199.68",
|
||||
"fd7a:115c:a1e0::1737:7960",
|
||||
"fd7a:115c:a1e0::2d01:c747",
|
||||
"fd7a:115c:a1e0::9e37:5a52"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "*",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
// ACL-A02
|
||||
//
|
||||
// ACL: accept: src=['autogroup:tagged'] dst=['*:*']
|
||||
//
|
||||
// Expected: Rules on 8 of 8 nodes
|
||||
{
|
||||
"test_id": "ACL-A02",
|
||||
"input": {
|
||||
"full_policy": {
|
||||
"groups": {
|
||||
"group:admins": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"group:developers": [
|
||||
"kristoffer@dalby.cc",
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"group:monitors": [
|
||||
"monitorpasskeykradalby@passkey"
|
||||
],
|
||||
"group:empty": []
|
||||
},
|
||||
"tagOwners": {
|
||||
"tag:server": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:prod": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:client": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:router": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:exit": [
|
||||
"kratail2tid@passkey"
|
||||
]
|
||||
},
|
||||
"hosts": {
|
||||
"webserver": "100.108.74.26",
|
||||
"prodbox": "100.103.8.15",
|
||||
"internal": "10.0.0.0/8",
|
||||
"subnet24": "192.168.1.0/24"
|
||||
},
|
||||
"autoApprovers": {
|
||||
"routes": {
|
||||
"10.33.0.0/16": [
|
||||
"tag:router"
|
||||
],
|
||||
"0.0.0.0/0": [
|
||||
"tag:exit"
|
||||
],
|
||||
"::/0": [
|
||||
"tag:exit"
|
||||
]
|
||||
}
|
||||
},
|
||||
"acls": [
|
||||
{
|
||||
"action": "accept",
|
||||
"src": [
|
||||
"autogroup:tagged"
|
||||
],
|
||||
"dst": [
|
||||
"*:*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"captures": {
|
||||
"exit-node": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"100.103.8.15",
|
||||
"100.108.74.26",
|
||||
"100.83.200.69",
|
||||
"100.85.66.106",
|
||||
"100.92.142.61",
|
||||
"fd7a:115c:a1e0::3e37:8e3d",
|
||||
"fd7a:115c:a1e0::5b37:80f",
|
||||
"fd7a:115c:a1e0::7c37:426a",
|
||||
"fd7a:115c:a1e0::b901:4a87",
|
||||
"fd7a:115c:a1e0::c537:c845"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "*",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"subnet-router": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"100.103.8.15",
|
||||
"100.108.74.26",
|
||||
"100.83.200.69",
|
||||
"100.85.66.106",
|
||||
"100.92.142.61",
|
||||
"fd7a:115c:a1e0::3e37:8e3d",
|
||||
"fd7a:115c:a1e0::5b37:80f",
|
||||
"fd7a:115c:a1e0::7c37:426a",
|
||||
"fd7a:115c:a1e0::b901:4a87",
|
||||
"fd7a:115c:a1e0::c537:c845"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "*",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"tagged-client": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"100.103.8.15",
|
||||
"100.108.74.26",
|
||||
"100.83.200.69",
|
||||
"100.85.66.106",
|
||||
"100.92.142.61",
|
||||
"fd7a:115c:a1e0::3e37:8e3d",
|
||||
"fd7a:115c:a1e0::5b37:80f",
|
||||
"fd7a:115c:a1e0::7c37:426a",
|
||||
"fd7a:115c:a1e0::b901:4a87",
|
||||
"fd7a:115c:a1e0::c537:c845"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "*",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"tagged-prod": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"100.103.8.15",
|
||||
"100.108.74.26",
|
||||
"100.83.200.69",
|
||||
"100.85.66.106",
|
||||
"100.92.142.61",
|
||||
"fd7a:115c:a1e0::3e37:8e3d",
|
||||
"fd7a:115c:a1e0::5b37:80f",
|
||||
"fd7a:115c:a1e0::7c37:426a",
|
||||
"fd7a:115c:a1e0::b901:4a87",
|
||||
"fd7a:115c:a1e0::c537:c845"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "*",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"tagged-server": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"100.103.8.15",
|
||||
"100.108.74.26",
|
||||
"100.83.200.69",
|
||||
"100.85.66.106",
|
||||
"100.92.142.61",
|
||||
"fd7a:115c:a1e0::3e37:8e3d",
|
||||
"fd7a:115c:a1e0::5b37:80f",
|
||||
"fd7a:115c:a1e0::7c37:426a",
|
||||
"fd7a:115c:a1e0::b901:4a87",
|
||||
"fd7a:115c:a1e0::c537:c845"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "*",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"user-kris": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"100.103.8.15",
|
||||
"100.108.74.26",
|
||||
"100.83.200.69",
|
||||
"100.85.66.106",
|
||||
"100.92.142.61",
|
||||
"fd7a:115c:a1e0::3e37:8e3d",
|
||||
"fd7a:115c:a1e0::5b37:80f",
|
||||
"fd7a:115c:a1e0::7c37:426a",
|
||||
"fd7a:115c:a1e0::b901:4a87",
|
||||
"fd7a:115c:a1e0::c537:c845"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "*",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"user-mon": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"100.103.8.15",
|
||||
"100.108.74.26",
|
||||
"100.83.200.69",
|
||||
"100.85.66.106",
|
||||
"100.92.142.61",
|
||||
"fd7a:115c:a1e0::3e37:8e3d",
|
||||
"fd7a:115c:a1e0::5b37:80f",
|
||||
"fd7a:115c:a1e0::7c37:426a",
|
||||
"fd7a:115c:a1e0::b901:4a87",
|
||||
"fd7a:115c:a1e0::c537:c845"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "*",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"user1": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"100.103.8.15",
|
||||
"100.108.74.26",
|
||||
"100.83.200.69",
|
||||
"100.85.66.106",
|
||||
"100.92.142.61",
|
||||
"fd7a:115c:a1e0::3e37:8e3d",
|
||||
"fd7a:115c:a1e0::5b37:80f",
|
||||
"fd7a:115c:a1e0::7c37:426a",
|
||||
"fd7a:115c:a1e0::b901:4a87",
|
||||
"fd7a:115c:a1e0::c537:c845"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "*",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
// ACL-A03
|
||||
//
|
||||
// ACL: accept: src=['autogroup:member', 'tag:client'] dst=['tag:server:22']
|
||||
//
|
||||
// Expected: Rules on tagged-server
|
||||
{
|
||||
"test_id": "ACL-A03",
|
||||
"input": {
|
||||
"full_policy": {
|
||||
"groups": {
|
||||
"group:admins": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"group:developers": [
|
||||
"kristoffer@dalby.cc",
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"group:monitors": [
|
||||
"monitorpasskeykradalby@passkey"
|
||||
],
|
||||
"group:empty": []
|
||||
},
|
||||
"tagOwners": {
|
||||
"tag:server": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:prod": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:client": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:router": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:exit": [
|
||||
"kratail2tid@passkey"
|
||||
]
|
||||
},
|
||||
"hosts": {
|
||||
"webserver": "100.108.74.26",
|
||||
"prodbox": "100.103.8.15",
|
||||
"internal": "10.0.0.0/8",
|
||||
"subnet24": "192.168.1.0/24"
|
||||
},
|
||||
"autoApprovers": {
|
||||
"routes": {
|
||||
"10.33.0.0/16": [
|
||||
"tag:router"
|
||||
],
|
||||
"0.0.0.0/0": [
|
||||
"tag:exit"
|
||||
],
|
||||
"::/0": [
|
||||
"tag:exit"
|
||||
]
|
||||
}
|
||||
},
|
||||
"acls": [
|
||||
{
|
||||
"action": "accept",
|
||||
"src": [
|
||||
"autogroup:member",
|
||||
"tag:client"
|
||||
],
|
||||
"dst": [
|
||||
"tag:server:22"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"captures": {
|
||||
"exit-node": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"subnet-router": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"tagged-client": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"tagged-prod": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"tagged-server": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"100.103.90.82",
|
||||
"100.110.121.96",
|
||||
"100.83.200.69",
|
||||
"100.90.199.68",
|
||||
"fd7a:115c:a1e0::1737:7960",
|
||||
"fd7a:115c:a1e0::2d01:c747",
|
||||
"fd7a:115c:a1e0::9e37:5a52",
|
||||
"fd7a:115c:a1e0::c537:c845"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "100.108.74.26",
|
||||
"Ports": {
|
||||
"First": 22,
|
||||
"Last": 22
|
||||
}
|
||||
},
|
||||
{
|
||||
"IP": "fd7a:115c:a1e0::b901:4a87",
|
||||
"Ports": {
|
||||
"First": 22,
|
||||
"Last": 22
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"user-kris": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"user-mon": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"user1": {
|
||||
"packet_filter_rules": null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
// ACL-A04
|
||||
//
|
||||
// ACL: accept: src=['*'] dst=['autogroup:self:*']
|
||||
//
|
||||
// Expected: Rules on user-kris, user-mon, user1
|
||||
{
|
||||
"test_id": "ACL-A04",
|
||||
"input": {
|
||||
"full_policy": {
|
||||
"groups": {
|
||||
"group:admins": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"group:developers": [
|
||||
"kristoffer@dalby.cc",
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"group:monitors": [
|
||||
"monitorpasskeykradalby@passkey"
|
||||
],
|
||||
"group:empty": []
|
||||
},
|
||||
"tagOwners": {
|
||||
"tag:server": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:prod": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:client": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:router": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:exit": [
|
||||
"kratail2tid@passkey"
|
||||
]
|
||||
},
|
||||
"hosts": {
|
||||
"webserver": "100.108.74.26",
|
||||
"prodbox": "100.103.8.15",
|
||||
"internal": "10.0.0.0/8",
|
||||
"subnet24": "192.168.1.0/24"
|
||||
},
|
||||
"autoApprovers": {
|
||||
"routes": {
|
||||
"10.33.0.0/16": [
|
||||
"tag:router"
|
||||
],
|
||||
"0.0.0.0/0": [
|
||||
"tag:exit"
|
||||
],
|
||||
"::/0": [
|
||||
"tag:exit"
|
||||
]
|
||||
}
|
||||
},
|
||||
"acls": [
|
||||
{
|
||||
"action": "accept",
|
||||
"src": [
|
||||
"*"
|
||||
],
|
||||
"dst": [
|
||||
"autogroup:self:*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"captures": {
|
||||
"exit-node": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"subnet-router": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"tagged-client": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"tagged-prod": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"tagged-server": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"user-kris": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"100.110.121.96",
|
||||
"fd7a:115c:a1e0::1737:7960"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "100.110.121.96",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
},
|
||||
{
|
||||
"IP": "fd7a:115c:a1e0::1737:7960",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"user-mon": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"100.103.90.82",
|
||||
"fd7a:115c:a1e0::9e37:5a52"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "100.103.90.82",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
},
|
||||
{
|
||||
"IP": "fd7a:115c:a1e0::9e37:5a52",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"user1": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"100.90.199.68",
|
||||
"fd7a:115c:a1e0::2d01:c747"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "100.90.199.68",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
},
|
||||
{
|
||||
"IP": "fd7a:115c:a1e0::2d01:c747",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
// ACL-A05
|
||||
//
|
||||
// ACL: accept: src=['*'] dst=['autogroup:internet:*']
|
||||
//
|
||||
// Expected: No filter rules
|
||||
{
|
||||
"test_id": "ACL-A05",
|
||||
"input": {
|
||||
"full_policy": {
|
||||
"groups": {
|
||||
"group:admins": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"group:developers": [
|
||||
"kristoffer@dalby.cc",
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"group:monitors": [
|
||||
"monitorpasskeykradalby@passkey"
|
||||
],
|
||||
"group:empty": []
|
||||
},
|
||||
"tagOwners": {
|
||||
"tag:server": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:prod": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:client": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:router": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:exit": [
|
||||
"kratail2tid@passkey"
|
||||
]
|
||||
},
|
||||
"hosts": {
|
||||
"webserver": "100.108.74.26",
|
||||
"prodbox": "100.103.8.15",
|
||||
"internal": "10.0.0.0/8",
|
||||
"subnet24": "192.168.1.0/24"
|
||||
},
|
||||
"autoApprovers": {
|
||||
"routes": {
|
||||
"10.33.0.0/16": [
|
||||
"tag:router"
|
||||
],
|
||||
"0.0.0.0/0": [
|
||||
"tag:exit"
|
||||
],
|
||||
"::/0": [
|
||||
"tag:exit"
|
||||
]
|
||||
}
|
||||
},
|
||||
"acls": [
|
||||
{
|
||||
"action": "accept",
|
||||
"src": [
|
||||
"*"
|
||||
],
|
||||
"dst": [
|
||||
"autogroup:internet:*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"captures": {
|
||||
"exit-node": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"subnet-router": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"tagged-client": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"tagged-prod": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"tagged-server": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"user-kris": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"user-mon": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"user1": {
|
||||
"packet_filter_rules": null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
// ACL-A06
|
||||
//
|
||||
// ACL: accept: src=['*'] dst=['autogroup:member:*']
|
||||
//
|
||||
// Expected: Rules on user-kris, user-mon, user1
|
||||
{
|
||||
"test_id": "ACL-A06",
|
||||
"input": {
|
||||
"full_policy": {
|
||||
"groups": {
|
||||
"group:admins": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"group:developers": [
|
||||
"kristoffer@dalby.cc",
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"group:monitors": [
|
||||
"monitorpasskeykradalby@passkey"
|
||||
],
|
||||
"group:empty": []
|
||||
},
|
||||
"tagOwners": {
|
||||
"tag:server": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:prod": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:client": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:router": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:exit": [
|
||||
"kratail2tid@passkey"
|
||||
]
|
||||
},
|
||||
"hosts": {
|
||||
"webserver": "100.108.74.26",
|
||||
"prodbox": "100.103.8.15",
|
||||
"internal": "10.0.0.0/8",
|
||||
"subnet24": "192.168.1.0/24"
|
||||
},
|
||||
"autoApprovers": {
|
||||
"routes": {
|
||||
"10.33.0.0/16": [
|
||||
"tag:router"
|
||||
],
|
||||
"0.0.0.0/0": [
|
||||
"tag:exit"
|
||||
],
|
||||
"::/0": [
|
||||
"tag:exit"
|
||||
]
|
||||
}
|
||||
},
|
||||
"acls": [
|
||||
{
|
||||
"action": "accept",
|
||||
"src": [
|
||||
"*"
|
||||
],
|
||||
"dst": [
|
||||
"autogroup:member:*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"captures": {
|
||||
"exit-node": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"subnet-router": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"tagged-client": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"tagged-prod": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"tagged-server": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"user-kris": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"10.33.0.0/16",
|
||||
"100.115.94.0-100.127.255.255",
|
||||
"100.64.0.0-100.115.91.255",
|
||||
"fd7a:115c:a1e0::/48"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "100.110.121.96",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
},
|
||||
{
|
||||
"IP": "fd7a:115c:a1e0::1737:7960",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"user-mon": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"10.33.0.0/16",
|
||||
"100.115.94.0-100.127.255.255",
|
||||
"100.64.0.0-100.115.91.255",
|
||||
"fd7a:115c:a1e0::/48"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "100.103.90.82",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
},
|
||||
{
|
||||
"IP": "fd7a:115c:a1e0::9e37:5a52",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"user1": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"10.33.0.0/16",
|
||||
"100.115.94.0-100.127.255.255",
|
||||
"100.64.0.0-100.115.91.255",
|
||||
"fd7a:115c:a1e0::/48"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "100.90.199.68",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
},
|
||||
{
|
||||
"IP": "fd7a:115c:a1e0::2d01:c747",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
// ACL-A07
|
||||
//
|
||||
// ACL: accept: src=['*'] dst=['autogroup:self:*', 'tag:server:22']
|
||||
//
|
||||
// Expected: Rules on tagged-server, user-kris, user-mon, user1
|
||||
{
|
||||
"test_id": "ACL-A07",
|
||||
"input": {
|
||||
"full_policy": {
|
||||
"groups": {
|
||||
"group:admins": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"group:developers": [
|
||||
"kristoffer@dalby.cc",
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"group:monitors": [
|
||||
"monitorpasskeykradalby@passkey"
|
||||
],
|
||||
"group:empty": []
|
||||
},
|
||||
"tagOwners": {
|
||||
"tag:server": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:prod": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:client": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:router": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:exit": [
|
||||
"kratail2tid@passkey"
|
||||
]
|
||||
},
|
||||
"hosts": {
|
||||
"webserver": "100.108.74.26",
|
||||
"prodbox": "100.103.8.15",
|
||||
"internal": "10.0.0.0/8",
|
||||
"subnet24": "192.168.1.0/24"
|
||||
},
|
||||
"autoApprovers": {
|
||||
"routes": {
|
||||
"10.33.0.0/16": [
|
||||
"tag:router"
|
||||
],
|
||||
"0.0.0.0/0": [
|
||||
"tag:exit"
|
||||
],
|
||||
"::/0": [
|
||||
"tag:exit"
|
||||
]
|
||||
}
|
||||
},
|
||||
"acls": [
|
||||
{
|
||||
"action": "accept",
|
||||
"src": [
|
||||
"*"
|
||||
],
|
||||
"dst": [
|
||||
"autogroup:self:*",
|
||||
"tag:server:22"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"captures": {
|
||||
"exit-node": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"subnet-router": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"tagged-client": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"tagged-prod": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"tagged-server": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"10.33.0.0/16",
|
||||
"100.115.94.0-100.127.255.255",
|
||||
"100.64.0.0-100.115.91.255",
|
||||
"fd7a:115c:a1e0::/48"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "100.108.74.26",
|
||||
"Ports": {
|
||||
"First": 22,
|
||||
"Last": 22
|
||||
}
|
||||
},
|
||||
{
|
||||
"IP": "fd7a:115c:a1e0::b901:4a87",
|
||||
"Ports": {
|
||||
"First": 22,
|
||||
"Last": 22
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"user-kris": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"100.110.121.96",
|
||||
"fd7a:115c:a1e0::1737:7960"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "100.110.121.96",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
},
|
||||
{
|
||||
"IP": "fd7a:115c:a1e0::1737:7960",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"user-mon": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"100.103.90.82",
|
||||
"fd7a:115c:a1e0::9e37:5a52"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "100.103.90.82",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
},
|
||||
{
|
||||
"IP": "fd7a:115c:a1e0::9e37:5a52",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"user1": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"100.90.199.68",
|
||||
"fd7a:115c:a1e0::2d01:c747"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "100.90.199.68",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
},
|
||||
{
|
||||
"IP": "fd7a:115c:a1e0::2d01:c747",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
// ACL-A08
|
||||
//
|
||||
// ACL: accept: src=['*'] dst=['autogroup:tagged:*']
|
||||
//
|
||||
// Expected: Rules on exit-node, subnet-router, tagged-client, tagged-prod, tagged-server
|
||||
{
|
||||
"test_id": "ACL-A08",
|
||||
"input": {
|
||||
"full_policy": {
|
||||
"groups": {
|
||||
"group:admins": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"group:developers": [
|
||||
"kristoffer@dalby.cc",
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"group:monitors": [
|
||||
"monitorpasskeykradalby@passkey"
|
||||
],
|
||||
"group:empty": []
|
||||
},
|
||||
"tagOwners": {
|
||||
"tag:server": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:prod": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:client": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:router": [
|
||||
"kratail2tid@passkey"
|
||||
],
|
||||
"tag:exit": [
|
||||
"kratail2tid@passkey"
|
||||
]
|
||||
},
|
||||
"hosts": {
|
||||
"webserver": "100.108.74.26",
|
||||
"prodbox": "100.103.8.15",
|
||||
"internal": "10.0.0.0/8",
|
||||
"subnet24": "192.168.1.0/24"
|
||||
},
|
||||
"autoApprovers": {
|
||||
"routes": {
|
||||
"10.33.0.0/16": [
|
||||
"tag:router"
|
||||
],
|
||||
"0.0.0.0/0": [
|
||||
"tag:exit"
|
||||
],
|
||||
"::/0": [
|
||||
"tag:exit"
|
||||
]
|
||||
}
|
||||
},
|
||||
"acls": [
|
||||
{
|
||||
"action": "accept",
|
||||
"src": [
|
||||
"*"
|
||||
],
|
||||
"dst": [
|
||||
"autogroup:tagged:*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"captures": {
|
||||
"exit-node": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"10.33.0.0/16",
|
||||
"100.115.94.0-100.127.255.255",
|
||||
"100.64.0.0-100.115.91.255",
|
||||
"fd7a:115c:a1e0::/48"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "100.85.66.106",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
},
|
||||
{
|
||||
"IP": "fd7a:115c:a1e0::7c37:426a",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"subnet-router": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"10.33.0.0/16",
|
||||
"100.115.94.0-100.127.255.255",
|
||||
"100.64.0.0-100.115.91.255",
|
||||
"fd7a:115c:a1e0::/48"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "100.92.142.61",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
},
|
||||
{
|
||||
"IP": "fd7a:115c:a1e0::3e37:8e3d",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"tagged-client": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"10.33.0.0/16",
|
||||
"100.115.94.0-100.127.255.255",
|
||||
"100.64.0.0-100.115.91.255",
|
||||
"fd7a:115c:a1e0::/48"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "100.83.200.69",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
},
|
||||
{
|
||||
"IP": "fd7a:115c:a1e0::c537:c845",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"tagged-prod": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"10.33.0.0/16",
|
||||
"100.115.94.0-100.127.255.255",
|
||||
"100.64.0.0-100.115.91.255",
|
||||
"fd7a:115c:a1e0::/48"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "100.103.8.15",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
},
|
||||
{
|
||||
"IP": "fd7a:115c:a1e0::5b37:80f",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"tagged-server": {
|
||||
"packet_filter_rules": [
|
||||
{
|
||||
"SrcIPs": [
|
||||
"10.33.0.0/16",
|
||||
"100.115.94.0-100.127.255.255",
|
||||
"100.64.0.0-100.115.91.255",
|
||||
"fd7a:115c:a1e0::/48"
|
||||
],
|
||||
"DstPorts": [
|
||||
{
|
||||
"IP": "100.108.74.26",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
},
|
||||
{
|
||||
"IP": "fd7a:115c:a1e0::b901:4a87",
|
||||
"Ports": {
|
||||
"First": 0,
|
||||
"Last": 65535
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"user-kris": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"user-mon": {
|
||||
"packet_filter_rules": null
|
||||
},
|
||||
"user1": {
|
||||
"packet_filter_rules": null
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user