mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-19 14:30:44 +09:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dcd95698e4 | |||
| 073f11b6bf | |||
| 9b6bfd05bc | |||
| fa94bd949c | |||
| cad0cb2f5c | |||
| c278418b94 | |||
| 3a81dbd1c0 | |||
| 9d4af0009d | |||
| 077621eef3 | |||
| d11be77f1f | |||
| f60f89647b | |||
| 387dcd679e | |||
| f2f7c5704a | |||
| c26cf2f2d8 | |||
| f34765ad1e | |||
| 7feb5a52e4 | |||
| 0cb9536784 | |||
| 960219e52c | |||
| 8201be9b4b | |||
| f55d6c3cb0 | |||
| 1ea3b676f2 | |||
| ac8fc70e7d | |||
| f678bcb6c2 | |||
| 3e2dd93c33 | |||
| 8038c39e23 | |||
| b9a9b5026f | |||
| 5816a5a8b1 | |||
| 1d57358314 | |||
| 3c125e62a3 | |||
| 8d3ae36675 |
@@ -59,7 +59,7 @@ jobs:
|
||||
nix develop --command -- ko build \
|
||||
--bare \
|
||||
--platform=linux/amd64,linux/arm64 \
|
||||
--tags=main-${GITHUB_SHA::7},development \
|
||||
--tags=main-${GITHUB_SHA::7} \
|
||||
./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},development \
|
||||
--tags=main-${GITHUB_SHA::7} \
|
||||
./cmd/headscale
|
||||
|
||||
binaries:
|
||||
|
||||
@@ -58,16 +58,15 @@ jobs:
|
||||
|
||||
# Find when needs-more-info was last added
|
||||
let events = (gh api $"repos/($env.GH_REPO)/issues/($number)/events"
|
||||
--paginate | from json)
|
||||
--paginate | from json | flatten)
|
||||
let label_event = ($events
|
||||
| where event == "labeled"
|
||||
| where label.name == "needs-more-info"
|
||||
| where event == "labeled" and 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)
|
||||
--paginate | from json | flatten)
|
||||
let human_responses = ($comments
|
||||
| where user.type != "Bot"
|
||||
| where { ($in.created_at | into datetime) > $label_added_at })
|
||||
|
||||
@@ -249,10 +249,6 @@ jobs:
|
||||
- TestAutoApproveMultiNetwork/webauth-group.*
|
||||
- TestSubnetRouteACLFiltering
|
||||
- TestGrantViaSubnetSteering
|
||||
- TestHASubnetRouterPingFailover
|
||||
- TestHASubnetRouterFailoverBothOffline
|
||||
- TestHASubnetRouterFailoverBothOfflineCablePull
|
||||
- TestHASubnetRouterFailoverDockerDisconnect
|
||||
- TestHeadscale
|
||||
- TestTailscaleNodesJoiningHeadcale
|
||||
- TestSSHOneUserToAll
|
||||
|
||||
+3
-4
@@ -42,9 +42,10 @@ source:
|
||||
- "vendor/"
|
||||
|
||||
nfpms:
|
||||
# Configure nFPM for .deb releases
|
||||
# Configure nFPM for .deb and .rpm releases
|
||||
#
|
||||
# See https://goreleaser.com/customization/package/nfpm/
|
||||
# See https://nfpm.goreleaser.com/configuration/
|
||||
# and https://goreleaser.com/customization/nfpm/
|
||||
#
|
||||
# Useful tools for debugging .debs:
|
||||
# List file contents: dpkg -c dist/headscale...deb
|
||||
@@ -78,8 +79,6 @@ 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:
|
||||
|
||||
+49
-131
@@ -29,11 +29,11 @@ A new `headscale auth` CLI command group supports the approval flow:
|
||||
|
||||
### Grants
|
||||
|
||||
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.
|
||||
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.
|
||||
[#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,92 +41,54 @@ 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
|
||||
|
||||
#### 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)
|
||||
- **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 `*`
|
||||
- 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)
|
||||
- **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)
|
||||
- Policies using tags, hosts, or IP addresses as sources for autogroup:self destinations will now fail validation
|
||||
- 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)
|
||||
- **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
|
||||
|
||||
#### 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)
|
||||
- **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)
|
||||
- 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
|
||||
|
||||
#### 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)
|
||||
- **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)
|
||||
- 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)
|
||||
@@ -134,56 +96,12 @@ connected" routers that maintain their control session but cannot route packets.
|
||||
- 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)
|
||||
|
||||
#### Grants
|
||||
## 0.28.1 (202x-xx-xx)
|
||||
|
||||
- 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)
|
||||
### Changes
|
||||
|
||||
#### 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)
|
||||
- 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)
|
||||
|
||||
#### 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)
|
||||
|
||||
## 0.28.0 (2026-02-04)
|
||||
|
||||
**Minimum supported Tailscale client version: v1.74.0**
|
||||
@@ -193,7 +111,7 @@ connected" routers that maintain their control session but cannot route packets.
|
||||
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/docs/features/tags) for details on how tags work.
|
||||
ownership. See the [Tailscale tags documentation](https://tailscale.com/kb/1068/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
|
||||
@@ -292,7 +210,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/docs/features/tailscale-ssh), the following rules are now enforced:
|
||||
Per [Tailscale SSH documentation](https://tailscale.com/kb/1193/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
|
||||
|
||||
@@ -421,8 +339,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/docs/reference/targets-and-selectors#autogroups)
|
||||
for a detailed explanation.
|
||||
[documentation](https://tailscale.com/kb/1018/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
|
||||
@@ -585,7 +503,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/docs/reference/syntax/policy-file#tailscale-ssh)
|
||||
[upstream documentation](https://tailscale.com/kb/1337/acl-syntax#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
|
||||
@@ -1099,7 +1017,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/docs/reference/syntax/policy-file#tailscale-ssh) (see docs for
|
||||
[SSH ACL](https://tailscale.com/kb/1018/acls/#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:
|
||||
@@ -1176,7 +1094,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/docs/features/access-control/acls).
|
||||
Please check [the new syntax](https://tailscale.com/kb/1018/acls/).
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1206,7 +1124,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. [Tailscale docs](https://tailscale.com/docs/reference/syntax/policy-file#randomizeclientport)
|
||||
port. [kb/1181/firewalls](https://tailscale.com/kb/1181/firewalls)
|
||||
[#624](https://github.com/juanfont/headscale/pull/624)
|
||||
- Improve obtuse UX regarding missing configuration
|
||||
(`ephemeral_node_inactivity_timeout` not set)
|
||||
|
||||
@@ -105,11 +105,6 @@ 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/docs/concepts/tailnet) is
|
||||
private network which Tailscale assigns to a user in terms of private users or an
|
||||
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
|
||||
organisation.
|
||||
|
||||
## Design goal
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
# 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
@@ -1,314 +0,0 @@
|
||||
// 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 lose a key, create a new one and revoke (expire) the old one.`,
|
||||
If you loose 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,10 +175,8 @@ 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)
|
||||
@@ -399,7 +397,6 @@ func nodesToPtables(
|
||||
}
|
||||
|
||||
var ipBuilder strings.Builder
|
||||
|
||||
for _, addr := range node.GetIpAddresses() {
|
||||
ip, err := netip.ParseAddr(addr)
|
||||
if err == nil {
|
||||
|
||||
@@ -63,7 +63,6 @@ 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
|
||||
|
||||
+12
-32
@@ -128,7 +128,7 @@ derp:
|
||||
#
|
||||
# This option is mostly interesting for people hosting
|
||||
# their own DERP servers:
|
||||
# https://tailscale.com/docs/reference/derp-servers/custom-derp-servers
|
||||
# https://tailscale.com/kb/1118/custom-derp-servers/
|
||||
#
|
||||
# paths:
|
||||
# - /etc/headscale/derp-example.yaml
|
||||
@@ -165,26 +165,6 @@ 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.
|
||||
@@ -283,7 +263,7 @@ log:
|
||||
## Policy
|
||||
# headscale supports Tailscale's ACL policies.
|
||||
# Please have a look to their KB to better
|
||||
# understand the concepts: https://tailscale.com/docs/features/access-control/acls
|
||||
# understand the concepts: https://tailscale.com/kb/1018/acls/
|
||||
policy:
|
||||
# The mode can be "file" or "database" that defines
|
||||
# where the ACL policies are stored and read from.
|
||||
@@ -297,9 +277,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/docs/features/access-control/acls
|
||||
# - https://tailscale.com/docs/features/magicdns
|
||||
# - https://tailscale.com/blog/2021-09-private-dns-with-magicdns
|
||||
# - https://tailscale.com/kb/1054/dns/
|
||||
# - https://tailscale.com/kb/1081/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
|
||||
@@ -309,12 +289,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/docs/reference/faq/dns-resolv-conf
|
||||
# https://tailscale.com/kb/1235/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/docs/features/magicdns).
|
||||
# Whether to use [MagicDNS](https://tailscale.com/kb/1081/magicdns/).
|
||||
magic_dns: true
|
||||
|
||||
# Defines the base domain to create the hostnames for MagicDNS.
|
||||
@@ -336,11 +316,11 @@ dns:
|
||||
- 2606:4700:4700::1111
|
||||
- 2606:4700:4700::1001
|
||||
|
||||
# NextDNS (see https://tailscale.com/docs/integrations/nextdns).
|
||||
# NextDNS (see https://tailscale.com/kb/1218/nextdns/).
|
||||
# "abc123" is example NextDNS ID, replace with yours.
|
||||
# - https://dns.nextdns.io/abc123
|
||||
|
||||
# Split DNS (see https://tailscale.com/docs/reference/dns-in-tailscale#restricted-nameservers),
|
||||
# Split DNS (see https://tailscale.com/kb/1054/dns/),
|
||||
# a map of domains and which DNS server to use for each.
|
||||
split: {}
|
||||
# foo.bar.com:
|
||||
@@ -445,7 +425,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/docs/features/logging#opt-out-of-client-logging
|
||||
# https://tailscale.com/kb/1011/log-mesh-traffic#opting-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
|
||||
@@ -454,12 +434,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/docs/integrations/firewalls for more information.
|
||||
# firewall devices. See https://tailscale.com/kb/1181/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/docs/features/taildrop
|
||||
# https://tailscale.com/kb/1106/taildrop/
|
||||
taildrop:
|
||||
# Enable or disable Taildrop for all nodes.
|
||||
# When enabled, nodes can send files to other nodes owned by the same user.
|
||||
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
# If you plan to somehow use headscale, please deploy your own DERP infra.
|
||||
# See: https://tailscale.com/docs/reference/derp-servers/custom-derp-servers
|
||||
# If you plan to somehow use headscale, please deploy your own DERP infra: https://tailscale.com/kb/1118/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/docs/concepts/device-visibility>.
|
||||
See also <https://tailscale.com/kb/1087/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/docs/features/logging#client-logs) and sends them to a central log service operated by
|
||||
clients](https://tailscale.com/kb/1011/log-mesh-traffic#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/docs/features/logging#opt-out-of-client-logging> for
|
||||
details.
|
||||
`--no-logs-no-support` to `tailscaled`. See
|
||||
<https://tailscale.com/kb/1011/log-mesh-traffic#opting-out-of-client-logging> for details.
|
||||
|
||||
+11
-12
@@ -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/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] [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] [Extra DNS records (Headscale only)](../ref/dns.md#setting-extra-dns-records)
|
||||
- [x] [Taildrop (File Sharing)](https://tailscale.com/docs/features/taildrop)
|
||||
- [x] [Taildrop (File Sharing)](https://tailscale.com/kb/1106/taildrop)
|
||||
- [x] [Tags](../ref/tags.md)
|
||||
- [x] [Routes](../ref/routes.md)
|
||||
- [x] [Subnet routers](../ref/routes.md#subnet-router)
|
||||
@@ -23,17 +23,16 @@ 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/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
|
||||
- [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
|
||||
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/docs/features/tailscale-ssh)
|
||||
- [x] [Tailscale SSH](https://tailscale.com/kb/1193/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/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))
|
||||
- [ ] [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))
|
||||
|
||||
+6
-13
@@ -3,8 +3,7 @@ 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 [manage permissions using ACLs](https://tailscale.com/docs/features/access-control/acls) for further
|
||||
information.
|
||||
Please check https://tailscale.com/kb/1018/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
|
||||
@@ -16,8 +15,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 in [Tailscale's ACL
|
||||
documentation](https://tailscale.com/docs/features/access-control/acls).
|
||||
Info on how these policies are written can be found
|
||||
[here](https://tailscale.com/kb/1018/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
|
||||
@@ -25,13 +24,13 @@ process. Headscale logs the result of ACL policy processing after each reload.
|
||||
|
||||
## Simple Examples
|
||||
|
||||
- [**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.
|
||||
- [**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.
|
||||
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
- [**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.
|
||||
- [**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.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -88,7 +87,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/docs/features/tags)
|
||||
// This is documented [here](https://tailscale.com/kb/1068/acl-tags#defining-a-tag)
|
||||
// and explained [here](https://tailscale.com/blog/rbac-like-it-was-meant-to-be/)
|
||||
"tagOwners": {
|
||||
// the administrators can add servers in production
|
||||
@@ -287,9 +286,3 @@ 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/docs/reference/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/kb/1023/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/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`
|
||||
- [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`
|
||||
- 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/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.
|
||||
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.
|
||||
|
||||
## 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/docs/reference/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/kb/1232/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/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
|
||||
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
|
||||
`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/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:
|
||||
[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:
|
||||
|
||||
- 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
|
||||
|
||||
@@ -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/docs/features/tailscale-ssh)
|
||||
into a personal node.
|
||||
tagged nodes, e.g. a tagged node is not allowed to [Tailscale SSH](https://tailscale.com/kb/1193/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/docs/reference/syntax/policy-file#tag-owners) section of the [ACL](acls.md). A
|
||||
simple example looks like this:
|
||||
[`tagOwners`](https://tailscale.com/kb/1337/policy-syntax#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"
|
||||
{
|
||||
|
||||
+20
-19
@@ -1,8 +1,7 @@
|
||||
# Routes
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
- [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
|
||||
@@ -73,8 +72,8 @@ $ sudo tailscale set --accept-routes
|
||||
```
|
||||
|
||||
Please refer to the official [Tailscale
|
||||
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.
|
||||
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.
|
||||
|
||||
### Restrict the use of a subnet router with ACL
|
||||
|
||||
@@ -136,9 +135,8 @@ 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/docs/reference/syntax/policy-file#auto-approvers) for more information on auto
|
||||
approvers.
|
||||
Please see the [official Tailscale documentation](https://tailscale.com/kb/1337/acl-syntax#autoapprovers) for more
|
||||
information on auto approvers.
|
||||
|
||||
## Exit node
|
||||
|
||||
@@ -201,8 +199,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/docs/features/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/kb/1103/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
|
||||
|
||||
@@ -284,23 +282,26 @@ 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/docs/reference/syntax/policy-file#autoapprovers)
|
||||
for more information on auto approvers.
|
||||
Please see the [official Tailscale documentation](https://tailscale.com/kb/1337/acl-syntax#autoapprovers) for more
|
||||
information on auto approvers.
|
||||
|
||||
## High availability
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
!!! 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.
|
||||
|
||||
## 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/docs/features/subnet-routers#enable-ip-forwarding) for how to
|
||||
official [Tailscale documentation](https://tailscale.com/kb/1019/subnets/?tab=linux#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/docs/features/tags)
|
||||
to learn how tags work and how to use them.
|
||||
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.
|
||||
|
||||
Tags can be applied during [node registration](registration.md):
|
||||
|
||||
|
||||
@@ -24,8 +24,7 @@ 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). An up-to date example
|
||||
configuration file is also available in `/usr/share/doc/headscale/examples/config-example.yaml`:
|
||||
1. [Configure headscale by editing the configuration file](../../ref/configuration.md):
|
||||
|
||||
```shell
|
||||
sudo nano /etc/headscale/config.yaml
|
||||
|
||||
@@ -25,8 +25,7 @@ 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/docs/concepts/macos-variants) and
|
||||
install it.
|
||||
Choose one of the available [Tailscale clients for macOS](https://tailscale.com/kb/1065/macos-variants) and install it.
|
||||
|
||||
### Configuring the headscale URL
|
||||
|
||||
|
||||
@@ -33,8 +33,7 @@ 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/docs/how-to/run-unattended).
|
||||
See also [Keep Tailscale running when I'm not logged in to my computer](https://tailscale.com/kb/1088/run-unattended)
|
||||
|
||||
### Failing node registration
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${prev.stdenv.hostPlatform.system};
|
||||
buildGo = pkgs.buildGo126Module;
|
||||
vendorHash = "sha256-8vTEkPEMbJ6DSOjcoQrYRyKSYI8jjcllTmJ6RXmUV9w=";
|
||||
vendorHash = "sha256-x0xXxa7sjyDwWLq8fO0Z/pbPefctzctK3TAdBea7FtY=";
|
||||
in
|
||||
{
|
||||
headscale = buildGo {
|
||||
|
||||
@@ -54,7 +54,6 @@ 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
|
||||
)
|
||||
@@ -97,14 +96,11 @@ 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
|
||||
@@ -115,7 +111,6 @@ 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
|
||||
@@ -140,7 +135,6 @@ 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
|
||||
@@ -151,7 +145,6 @@ 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
|
||||
@@ -172,10 +165,8 @@ 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
|
||||
@@ -198,7 +189,6 @@ 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
|
||||
@@ -210,7 +200,6 @@ 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
|
||||
@@ -221,7 +210,6 @@ 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
|
||||
@@ -236,7 +224,6 @@ 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
|
||||
@@ -246,15 +233,10 @@ 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,14 +14,10 @@ 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=
|
||||
@@ -44,8 +40,6 @@ 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=
|
||||
@@ -170,8 +164,6 @@ 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=
|
||||
@@ -220,8 +212,6 @@ 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=
|
||||
@@ -294,15 +284,11 @@ 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=
|
||||
@@ -384,8 +370,6 @@ 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=
|
||||
@@ -446,8 +430,6 @@ 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=
|
||||
@@ -505,8 +487,6 @@ 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=
|
||||
@@ -651,9 +631,6 @@ 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=
|
||||
@@ -670,8 +647,6 @@ 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=
|
||||
@@ -702,8 +677,6 @@ 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,31 +276,6 @@ 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():
|
||||
@@ -357,9 +332,6 @@ func (h *Headscale) scheduledTasks(ctx context.Context) {
|
||||
h.cfg.TailcfgDNSConfig.ExtraRecords = records
|
||||
|
||||
h.Change(change.ExtraRecords())
|
||||
|
||||
case <-haHealthChan:
|
||||
haProber.ProbeOnce(ctx, h.Change)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -488,20 +460,6 @@ 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{
|
||||
@@ -515,7 +473,6 @@ 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)
|
||||
|
||||
@@ -552,10 +509,6 @@ 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)
|
||||
|
||||
@@ -1156,11 +1109,6 @@ 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.
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
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,46 +11,11 @@
|
||||
--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;
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
/* Base Typography */
|
||||
.md-typeset {
|
||||
font-size: 1rem;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
color: var(--md-default-fg-color);
|
||||
font-family: var(--md-text-font);
|
||||
@@ -111,33 +76,16 @@
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
.md-typeset li {
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
/* Links - underline for accessibility (don't rely on color alone) */
|
||||
/* Links */
|
||||
.md-typeset a {
|
||||
color: var(--md-typeset-a-color);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 2px;
|
||||
text-decoration: none;
|
||||
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) */
|
||||
@@ -170,7 +118,6 @@ input:focus-visible {
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* Links in code */
|
||||
@@ -178,36 +125,6 @@ input:focus-visible {
|
||||
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;
|
||||
@@ -221,17 +138,6 @@ input:focus-visible {
|
||||
@media (max-width: 768px) {
|
||||
.headscale-logo {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
+6
-4
@@ -10,6 +10,7 @@ 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"
|
||||
@@ -335,10 +336,11 @@ func registrationDataFromRequest(
|
||||
req tailcfg.RegisterRequest,
|
||||
machineKey key.MachinePublic,
|
||||
) *types.RegistrationData {
|
||||
var hostname string
|
||||
if req.Hostinfo != nil {
|
||||
hostname = req.Hostinfo.Hostname
|
||||
}
|
||||
hostname := util.EnsureHostname(
|
||||
req.Hostinfo.View(),
|
||||
machineKey.String(),
|
||||
req.NodeKey.String(),
|
||||
)
|
||||
|
||||
regData := &types.RegistrationData{
|
||||
MachineKey: machineKey,
|
||||
|
||||
+9
-11
@@ -816,12 +816,10 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
validate: func(t *testing.T, resp *tailcfg.RegisterResponse, app *Headscale) { //nolint:thelper //nolint:thelper
|
||||
assert.True(t, resp.MachineAuthorized)
|
||||
|
||||
// Raw hostname is preserved (empty in, empty stored), and
|
||||
// GivenName falls back to the literal "node" per SaaS.
|
||||
// Node should be created with generated hostname
|
||||
node, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
|
||||
assert.True(t, found)
|
||||
assert.Empty(t, node.Hostname())
|
||||
assert.Equal(t, "node", node.GivenName())
|
||||
assert.NotEmpty(t, node.Hostname())
|
||||
},
|
||||
},
|
||||
// TEST: Nil hostinfo is handled with defensive code
|
||||
@@ -856,12 +854,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)
|
||||
|
||||
// With nil Hostinfo the raw hostname stays empty and GivenName
|
||||
// falls back to the literal "node" per the SaaS spec.
|
||||
// Node should be created with generated hostname from defensive code
|
||||
node, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
|
||||
assert.True(t, found)
|
||||
assert.Empty(t, node.Hostname())
|
||||
assert.Equal(t, "node", node.GivenName())
|
||||
assert.NotEmpty(t, node.Hostname())
|
||||
// Hostname should start with "node-" (generated from machine key)
|
||||
assert.True(t, strings.HasPrefix(node.Hostname(), "node-"))
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2253,9 +2251,9 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
assert.True(t, found, "node should be registered despite nil hostinfo")
|
||||
|
||||
if found {
|
||||
// Raw hostname stays empty; GivenName falls back to "node".
|
||||
assert.Empty(t, node.Hostname())
|
||||
assert.Equal(t, "node", node.GivenName())
|
||||
// Should have some default hostname or handle nil gracefully
|
||||
hostname := node.Hostname()
|
||||
assert.NotEmpty(t, hostname, "should have some hostname even with nil hostinfo")
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -379,7 +379,7 @@ func TestEphemeralGarbageCollectorConcurrentScheduleAndClose(t *testing.T) {
|
||||
stopScheduling := make(chan struct{})
|
||||
|
||||
// Track how many nodes have been scheduled
|
||||
var scheduledCount atomic.Int64
|
||||
var scheduledCount 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
|
||||
scheduledCount.Add(1)
|
||||
atomic.AddInt64(&scheduledCount, 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 scheduledCount.Load() < int64(numSchedulers*closeAfterNodes) {
|
||||
for atomic.LoadInt64(&scheduledCount) < int64(numSchedulers*closeAfterNodes) {
|
||||
runtime.Gosched()
|
||||
}
|
||||
|
||||
|
||||
+87
-6
@@ -5,9 +5,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -19,7 +21,6 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/util/dnsname"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -33,6 +34,8 @@ 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")
|
||||
@@ -288,7 +291,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 := dnsname.ValidLabel(newName)
|
||||
err := util.ValidateHostname(newName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("renaming node: %w", err)
|
||||
}
|
||||
@@ -296,7 +299,8 @@ func RenameNode(tx *gorm.DB,
|
||||
// Check if the new name is unique
|
||||
var count int64
|
||||
|
||||
if err := tx.Model(&types.Node{}).Where("given_name = ? AND id != ?", newName, nodeID).Count(&count).Error; err != nil { //nolint:noinlineerr
|
||||
err = tx.Model(&types.Node{}).Where("given_name = ? AND id != ?", newName, nodeID).Count(&count).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking name uniqueness: %w", err)
|
||||
}
|
||||
|
||||
@@ -423,11 +427,22 @@ 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 == "" {
|
||||
node.GivenName = dnsname.SanitizeHostname(node.Hostname)
|
||||
if node.GivenName == "" {
|
||||
node.GivenName = "node"
|
||||
givenName, err := EnsureUniqueGivenName(tx, node.Hostname)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ensuring unique given name: %w", err)
|
||||
}
|
||||
|
||||
node.GivenName = givenName
|
||||
}
|
||||
|
||||
if err := tx.Save(&node).Error; err != nil { //nolint:noinlineerr
|
||||
@@ -469,6 +484,72 @@ 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
|
||||
|
||||
+395
-3
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -226,6 +227,122 @@ 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 {
|
||||
@@ -516,7 +633,7 @@ func TestEphemeralGarbageCollectorLoads(t *testing.T) {
|
||||
|
||||
want := 1000
|
||||
|
||||
var deletedCount atomic.Int64
|
||||
var deletedCount int64
|
||||
|
||||
e := NewEphemeralGarbageCollector(func(ni types.NodeID) {
|
||||
mu.Lock()
|
||||
@@ -527,7 +644,7 @@ func TestEphemeralGarbageCollectorLoads(t *testing.T) {
|
||||
|
||||
got = append(got, ni)
|
||||
|
||||
deletedCount.Add(1)
|
||||
atomic.AddInt64(&deletedCount, 1)
|
||||
})
|
||||
go e.Start()
|
||||
|
||||
@@ -538,7 +655,7 @@ func TestEphemeralGarbageCollectorLoads(t *testing.T) {
|
||||
|
||||
// Wait for all deletions to complete
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
count := deletedCount.Load()
|
||||
count := atomic.LoadInt64(&deletedCount)
|
||||
assert.Equal(c, int64(want), count, "all nodes should be deleted")
|
||||
}, 10*time.Second, 50*time.Millisecond, "waiting for all deletions")
|
||||
|
||||
@@ -625,6 +742,281 @@ 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
|
||||
|
||||
@@ -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.ValidateUsername(user.Name)
|
||||
err := util.ValidateHostname(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.ValidateUsername(newName); err != nil { //nolint:noinlineerr
|
||||
if err = util.ValidateHostname(newName); err != nil { //nolint:noinlineerr
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
+4
-138
@@ -1,21 +1,16 @@
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -329,41 +324,6 @@ 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.
|
||||
@@ -379,7 +339,7 @@ func (h *Headscale) debugHTTPServer() *http.Server {
|
||||
|
||||
debugHTTPServer := &http.Server{
|
||||
Addr: h.cfg.MetricsAddr,
|
||||
Handler: securityHeaders(debugMux),
|
||||
Handler: debugMux,
|
||||
ReadTimeout: types.HTTPTimeout,
|
||||
WriteTimeout: 0,
|
||||
}
|
||||
@@ -435,13 +395,13 @@ func (h *Headscale) debugBatcher() string {
|
||||
}
|
||||
|
||||
if node.activeConnections > 0 {
|
||||
fmt.Fprintf(&sb, "Node %d:\t%s (%d connections)\n", node.id, status, node.activeConnections)
|
||||
sb.WriteString(fmt.Sprintf("Node %d:\t%s (%d connections)\n", node.id, status, node.activeConnections))
|
||||
} else {
|
||||
fmt.Fprintf(&sb, "Node %d:\t%s\n", node.id, status)
|
||||
sb.WriteString(fmt.Sprintf("Node %d:\t%s\n", node.id, status))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(&sb, "\nSummary: %d connected, %d total\n", connectedCount, totalNodes)
|
||||
sb.WriteString(fmt.Sprintf("\nSummary: %d connected, %d total\n", connectedCount, totalNodes))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@@ -476,97 +436,3 @@ 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.",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,54 +44,6 @@ 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,8 +12,6 @@ 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
|
||||
@@ -57,73 +55,3 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -782,7 +782,6 @@ 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
|
||||
|
||||
@@ -16,6 +16,7 @@ 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"
|
||||
@@ -140,9 +141,9 @@ type node struct {
|
||||
ch chan *tailcfg.MapResponse
|
||||
|
||||
// Update tracking (all accessed atomically for thread safety)
|
||||
updateCount atomic.Int64
|
||||
patchCount atomic.Int64
|
||||
fullCount atomic.Int64
|
||||
updateCount int64
|
||||
patchCount int64
|
||||
fullCount int64
|
||||
maxPeersCount atomic.Int64
|
||||
lastPeerCount atomic.Int64
|
||||
stop chan struct{}
|
||||
@@ -403,14 +404,14 @@ func (n *node) start() {
|
||||
for {
|
||||
select {
|
||||
case data := <-n.ch:
|
||||
n.updateCount.Add(1)
|
||||
atomic.AddInt64(&n.updateCount, 1)
|
||||
|
||||
// Parse update and track detailed stats
|
||||
info := parseUpdateAndAnalyze(data)
|
||||
{
|
||||
// Track update types
|
||||
if info.IsFull {
|
||||
n.fullCount.Add(1)
|
||||
atomic.AddInt64(&n.fullCount, 1)
|
||||
n.lastPeerCount.Store(int64(info.PeerCount))
|
||||
// Update max peers seen using compare-and-swap for thread safety
|
||||
for {
|
||||
@@ -426,7 +427,7 @@ func (n *node) start() {
|
||||
}
|
||||
|
||||
if info.IsPatch {
|
||||
n.patchCount.Add(1)
|
||||
atomic.AddInt64(&n.patchCount, 1)
|
||||
// For patches, we track how many patch items using compare-and-swap
|
||||
for {
|
||||
current := n.maxPeersCount.Load()
|
||||
@@ -465,9 +466,9 @@ func (n *node) cleanup() NodeStats {
|
||||
}
|
||||
|
||||
return NodeStats{
|
||||
TotalUpdates: n.updateCount.Load(),
|
||||
PatchUpdates: n.patchCount.Load(),
|
||||
FullUpdates: n.fullCount.Load(),
|
||||
TotalUpdates: atomic.LoadInt64(&n.updateCount),
|
||||
PatchUpdates: atomic.LoadInt64(&n.patchCount),
|
||||
FullUpdates: atomic.LoadInt64(&n.fullCount),
|
||||
MaxPeersSeen: int(n.maxPeersCount.Load()),
|
||||
LastPeerCount: int(n.lastPeerCount.Load()),
|
||||
}
|
||||
@@ -508,7 +509,7 @@ func TestEnhancedNodeTracking(t *testing.T) {
|
||||
|
||||
// Wait for tracking goroutine to process the update
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
assert.GreaterOrEqual(c, testNode.updateCount.Load(), int64(1), "should have processed the update")
|
||||
assert.GreaterOrEqual(c, atomic.LoadInt64(&testNode.updateCount), int64(1), "should have processed the update")
|
||||
}, time.Second, 10*time.Millisecond, "waiting for update to be processed")
|
||||
|
||||
// Check stats
|
||||
@@ -552,7 +553,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, testNode.updateCount.Load(), int64(1), "should have received updates")
|
||||
assert.GreaterOrEqual(c, atomic.LoadInt64(&testNode.updateCount), int64(1), "should have received updates")
|
||||
}, time.Second, 10*time.Millisecond, "waiting for updates to be processed")
|
||||
|
||||
// Check stats
|
||||
@@ -573,6 +574,12 @@ 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
|
||||
@@ -2134,8 +2141,8 @@ func TestNodeDeletedWhileChangesPending(t *testing.T) {
|
||||
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
// Node 1 and 2 should receive updates
|
||||
stats1 := NodeStats{TotalUpdates: node1.updateCount.Load()}
|
||||
stats2 := NodeStats{TotalUpdates: node2.updateCount.Load()}
|
||||
stats1 := NodeStats{TotalUpdates: atomic.LoadInt64(&node1.updateCount)}
|
||||
stats2 := NodeStats{TotalUpdates: atomic.LoadInt64(&node2.updateCount)}
|
||||
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,12 +277,6 @@ 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,10 +308,6 @@ func (m *mapper) buildFromChange(
|
||||
builder.WithPeerChangedPatch(resp.PeerPatches)
|
||||
}
|
||||
|
||||
if resp.PingRequest != nil {
|
||||
builder.WithPingRequest(resp.PingRequest)
|
||||
}
|
||||
|
||||
return builder.Build()
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ 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"
|
||||
@@ -208,30 +209,25 @@ 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()...)
|
||||
|
||||
// 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")},
|
||||
}
|
||||
// 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"))
|
||||
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(primaries[id], nv.ExitRoutes())
|
||||
return slices.Concat(primary.PrimaryRoutes(id), nv.ExitRoutes())
|
||||
},
|
||||
cfg,
|
||||
)
|
||||
|
||||
+4
-28
@@ -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/{src_node_id}/to/{dst_node_id}", ns.SSHActionHandler)
|
||||
r.Get("/ssh/action/from/{src_node_id}/to/{dst_node_id}", ns.SSHActionHandler)
|
||||
|
||||
// Not implemented yet
|
||||
//
|
||||
@@ -295,31 +295,6 @@ 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
|
||||
|
||||
@@ -419,6 +394,7 @@ 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()
|
||||
|
||||
@@ -519,8 +495,8 @@ func (ns *noiseServer) sshActionHoldAndDelegate(
|
||||
) (*tailcfg.SSHAction, error) {
|
||||
holdURL, err := url.Parse(
|
||||
ns.headscale.cfg.ServerURL +
|
||||
"/machine/ssh/action/$SRC_NODE_ID/to/$DST_NODE_ID" +
|
||||
"?local_user=$LOCAL_USER",
|
||||
"/machine/ssh/action/from/$SRC_NODE_ID/to/$DST_NODE_ID" +
|
||||
"?ssh_user=$SSH_USER&local_user=$LOCAL_USER",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, NewHTTPError(
|
||||
|
||||
+2
-39
@@ -198,50 +198,13 @@ 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/%d/to/%d", src.Uint64(), dst.Uint64())
|
||||
url := fmt.Sprintf("/machine/ssh/action/from/%d/to/%d", src.Uint64(), dst.Uint64())
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
|
||||
rctx := chi.NewRouteContext()
|
||||
@@ -352,7 +315,7 @@ func TestSSHActionFollowUp_RejectsBindingMismatch(t *testing.T) {
|
||||
}
|
||||
|
||||
url := fmt.Sprintf(
|
||||
"/machine/ssh/action/%d/to/%d?auth_id=%s",
|
||||
"/machine/ssh/action/from/%d/to/%d?auth_id=%s",
|
||||
srcOther.ID.Uint64(), dstOther.ID.Uint64(), authID.String(),
|
||||
)
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
|
||||
+48
-39
@@ -155,21 +155,21 @@ func (a *AuthProviderOIDC) authHandler(
|
||||
) {
|
||||
authID, err := authIDFromRequest(req)
|
||||
if err != nil {
|
||||
httpUserError(writer, err)
|
||||
httpError(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Set the state and nonce cookies to protect against CSRF attacks
|
||||
state, err := setCSRFCookie(writer, req, "state")
|
||||
if err != nil {
|
||||
httpUserError(writer, err)
|
||||
httpError(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Set the state and nonce cookies to protect against CSRF attacks
|
||||
nonce, err := setCSRFCookie(writer, req, "nonce")
|
||||
if err != nil {
|
||||
httpUserError(writer, err)
|
||||
httpError(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
) {
|
||||
code, state, err := extractCodeAndStateParamFromRequest(req)
|
||||
if err != nil {
|
||||
httpUserError(writer, err)
|
||||
httpError(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -230,29 +230,29 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
|
||||
cookieState, err := req.Cookie(stateCookieName)
|
||||
if err != nil {
|
||||
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "state not found", err))
|
||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "state not found", err))
|
||||
return
|
||||
}
|
||||
|
||||
if state != cookieState.Value {
|
||||
httpUserError(writer, NewHTTPError(http.StatusForbidden, "state did not match", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "state did not match", nil))
|
||||
return
|
||||
}
|
||||
|
||||
oauth2Token, err := a.getOauth2Token(req.Context(), code, state)
|
||||
if err != nil {
|
||||
httpUserError(writer, err)
|
||||
httpError(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
idToken, err := a.extractIDToken(req.Context(), oauth2Token)
|
||||
if err != nil {
|
||||
httpUserError(writer, err)
|
||||
httpError(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
if idToken.Nonce == "" {
|
||||
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "nonce not found in IDToken", err))
|
||||
httpError(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 {
|
||||
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "nonce not found", err))
|
||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "nonce not found", err))
|
||||
return
|
||||
}
|
||||
|
||||
if idToken.Nonce != nonce.Value {
|
||||
httpUserError(writer, NewHTTPError(http.StatusForbidden, "nonce did not match", nil))
|
||||
httpError(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
|
||||
httpUserError(writer, fmt.Errorf("decoding ID token claims: %w", err))
|
||||
httpError(writer, fmt.Errorf("decoding ID token claims: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -310,17 +310,26 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
// against allowed emails, email domains, and groups.
|
||||
err = doOIDCAuthorization(a.cfg, &claims)
|
||||
if err != nil {
|
||||
httpUserError(writer, err)
|
||||
httpError(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
user, _, err := a.createOrUpdateUserFromClaim(&claims)
|
||||
if err != nil {
|
||||
httpUserError(writer, NewHTTPError(
|
||||
http.StatusInternalServerError,
|
||||
"could not create or update user",
|
||||
err,
|
||||
))
|
||||
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")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
@@ -332,7 +341,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")
|
||||
httpUserError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -358,7 +367,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")
|
||||
httpUserError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -367,7 +376,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")
|
||||
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "auth session is not for SSH check", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "auth session is not for SSH check", nil))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -380,7 +389,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")
|
||||
httpUserError(writer, NewHTTPError(http.StatusGone, "src node no longer exists", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusGone, "src node no longer exists", nil))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -395,7 +404,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")
|
||||
httpUserError(writer, NewHTTPError(http.StatusForbidden, "src node has no user owner", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "src node has no user owner", nil))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -408,7 +417,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")
|
||||
httpUserError(writer, NewHTTPError(http.StatusForbidden, "OIDC user is not the owner of the SSH source node", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "OIDC user is not the owner of the SSH source node", nil))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -671,7 +680,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")
|
||||
httpUserError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -680,14 +689,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")
|
||||
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "auth session is not for node registration", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "auth session is not for node registration", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
csrf, err := util.GenerateRandomStringURLSafe(32)
|
||||
if err != nil {
|
||||
httpUserError(writer, fmt.Errorf("generating csrf token: %w", err))
|
||||
httpError(writer, fmt.Errorf("generating csrf token: %w", err))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -739,14 +748,14 @@ func (a *AuthProviderOIDC) RegisterConfirmHandler(
|
||||
req *http.Request,
|
||||
) {
|
||||
if req.Method != http.MethodPost {
|
||||
httpUserError(writer, errMethodNotAllowed)
|
||||
httpError(writer, errMethodNotAllowed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
authID, err := authIDFromRequest(req)
|
||||
if err != nil {
|
||||
httpUserError(writer, err)
|
||||
httpError(writer, err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -757,54 +766,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
|
||||
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "invalid form", err))
|
||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "invalid form", err))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
formCSRF := req.PostFormValue(registerConfirmCSRFCookie) //nolint:gosec // body is bounded above
|
||||
if formCSRF == "" {
|
||||
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "missing csrf token", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "missing csrf token", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
cookie, err := req.Cookie(registerConfirmCSRFCookie)
|
||||
if err != nil {
|
||||
httpUserError(writer, NewHTTPError(http.StatusForbidden, "missing csrf cookie", err))
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "missing csrf cookie", err))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if cookie.Value != formCSRF {
|
||||
httpUserError(writer, NewHTTPError(http.StatusForbidden, "csrf token mismatch", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "csrf token mismatch", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
authReq, ok := a.h.state.GetAuthCacheEntry(authID)
|
||||
if !ok {
|
||||
httpUserError(writer, NewHTTPError(http.StatusGone, "registration session expired", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusGone, "registration session expired", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
pending := authReq.PendingConfirmation()
|
||||
if pending == nil {
|
||||
httpUserError(writer, NewHTTPError(http.StatusForbidden, "registration not OIDC-authorized", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "registration not OIDC-authorized", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if pending.CSRF != cookie.Value {
|
||||
httpUserError(writer, NewHTTPError(http.StatusForbidden, "csrf token does not match cached registration", nil))
|
||||
httpError(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 {
|
||||
httpUserError(writer, fmt.Errorf("looking up user: %w", err))
|
||||
httpError(writer, fmt.Errorf("looking up user: %w", err))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -812,12 +821,12 @@ func (a *AuthProviderOIDC) RegisterConfirmHandler(
|
||||
newNode, err := a.handleRegistration(user, authID, pending.NodeExpiry)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNodeNotFoundRegistrationCache) {
|
||||
httpUserError(writer, NewHTTPError(http.StatusGone, "registration session expired", err))
|
||||
httpError(writer, NewHTTPError(http.StatusGone, "registration session expired", err))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
httpUserError(writer, err)
|
||||
httpError(writer, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,71 +7,6 @@ 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,11 +53,6 @@ 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)
|
||||
@@ -101,20 +96,18 @@ func (m *Match) DestsOverlapsPrefixes(prefixes ...netip.Prefix) bool {
|
||||
return slices.ContainsFunc(prefixes, m.dests.OverlapsPrefix)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
func (m *Match) DestsIsTheInternet() bool {
|
||||
if m.dests.ContainsPrefix(tsaddr.AllIPv4()) ||
|
||||
m.dests.ContainsPrefix(tsaddr.AllIPv6()) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Superset-of-TheInternet check handles merged filter rules
|
||||
// where the internet prefixes are combined with other dests.
|
||||
// Check if dests contains all prefixes of TheInternet (superset check)
|
||||
theInternet := util.TheInternet()
|
||||
for _, prefix := range theInternet.Prefixes() {
|
||||
if !m.dests.ContainsPrefix(prefix) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package matcher
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -429,44 +428,4 @@ func TestDebugString(t *testing.T) {
|
||||
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,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/juanfont/headscale/hscontrol/policy/matcher"
|
||||
@@ -1263,11 +1264,11 @@ func TestSSHPolicyRules(t *testing.T) {
|
||||
},
|
||||
Action: &tailcfg.SSHAction{
|
||||
Accept: false,
|
||||
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,
|
||||
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,
|
||||
},
|
||||
},
|
||||
}},
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
// 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
|
||||
@@ -17,7 +17,6 @@ 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()
|
||||
|
||||
for _, rule := range rules {
|
||||
// Handle CapGrant rules separately — they use CapGrant[].Dsts
|
||||
@@ -34,16 +33,18 @@ 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: unparseable dests are dropped.
|
||||
// Fail closed, if we can't parse it, then we should not allow
|
||||
// access.
|
||||
if err != nil {
|
||||
continue
|
||||
continue DEST_LOOP
|
||||
}
|
||||
|
||||
if node.InIPSet(expanded) {
|
||||
dests = append(dests, dest)
|
||||
continue
|
||||
continue DEST_LOOP
|
||||
}
|
||||
|
||||
// If the node has approved subnet routes, preserve
|
||||
@@ -54,17 +55,20 @@ func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcf
|
||||
// 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)
|
||||
for _, subnetRoute := range node.SubnetRoutes() {
|
||||
if expanded.OverlapsPrefix(subnetRoute) {
|
||||
dests = append(dests, dest)
|
||||
continue DEST_LOOP
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(dests) > 0 {
|
||||
// Struct-copy preserves any unknown future FilterRule
|
||||
// fields.
|
||||
out := rule
|
||||
out.DstPorts = dests
|
||||
ret = append(ret, out)
|
||||
ret = append(ret, tailcfg.FilterRule{
|
||||
SrcIPs: rule.SrcIPs,
|
||||
DstPorts: dests,
|
||||
IPProto: rule.IPProto,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +87,6 @@ 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
|
||||
@@ -108,15 +111,13 @@ func reduceCapGrantRule(
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Also check approved subnet routes — nodes serving
|
||||
// approved routes should receive CapGrant rules for
|
||||
// destinations that overlap those routes. SubnetRoutes()
|
||||
// excludes both unapproved and exit routes, matching
|
||||
// Tailscale SaaS behavior.
|
||||
for _, dst := range cg.Dsts {
|
||||
for _, subnetRoute := range subnetRoutes {
|
||||
for _, subnetRoute := range node.SubnetRoutes() {
|
||||
if dst.Overlaps(subnetRoute) {
|
||||
// For route overlaps, keep the original prefix.
|
||||
matchingDsts = append(matchingDsts, dst)
|
||||
@@ -125,13 +126,6 @@ func reduceCapGrantRule(
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -1203,55 +1203,6 @@ func TestReduceFilterRulesCapGrant(t *testing.T) {
|
||||
},
|
||||
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 {
|
||||
|
||||
@@ -72,10 +72,9 @@ type viaGrantData struct {
|
||||
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).
|
||||
// userNodeIndex maps user IDs to their untagged nodes. Precomputed
|
||||
// once per node-set change, used by compileAutogroupSelf to avoid
|
||||
// O(N) scans per self-grant per node.
|
||||
type userNodeIndex map[uint][]types.NodeView
|
||||
|
||||
func buildUserNodeIndex(
|
||||
@@ -101,7 +100,7 @@ func (pol *Policy) compileGrants(
|
||||
users types.Users,
|
||||
nodes views.Slice[types.NodeView],
|
||||
) []compiledGrant {
|
||||
if pol == nil || (pol.ACLs == nil && pol.Grants == nil) {
|
||||
if pol == nil || (pol.ACLs == nil && len(pol.Grants) == 0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -270,9 +269,7 @@ func (pol *Policy) compileOneViaGrant(
|
||||
|
||||
// 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.
|
||||
// This is the shared source resolution used by all grant categories.
|
||||
func resolveSources(
|
||||
pol *Policy,
|
||||
sources Aliases,
|
||||
@@ -460,9 +457,8 @@ func hasPerNodeGrants(grants []compiledGrant) bool {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// grants. Only includes pre-compiled rules from non-via grants.
|
||||
// Via grants produce no global rules (they are per-node only).
|
||||
func globalFilterRules(grants []compiledGrant) []tailcfg.FilterRule {
|
||||
var rules []tailcfg.FilterRule
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
@@ -63,7 +62,7 @@ func companionCapGrantRules(
|
||||
}
|
||||
|
||||
slices.SortFunc(pairs, func(a, b pair) int {
|
||||
return cmp.Compare(a.original, b.original)
|
||||
return strings.Compare(string(a.original), string(b.original))
|
||||
})
|
||||
|
||||
companions := make([]tailcfg.FilterRule, 0, len(pairs))
|
||||
@@ -138,7 +137,7 @@ func (pol *Policy) compileFilterRules(
|
||||
users types.Users,
|
||||
nodes views.Slice[types.NodeView],
|
||||
) ([]tailcfg.FilterRule, error) {
|
||||
if pol == nil || (pol.ACLs == nil && pol.Grants == nil) {
|
||||
if pol == nil || (pol.ACLs == nil && len(pol.Grants) == 0) {
|
||||
return tailcfg.FilterAllowAll, nil
|
||||
}
|
||||
|
||||
@@ -230,8 +229,6 @@ 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:
|
||||
@@ -243,13 +240,13 @@ func checkPeriodFromRule(rule SSH) time.Duration {
|
||||
}
|
||||
}
|
||||
|
||||
func sshCheck(baseURL string, _ time.Duration) tailcfg.SSHAction {
|
||||
holdURL := baseURL + "/machine/ssh/action/$SRC_NODE_ID/to/$DST_NODE_ID?local_user=$LOCAL_USER"
|
||||
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"
|
||||
|
||||
return tailcfg.SSHAction{
|
||||
Reject: false,
|
||||
Accept: false,
|
||||
SessionDuration: 0,
|
||||
SessionDuration: duration,
|
||||
// Replaced in the client:
|
||||
// * $SRC_NODE_IP (URL escaped)
|
||||
// * $SRC_NODE_ID (Node.ID as int64 string)
|
||||
@@ -258,9 +255,9 @@ func sshCheck(baseURL string, _ time.Duration) tailcfg.SSHAction {
|
||||
// * $SSH_USER (URL escaped, ssh user requested)
|
||||
// * $LOCAL_USER (URL escaped, local user mapped)
|
||||
HoldAndDelegate: holdURL,
|
||||
AllowAgentForwarding: false,
|
||||
AllowLocalPortForwarding: false,
|
||||
AllowRemotePortForwarding: false,
|
||||
AllowAgentForwarding: true,
|
||||
AllowLocalPortForwarding: true,
|
||||
AllowRemotePortForwarding: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, time.Duration(0), rule.Action.SessionDuration)
|
||||
assert.Equal(t, 24*time.Hour, 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,24 +2632,25 @@ 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
|
||||
name string
|
||||
checkPeriod *SSHCheckPeriod
|
||||
wantDuration time.Duration
|
||||
}{
|
||||
{
|
||||
name: "nil period",
|
||||
checkPeriod: nil,
|
||||
name: "nil period defaults to 12h",
|
||||
checkPeriod: nil,
|
||||
wantDuration: SSHCheckPeriodDefault,
|
||||
},
|
||||
{
|
||||
name: "always period",
|
||||
checkPeriod: &SSHCheckPeriod{Always: true},
|
||||
name: "always period uses 0",
|
||||
checkPeriod: &SSHCheckPeriod{Always: true},
|
||||
wantDuration: 0,
|
||||
},
|
||||
{
|
||||
name: "explicit 2h",
|
||||
checkPeriod: &SSHCheckPeriod{Duration: 2 * time.Hour},
|
||||
name: "explicit 2h",
|
||||
checkPeriod: &SSHCheckPeriod{Duration: 2 * time.Hour},
|
||||
wantDuration: 2 * time.Hour,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2681,7 +2682,7 @@ func TestCompileSSHPolicy_CheckPeriodVariants(t *testing.T) {
|
||||
require.Len(t, sshPolicy.Rules, 1)
|
||||
|
||||
rule := sshPolicy.Rules[0]
|
||||
assert.Equal(t, time.Duration(0), rule.Action.SessionDuration)
|
||||
assert.Equal(t, tt.wantDuration, 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")
|
||||
@@ -3537,26 +3538,11 @@ func TestFilterAllowAllFix(t *testing.T) {
|
||||
wantFilterAllow: true,
|
||||
},
|
||||
{
|
||||
name: "nil ACLs and empty grants denies all",
|
||||
name: "nil ACLs and empty grants returns FilterAllowAll",
|
||||
pol: &Policy{
|
||||
Grants: []Grant{},
|
||||
},
|
||||
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,
|
||||
wantFilterAllow: true,
|
||||
},
|
||||
{
|
||||
name: "both ACLs and grants should not return FilterAllowAll",
|
||||
|
||||
+30
-179
@@ -55,11 +55,6 @@ type PolicyManager struct {
|
||||
// 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
|
||||
@@ -83,12 +78,11 @@ func NewPolicyManager(b []byte, users []types.User, nodes views.Slice[types.Node
|
||||
}
|
||||
|
||||
pm := PolicyManager{
|
||||
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()),
|
||||
pol: policy,
|
||||
users: users,
|
||||
nodes: nodes,
|
||||
sshPolicyMap: make(map[types.NodeID]*tailcfg.SSHPolicy, nodes.Len()),
|
||||
filterRulesMap: make(map[types.NodeID][]tailcfg.FilterRule, nodes.Len()),
|
||||
}
|
||||
|
||||
_, err = pm.updateLocked()
|
||||
@@ -109,7 +103,7 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
|
||||
pm.needsPerNodeFilter = hasPerNodeGrants(pm.compiledGrants)
|
||||
|
||||
var filter []tailcfg.FilterRule
|
||||
if pm.pol == nil || (pm.pol.ACLs == nil && pm.pol.Grants == nil) {
|
||||
if pm.pol == nil || (pm.pol.ACLs == nil && len(pm.pol.Grants) == 0) {
|
||||
filter = tailcfg.FilterAllowAll
|
||||
} else {
|
||||
filter = globalFilterRules(pm.compiledGrants)
|
||||
@@ -213,7 +207,6 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
|
||||
// that nodes has been added or removed.
|
||||
clear(pm.sshPolicyMap)
|
||||
clear(pm.filterRulesMap)
|
||||
clear(pm.matchersForNodeMap)
|
||||
}
|
||||
|
||||
// If nothing changed, no need to update nodes
|
||||
@@ -234,11 +227,6 @@ 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()
|
||||
@@ -259,12 +247,9 @@ 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. 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).
|
||||
// parameters that a client could tamper with.
|
||||
// It returns the check period duration and whether a matching check
|
||||
// rule was found.
|
||||
func (pm *PolicyManager) SSHCheckParams(
|
||||
srcNodeID, dstNodeID types.NodeID,
|
||||
) (time.Duration, bool) {
|
||||
@@ -517,9 +502,6 @@ func (pm *PolicyManager) filterForNodeLocked(
|
||||
// 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
|
||||
@@ -537,10 +519,6 @@ 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
|
||||
@@ -556,17 +534,11 @@ func (pm *PolicyManager) MatchersForNode(node types.NodeView) ([]matcher.Match,
|
||||
return pm.matchers, nil
|
||||
}
|
||||
|
||||
if cached, ok := pm.matchersForNodeMap[node.ID()]; ok {
|
||||
return cached, 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
|
||||
return matcher.MatchesFromFilterRules(unreduced), nil
|
||||
}
|
||||
|
||||
// SetUsers updates the users in the policy manager and updates the filter rules.
|
||||
@@ -638,7 +610,6 @@ func (pm *PolicyManager) SetNodes(nodes views.Slice[types.NodeView]) (bool, erro
|
||||
// This ensures fresh filter rules are generated for all nodes
|
||||
clear(pm.sshPolicyMap)
|
||||
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)
|
||||
@@ -787,9 +758,6 @@ 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
|
||||
@@ -801,9 +769,16 @@ func (pm *PolicyManager) NodeCanApproveRoute(node types.NodeView, route netip.Pr
|
||||
return false
|
||||
}
|
||||
|
||||
return slices.ContainsFunc(node.IPs(), pm.exitSet.Contains)
|
||||
if slices.ContainsFunc(node.IPs(), pm.exitSet.Contains) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
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
|
||||
@@ -836,10 +811,6 @@ 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
|
||||
|
||||
@@ -860,33 +831,28 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via
|
||||
grants = append(grants, aclToGrants(acl)...)
|
||||
}
|
||||
|
||||
// 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 _, grant := range grants {
|
||||
if len(grant.Via) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if viewer matches any grant source.
|
||||
viewerMatches := false
|
||||
|
||||
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(viewerIPs, ips.Contains) {
|
||||
viewerMatchesGrant[i] = true
|
||||
if ips != nil && slices.ContainsFunc(viewer.IPs(), ips.Contains) {
|
||||
viewerMatches = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, grant := range grants {
|
||||
if len(grant.Via) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if !viewerMatchesGrant[i] {
|
||||
if !viewerMatches {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -906,7 +872,8 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via
|
||||
// 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.
|
||||
// mechanism, not through AllowedIPs. Verified by golden
|
||||
// captures GRANT-V14 through GRANT-V36.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -932,113 +899,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -1251,12 +1111,10 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S
|
||||
if found {
|
||||
if _, affected := affectedUsers[nodeUserID]; affected {
|
||||
delete(pm.filterRulesMap, nodeID)
|
||||
delete(pm.matchersForNodeMap, nodeID)
|
||||
}
|
||||
} else {
|
||||
// Node not found in either old or new list, clear it
|
||||
delete(pm.filterRulesMap, nodeID)
|
||||
delete(pm.matchersForNodeMap, nodeID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1305,7 +1163,6 @@ func (pm *PolicyManager) invalidateGlobalPolicyCache(newNodes views.Slice[types.
|
||||
|
||||
if newNode.HasNetworkChanges(oldNode) {
|
||||
delete(pm.filterRulesMap, nodeID)
|
||||
delete(pm.matchersForNodeMap, nodeID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1315,12 +1172,6 @@ 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.
|
||||
|
||||
@@ -44,20 +44,6 @@ 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 {
|
||||
|
||||
@@ -16,7 +16,6 @@ package v2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -142,20 +141,18 @@ func cmpOptions() []cmp.Option {
|
||||
return a.Bits() < b.Bits()
|
||||
}),
|
||||
// Compare tailcfg.RawMessage semantically (it's a string type
|
||||
// containing JSON) to handle indentation differences. Both
|
||||
// sides must be valid JSON — golden data parse failures are
|
||||
// always errors.
|
||||
// containing JSON) to handle indentation differences.
|
||||
cmp.Comparer(func(a, b tailcfg.RawMessage) bool {
|
||||
var va, vb any
|
||||
|
||||
err := json.Unmarshal([]byte(a), &va)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("golden RawMessage A unparseable: %v", err))
|
||||
return a == b
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(b), &vb)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("golden RawMessage B unparseable: %v", err))
|
||||
return a == b
|
||||
}
|
||||
|
||||
ja, _ := json.Marshal(va)
|
||||
@@ -166,6 +163,7 @@ func cmpOptions() []cmp.Option {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 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.
|
||||
@@ -256,6 +254,22 @@ func loadACLTestFile(t *testing.T, path string) *testcapture.Capture {
|
||||
return c
|
||||
}
|
||||
|
||||
// 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.
|
||||
}
|
||||
|
||||
// TestACLCompat is a data-driven test that loads all ACL-*.json test files
|
||||
// and compares headscale's ACL engine output against the expected behavior.
|
||||
//
|
||||
@@ -284,6 +298,16 @@ func TestACLCompat(t *testing.T) {
|
||||
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)
|
||||
|
||||
@@ -302,6 +326,13 @@ func TestACLCompat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// aclErrorMessageMap maps Tailscale SaaS error substrings to headscale
|
||||
// equivalents. Populated as mismatches are discovered.
|
||||
var aclErrorMessageMap = map[string]string{
|
||||
// Add known wording differences here as they are discovered.
|
||||
// Example: "tag not found" -> "undefined tag",
|
||||
}
|
||||
|
||||
// testACLError verifies that an invalid policy produces the expected error.
|
||||
func testACLError(t *testing.T, tf *testcapture.Capture) {
|
||||
t.Helper()
|
||||
@@ -343,9 +374,12 @@ func testACLError(t *testing.T, tf *testcapture.Capture) {
|
||||
)
|
||||
}
|
||||
|
||||
// assertACLErrorContains requires that headscale's error contains the
|
||||
// Tailscale SaaS error message verbatim. Divergence means an emitter
|
||||
// needs to be aligned, not papered over with a translation table.
|
||||
// assertACLErrorContains checks that an error message matches the
|
||||
// expected Tailscale SaaS message, using progressive fallbacks:
|
||||
// 1. Direct substring match
|
||||
// 2. Mapped equivalent from aclErrorMessageMap
|
||||
// 3. Key-part extraction (tags, autogroups, port, undefined)
|
||||
// 4. t.Errorf on no match (strict)
|
||||
func assertACLErrorContains(
|
||||
t *testing.T,
|
||||
err error,
|
||||
@@ -355,10 +389,37 @@ func assertACLErrorContains(
|
||||
t.Helper()
|
||||
|
||||
errStr := err.Error()
|
||||
|
||||
// 1. Direct substring match.
|
||||
if strings.Contains(errStr, wantMsg) {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Mapped equivalent.
|
||||
for tsKey, hsKey := range aclErrorMessageMap {
|
||||
if strings.Contains(wantMsg, tsKey) &&
|
||||
strings.Contains(errStr, hsKey) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Key-part extraction.
|
||||
for _, part := range []string{
|
||||
"autogroup:self",
|
||||
"not valid on the src",
|
||||
"port range",
|
||||
"tag not found",
|
||||
"tag:",
|
||||
"undefined",
|
||||
"capability",
|
||||
} {
|
||||
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"+
|
||||
|
||||
@@ -456,22 +456,85 @@ func testGrantError(t *testing.T, policyJSON []byte, tf *testcapture.Capture) {
|
||||
tf.TestID, wantMsg)
|
||||
}
|
||||
|
||||
// assertGrantErrorContains requires that headscale's error contains
|
||||
// the Tailscale SaaS error message verbatim. Divergence means an
|
||||
// emitter needs to be aligned, not papered over with a translation
|
||||
// table.
|
||||
// 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).
|
||||
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(
|
||||
@@ -491,9 +554,34 @@ func testGrantSuccess(
|
||||
|
||||
for nodeName, capture := range tf.Captures {
|
||||
t.Run(nodeName, func(t *testing.T) {
|
||||
// Check if this node was offline during capture.
|
||||
// kakuna (tag:prod) was frequently offline (132 of 188 success tests).
|
||||
// When offline, packet_filter_rules is null and topology shows
|
||||
// hostname="unknown" with empty tags.
|
||||
if len(capture.PacketFilterRules) == 0 {
|
||||
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 empty rules — means Tailscale
|
||||
// produced no rules. headscale should also produce no rules.
|
||||
}
|
||||
|
||||
node := findGrantsNode(nodes, nodeName)
|
||||
require.NotNilf(t, node,
|
||||
"golden node %s not found in test setup", nodeName)
|
||||
if node == nil {
|
||||
t.Skipf(
|
||||
"node %s not found in test setup (may be a test-specific node)",
|
||||
nodeName,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Compile headscale filter rules for this node
|
||||
gotRules, err := pol.compileFilterRulesForNode(
|
||||
|
||||
@@ -171,6 +171,9 @@ func buildRoutesUsersAndNodes(
|
||||
return users, nodes
|
||||
}
|
||||
|
||||
// routesSkipReasons documents WHY tests are expected to fail.
|
||||
var routesSkipReasons = map[string]string{}
|
||||
|
||||
// subnetToSubnetFiles lists the golden files that test subnet-to-subnet
|
||||
// ACL scenarios. These are the scenarios where the fix for issue #3157
|
||||
// (CanAccess considering subnet routes as source identity) is critical.
|
||||
@@ -207,6 +210,15 @@ func TestRoutesCompat(t *testing.T) {
|
||||
t.Run(tf.TestID, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if reason, ok := routesSkipReasons[tf.TestID]; ok {
|
||||
t.Skipf(
|
||||
"TODO: %s — see routesSkipReasons for details",
|
||||
reason,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Build topology from JSON
|
||||
users, nodes := buildRoutesUsersAndNodes(t, tf.Topology)
|
||||
|
||||
@@ -233,8 +245,14 @@ func TestRoutesCompat(t *testing.T) {
|
||||
for nodeName, capture := range tf.Captures {
|
||||
t.Run(nodeName, func(t *testing.T) {
|
||||
node := findNodeByGivenName(nodes, nodeName)
|
||||
require.NotNilf(t, node,
|
||||
"golden node %s not found in topology", nodeName)
|
||||
if node == nil {
|
||||
t.Skipf(
|
||||
"node %s not found in topology",
|
||||
nodeName,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
compiledRules, err := pol.compileFilterRulesForNode(
|
||||
users,
|
||||
@@ -514,12 +532,8 @@ func TestRoutesCompatPeerVisibility(t *testing.T) {
|
||||
)
|
||||
|
||||
t.Run("CanAccess", func(t *testing.T) {
|
||||
// Peer visibility is satisfied if either direction of
|
||||
// CanAccess returns true — that mirrors the
|
||||
// ReduceNodes semantics used at runtime. An ACL may
|
||||
// legitimately grant access in only one direction
|
||||
// (tested in ReduceNodes below), and a symmetric
|
||||
// assertion would reject the intentional asymmetry.
|
||||
// For each expected pair, verify that at least one
|
||||
// direction of CanAccess returns true.
|
||||
for pair := range wantPairs {
|
||||
nodeA := findNodeByGivenName(nodes, pair[0])
|
||||
nodeB := findNodeByGivenName(nodes, pair[1])
|
||||
@@ -530,15 +544,15 @@ func TestRoutesCompatPeerVisibility(t *testing.T) {
|
||||
"node %s not found", pair[1],
|
||||
)
|
||||
|
||||
matchersA, err := pm.MatchersForNode(nodeA.View())
|
||||
require.NoError(t, err)
|
||||
matchersB, err := pm.MatchersForNode(nodeB.View())
|
||||
// Get matchers — these are the unreduced global
|
||||
// matchers used for peer relationship determination.
|
||||
matchers, err := pm.MatchersForNode(nodeA.View())
|
||||
require.NoError(t, err)
|
||||
|
||||
canAccess := nodeA.View().CanAccess(
|
||||
matchersA, nodeB.View(),
|
||||
matchers, nodeB.View(),
|
||||
) || nodeB.View().CanAccess(
|
||||
matchersB, nodeA.View(),
|
||||
matchers, nodeA.View(),
|
||||
)
|
||||
assert.Truef(t, canAccess,
|
||||
"%s: %s and %s should be peers "+
|
||||
@@ -629,18 +643,11 @@ func TestRoutesCompatPeerVisibility(t *testing.T) {
|
||||
|
||||
for _, rule := range rules {
|
||||
for _, dp := range rule.DstPorts {
|
||||
if dp.IP == "*" {
|
||||
continue
|
||||
}
|
||||
|
||||
prefix, err := netip.ParsePrefix(dp.IP)
|
||||
prefix, err := netip.ParsePrefix(
|
||||
dp.IP,
|
||||
)
|
||||
if err != nil {
|
||||
// DstPorts.IP can be a bare IP without /prefix.
|
||||
addr, addrErr := netip.ParseAddr(dp.IP)
|
||||
require.NoErrorf(t, addrErr,
|
||||
"golden DstPorts.IP %q unparseable as prefix or addr", dp.IP)
|
||||
|
||||
prefix = netip.PrefixFrom(addr, addr.BitLen())
|
||||
continue
|
||||
}
|
||||
|
||||
dstPrefixes = append(
|
||||
@@ -760,18 +767,12 @@ func TestRoutesCompatAutoApproval(t *testing.T) {
|
||||
len(nodeDef.ApprovedRoutes),
|
||||
)
|
||||
for _, r := range nodeDef.ApprovedRoutes {
|
||||
pfx, err := netip.ParsePrefix(r)
|
||||
require.NoErrorf(t, err,
|
||||
"golden approved_route %q for %s", r, nodeName)
|
||||
|
||||
approvedSet[pfx] = true
|
||||
approvedSet[netip.MustParsePrefix(r)] = true
|
||||
}
|
||||
|
||||
t.Run(nodeName, func(t *testing.T) {
|
||||
for _, routeStr := range nodeDef.RoutableIPs {
|
||||
route, err := netip.ParsePrefix(routeStr)
|
||||
require.NoErrorf(t, err,
|
||||
"golden routable_ip %q for %s", routeStr, nodeName)
|
||||
route := netip.MustParsePrefix(routeStr)
|
||||
|
||||
wantApproved := approvedSet[route]
|
||||
gotApproved := pm.NodeCanApproveRoute(
|
||||
@@ -840,6 +841,12 @@ func TestRoutesCompatReduceRoutes(t *testing.T) {
|
||||
t.Run(tf.TestID, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if reason, ok := routesSkipReasons[tf.TestID]; ok {
|
||||
t.Skipf("TODO: %s", reason)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Build topology from JSON.
|
||||
users, nodes := buildRoutesUsersAndNodes(t, tf.Topology)
|
||||
|
||||
@@ -872,17 +879,9 @@ func TestRoutesCompatReduceRoutes(t *testing.T) {
|
||||
var dstPrefixes []netip.Prefix
|
||||
|
||||
for _, dp := range rule.DstPorts {
|
||||
if dp.IP == "*" {
|
||||
continue
|
||||
}
|
||||
|
||||
prefix, parseErr := netip.ParsePrefix(dp.IP)
|
||||
if parseErr != nil {
|
||||
addr, addrErr := netip.ParseAddr(dp.IP)
|
||||
require.NoErrorf(t, addrErr,
|
||||
"golden DstPorts.IP %q unparseable", dp.IP)
|
||||
|
||||
prefix = netip.PrefixFrom(addr, addr.BitLen())
|
||||
continue
|
||||
}
|
||||
|
||||
if !slices.Contains(dstPrefixes, prefix) {
|
||||
@@ -898,27 +897,12 @@ func TestRoutesCompatReduceRoutes(t *testing.T) {
|
||||
var srcBuilder netipx.IPSetBuilder
|
||||
|
||||
for _, srcIP := range rule.SrcIPs {
|
||||
if srcIP == "*" {
|
||||
continue
|
||||
}
|
||||
|
||||
// SrcIPs can be CIDR prefixes, bare IPs, or
|
||||
// dash-separated IP ranges (e.g. "100.115.94.0-100.127.255.255").
|
||||
if strings.Contains(srcIP, "-") {
|
||||
ipRange, rangeErr := netipx.ParseIPRange(srcIP)
|
||||
require.NoErrorf(t, rangeErr,
|
||||
"golden SrcIP range %q unparseable", srcIP)
|
||||
|
||||
srcBuilder.AddRange(ipRange)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
prefix, parseErr := netip.ParsePrefix(srcIP)
|
||||
if parseErr != nil {
|
||||
addr, parseErr2 := netip.ParseAddr(srcIP)
|
||||
require.NoErrorf(t, parseErr2,
|
||||
"golden SrcIP %q unparseable as prefix or addr", srcIP)
|
||||
if parseErr2 != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
srcBuilder.Add(addr)
|
||||
|
||||
@@ -1170,6 +1154,12 @@ func TestRoutesCompatNoPeersBeyondCaptures(t *testing.T) {
|
||||
t.Run(tf.TestID, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if reason, ok := routesSkipReasons[tf.TestID]; ok {
|
||||
t.Skipf("TODO: %s", reason)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
users, nodes := buildRoutesUsersAndNodes(t, tf.Topology)
|
||||
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
|
||||
|
||||
@@ -1433,9 +1423,14 @@ func TestRoutesCompatPeerAllowedIPs(t *testing.T) {
|
||||
t.Run(tf.TestID, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// SaaS rejected this policy — verify headscale also rejects it.
|
||||
if reason, ok := routesSkipReasons[tf.TestID]; ok {
|
||||
t.Skipf("TODO: %s", reason)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if tf.Error {
|
||||
testRoutesError(t, tf)
|
||||
t.Skipf("%s: SaaS rejected the policy", tf.TestID)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -1513,67 +1508,3 @@ func TestRoutesCompatPeerAllowedIPs(t *testing.T) {
|
||||
"no golden files with netmap data found — test is vacuous",
|
||||
)
|
||||
}
|
||||
|
||||
// testRoutesError verifies that an invalid policy produces the expected error.
|
||||
func testRoutesError(t *testing.T, tf *testcapture.Capture) {
|
||||
t.Helper()
|
||||
|
||||
policyJSON := convertPolicyUserEmails(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 != "" {
|
||||
assertRoutesErrorContains(t, err, wantMsg, tf.TestID)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err = pol.validate()
|
||||
if err != nil {
|
||||
if tf.Input.APIResponseBody != nil {
|
||||
wantMsg := tf.Input.APIResponseBody.Message
|
||||
if wantMsg != "" {
|
||||
assertRoutesErrorContains(t, err, wantMsg, tf.TestID)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
t.Errorf(
|
||||
"%s: expected error but policy parsed and validated successfully",
|
||||
tf.TestID,
|
||||
)
|
||||
}
|
||||
|
||||
// assertRoutesErrorContains requires that headscale's error contains
|
||||
// the Tailscale SaaS error message verbatim. Divergence means an
|
||||
// emitter needs to be aligned, not papered over with a translation
|
||||
// table.
|
||||
func assertRoutesErrorContains(
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ package v2
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
@@ -31,6 +30,7 @@ import (
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
|
||||
// setupSSHDataCompatUsers returns the 3 test users for SSH data-driven
|
||||
// compatibility tests. Users get norse-god names; nodes get original-151
|
||||
// pokémon names — matching the anonymized identifiers tscap writes into
|
||||
@@ -129,24 +129,11 @@ func loadSSHTestFile(t *testing.T, path string) *testcapture.Capture {
|
||||
// sshSkipReasons documents why each skipped test fails and what needs to be
|
||||
// fixed. Tests are grouped by root cause to identify high-impact changes.
|
||||
var sshSkipReasons = map[string]string{
|
||||
// USER_PASSKEY_WILDCARD (2 tests)
|
||||
//
|
||||
// user:*@passkey wildcard pattern not supported in headscale.
|
||||
// headscale does not support passkey authentication and has no
|
||||
// equivalent for the user:*@passkey wildcard pattern.
|
||||
// equivalent for this 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-*.hujson test
|
||||
@@ -192,10 +179,9 @@ func TestSSHDataCompat(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
// SaaS rejected this policy — verify headscale also rejects it.
|
||||
// Skip captures the SaaS rejected — no expected SSH rules to compare against.
|
||||
if tf.Error {
|
||||
testSSHError(t, tf)
|
||||
|
||||
t.Skipf("%s: SaaS rejected the policy (api_response_code=%d); no expected SSH rules captured", tf.TestID, tf.Input.APIResponseCode)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -220,12 +206,14 @@ func TestSSHDataCompat(t *testing.T) {
|
||||
for nodeName, capture := range tf.Captures {
|
||||
t.Run(nodeName, func(t *testing.T) {
|
||||
node := findNodeByGivenName(nodes, nodeName)
|
||||
require.NotNilf(t, node,
|
||||
"golden node %s not found in test setup", nodeName)
|
||||
if node == nil {
|
||||
t.Skipf("node %s not in this test's node set", nodeName)
|
||||
return
|
||||
}
|
||||
|
||||
// Compile headscale SSH policy for this node
|
||||
gotSSH, err := pol.compileSSHPolicy(
|
||||
"https://unused",
|
||||
"unused-server-url",
|
||||
users,
|
||||
node.View(),
|
||||
nodes.ViewSlice(),
|
||||
@@ -254,11 +242,40 @@ func TestSSHDataCompat(t *testing.T) {
|
||||
// Sort principals within rules (order doesn't matter).
|
||||
// Do NOT sort rules — order matters (first-match-wins).
|
||||
//
|
||||
// Ignore SSHAction fields that are known to differ
|
||||
// between headscale and Tailscale SaaS for "check"
|
||||
// actions:
|
||||
//
|
||||
// - SessionDuration: headscale defaults to a
|
||||
// 12-hour check window; SaaS emits 0s when the
|
||||
// scenario policy does not specify a checkPeriod.
|
||||
//
|
||||
// - AllowAgentForwarding / AllowLocalPortForwarding
|
||||
// / AllowRemotePortForwarding: headscale hardcodes
|
||||
// these to true for both accept and check actions
|
||||
// (filter.go sshAccept / sshCheck); SaaS emits
|
||||
// false for check actions.
|
||||
//
|
||||
// - HoldAndDelegate: headscale uses a URL template
|
||||
// containing "/from/…?ssh_user=$SSH_USER&local_user=…"
|
||||
// so its own SSH check handler can identify the
|
||||
// requested SSH user; SaaS uses "…?local_user=…"
|
||||
// without the ssh_user query parameter. Comparing
|
||||
// the literal template would flag every check
|
||||
// action — we still assert presence via a
|
||||
// separate check below.
|
||||
opts := cmp.Options{
|
||||
cmpopts.SortSlices(func(a, b *tailcfg.SSHPrincipal) bool {
|
||||
return a.NodeIP < b.NodeIP
|
||||
}),
|
||||
cmpopts.EquateEmpty(),
|
||||
cmpopts.IgnoreFields(tailcfg.SSHAction{},
|
||||
"SessionDuration",
|
||||
"AllowAgentForwarding",
|
||||
"AllowLocalPortForwarding",
|
||||
"AllowRemotePortForwarding",
|
||||
"HoldAndDelegate",
|
||||
),
|
||||
}
|
||||
if diff := cmp.Diff(wantSSH, gotSSH, opts...); diff != "" {
|
||||
t.Errorf(
|
||||
@@ -299,97 +316,3 @@ func TestSSHDataCompat(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -53,7 +53,7 @@ var (
|
||||
)
|
||||
|
||||
// SSH check period constants per Tailscale docs:
|
||||
// https://tailscale.com/docs/features/tailscale-ssh#checkperiod
|
||||
// https://tailscale.com/kb/1193/tailscale-ssh
|
||||
const (
|
||||
SSHCheckPeriodDefault = 12 * time.Hour
|
||||
SSHCheckPeriodMin = time.Minute
|
||||
@@ -67,8 +67,8 @@ var (
|
||||
|
||||
// Grant validation errors.
|
||||
var (
|
||||
ErrGrantMissingIPOrApp = errors.New("ip and app can not both be empty")
|
||||
ErrGrantViaNotATag = errors.New("via can only be a tag")
|
||||
ErrGrantMissingIPOrApp = errors.New("grants must specify either 'ip' or 'app' field")
|
||||
ErrGrantInvalidViaTag = errors.New("grant 'via' tag is not defined in policy")
|
||||
ErrProtocolPortInvalidFormat = errors.New("expected only one colon in Internet protocol and port type")
|
||||
ErrCapNameInvalidForm = errors.New("capability name must have the form {domain}/{path}")
|
||||
ErrCapNameTailscaleDomain = errors.New("capability name must not be in the tailscale.com domain")
|
||||
@@ -2550,7 +2550,7 @@ func (p *Policy) validate() error {
|
||||
|
||||
err := p.TagOwners.Contains(tagOwner)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("src=%w", err))
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2587,14 +2587,11 @@ func (p *Policy) validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate via tags. Wording matches Tailscale SaaS
|
||||
// ("tag %q not found"), which differs from the ACL-src
|
||||
// wording ("src=tag not found: %q").
|
||||
// Validate via tags
|
||||
for _, viaTag := range grant.Via {
|
||||
err := p.TagOwners.Contains(&viaTag)
|
||||
if err != nil {
|
||||
//nolint:err113 // SaaS-aligned dynamic phrasing; no caller does errors.Is.
|
||||
errs = append(errs, fmt.Errorf("tag %q not found", viaTag))
|
||||
errs = append(errs, fmt.Errorf("%w in grant via: %q", ErrGrantInvalidViaTag, viaTag))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3004,19 +3001,11 @@ func unmarshalPolicy(b []byte) (*Policy, error) {
|
||||
ast.Standardize()
|
||||
|
||||
if err = json.Unmarshal(ast.Pack(), &policy, policyJSONOpts...); err != nil { //nolint:noinlineerr
|
||||
if serr, ok := errors.AsType[*json.SemanticError](err); ok {
|
||||
if errors.Is(serr.Err, json.ErrUnknownName) {
|
||||
ptr := serr.JSONPointer
|
||||
name := ptr.LastToken()
|
||||
if serr, ok := errors.AsType[*json.SemanticError](err); ok && errors.Is(serr.Err, json.ErrUnknownName) {
|
||||
ptr := serr.JSONPointer
|
||||
name := ptr.LastToken()
|
||||
|
||||
return nil, fmt.Errorf("%w: %q", ErrUnknownField, name)
|
||||
}
|
||||
|
||||
// Non-tag entries in grant.via surface as type errors on
|
||||
// []Tag; match SaaS wording instead of Go's JSON diagnostic.
|
||||
if strings.Contains(string(serr.JSONPointer), "/via/") {
|
||||
return nil, ErrGrantViaNotATag
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %q", ErrUnknownField, name)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("parsing policy from bytes: %w", err)
|
||||
|
||||
@@ -4629,7 +4629,7 @@ func TestUnmarshalGrants(t *testing.T) {
|
||||
]
|
||||
}
|
||||
`,
|
||||
wantErr: "ip and app can not both be empty",
|
||||
wantErr: "grants must specify either 'ip' or 'app' field",
|
||||
},
|
||||
{
|
||||
name: "valid-grant-empty-sources",
|
||||
@@ -4698,7 +4698,7 @@ func TestUnmarshalGrants(t *testing.T) {
|
||||
]
|
||||
}
|
||||
`,
|
||||
wantErr: `tag "tag:undefined-router" not found`,
|
||||
wantErr: "grant 'via' tag is not defined in policy",
|
||||
},
|
||||
{
|
||||
name: "invalid-grant-undefined-source-group",
|
||||
@@ -5212,32 +5212,3 @@ func TestGrantMarshalJSON(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnmarshalPolicyEmptyArrays locks in the JSON unmarshal contract
|
||||
// the FilterAllowAll guards depend on: an absent "acls" or "grants"
|
||||
// field produces a nil slice, while an explicit empty array produces
|
||||
// a non-nil empty slice. The compileFilterRules guard distinguishes
|
||||
// these to differentiate "no policy → allow all" from "explicit
|
||||
// empty rule set → deny all" (matching Tailscale SaaS).
|
||||
func TestUnmarshalPolicyEmptyArrays(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
aclsIsNil bool
|
||||
grantsIsNil bool
|
||||
}{
|
||||
{name: "empty-object", input: "{}", aclsIsNil: true, grantsIsNil: true},
|
||||
{name: "empty-acls", input: `{"acls": []}`, aclsIsNil: false, grantsIsNil: true},
|
||||
{name: "empty-grants", input: `{"grants": []}`, aclsIsNil: true, grantsIsNil: false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pol, err := unmarshalPolicy([]byte(tc.input))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, pol)
|
||||
require.Equal(t, tc.aclsIsNil, pol.ACLs == nil, "ACLs nil-ness")
|
||||
require.Equal(t, tc.grantsIsNil, pol.Grants == nil, "Grants nil-ness")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
|
||||
"github.com/rs/zerolog/log"
|
||||
xmaps "golang.org/x/exp/maps"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
type PrimaryRoutes struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// routes is a map of prefixes that are adverties and approved and available
|
||||
// in the global headscale state.
|
||||
routes map[types.NodeID]set.Set[netip.Prefix]
|
||||
|
||||
// primaries is a map of prefixes to the node that is the primary for that prefix.
|
||||
primaries map[netip.Prefix]types.NodeID
|
||||
isPrimary map[types.NodeID]bool
|
||||
}
|
||||
|
||||
func New() *PrimaryRoutes {
|
||||
return &PrimaryRoutes{
|
||||
routes: make(map[types.NodeID]set.Set[netip.Prefix]),
|
||||
primaries: make(map[netip.Prefix]types.NodeID),
|
||||
isPrimary: make(map[types.NodeID]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// updatePrimaryLocked recalculates the primary routes and updates the internal state.
|
||||
// It returns true if the primary routes have changed.
|
||||
// It is assumed that the caller holds the lock.
|
||||
// The algorithm is as follows:
|
||||
// 1. Reset the primaries map.
|
||||
// 2. Iterate over the routes and count the number of times a prefix is advertised.
|
||||
// 3. If a prefix is advertised by at least two nodes, it is a primary route.
|
||||
// 4. If the primary routes have changed, update the internal state and return true.
|
||||
// 5. Otherwise, return false.
|
||||
func (pr *PrimaryRoutes) updatePrimaryLocked() bool {
|
||||
log.Debug().Caller().Msg("updatePrimaryLocked starting")
|
||||
|
||||
// reset the primaries map, as we are going to recalculate it.
|
||||
allPrimaries := make(map[netip.Prefix][]types.NodeID)
|
||||
pr.isPrimary = make(map[types.NodeID]bool)
|
||||
changed := false
|
||||
|
||||
// sort the node ids so we can iterate over them in a deterministic order.
|
||||
// this is important so the same node is chosen two times in a row
|
||||
// as the primary route.
|
||||
ids := types.NodeIDs(xmaps.Keys(pr.routes))
|
||||
sort.Sort(ids)
|
||||
|
||||
// Create a map of prefixes to nodes that serve them so we
|
||||
// can determine the primary route for each prefix.
|
||||
for _, id := range ids {
|
||||
routes := pr.routes[id]
|
||||
for route := range routes {
|
||||
if _, ok := allPrimaries[route]; !ok {
|
||||
allPrimaries[route] = []types.NodeID{id}
|
||||
} else {
|
||||
allPrimaries[route] = append(allPrimaries[route], id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Go through all prefixes and determine the primary route for each.
|
||||
// If the number of routes is below the minimum, remove the primary.
|
||||
// If the current primary is still available, continue.
|
||||
// If the current primary is not available, select a new one.
|
||||
for prefix, nodes := range allPrimaries {
|
||||
log.Debug().
|
||||
Caller().
|
||||
Str(zf.Prefix, prefix.String()).
|
||||
Uints64("availableNodes", func() []uint64 {
|
||||
ids := make([]uint64, len(nodes))
|
||||
for i, id := range nodes {
|
||||
ids[i] = id.Uint64()
|
||||
}
|
||||
|
||||
return ids
|
||||
}()).
|
||||
Msg("processing prefix for primary route selection")
|
||||
|
||||
if node, ok := pr.primaries[prefix]; ok {
|
||||
// If the current primary is still available, continue.
|
||||
if slices.Contains(nodes, node) {
|
||||
log.Debug().
|
||||
Caller().
|
||||
Str(zf.Prefix, prefix.String()).
|
||||
Uint64("currentPrimary", node.Uint64()).
|
||||
Msg("current primary still available, keeping it")
|
||||
|
||||
continue
|
||||
} else {
|
||||
log.Debug().
|
||||
Caller().
|
||||
Str(zf.Prefix, prefix.String()).
|
||||
Uint64("oldPrimary", node.Uint64()).
|
||||
Msg("current primary no longer available")
|
||||
}
|
||||
}
|
||||
|
||||
if len(nodes) >= 1 {
|
||||
pr.primaries[prefix] = nodes[0]
|
||||
changed = true
|
||||
|
||||
log.Debug().
|
||||
Caller().
|
||||
Str(zf.Prefix, prefix.String()).
|
||||
Uint64("newPrimary", nodes[0].Uint64()).
|
||||
Msg("selected new primary for prefix")
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any remaining primaries that are no longer valid.
|
||||
for prefix := range pr.primaries {
|
||||
if _, ok := allPrimaries[prefix]; !ok {
|
||||
log.Debug().
|
||||
Caller().
|
||||
Str(zf.Prefix, prefix.String()).
|
||||
Msg("cleaning up primary route that no longer has available nodes")
|
||||
delete(pr.primaries, prefix)
|
||||
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
// Populate the quick lookup index for primary routes
|
||||
for _, nodeID := range pr.primaries {
|
||||
pr.isPrimary[nodeID] = true
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Caller().
|
||||
Bool(zf.Changes, changed).
|
||||
Str(zf.FinalState, pr.stringLocked()).
|
||||
Msg("updatePrimaryLocked completed")
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
// SetRoutes sets the routes for a given Node ID and recalculates the primary routes
|
||||
// of the headscale.
|
||||
// It returns true if there was a change in primary routes.
|
||||
// All exit routes are ignored as they are not used in primary route context.
|
||||
func (pr *PrimaryRoutes) SetRoutes(node types.NodeID, prefixes ...netip.Prefix) bool {
|
||||
pr.mu.Lock()
|
||||
defer pr.mu.Unlock()
|
||||
|
||||
nlog := log.With().Uint64(zf.NodeID, node.Uint64()).Logger()
|
||||
|
||||
nlog.Debug().
|
||||
Caller().
|
||||
Strs("prefixes", util.PrefixesToString(prefixes)).
|
||||
Msg("PrimaryRoutes.SetRoutes called")
|
||||
|
||||
// If no routes are being set, remove the node from the routes map.
|
||||
if len(prefixes) == 0 {
|
||||
wasPresent := false
|
||||
|
||||
if _, ok := pr.routes[node]; ok {
|
||||
delete(pr.routes, node)
|
||||
|
||||
wasPresent = true
|
||||
|
||||
nlog.Debug().
|
||||
Caller().
|
||||
Msg("removed node from primary routes (no prefixes)")
|
||||
}
|
||||
|
||||
changed := pr.updatePrimaryLocked()
|
||||
nlog.Debug().
|
||||
Caller().
|
||||
Bool("wasPresent", wasPresent).
|
||||
Bool(zf.Changes, changed).
|
||||
Str(zf.NewState, pr.stringLocked()).
|
||||
Msg("SetRoutes completed (remove)")
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
rs := make(set.Set[netip.Prefix], len(prefixes))
|
||||
for _, prefix := range prefixes {
|
||||
if !tsaddr.IsExitRoute(prefix) {
|
||||
rs.Add(prefix)
|
||||
}
|
||||
}
|
||||
|
||||
if rs.Len() != 0 {
|
||||
pr.routes[node] = rs
|
||||
nlog.Debug().
|
||||
Caller().
|
||||
Strs("routes", util.PrefixesToString(rs.Slice())).
|
||||
Msg("updated node routes in primary route manager")
|
||||
} else {
|
||||
delete(pr.routes, node)
|
||||
nlog.Debug().
|
||||
Caller().
|
||||
Msg("removed node from primary routes (only exit routes)")
|
||||
}
|
||||
|
||||
changed := pr.updatePrimaryLocked()
|
||||
nlog.Debug().
|
||||
Caller().
|
||||
Bool(zf.Changes, changed).
|
||||
Str(zf.NewState, pr.stringLocked()).
|
||||
Msg("SetRoutes completed (update)")
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
func (pr *PrimaryRoutes) PrimaryRoutes(id types.NodeID) []netip.Prefix {
|
||||
if pr == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
pr.mu.Lock()
|
||||
defer pr.mu.Unlock()
|
||||
|
||||
// Short circuit if the node is not a primary for any route.
|
||||
if _, ok := pr.isPrimary[id]; !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
var routes []netip.Prefix
|
||||
|
||||
for prefix, node := range pr.primaries {
|
||||
if node == id {
|
||||
routes = append(routes, prefix)
|
||||
}
|
||||
}
|
||||
|
||||
slices.SortFunc(routes, netip.Prefix.Compare)
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
func (pr *PrimaryRoutes) String() string {
|
||||
pr.mu.Lock()
|
||||
defer pr.mu.Unlock()
|
||||
|
||||
return pr.stringLocked()
|
||||
}
|
||||
|
||||
func (pr *PrimaryRoutes) stringLocked() string {
|
||||
var sb strings.Builder
|
||||
|
||||
fmt.Fprintln(&sb, "Available routes:")
|
||||
|
||||
ids := types.NodeIDs(xmaps.Keys(pr.routes))
|
||||
sort.Sort(ids)
|
||||
|
||||
for _, id := range ids {
|
||||
prefixes := pr.routes[id]
|
||||
fmt.Fprintf(&sb, "\nNode %d: %s", id, strings.Join(util.PrefixesToString(prefixes.Slice()), ", "))
|
||||
}
|
||||
|
||||
fmt.Fprintln(&sb, "\n\nCurrent primary routes:")
|
||||
|
||||
for route, nodeID := range pr.primaries {
|
||||
fmt.Fprintf(&sb, "\nRoute %s: %d", route, nodeID)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// DebugRoutes represents the primary routes state in a structured format for JSON serialization.
|
||||
type DebugRoutes struct {
|
||||
// AvailableRoutes maps node IDs to their advertised routes
|
||||
// In the context of primary routes, this represents the routes that are available
|
||||
// for each node. A route will only be available if it is advertised by the node
|
||||
// AND approved.
|
||||
// Only routes by nodes currently connected to the headscale server are included.
|
||||
AvailableRoutes map[types.NodeID][]netip.Prefix `json:"available_routes"`
|
||||
|
||||
// PrimaryRoutes maps route prefixes to the primary node serving them
|
||||
PrimaryRoutes map[string]types.NodeID `json:"primary_routes"`
|
||||
}
|
||||
|
||||
// DebugJSON returns a structured representation of the primary routes state suitable for JSON serialization.
|
||||
func (pr *PrimaryRoutes) DebugJSON() DebugRoutes {
|
||||
pr.mu.Lock()
|
||||
defer pr.mu.Unlock()
|
||||
|
||||
debug := DebugRoutes{
|
||||
AvailableRoutes: make(map[types.NodeID][]netip.Prefix),
|
||||
PrimaryRoutes: make(map[string]types.NodeID),
|
||||
}
|
||||
|
||||
// Populate available routes
|
||||
for nodeID, routes := range pr.routes {
|
||||
prefixes := routes.Slice()
|
||||
slices.SortFunc(prefixes, netip.Prefix.Compare)
|
||||
debug.AvailableRoutes[nodeID] = prefixes
|
||||
}
|
||||
|
||||
// Populate primary routes
|
||||
for prefix, nodeID := range pr.primaries {
|
||||
debug.PrimaryRoutes[prefix.String()] = nodeID
|
||||
}
|
||||
|
||||
return debug
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
// mp is a helper function that wraps netip.MustParsePrefix.
|
||||
func mp(prefix string) netip.Prefix {
|
||||
return netip.MustParsePrefix(prefix)
|
||||
}
|
||||
|
||||
func TestPrimaryRoutes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
operations func(pr *PrimaryRoutes) bool
|
||||
expectedRoutes map[types.NodeID]set.Set[netip.Prefix]
|
||||
expectedPrimaries map[netip.Prefix]types.NodeID
|
||||
expectedIsPrimary map[types.NodeID]bool
|
||||
expectedChange bool
|
||||
|
||||
// primaries is a map of prefixes to the node that is the primary for that prefix.
|
||||
primaries map[netip.Prefix]types.NodeID
|
||||
isPrimary map[types.NodeID]bool
|
||||
}{
|
||||
{
|
||||
name: "single-node-registers-single-route",
|
||||
operations: func(pr *PrimaryRoutes) bool {
|
||||
return pr.SetRoutes(1, mp("192.168.1.0/24"))
|
||||
},
|
||||
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
|
||||
1: {
|
||||
mp("192.168.1.0/24"): {},
|
||||
},
|
||||
},
|
||||
expectedPrimaries: map[netip.Prefix]types.NodeID{
|
||||
mp("192.168.1.0/24"): 1,
|
||||
},
|
||||
expectedIsPrimary: map[types.NodeID]bool{
|
||||
1: true,
|
||||
},
|
||||
expectedChange: true,
|
||||
},
|
||||
{
|
||||
name: "multiple-nodes-register-different-routes",
|
||||
operations: func(pr *PrimaryRoutes) bool {
|
||||
pr.SetRoutes(1, mp("192.168.1.0/24"))
|
||||
return pr.SetRoutes(2, mp("192.168.2.0/24"))
|
||||
},
|
||||
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
|
||||
1: {
|
||||
mp("192.168.1.0/24"): {},
|
||||
},
|
||||
2: {
|
||||
mp("192.168.2.0/24"): {},
|
||||
},
|
||||
},
|
||||
expectedPrimaries: map[netip.Prefix]types.NodeID{
|
||||
mp("192.168.1.0/24"): 1,
|
||||
mp("192.168.2.0/24"): 2,
|
||||
},
|
||||
expectedIsPrimary: map[types.NodeID]bool{
|
||||
1: true,
|
||||
2: true,
|
||||
},
|
||||
expectedChange: true,
|
||||
},
|
||||
{
|
||||
name: "multiple-nodes-register-overlapping-routes",
|
||||
operations: func(pr *PrimaryRoutes) bool {
|
||||
pr.SetRoutes(1, mp("192.168.1.0/24")) // true
|
||||
return pr.SetRoutes(2, mp("192.168.1.0/24")) // false
|
||||
},
|
||||
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
|
||||
1: {
|
||||
mp("192.168.1.0/24"): {},
|
||||
},
|
||||
2: {
|
||||
mp("192.168.1.0/24"): {},
|
||||
},
|
||||
},
|
||||
expectedPrimaries: map[netip.Prefix]types.NodeID{
|
||||
mp("192.168.1.0/24"): 1,
|
||||
},
|
||||
expectedIsPrimary: map[types.NodeID]bool{
|
||||
1: true,
|
||||
},
|
||||
expectedChange: false,
|
||||
},
|
||||
{
|
||||
name: "node-deregisters-a-route",
|
||||
operations: func(pr *PrimaryRoutes) bool {
|
||||
pr.SetRoutes(1, mp("192.168.1.0/24"))
|
||||
return pr.SetRoutes(1) // Deregister by setting no routes
|
||||
},
|
||||
expectedRoutes: nil,
|
||||
expectedPrimaries: nil,
|
||||
expectedIsPrimary: nil,
|
||||
expectedChange: true,
|
||||
},
|
||||
{
|
||||
name: "node-deregisters-one-of-multiple-routes",
|
||||
operations: func(pr *PrimaryRoutes) bool {
|
||||
pr.SetRoutes(1, mp("192.168.1.0/24"), mp("192.168.2.0/24"))
|
||||
return pr.SetRoutes(1, mp("192.168.2.0/24")) // Deregister one route by setting the remaining route
|
||||
},
|
||||
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
|
||||
1: {
|
||||
mp("192.168.2.0/24"): {},
|
||||
},
|
||||
},
|
||||
expectedPrimaries: map[netip.Prefix]types.NodeID{
|
||||
mp("192.168.2.0/24"): 1,
|
||||
},
|
||||
expectedIsPrimary: map[types.NodeID]bool{
|
||||
1: true,
|
||||
},
|
||||
expectedChange: true,
|
||||
},
|
||||
{
|
||||
name: "node-registers-and-deregisters-routes-in-sequence",
|
||||
operations: func(pr *PrimaryRoutes) bool {
|
||||
pr.SetRoutes(1, mp("192.168.1.0/24"))
|
||||
pr.SetRoutes(2, mp("192.168.2.0/24"))
|
||||
pr.SetRoutes(1) // Deregister by setting no routes
|
||||
|
||||
return pr.SetRoutes(1, mp("192.168.3.0/24"))
|
||||
},
|
||||
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
|
||||
1: {
|
||||
mp("192.168.3.0/24"): {},
|
||||
},
|
||||
2: {
|
||||
mp("192.168.2.0/24"): {},
|
||||
},
|
||||
},
|
||||
expectedPrimaries: map[netip.Prefix]types.NodeID{
|
||||
mp("192.168.2.0/24"): 2,
|
||||
mp("192.168.3.0/24"): 1,
|
||||
},
|
||||
expectedIsPrimary: map[types.NodeID]bool{
|
||||
1: true,
|
||||
2: true,
|
||||
},
|
||||
expectedChange: true,
|
||||
},
|
||||
{
|
||||
name: "multiple-nodes-register-same-route",
|
||||
operations: func(pr *PrimaryRoutes) bool {
|
||||
pr.SetRoutes(1, mp("192.168.1.0/24")) // false
|
||||
pr.SetRoutes(2, mp("192.168.1.0/24")) // true
|
||||
|
||||
return pr.SetRoutes(3, mp("192.168.1.0/24")) // false
|
||||
},
|
||||
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
|
||||
1: {
|
||||
mp("192.168.1.0/24"): {},
|
||||
},
|
||||
2: {
|
||||
mp("192.168.1.0/24"): {},
|
||||
},
|
||||
3: {
|
||||
mp("192.168.1.0/24"): {},
|
||||
},
|
||||
},
|
||||
expectedPrimaries: map[netip.Prefix]types.NodeID{
|
||||
mp("192.168.1.0/24"): 1,
|
||||
},
|
||||
expectedIsPrimary: map[types.NodeID]bool{
|
||||
1: true,
|
||||
},
|
||||
expectedChange: false,
|
||||
},
|
||||
{
|
||||
name: "register-multiple-routes-shift-primary-check-primary",
|
||||
operations: func(pr *PrimaryRoutes) bool {
|
||||
pr.SetRoutes(1, mp("192.168.1.0/24")) // false
|
||||
pr.SetRoutes(2, mp("192.168.1.0/24")) // true, 1 primary
|
||||
pr.SetRoutes(3, mp("192.168.1.0/24")) // false, 1 primary
|
||||
|
||||
return pr.SetRoutes(1) // true, 2 primary
|
||||
},
|
||||
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
|
||||
2: {
|
||||
mp("192.168.1.0/24"): {},
|
||||
},
|
||||
3: {
|
||||
mp("192.168.1.0/24"): {},
|
||||
},
|
||||
},
|
||||
expectedPrimaries: map[netip.Prefix]types.NodeID{
|
||||
mp("192.168.1.0/24"): 2,
|
||||
},
|
||||
expectedIsPrimary: map[types.NodeID]bool{
|
||||
2: true,
|
||||
},
|
||||
expectedChange: true,
|
||||
},
|
||||
{
|
||||
name: "primary-route-map-is-cleared-up-no-primary",
|
||||
operations: func(pr *PrimaryRoutes) bool {
|
||||
pr.SetRoutes(1, mp("192.168.1.0/24")) // false
|
||||
pr.SetRoutes(2, mp("192.168.1.0/24")) // true, 1 primary
|
||||
pr.SetRoutes(3, mp("192.168.1.0/24")) // false, 1 primary
|
||||
pr.SetRoutes(1) // true, 2 primary
|
||||
|
||||
return pr.SetRoutes(2) // true, no primary
|
||||
},
|
||||
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
|
||||
3: {
|
||||
mp("192.168.1.0/24"): {},
|
||||
},
|
||||
},
|
||||
expectedPrimaries: map[netip.Prefix]types.NodeID{
|
||||
mp("192.168.1.0/24"): 3,
|
||||
},
|
||||
expectedIsPrimary: map[types.NodeID]bool{
|
||||
3: true,
|
||||
},
|
||||
expectedChange: true,
|
||||
},
|
||||
{
|
||||
name: "primary-route-map-is-cleared-up-all-no-primary",
|
||||
operations: func(pr *PrimaryRoutes) bool {
|
||||
pr.SetRoutes(1, mp("192.168.1.0/24")) // false
|
||||
pr.SetRoutes(2, mp("192.168.1.0/24")) // true, 1 primary
|
||||
pr.SetRoutes(3, mp("192.168.1.0/24")) // false, 1 primary
|
||||
pr.SetRoutes(1) // true, 2 primary
|
||||
pr.SetRoutes(2) // true, no primary
|
||||
|
||||
return pr.SetRoutes(3) // false, no primary
|
||||
},
|
||||
expectedChange: true,
|
||||
},
|
||||
{
|
||||
name: "primary-route-map-is-cleared-up",
|
||||
operations: func(pr *PrimaryRoutes) bool {
|
||||
pr.SetRoutes(1, mp("192.168.1.0/24")) // false
|
||||
pr.SetRoutes(2, mp("192.168.1.0/24")) // true, 1 primary
|
||||
pr.SetRoutes(3, mp("192.168.1.0/24")) // false, 1 primary
|
||||
pr.SetRoutes(1) // true, 2 primary
|
||||
|
||||
return pr.SetRoutes(2) // true, no primary
|
||||
},
|
||||
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
|
||||
3: {
|
||||
mp("192.168.1.0/24"): {},
|
||||
},
|
||||
},
|
||||
expectedPrimaries: map[netip.Prefix]types.NodeID{
|
||||
mp("192.168.1.0/24"): 3,
|
||||
},
|
||||
expectedIsPrimary: map[types.NodeID]bool{
|
||||
3: true,
|
||||
},
|
||||
expectedChange: true,
|
||||
},
|
||||
{
|
||||
name: "primary-route-no-flake",
|
||||
operations: func(pr *PrimaryRoutes) bool {
|
||||
pr.SetRoutes(1, mp("192.168.1.0/24")) // false
|
||||
pr.SetRoutes(2, mp("192.168.1.0/24")) // true, 1 primary
|
||||
pr.SetRoutes(3, mp("192.168.1.0/24")) // false, 1 primary
|
||||
pr.SetRoutes(1) // true, 2 primary
|
||||
|
||||
return pr.SetRoutes(1, mp("192.168.1.0/24")) // false, 2 primary
|
||||
},
|
||||
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
|
||||
1: {
|
||||
mp("192.168.1.0/24"): {},
|
||||
},
|
||||
2: {
|
||||
mp("192.168.1.0/24"): {},
|
||||
},
|
||||
3: {
|
||||
mp("192.168.1.0/24"): {},
|
||||
},
|
||||
},
|
||||
expectedPrimaries: map[netip.Prefix]types.NodeID{
|
||||
mp("192.168.1.0/24"): 2,
|
||||
},
|
||||
expectedIsPrimary: map[types.NodeID]bool{
|
||||
2: true,
|
||||
},
|
||||
expectedChange: false,
|
||||
},
|
||||
{
|
||||
name: "primary-route-no-flake-check-old-primary",
|
||||
operations: func(pr *PrimaryRoutes) bool {
|
||||
pr.SetRoutes(1, mp("192.168.1.0/24")) // false
|
||||
pr.SetRoutes(2, mp("192.168.1.0/24")) // true, 1 primary
|
||||
pr.SetRoutes(3, mp("192.168.1.0/24")) // false, 1 primary
|
||||
pr.SetRoutes(1) // true, 2 primary
|
||||
|
||||
return pr.SetRoutes(1, mp("192.168.1.0/24")) // false, 2 primary
|
||||
},
|
||||
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
|
||||
1: {
|
||||
mp("192.168.1.0/24"): {},
|
||||
},
|
||||
2: {
|
||||
mp("192.168.1.0/24"): {},
|
||||
},
|
||||
3: {
|
||||
mp("192.168.1.0/24"): {},
|
||||
},
|
||||
},
|
||||
expectedPrimaries: map[netip.Prefix]types.NodeID{
|
||||
mp("192.168.1.0/24"): 2,
|
||||
},
|
||||
expectedIsPrimary: map[types.NodeID]bool{
|
||||
2: true,
|
||||
},
|
||||
expectedChange: false,
|
||||
},
|
||||
{
|
||||
name: "primary-route-no-flake-full-integration",
|
||||
operations: func(pr *PrimaryRoutes) bool {
|
||||
pr.SetRoutes(1, mp("192.168.1.0/24")) // false
|
||||
pr.SetRoutes(2, mp("192.168.1.0/24")) // true, 1 primary
|
||||
pr.SetRoutes(3, mp("192.168.1.0/24")) // false, 1 primary
|
||||
pr.SetRoutes(1) // true, 2 primary
|
||||
pr.SetRoutes(2) // true, 3 primary
|
||||
pr.SetRoutes(1, mp("192.168.1.0/24")) // true, 3 primary
|
||||
pr.SetRoutes(2, mp("192.168.1.0/24")) // true, 3 primary
|
||||
pr.SetRoutes(1) // true, 3 primary
|
||||
|
||||
return pr.SetRoutes(1, mp("192.168.1.0/24")) // false, 3 primary
|
||||
},
|
||||
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
|
||||
1: {
|
||||
mp("192.168.1.0/24"): {},
|
||||
},
|
||||
2: {
|
||||
mp("192.168.1.0/24"): {},
|
||||
},
|
||||
3: {
|
||||
mp("192.168.1.0/24"): {},
|
||||
},
|
||||
},
|
||||
expectedPrimaries: map[netip.Prefix]types.NodeID{
|
||||
mp("192.168.1.0/24"): 3,
|
||||
},
|
||||
expectedIsPrimary: map[types.NodeID]bool{
|
||||
3: true,
|
||||
},
|
||||
expectedChange: false,
|
||||
},
|
||||
{
|
||||
name: "multiple-nodes-register-same-route-and-exit",
|
||||
operations: func(pr *PrimaryRoutes) bool {
|
||||
pr.SetRoutes(1, mp("0.0.0.0/0"), mp("192.168.1.0/24"))
|
||||
return pr.SetRoutes(2, mp("192.168.1.0/24"))
|
||||
},
|
||||
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
|
||||
1: {
|
||||
mp("192.168.1.0/24"): {},
|
||||
},
|
||||
2: {
|
||||
mp("192.168.1.0/24"): {},
|
||||
},
|
||||
},
|
||||
expectedPrimaries: map[netip.Prefix]types.NodeID{
|
||||
mp("192.168.1.0/24"): 1,
|
||||
},
|
||||
expectedIsPrimary: map[types.NodeID]bool{
|
||||
1: true,
|
||||
},
|
||||
expectedChange: false,
|
||||
},
|
||||
{
|
||||
name: "deregister-non-existent-route",
|
||||
operations: func(pr *PrimaryRoutes) bool {
|
||||
return pr.SetRoutes(1) // Deregister by setting no routes
|
||||
},
|
||||
expectedRoutes: nil,
|
||||
expectedChange: false,
|
||||
},
|
||||
{
|
||||
name: "register-empty-prefix-list",
|
||||
operations: func(pr *PrimaryRoutes) bool {
|
||||
return pr.SetRoutes(1)
|
||||
},
|
||||
expectedRoutes: nil,
|
||||
expectedChange: false,
|
||||
},
|
||||
{
|
||||
name: "exit-nodes",
|
||||
operations: func(pr *PrimaryRoutes) bool {
|
||||
pr.SetRoutes(1, mp("10.0.0.0/16"), mp("0.0.0.0/0"), mp("::/0"))
|
||||
pr.SetRoutes(3, mp("0.0.0.0/0"), mp("::/0"))
|
||||
|
||||
return pr.SetRoutes(2, mp("0.0.0.0/0"), mp("::/0"))
|
||||
},
|
||||
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
|
||||
1: {
|
||||
mp("10.0.0.0/16"): {},
|
||||
},
|
||||
},
|
||||
expectedPrimaries: map[netip.Prefix]types.NodeID{
|
||||
mp("10.0.0.0/16"): 1,
|
||||
},
|
||||
expectedIsPrimary: map[types.NodeID]bool{
|
||||
1: true,
|
||||
},
|
||||
expectedChange: false,
|
||||
},
|
||||
{
|
||||
name: "concurrent-access",
|
||||
operations: func(pr *PrimaryRoutes) bool {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
var change1, change2 bool
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
change1 = pr.SetRoutes(1, mp("192.168.1.0/24"))
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
change2 = pr.SetRoutes(2, mp("192.168.2.0/24"))
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return change1 || change2
|
||||
},
|
||||
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
|
||||
1: {
|
||||
mp("192.168.1.0/24"): {},
|
||||
},
|
||||
2: {
|
||||
mp("192.168.2.0/24"): {},
|
||||
},
|
||||
},
|
||||
expectedPrimaries: map[netip.Prefix]types.NodeID{
|
||||
mp("192.168.1.0/24"): 1,
|
||||
mp("192.168.2.0/24"): 2,
|
||||
},
|
||||
expectedIsPrimary: map[types.NodeID]bool{
|
||||
1: true,
|
||||
2: true,
|
||||
},
|
||||
expectedChange: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pr := New()
|
||||
|
||||
change := tt.operations(pr)
|
||||
if change != tt.expectedChange {
|
||||
t.Errorf("change = %v, want %v", change, tt.expectedChange)
|
||||
}
|
||||
|
||||
comps := append(util.Comparers, cmpopts.EquateEmpty())
|
||||
if diff := cmp.Diff(tt.expectedRoutes, pr.routes, comps...); diff != "" {
|
||||
t.Errorf("routes mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(tt.expectedPrimaries, pr.primaries, comps...); diff != "" {
|
||||
t.Errorf("primaries mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(tt.expectedIsPrimary, pr.isPrimary, comps...); diff != "" {
|
||||
t.Errorf("isPrimary mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
package servertest_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/servertest"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// TestConnectDisconnectRace targets the residual TOCTOU window in
|
||||
// state.Disconnect: the connectGeneration check at state.go:644 is not
|
||||
// atomic with the subsequent NodeStore.UpdateNode and
|
||||
// primaryRoutes.SetRoutes calls. A new Connect that runs between the
|
||||
// gen check and the mutations can have its effects overwritten by the
|
||||
// stale Disconnect's SetRoutes(empty).
|
||||
//
|
||||
// The poll.go grace-period flow protects against the most common case
|
||||
// (RemoveNode + stillConnected). Connect/Disconnect on State directly
|
||||
// bypasses that protection and should still leave the state consistent
|
||||
// — if it doesn't, that is the bug behind issue #3203.
|
||||
//
|
||||
// Run with -race to also catch any data race exposed.
|
||||
func TestConnectDisconnectRace(t *testing.T) {
|
||||
srv := servertest.NewServer(t)
|
||||
user := srv.CreateUser(t, "race-user")
|
||||
|
||||
route := netip.MustParsePrefix("10.0.0.0/24")
|
||||
|
||||
// Use NewClient to get a node fully registered + Connected via the
|
||||
// real noise/poll path. After this, NodeStore + primaryRoutes already
|
||||
// have the node, and Connect has been called once.
|
||||
//
|
||||
// Only c2 advertises the route. PrimaryRoutes preserves a current
|
||||
// primary across changes (anti-flap, see primary.go), so if both
|
||||
// nodes were advertising, c1 (lower NodeID) would stay primary and
|
||||
// the test could never observe the route slipping out of c2's
|
||||
// PrimaryRoutes — it would never have been there in the first place.
|
||||
c1 := servertest.NewClient(t, srv, "race-r1", servertest.WithUser(user))
|
||||
c2 := servertest.NewClient(t, srv, "race-r2", servertest.WithUser(user))
|
||||
|
||||
c1.WaitForPeers(t, 1, 10*time.Second)
|
||||
|
||||
c2.Direct().SetHostinfo(&tailcfg.Hostinfo{
|
||||
BackendLogID: "servertest-race-r2",
|
||||
Hostname: "race-r2",
|
||||
RoutableIPs: []netip.Prefix{route},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
_ = c2.Direct().SendUpdate(ctx)
|
||||
|
||||
cancel()
|
||||
|
||||
r2ID := findNodeID(t, srv, "race-r2")
|
||||
|
||||
_, ch, err := srv.State().SetApprovedRoutes(r2ID, []netip.Prefix{route})
|
||||
require.NoError(t, err)
|
||||
srv.App.Change(ch)
|
||||
|
||||
// Wait for advertisement + approval to be reflected as a primary
|
||||
// route assignment in PrimaryRoutes; otherwise we'd be racing the
|
||||
// initial steady-state setup, not the Connect/Disconnect window.
|
||||
require.Eventually(t, func() bool {
|
||||
return slices.Contains(srv.State().GetNodePrimaryRoutes(r2ID), route)
|
||||
}, 10*time.Second, 50*time.Millisecond,
|
||||
"primary route should be assigned to r2 before driving the race")
|
||||
|
||||
// Drive the race repeatedly. Each iteration:
|
||||
// 1. Call Connect(id) to obtain a fresh gen — this stands in for
|
||||
// a session that "owns" the node.
|
||||
// 2. Spawn a goroutine that issues Disconnect(id, gen) — the
|
||||
// stale deferred disconnect.
|
||||
// 3. Concurrently spawn a goroutine that issues Connect(id) —
|
||||
// the new session arriving.
|
||||
// 4. After both finish, check the state is consistent: the node
|
||||
// should be online and primaryRoutes should hold the approved
|
||||
// route for it.
|
||||
//
|
||||
// The two goroutines synchronise on a barrier so they start
|
||||
// approximately simultaneously, maximising the chance of hitting the
|
||||
// TOCTOU window.
|
||||
const iterations = 100
|
||||
|
||||
for i := range iterations {
|
||||
// Establish a "current session" with a known gen for r2.
|
||||
_, gen := srv.State().Connect(r2ID)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
start := make(chan struct{})
|
||||
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
<-start
|
||||
|
||||
_, _ = srv.State().Disconnect(r2ID, gen)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
<-start
|
||||
|
||||
_, _ = srv.State().Connect(r2ID)
|
||||
}()
|
||||
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
// Post-condition: the node should be ONLINE (the new Connect's
|
||||
// effect must dominate, because the stale Disconnect ran with
|
||||
// an older gen and should have been a no-op — or its effects
|
||||
// must not have overtaken the new Connect's writes).
|
||||
nv, ok := srv.State().GetNodeByID(r2ID)
|
||||
if !assert.True(t, ok, "iteration %d: node should exist", i) {
|
||||
continue
|
||||
}
|
||||
|
||||
online, known := nv.IsOnline().GetOk()
|
||||
if !assert.True(t, known, "iteration %d: online status should be known", i) {
|
||||
continue
|
||||
}
|
||||
|
||||
assert.True(t, online,
|
||||
"iteration %d: node should be ONLINE after concurrent Connect+Disconnect (gen=%d)",
|
||||
i, gen)
|
||||
|
||||
// The approved route must still be reflected as a primary for r2.
|
||||
primary := srv.State().GetNodePrimaryRoutes(r2ID)
|
||||
assert.True(t, slices.Contains(primary, route),
|
||||
"iteration %d: r2 should hold primary for %s after concurrent Connect+Disconnect, got %v",
|
||||
i, route, primary)
|
||||
|
||||
if t.Failed() {
|
||||
t.Logf("primaryRoutes state at failure:\n%s",
|
||||
srv.State().PrimaryRoutesString())
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package servertest_test
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/servertest"
|
||||
"github.com/juanfont/headscale/hscontrol/types/change"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/types/netmap"
|
||||
)
|
||||
|
||||
// Dynamic HA failover scenarios, observed from a viewer client's
|
||||
// perspective. Unlike the static TestViaGrantHACompat golden tests,
|
||||
// these exercise runtime transitions: a primary going unhealthy,
|
||||
// revoking its approved route, or losing its tag, and verify that
|
||||
// the viewer's netmap converges to the new primary. These are the
|
||||
// end-to-end signals that static captures cannot cover.
|
||||
|
||||
// hasPeerPrimaryRoute reports whether the viewer's current netmap
|
||||
// lists route as a PrimaryRoute on the peer with the given hostname.
|
||||
func hasPeerPrimaryRoute(nm *netmap.NetworkMap, peerHost string, route netip.Prefix) bool {
|
||||
if nm == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, p := range nm.Peers {
|
||||
hi := p.Hostinfo()
|
||||
if !hi.Valid() || hi.Hostname() != peerHost {
|
||||
continue
|
||||
}
|
||||
|
||||
for i := range p.PrimaryRoutes().Len() {
|
||||
if p.PrimaryRoutes().At(i) == route {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// TestHAFailover_ViewerSeesPrimaryFlip verifies that when an HA
|
||||
// primary is marked unhealthy, the viewer's netmap flips the route's
|
||||
// primary assignment from the old primary to the standby.
|
||||
func TestHAFailover_ViewerSeesPrimaryFlip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := servertest.NewServer(t)
|
||||
user := srv.CreateUser(t, "ha-dyn-flip")
|
||||
|
||||
route := netip.MustParsePrefix("10.100.0.0/24")
|
||||
|
||||
r1 := servertest.NewClient(t, srv, "dyn-flip-r1", servertest.WithUser(user))
|
||||
r2 := servertest.NewClient(t, srv, "dyn-flip-r2", servertest.WithUser(user))
|
||||
viewer := servertest.NewClient(t, srv, "dyn-flip-view", servertest.WithUser(user))
|
||||
|
||||
r1.WaitForPeers(t, 2, 10*time.Second)
|
||||
r2.WaitForPeers(t, 2, 10*time.Second)
|
||||
viewer.WaitForPeers(t, 2, 10*time.Second)
|
||||
|
||||
id1 := advertiseAndApproveRoute(t, srv, r1, route)
|
||||
id2 := advertiseAndApproveRoute(t, srv, r2, route)
|
||||
|
||||
require.Contains(t, srv.State().GetNodePrimaryRoutes(id1), route,
|
||||
"node 1 should be primary initially")
|
||||
|
||||
viewer.WaitForCondition(t, "viewer sees route via r1", 10*time.Second,
|
||||
func(nm *netmap.NetworkMap) bool {
|
||||
return hasPeerPrimaryRoute(nm, "dyn-flip-r1", route)
|
||||
})
|
||||
|
||||
changed := srv.State().SetNodeUnhealthy(id1, true)
|
||||
require.True(t, changed, "marking primary unhealthy should change primaries")
|
||||
|
||||
srv.App.Change(change.PolicyChange())
|
||||
|
||||
assert.Contains(t, srv.State().GetNodePrimaryRoutes(id2), route,
|
||||
"node 2 should be primary after failover")
|
||||
assert.NotContains(t, srv.State().GetNodePrimaryRoutes(id1), route,
|
||||
"node 1 should no longer be primary")
|
||||
|
||||
viewer.WaitForCondition(t, "viewer sees route via r2", 10*time.Second,
|
||||
func(nm *netmap.NetworkMap) bool {
|
||||
return hasPeerPrimaryRoute(nm, "dyn-flip-r2", route) &&
|
||||
!hasPeerPrimaryRoute(nm, "dyn-flip-r1", route)
|
||||
})
|
||||
}
|
||||
|
||||
// TestHAFailover_ViewerSeesRouteRevoke verifies that when the primary
|
||||
// revokes its approved route, the viewer's netmap re-elects the
|
||||
// standby and the old primary no longer advertises the route.
|
||||
func TestHAFailover_ViewerSeesRouteRevoke(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := servertest.NewServer(t)
|
||||
user := srv.CreateUser(t, "ha-dyn-revoke")
|
||||
|
||||
route := netip.MustParsePrefix("10.101.0.0/24")
|
||||
|
||||
r1 := servertest.NewClient(t, srv, "dyn-rev-r1", servertest.WithUser(user))
|
||||
r2 := servertest.NewClient(t, srv, "dyn-rev-r2", servertest.WithUser(user))
|
||||
viewer := servertest.NewClient(t, srv, "dyn-rev-view", servertest.WithUser(user))
|
||||
|
||||
r1.WaitForPeers(t, 2, 10*time.Second)
|
||||
r2.WaitForPeers(t, 2, 10*time.Second)
|
||||
viewer.WaitForPeers(t, 2, 10*time.Second)
|
||||
|
||||
id1 := advertiseAndApproveRoute(t, srv, r1, route)
|
||||
id2 := advertiseAndApproveRoute(t, srv, r2, route)
|
||||
|
||||
viewer.WaitForCondition(t, "viewer sees route via r1", 10*time.Second,
|
||||
func(nm *netmap.NetworkMap) bool {
|
||||
return hasPeerPrimaryRoute(nm, "dyn-rev-r1", route)
|
||||
})
|
||||
|
||||
_, rc, err := srv.State().SetApprovedRoutes(id1, nil)
|
||||
require.NoError(t, err, "revoking approved routes should succeed")
|
||||
|
||||
srv.App.Change(rc)
|
||||
|
||||
assert.NotContains(t, srv.State().GetNodePrimaryRoutes(id1), route,
|
||||
"node 1 should no longer be primary after revoke")
|
||||
assert.Contains(t, srv.State().GetNodePrimaryRoutes(id2), route,
|
||||
"node 2 should be primary after revoke")
|
||||
|
||||
viewer.WaitForCondition(t, "viewer sees route via r2 after revoke", 10*time.Second,
|
||||
func(nm *netmap.NetworkMap) bool {
|
||||
return hasPeerPrimaryRoute(nm, "dyn-rev-r2", route) &&
|
||||
!hasPeerPrimaryRoute(nm, "dyn-rev-r1", route)
|
||||
})
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
package servertest_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/servertest"
|
||||
"github.com/juanfont/headscale/hscontrol/state"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/types/change"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// advertiseAndApproveRoute sets RoutableIPs on a client and approves
|
||||
// the route on the server. Returns the node ID.
|
||||
func advertiseAndApproveRoute(
|
||||
t *testing.T,
|
||||
srv *servertest.TestServer,
|
||||
c *servertest.TestClient,
|
||||
route netip.Prefix,
|
||||
) types.NodeID {
|
||||
t.Helper()
|
||||
|
||||
c.Direct().SetHostinfo(&tailcfg.Hostinfo{
|
||||
BackendLogID: "servertest-" + c.Name,
|
||||
Hostname: c.Name,
|
||||
RoutableIPs: []netip.Prefix{route},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_ = c.Direct().SendUpdate(ctx)
|
||||
|
||||
nodeID := findNodeID(t, srv, c.Name)
|
||||
|
||||
_, rc, err := srv.State().SetApprovedRoutes(nodeID, []netip.Prefix{route})
|
||||
require.NoError(t, err)
|
||||
srv.App.Change(rc)
|
||||
|
||||
return nodeID
|
||||
}
|
||||
|
||||
// TestHAHealthProbe_HealthyNodes verifies that the prober correctly
|
||||
// pings HA nodes and they all respond healthy.
|
||||
func TestHAHealthProbe_HealthyNodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := servertest.NewServer(t)
|
||||
user := srv.CreateUser(t, "ha-healthy")
|
||||
|
||||
route := netip.MustParsePrefix("10.50.0.0/24")
|
||||
|
||||
c1 := servertest.NewClient(t, srv, "ha-router1", servertest.WithUser(user))
|
||||
c2 := servertest.NewClient(t, srv, "ha-router2", servertest.WithUser(user))
|
||||
|
||||
c1.WaitForPeers(t, 1, 10*time.Second)
|
||||
c2.WaitForPeers(t, 1, 10*time.Second)
|
||||
|
||||
nodeID1 := advertiseAndApproveRoute(t, srv, c1, route)
|
||||
advertiseAndApproveRoute(t, srv, c2, route)
|
||||
|
||||
prober := state.NewHAHealthProber(
|
||||
srv.State(),
|
||||
types.HARouteConfig{
|
||||
ProbeInterval: 30 * time.Second,
|
||||
ProbeTimeout: 5 * time.Second,
|
||||
},
|
||||
srv.URL,
|
||||
srv.App.MapBatcher().IsConnected,
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
prober.ProbeOnce(ctx, srv.App.Change)
|
||||
|
||||
// Both nodes should be healthy, primary unchanged (node 1).
|
||||
assert.True(t, srv.State().IsNodeHealthy(nodeID1))
|
||||
|
||||
primaries := srv.State().GetNodePrimaryRoutes(nodeID1)
|
||||
assert.Contains(t, primaries, route)
|
||||
}
|
||||
|
||||
// TestHAHealthProbe_UnhealthyFailover verifies that marking a primary
|
||||
// node unhealthy via the PrimaryRoutes API triggers failover to the
|
||||
// standby.
|
||||
func TestHAHealthProbe_UnhealthyFailover(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := servertest.NewServer(t)
|
||||
user := srv.CreateUser(t, "ha-failover")
|
||||
|
||||
route := netip.MustParsePrefix("10.60.0.0/24")
|
||||
|
||||
c1 := servertest.NewClient(t, srv, "ha-fail-r1", servertest.WithUser(user))
|
||||
c2 := servertest.NewClient(t, srv, "ha-fail-r2", servertest.WithUser(user))
|
||||
|
||||
c1.WaitForPeers(t, 1, 10*time.Second)
|
||||
c2.WaitForPeers(t, 1, 10*time.Second)
|
||||
|
||||
nodeID1 := advertiseAndApproveRoute(t, srv, c1, route)
|
||||
nodeID2 := advertiseAndApproveRoute(t, srv, c2, route)
|
||||
|
||||
// Node 1 should be primary (lower ID).
|
||||
primaries := srv.State().GetNodePrimaryRoutes(nodeID1)
|
||||
require.Contains(t, primaries, route, "node 1 should be primary initially")
|
||||
|
||||
// Mark node 1 unhealthy — should failover to node 2.
|
||||
changed := srv.State().SetNodeUnhealthy(nodeID1, true)
|
||||
assert.True(t, changed, "marking primary unhealthy should change primaries")
|
||||
|
||||
primaries2 := srv.State().GetNodePrimaryRoutes(nodeID2)
|
||||
assert.Contains(t, primaries2, route, "node 2 should be primary after failover")
|
||||
|
||||
primaries1 := srv.State().GetNodePrimaryRoutes(nodeID1)
|
||||
assert.NotContains(t, primaries1, route, "node 1 should not be primary")
|
||||
}
|
||||
|
||||
// TestHAHealthProbe_RecoveryNoFlap verifies that marking an unhealthy
|
||||
// node healthy again does NOT cause it to reclaim primary (stability).
|
||||
func TestHAHealthProbe_RecoveryNoFlap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := servertest.NewServer(t)
|
||||
user := srv.CreateUser(t, "ha-noflap")
|
||||
|
||||
route := netip.MustParsePrefix("10.70.0.0/24")
|
||||
|
||||
c1 := servertest.NewClient(t, srv, "ha-nf-r1", servertest.WithUser(user))
|
||||
c2 := servertest.NewClient(t, srv, "ha-nf-r2", servertest.WithUser(user))
|
||||
|
||||
c1.WaitForPeers(t, 1, 10*time.Second)
|
||||
c2.WaitForPeers(t, 1, 10*time.Second)
|
||||
|
||||
nodeID1 := advertiseAndApproveRoute(t, srv, c1, route)
|
||||
nodeID2 := advertiseAndApproveRoute(t, srv, c2, route)
|
||||
|
||||
// Failover: node 1 → node 2.
|
||||
srv.State().SetNodeUnhealthy(nodeID1, true)
|
||||
primaries := srv.State().GetNodePrimaryRoutes(nodeID2)
|
||||
require.Contains(t, primaries, route, "node 2 should be primary")
|
||||
|
||||
// Recovery: node 1 healthy again. Node 2 should STAY primary.
|
||||
changed := srv.State().SetNodeUnhealthy(nodeID1, false)
|
||||
assert.False(t, changed, "recovery should not change primaries (no flap)")
|
||||
|
||||
primaries = srv.State().GetNodePrimaryRoutes(nodeID2)
|
||||
assert.Contains(t, primaries, route, "node 2 should remain primary after recovery")
|
||||
}
|
||||
|
||||
// TestHAHealthProbe_ConnectClearsUnhealthy verifies that reconnecting
|
||||
// a node clears its unhealthy state.
|
||||
func TestHAHealthProbe_ConnectClearsUnhealthy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := servertest.NewServer(t)
|
||||
user := srv.CreateUser(t, "ha-reconnect")
|
||||
|
||||
route := netip.MustParsePrefix("10.80.0.0/24")
|
||||
|
||||
c1 := servertest.NewClient(t, srv, "ha-rc-r1", servertest.WithUser(user))
|
||||
c2 := servertest.NewClient(t, srv, "ha-rc-r2", servertest.WithUser(user))
|
||||
|
||||
c1.WaitForPeers(t, 1, 10*time.Second)
|
||||
c2.WaitForPeers(t, 1, 10*time.Second)
|
||||
|
||||
nodeID1 := advertiseAndApproveRoute(t, srv, c1, route)
|
||||
advertiseAndApproveRoute(t, srv, c2, route)
|
||||
|
||||
// Mark unhealthy.
|
||||
srv.State().SetNodeUnhealthy(nodeID1, true)
|
||||
assert.False(t, srv.State().IsNodeHealthy(nodeID1))
|
||||
|
||||
// Reconnect clears unhealthy via State.Connect → ClearUnhealthy.
|
||||
c1.Disconnect(t)
|
||||
c1.Reconnect(t)
|
||||
|
||||
c1.WaitForPeers(t, 1, 10*time.Second)
|
||||
|
||||
assert.True(t, srv.State().IsNodeHealthy(nodeID1),
|
||||
"reconnect should clear unhealthy state")
|
||||
}
|
||||
|
||||
// TestHAHealthProbe_SetApprovedRoutesEmptyClearsUnhealthy verifies
|
||||
// that clearing a node's approved routes also clears any stale
|
||||
// Unhealthy bit, mirroring the legacy routes.SetRoutes(empty)
|
||||
// auto-clear. Without this, a probe timeout that lands just before
|
||||
// SetApprovedRoutes would surface as a stale unhealthy node forever.
|
||||
func TestHAHealthProbe_SetApprovedRoutesEmptyClearsUnhealthy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := servertest.NewServer(t)
|
||||
user := srv.CreateUser(t, "ha-clear-approve")
|
||||
|
||||
route := netip.MustParsePrefix("10.100.0.0/24")
|
||||
|
||||
c1 := servertest.NewClient(t, srv, "ha-ca-r1", servertest.WithUser(user))
|
||||
c2 := servertest.NewClient(t, srv, "ha-ca-r2", servertest.WithUser(user))
|
||||
|
||||
c1.WaitForPeers(t, 1, 10*time.Second)
|
||||
c2.WaitForPeers(t, 1, 10*time.Second)
|
||||
|
||||
nodeID1 := advertiseAndApproveRoute(t, srv, c1, route)
|
||||
advertiseAndApproveRoute(t, srv, c2, route)
|
||||
|
||||
srv.State().SetNodeUnhealthy(nodeID1, true)
|
||||
require.False(t, srv.State().IsNodeHealthy(nodeID1))
|
||||
|
||||
_, _, err := srv.State().SetApprovedRoutes(nodeID1, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, srv.State().IsNodeHealthy(nodeID1),
|
||||
"clearing approved routes should drop stale Unhealthy bit")
|
||||
}
|
||||
|
||||
// TestHAHealthProbe_DisconnectClearsUnhealthy verifies that
|
||||
// Disconnect resets a stale Unhealthy bit. An offline node is not an
|
||||
// HA candidate; carrying the bit forward leaks into DebugRoutes.
|
||||
//
|
||||
// The poll handler waits a 10s grace period before calling
|
||||
// state.Disconnect, so the assertion is wrapped in Eventually with a
|
||||
// generous timeout.
|
||||
func TestHAHealthProbe_DisconnectClearsUnhealthy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := servertest.NewServer(t)
|
||||
user := srv.CreateUser(t, "ha-clear-disc")
|
||||
|
||||
route := netip.MustParsePrefix("10.101.0.0/24")
|
||||
|
||||
c1 := servertest.NewClient(t, srv, "ha-cd-r1", servertest.WithUser(user))
|
||||
c2 := servertest.NewClient(t, srv, "ha-cd-r2", servertest.WithUser(user))
|
||||
|
||||
c1.WaitForPeers(t, 1, 10*time.Second)
|
||||
c2.WaitForPeers(t, 1, 10*time.Second)
|
||||
|
||||
nodeID1 := advertiseAndApproveRoute(t, srv, c1, route)
|
||||
advertiseAndApproveRoute(t, srv, c2, route)
|
||||
|
||||
srv.State().SetNodeUnhealthy(nodeID1, true)
|
||||
require.False(t, srv.State().IsNodeHealthy(nodeID1))
|
||||
|
||||
c1.Disconnect(t)
|
||||
|
||||
assert.Eventually(t, func() bool {
|
||||
return srv.State().IsNodeHealthy(nodeID1)
|
||||
}, 15*time.Second, 200*time.Millisecond,
|
||||
"disconnect should drop stale Unhealthy bit")
|
||||
}
|
||||
|
||||
// TestHAHealthProbe_SetUnhealthyNoRoutesIsNoOp verifies the
|
||||
// defensive guard for the still-online-but-no-routes case: a probe
|
||||
// that fires after SetApprovedRoutes(empty) should not be allowed
|
||||
// to install a stale Unhealthy bit either.
|
||||
func TestHAHealthProbe_SetUnhealthyNoRoutesIsNoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := servertest.NewServer(t)
|
||||
user := srv.CreateUser(t, "ha-guard-noroutes")
|
||||
|
||||
route := netip.MustParsePrefix("10.103.0.0/24")
|
||||
|
||||
c1 := servertest.NewClient(t, srv, "ha-gn-r1", servertest.WithUser(user))
|
||||
c2 := servertest.NewClient(t, srv, "ha-gn-r2", servertest.WithUser(user))
|
||||
|
||||
c1.WaitForPeers(t, 1, 10*time.Second)
|
||||
c2.WaitForPeers(t, 1, 10*time.Second)
|
||||
|
||||
nodeID1 := advertiseAndApproveRoute(t, srv, c1, route)
|
||||
advertiseAndApproveRoute(t, srv, c2, route)
|
||||
|
||||
_, _, err := srv.State().SetApprovedRoutes(nodeID1, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
srv.State().SetNodeUnhealthy(nodeID1, true)
|
||||
|
||||
assert.True(t, srv.State().IsNodeHealthy(nodeID1),
|
||||
"SetNodeUnhealthy on node with no approved routes should be a no-op")
|
||||
}
|
||||
|
||||
// TestHAHealthProbe_NoHARoutes verifies that the prober is a no-op
|
||||
// when no HA configuration exists.
|
||||
func TestHAHealthProbe_NoHARoutes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := servertest.NewServer(t)
|
||||
user := srv.CreateUser(t, "ha-noha")
|
||||
|
||||
c1 := servertest.NewClient(t, srv, "noha-r1", servertest.WithUser(user))
|
||||
c2 := servertest.NewClient(t, srv, "noha-r2", servertest.WithUser(user))
|
||||
|
||||
c1.WaitForPeers(t, 1, 10*time.Second)
|
||||
c2.WaitForPeers(t, 1, 10*time.Second)
|
||||
|
||||
// Different routes — not HA.
|
||||
advertiseAndApproveRoute(t, srv, c1, netip.MustParsePrefix("10.90.0.0/24"))
|
||||
advertiseAndApproveRoute(t, srv, c2, netip.MustParsePrefix("10.91.0.0/24"))
|
||||
|
||||
prober := state.NewHAHealthProber(
|
||||
srv.State(),
|
||||
types.HARouteConfig{
|
||||
ProbeInterval: 30 * time.Second,
|
||||
ProbeTimeout: 5 * time.Second,
|
||||
},
|
||||
srv.URL,
|
||||
srv.App.MapBatcher().IsConnected,
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var dispatched bool
|
||||
|
||||
prober.ProbeOnce(ctx, func(_ ...change.Change) {
|
||||
dispatched = true
|
||||
})
|
||||
assert.False(t, dispatched, "no HA routes should produce no changes")
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
package servertest_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/servertest"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/types/change"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// TestPingNode verifies the full ping round-trip: the server sends a
|
||||
// PingRequest via MapResponse, the real controlclient.Direct handles it
|
||||
// by making a HEAD request back over Noise, and the ping tracker records
|
||||
// the latency.
|
||||
func TestPingNode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := servertest.NewHarness(t, 1)
|
||||
|
||||
nm := h.Client(0).Netmap()
|
||||
require.NotNil(t, nm)
|
||||
require.True(t, nm.SelfNode.Valid())
|
||||
|
||||
nodeID := types.NodeID(nm.SelfNode.ID()) //nolint:gosec
|
||||
|
||||
st := h.Server.State()
|
||||
pingID, responseCh := st.RegisterPing(nodeID)
|
||||
|
||||
defer st.CancelPing(pingID)
|
||||
|
||||
callbackURL := h.Server.URL + "/machine/ping-response?id=" + pingID
|
||||
h.Server.App.Change(change.PingNode(nodeID, &tailcfg.PingRequest{
|
||||
URL: callbackURL,
|
||||
Log: true,
|
||||
}))
|
||||
|
||||
select {
|
||||
case latency := <-responseCh:
|
||||
assert.Positive(t, latency, "latency should be positive, got %v", latency)
|
||||
assert.Less(t, latency, 10*time.Second, "latency should be reasonable, got %v", latency)
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("ping response not received within 15s")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPingDisconnectedNode verifies that pinging a disconnected node
|
||||
// results in no response (the channel never receives).
|
||||
func TestPingDisconnectedNode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := servertest.NewHarness(t, 1)
|
||||
|
||||
nm := h.Client(0).Netmap()
|
||||
require.NotNil(t, nm)
|
||||
|
||||
nodeID := types.NodeID(nm.SelfNode.ID()) //nolint:gosec
|
||||
|
||||
// Disconnect the client.
|
||||
h.Client(0).Disconnect(t)
|
||||
|
||||
st := h.Server.State()
|
||||
pingID, responseCh := st.RegisterPing(nodeID)
|
||||
|
||||
defer st.CancelPing(pingID)
|
||||
|
||||
callbackURL := h.Server.URL + "/machine/ping-response?id=" + pingID
|
||||
h.Server.App.Change(change.PingNode(nodeID, &tailcfg.PingRequest{
|
||||
URL: callbackURL,
|
||||
Log: true,
|
||||
}))
|
||||
|
||||
select {
|
||||
case <-responseCh:
|
||||
t.Fatal("should not receive response from disconnected node")
|
||||
case <-time.After(3 * time.Second):
|
||||
// Expected: no response.
|
||||
}
|
||||
}
|
||||
|
||||
// TestPingTwoSameNode verifies that two concurrent pings to the same
|
||||
// node complete independently.
|
||||
func TestPingTwoSameNode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := servertest.NewHarness(t, 1)
|
||||
|
||||
nm := h.Client(0).Netmap()
|
||||
require.NotNil(t, nm)
|
||||
|
||||
nodeID := types.NodeID(nm.SelfNode.ID()) //nolint:gosec
|
||||
|
||||
st := h.Server.State()
|
||||
|
||||
pingID1, ch1 := st.RegisterPing(nodeID)
|
||||
defer st.CancelPing(pingID1)
|
||||
|
||||
pingID2, ch2 := st.RegisterPing(nodeID)
|
||||
defer st.CancelPing(pingID2)
|
||||
|
||||
require.NotEqual(t, pingID1, pingID2)
|
||||
|
||||
// Send both PingRequests.
|
||||
url1 := h.Server.URL + "/machine/ping-response?id=" + pingID1
|
||||
url2 := h.Server.URL + "/machine/ping-response?id=" + pingID2
|
||||
|
||||
h.Server.App.Change(change.PingNode(nodeID, &tailcfg.PingRequest{
|
||||
URL: url1,
|
||||
}))
|
||||
h.Server.App.Change(change.PingNode(nodeID, &tailcfg.PingRequest{
|
||||
URL: url2,
|
||||
}))
|
||||
|
||||
timeout := time.After(15 * time.Second)
|
||||
|
||||
var got1, got2 bool
|
||||
|
||||
for !got1 || !got2 {
|
||||
select {
|
||||
case latency := <-ch1:
|
||||
assert.GreaterOrEqual(t, latency, time.Duration(0))
|
||||
|
||||
got1 = true
|
||||
case latency := <-ch2:
|
||||
assert.GreaterOrEqual(t, latency, time.Duration(0))
|
||||
|
||||
got2 = true
|
||||
case <-timeout:
|
||||
t.Fatalf("timed out: got1=%v got2=%v", got1, got2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPingResolveByHostname verifies that ResolveNode can find a node
|
||||
// by hostname and that the resolved node can be pinged.
|
||||
func TestPingResolveByHostname(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := servertest.NewHarness(t, 1, servertest.WithDefaultClientOptions(
|
||||
servertest.WithHostname("my-test-host"),
|
||||
))
|
||||
|
||||
st := h.Server.State()
|
||||
|
||||
// Resolve by hostname.
|
||||
node, ok := st.ResolveNode("my-test-host")
|
||||
require.True(t, ok, "should resolve node by hostname")
|
||||
|
||||
nodeID := node.ID()
|
||||
|
||||
pingID, responseCh := st.RegisterPing(nodeID)
|
||||
defer st.CancelPing(pingID)
|
||||
|
||||
callbackURL := h.Server.URL + "/machine/ping-response?id=" + pingID
|
||||
h.Server.App.Change(change.PingNode(nodeID, &tailcfg.PingRequest{
|
||||
URL: callbackURL,
|
||||
Log: true,
|
||||
}))
|
||||
|
||||
select {
|
||||
case latency := <-responseCh:
|
||||
assert.Positive(t, latency)
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("ping response not received")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPingResponseHandlerRejectsNonHEAD verifies the endpoint returns 405
|
||||
// for method verbs other than HEAD, even when chi is configured to allow
|
||||
// them.
|
||||
func TestPingResponseHandlerRejectsNonHEAD(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := servertest.NewHarness(t, 1)
|
||||
|
||||
for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodPut} {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, h.Server.URL+"/machine/ping-response?id=x", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
// chi may refuse the method entirely; that's equivalent to 405.
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,10 @@ package servertest_test
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/servertest"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -213,134 +211,6 @@ func TestRoutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Reproduces https://github.com/juanfont/headscale/issues/3203:
|
||||
// HA tracking loses the secondary subnet router after all routers serving
|
||||
// the route have been offline simultaneously and one of them returns.
|
||||
//
|
||||
// Two assertions split the failure surface:
|
||||
// R1 — server-side primary route state restores after reconnect.
|
||||
// R2 — observer's netmap shows the reconnected router online with
|
||||
// the route in its primary set.
|
||||
// If R1 fails the bug is in state.Connect / primaryRoutes; if R1 passes
|
||||
// and R2 fails the bug is in change broadcast / mapBatcher.
|
||||
//
|
||||
// Caveat: servertest's Reconnect re-registers via TryLogin in addition
|
||||
// to starting a new poll session. Production reconnects after a brief
|
||||
// network outage may bypass re-registration. If this test passes on
|
||||
// main, fall back to the integration variant noted in the plan
|
||||
// (TestHASubnetRouterFailover with all routers offline simultaneously).
|
||||
t.Run("ha_secondary_recovers_after_all_offline", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := servertest.NewServer(t)
|
||||
user := srv.CreateUser(t, "ha3203-user")
|
||||
|
||||
route := netip.MustParsePrefix("10.0.0.0/24")
|
||||
|
||||
r1 := servertest.NewClient(t, srv, "ha3203-router1",
|
||||
servertest.WithUser(user))
|
||||
r2 := servertest.NewClient(t, srv, "ha3203-router2",
|
||||
servertest.WithUser(user))
|
||||
obs := servertest.NewClient(t, srv, "ha3203-observer",
|
||||
servertest.WithUser(user))
|
||||
|
||||
obs.WaitForPeers(t, 2, 10*time.Second)
|
||||
|
||||
// Both routers advertise the same route via their hostinfo.
|
||||
advertise := func(c *servertest.TestClient, name string) {
|
||||
t.Helper()
|
||||
c.Direct().SetHostinfo(&tailcfg.Hostinfo{
|
||||
BackendLogID: "servertest-" + name,
|
||||
Hostname: name,
|
||||
RoutableIPs: []netip.Prefix{route},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_ = c.Direct().SendUpdate(ctx)
|
||||
}
|
||||
advertise(r1, "ha3203-router1")
|
||||
advertise(r2, "ha3203-router2")
|
||||
|
||||
// Approve the route on both routers explicitly. Auto-approvers
|
||||
// would also work but introduce a policy dependency the harness
|
||||
// does not currently set up here.
|
||||
approve := func(name string) {
|
||||
t.Helper()
|
||||
id := findNodeID(t, srv, name)
|
||||
|
||||
_, ch, err := srv.State().SetApprovedRoutes(id, []netip.Prefix{route})
|
||||
require.NoError(t, err)
|
||||
srv.App.Change(ch)
|
||||
}
|
||||
approve("ha3203-router1")
|
||||
approve("ha3203-router2")
|
||||
|
||||
// Sanity: r1 starts as primary (lower NodeID by registration order).
|
||||
r1ID := findNodeID(t, srv, "ha3203-router1")
|
||||
r2ID := findNodeID(t, srv, "ha3203-router2")
|
||||
|
||||
hasRoute := func(id types.NodeID) bool {
|
||||
return slices.Contains(srv.State().GetNodePrimaryRoutes(id), route)
|
||||
}
|
||||
|
||||
assert.Eventually(t, func() bool { return hasRoute(r1ID) },
|
||||
10*time.Second, 100*time.Millisecond,
|
||||
"r1 should be primary initially")
|
||||
|
||||
// 1. Take r1 offline. After the 10s grace period, r2 should take over.
|
||||
r1.Disconnect(t)
|
||||
assert.Eventually(t, func() bool { return hasRoute(r2ID) && !hasRoute(r1ID) },
|
||||
20*time.Second, 200*time.Millisecond,
|
||||
"r2 should take over as primary after r1 offline")
|
||||
|
||||
// 2. Take r2 offline. With both routers gone, no primary should remain.
|
||||
r2.Disconnect(t)
|
||||
assert.Eventually(t, func() bool { return !hasRoute(r1ID) && !hasRoute(r2ID) },
|
||||
20*time.Second, 200*time.Millisecond,
|
||||
"no primary should be assigned while both routers are offline")
|
||||
|
||||
// 3. Reconnect r2 (cable plugged back in).
|
||||
r2.Reconnect(t)
|
||||
|
||||
// Hostinfo is part of the controlclient.Direct state; the Reconnect
|
||||
// helper re-registers via TryLogin which carries the same Hostinfo
|
||||
// that was set above. Push it again to be sure the announced route
|
||||
// is registered in the new session.
|
||||
advertise(r2, "ha3203-router2")
|
||||
|
||||
// R1: server-side state must restore r2 as primary.
|
||||
assert.Eventually(t, func() bool { return hasRoute(r2ID) },
|
||||
15*time.Second, 200*time.Millisecond,
|
||||
"R1: r2 should be re-registered as primary after reconnect — issue #3203")
|
||||
|
||||
// R2: observer must see r2 online with the route in its primary set.
|
||||
obs.WaitForCondition(t, "R2: observer sees r2 online with primary route",
|
||||
15*time.Second,
|
||||
func(nm *netmap.NetworkMap) bool {
|
||||
for _, p := range nm.Peers {
|
||||
hi := p.Hostinfo()
|
||||
if !hi.Valid() || hi.Hostname() != "ha3203-router2" {
|
||||
continue
|
||||
}
|
||||
|
||||
online, known := p.Online().GetOk()
|
||||
if !known || !online {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := range p.PrimaryRoutes().Len() {
|
||||
if p.PrimaryRoutes().At(i) == route {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// findNodeID is defined in issues_test.go.
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
// This file implements data-driven via grant compatibility tests using
|
||||
// golden data captured from Tailscale SaaS (v29, v30, v31, v33, v35,
|
||||
// v36). These scenarios exercise via grant steering with peer
|
||||
// connectivity and cross-subnet forwarding.
|
||||
//
|
||||
// Test data source: ../policy/v2/testdata/grant_results/via-grant-v{29,30,31,33,35,36}.hujson
|
||||
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
|
||||
package servertest_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -17,13 +12,59 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/servertest"
|
||||
"github.com/juanfont/headscale/hscontrol/types/testcapture"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tailscale/hujson"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/netmap"
|
||||
)
|
||||
|
||||
// goldenFile represents a golden capture from Tailscale SaaS with full
|
||||
// netmap data per node.
|
||||
type goldenFile struct {
|
||||
TestID string `json:"test_id"`
|
||||
Error bool `json:"error"`
|
||||
Input struct {
|
||||
FullPolicy json.RawMessage `json:"full_policy"`
|
||||
} `json:"input"`
|
||||
Topology struct {
|
||||
Nodes map[string]goldenNode `json:"nodes"`
|
||||
} `json:"topology"`
|
||||
Captures map[string]goldenCapture `json:"captures"`
|
||||
}
|
||||
|
||||
type goldenNode struct {
|
||||
Hostname string `json:"hostname"`
|
||||
Tags []string `json:"tags"`
|
||||
IPv4 string `json:"ipv4"`
|
||||
IPv6 string `json:"ipv6"`
|
||||
AdvertisedRoutes []string `json:"routable_ips"`
|
||||
ApprovedRoutes []string `json:"approved_routes"`
|
||||
}
|
||||
|
||||
type goldenCapture struct {
|
||||
PacketFilterRules json.RawMessage `json:"packet_filter_rules"`
|
||||
Netmap *goldenNetmap `json:"netmap"`
|
||||
Whois map[string]goldenWhois `json:"whois"`
|
||||
}
|
||||
|
||||
type goldenNetmap struct {
|
||||
Peers []goldenPeer `json:"Peers"`
|
||||
PacketFilterRules json.RawMessage `json:"PacketFilterRules"`
|
||||
}
|
||||
|
||||
type goldenPeer struct {
|
||||
Name string `json:"Name"`
|
||||
AllowedIPs []string `json:"AllowedIPs"`
|
||||
PrimaryRoutes []string `json:"PrimaryRoutes"`
|
||||
Tags []string `json:"Tags"`
|
||||
}
|
||||
|
||||
type goldenWhois struct {
|
||||
PeerName string `json:"peer_name"`
|
||||
Response *json.RawMessage `json:"response"`
|
||||
}
|
||||
|
||||
// viaCompatTests lists golden captures that exercise via grant steering.
|
||||
var viaCompatTests = []struct {
|
||||
id string
|
||||
@@ -32,9 +73,12 @@ var viaCompatTests = []struct {
|
||||
{"via-grant-v29", "crossed subnet steering: group-a via router-a, group-b via router-b"},
|
||||
{"via-grant-v30", "crossed mixed: subnet via router-a/b, exit via exit-b/a"},
|
||||
{"via-grant-v31", "peer connectivity + via exit A/B steering"},
|
||||
{"via-grant-v33", "single via grant + HA primary election"},
|
||||
{"via-grant-v35", "via grant with unadvertised destination"},
|
||||
{"via-grant-v36", "full complex: peer connectivity + crossed subnet + crossed exit"},
|
||||
// TODO: V33 and V35 are not yet compatible due to:
|
||||
// - V33: PrimaryRoutes election differs (node ID ordering between SaaS and headscale)
|
||||
// - V35: Extra peer visibility (autoApprovers create different routing topology)
|
||||
// The inferNodeRoutes Tier 3 fix enables route inference for these files,
|
||||
// but the above issues need resolution first.
|
||||
}
|
||||
|
||||
// TestViaGrantMapCompat loads golden captures from Tailscale SaaS and
|
||||
@@ -46,7 +90,7 @@ var viaCompatTests = []struct {
|
||||
//
|
||||
// CROSS-DEPENDENCY WARNING:
|
||||
// This test reads golden files from ../policy/v2/testdata/grant_results/
|
||||
// (specifically via-grant-v29, v30, v31, v33, v35, v36). These files are shared
|
||||
// (specifically via-grant-v29, v30, v31, v36). These files are shared
|
||||
// with TestGrantsCompat in the policy/v2 package. Any changes to the
|
||||
// file format, field structure, or naming must be coordinated with
|
||||
// BOTH tests.
|
||||
@@ -68,28 +112,53 @@ func TestViaGrantMapCompat(t *testing.T) {
|
||||
path := filepath.Join(
|
||||
"..", "policy", "v2", "testdata", "grant_results", tc.id+".hujson",
|
||||
)
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err, "failed to read golden file %s", path)
|
||||
|
||||
c, err := testcapture.Read(path)
|
||||
require.NoError(t, err, "failed to read %s", path)
|
||||
ast, err := hujson.Parse(data)
|
||||
require.NoError(t, err, "failed to parse HuJSON in %s", path)
|
||||
ast.Standardize()
|
||||
|
||||
if c.Error {
|
||||
var gf goldenFile
|
||||
require.NoError(t, json.Unmarshal(ast.Pack(), &gf))
|
||||
|
||||
if gf.Error {
|
||||
t.Skipf("test %s is an error case", tc.id)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
runViaMapCompat(t, c)
|
||||
runViaMapCompat(t, gf)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
|
||||
// taggedNodes are the nodes we create in the servertest. Names match
|
||||
// the anonymized pokémon identifiers tscap writes into golden files —
|
||||
// see github.com/kradalby/tscap/anonymize for the full substitution
|
||||
// table (big-router→caterpie, exit-a→pidgey, etc).
|
||||
var taggedNodes = []string{
|
||||
"caterpie", // big-router
|
||||
"pidgey", // exit-a
|
||||
"pidgeotto", // exit-b
|
||||
"charmander", // exit-node
|
||||
"rattata", // group-a-client
|
||||
"raticate", // group-b-client
|
||||
"spearow", // router-a
|
||||
"fearow", // router-b
|
||||
"squirtle", // subnet-router
|
||||
"weedle", // tagged-client
|
||||
"beedrill", // tagged-server
|
||||
"kakuna", // tagged-prod
|
||||
"blastoise", // multi-exit-router
|
||||
}
|
||||
|
||||
func runViaMapCompat(t *testing.T, gf goldenFile) {
|
||||
t.Helper()
|
||||
|
||||
srv := servertest.NewServer(t)
|
||||
tagUser := srv.CreateUser(t, "tag-user")
|
||||
|
||||
policyJSON := convertCapturePolicy(t, c)
|
||||
policyJSON := convertViaPolicy(gf.Input.FullPolicy)
|
||||
|
||||
changed, err := srv.State().SetPolicy(policyJSON)
|
||||
require.NoError(t, err, "failed to set policy")
|
||||
@@ -101,20 +170,15 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
|
||||
}
|
||||
|
||||
// Create tagged clients matching the golden topology.
|
||||
// Nodes are created in SaaS registration order so headscale assigns
|
||||
// sequential DB IDs in the same relative order. This matters for
|
||||
// PrimaryRoutes election which uses lowest-node-ID-wins — the
|
||||
// tiebreaker must pick the same node as SaaS.
|
||||
clients := map[string]*servertest.TestClient{}
|
||||
order := captureNodeOrder(t, c)
|
||||
|
||||
for _, name := range order {
|
||||
topoNode, exists := c.Topology.Nodes[name]
|
||||
for _, name := range taggedNodes {
|
||||
topoNode, exists := gf.Topology.Nodes[name]
|
||||
if !exists || len(topoNode.Tags) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, inCaptures := c.Captures[name]; !inCaptures {
|
||||
if _, inCaptures := gf.Captures[name]; !inCaptures {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -127,9 +191,10 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
|
||||
require.NotEmpty(t, clients, "no relevant nodes created")
|
||||
|
||||
// Determine which routes each node should advertise. If the golden
|
||||
// topology has explicit routable_ips, use those. Otherwise infer
|
||||
// from the netmap peer AllowedIPs and packet filter dst prefixes.
|
||||
nodeRoutes := inferNodeRoutes(t, c)
|
||||
// topology has explicit advertised_routes, use those. Otherwise infer
|
||||
// from the policy's autoApprovers.routes: if a node's tags match an
|
||||
// approver tag for a route prefix, the node should advertise it.
|
||||
nodeRoutes := inferNodeRoutes(gf)
|
||||
|
||||
// Build approved routes from topology. The topology's approved_routes
|
||||
// field records what SaaS actually approved (which may be a subset of
|
||||
@@ -137,7 +202,7 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
|
||||
// ensures exit routes are only approved when SaaS approved them.
|
||||
nodeApproved := map[string][]netip.Prefix{}
|
||||
|
||||
for name, node := range c.Topology.Nodes {
|
||||
for name, node := range gf.Topology.Nodes {
|
||||
for _, r := range node.ApprovedRoutes {
|
||||
nodeApproved[name] = append(
|
||||
nodeApproved[name], netip.MustParsePrefix(r),
|
||||
@@ -145,31 +210,22 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
|
||||
}
|
||||
}
|
||||
|
||||
// Advertise and approve routes in SaaS registration order. Via
|
||||
// grants depend on routes being advertised for compileViaGrant to
|
||||
// produce filter rules. The order matters because PrimaryRoutes
|
||||
// election is sticky — the first node to register a prefix becomes
|
||||
// primary.
|
||||
for _, name := range order {
|
||||
cl, ok := clients[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Advertise and approve routes FIRST. Via grants depend on routes
|
||||
// being advertised for compileViaGrant to produce filter rules.
|
||||
for name, c := range clients {
|
||||
routes := nodeRoutes[name]
|
||||
if len(routes) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
cl.Direct().SetHostinfo(&tailcfg.Hostinfo{
|
||||
c.Direct().SetHostinfo(&tailcfg.Hostinfo{
|
||||
BackendLogID: "servertest-" + name,
|
||||
Hostname: name,
|
||||
RoutableIPs: routes,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
require.NoError(t, cl.Direct().SendUpdate(ctx),
|
||||
"route advertisement for %s should succeed", name)
|
||||
_ = c.Direct().SendUpdate(ctx)
|
||||
|
||||
cancel()
|
||||
|
||||
@@ -182,8 +238,8 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
|
||||
}
|
||||
|
||||
// Wait for peers based on golden netmap expected counts.
|
||||
for viewerName, cl := range clients {
|
||||
capture := c.Captures[viewerName]
|
||||
for viewerName, c := range clients {
|
||||
capture := gf.Captures[viewerName]
|
||||
if capture.Netmap == nil {
|
||||
continue
|
||||
}
|
||||
@@ -191,64 +247,62 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
|
||||
expected := 0
|
||||
|
||||
for _, peer := range capture.Netmap.Peers {
|
||||
peerName := extractHostname(peer.Name())
|
||||
peerName := extractHostname(peer.Name)
|
||||
if _, isOurs := clients[peerName]; isOurs {
|
||||
expected++
|
||||
}
|
||||
}
|
||||
|
||||
if expected > 0 {
|
||||
cl.WaitForPeers(t, expected, 30*time.Second)
|
||||
c.WaitForPeers(t, expected, 30*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all nodes have received at least one MapResponse,
|
||||
// including nodes with 0 expected peers that skipped WaitForPeers.
|
||||
for name, cl := range clients {
|
||||
cl.WaitForCondition(t, name+" initial netmap", 15*time.Second,
|
||||
for name, c := range clients {
|
||||
c.WaitForCondition(t, name+" initial netmap", 15*time.Second,
|
||||
func(nm *netmap.NetworkMap) bool {
|
||||
return nm != nil
|
||||
})
|
||||
}
|
||||
|
||||
// Compare each viewer's MapResponse against the golden netmap.
|
||||
for viewerName, cl := range clients {
|
||||
capture := c.Captures[viewerName]
|
||||
for viewerName, c := range clients {
|
||||
capture := gf.Captures[viewerName]
|
||||
if capture.Netmap == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
t.Run(viewerName, func(t *testing.T) {
|
||||
nm := cl.Netmap()
|
||||
nm := c.Netmap()
|
||||
require.NotNil(t, nm, "netmap is nil")
|
||||
|
||||
compareNetmap(t, nm, capture, clients)
|
||||
compareNetmap(t, nm, capture.Netmap, clients)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// compareNetmap compares the headscale MapResponse against the
|
||||
// captured netmap data in an IP-independent way. It validates:
|
||||
// compareNetmap compares the headscale MapResponse against the golden
|
||||
// netmap data in an IP-independent way. It validates:
|
||||
// - Peer visibility (which peers are present, by hostname)
|
||||
// - Route prefixes in AllowedIPs (non-Tailscale-IP entries like 10.44.0.0/16)
|
||||
// - Number of Tailscale IPs per peer (should be 2: one v4 + one v6)
|
||||
// - PrimaryRoutes per peer
|
||||
// - PacketFilter rule count and non-Tailscale dst prefixes
|
||||
// - PacketFilter rule count
|
||||
func compareNetmap(
|
||||
t *testing.T,
|
||||
got *netmap.NetworkMap,
|
||||
want testcapture.Node,
|
||||
want *goldenNetmap,
|
||||
clients map[string]*servertest.TestClient,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
require.NotNil(t, want.Netmap, "golden Netmap is nil")
|
||||
|
||||
// Build golden peer map (only peers in our client set).
|
||||
wantPeers := map[string]tailcfg.NodeView{}
|
||||
wantPeers := map[string]goldenPeer{}
|
||||
|
||||
for _, p := range want.Netmap.Peers {
|
||||
name := extractHostname(p.Name())
|
||||
for _, p := range want.Peers {
|
||||
name := extractHostname(p.Name)
|
||||
if _, isOurs := clients[name]; isOurs {
|
||||
wantPeers[name] = p
|
||||
}
|
||||
@@ -313,7 +367,7 @@ func compareNetmap(
|
||||
for name, wantPeer := range wantPeers {
|
||||
gotPeer, visible := gotPeers[name]
|
||||
if !visible {
|
||||
wantRoutes := extractRoutePrefixesView(wantPeer.AllowedIPs())
|
||||
wantRoutes := extractRoutePrefixes(wantPeer.AllowedIPs)
|
||||
t.Errorf("peer %s: visible in Tailscale SaaS (routes=%v), missing in headscale",
|
||||
name, wantRoutes)
|
||||
|
||||
@@ -321,7 +375,7 @@ func compareNetmap(
|
||||
}
|
||||
|
||||
// Compare route prefixes in AllowedIPs (IP-independent).
|
||||
wantRoutes := extractRoutePrefixesView(wantPeer.AllowedIPs())
|
||||
wantRoutes := extractRoutePrefixes(wantPeer.AllowedIPs)
|
||||
slices.Sort(wantRoutes)
|
||||
|
||||
assert.Equalf(t, wantRoutes, gotPeer.RoutePrefixes,
|
||||
@@ -329,7 +383,7 @@ func compareNetmap(
|
||||
|
||||
// Tailscale IPs: count should match, and they must belong to
|
||||
// this peer (not some other node's IPs).
|
||||
wantTSIPCount := countTailscaleIPsView(wantPeer.AllowedIPs())
|
||||
wantTSIPCount := countTailscaleIPs(wantPeer.AllowedIPs)
|
||||
|
||||
assert.Lenf(t, gotPeer.TailscaleIPs, wantTSIPCount,
|
||||
"peer %s: Tailscale IP count mismatch", name)
|
||||
@@ -354,12 +408,7 @@ func compareNetmap(
|
||||
}
|
||||
|
||||
// Compare PrimaryRoutes.
|
||||
var wantPRoutes []string
|
||||
for i := range wantPeer.PrimaryRoutes().Len() {
|
||||
wantPRoutes = append(wantPRoutes, wantPeer.PrimaryRoutes().At(i).String())
|
||||
}
|
||||
|
||||
assert.ElementsMatchf(t, wantPRoutes, gotPeer.PrimaryRoutes,
|
||||
assert.ElementsMatchf(t, wantPeer.PrimaryRoutes, gotPeer.PrimaryRoutes,
|
||||
"peer %s: PrimaryRoutes mismatch", name)
|
||||
}
|
||||
|
||||
@@ -371,41 +420,46 @@ func compareNetmap(
|
||||
}
|
||||
|
||||
// Compare PacketFilter rules (IP-independent).
|
||||
wantFilterRules := want.PacketFilterRules
|
||||
// Golden file SrcIPs contain literal Tailscale SaaS CGNAT addresses
|
||||
// that differ from headscale-allocated IPs, so we compare structural
|
||||
// properties rather than literal SrcIP values.
|
||||
var wantFilterRules []tailcfg.FilterRule
|
||||
if len(want.PacketFilterRules) > 0 &&
|
||||
string(want.PacketFilterRules) != "null" {
|
||||
_ = json.Unmarshal(want.PacketFilterRules, &wantFilterRules)
|
||||
}
|
||||
|
||||
if !assert.Lenf(t, got.PacketFilter, len(wantFilterRules),
|
||||
"PacketFilter rule count mismatch") {
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve SaaS IPs → peer name and HS IPs → peer name so we can
|
||||
// compare rule sources structurally. Tailscale IPs in SaaS vs HS
|
||||
// allocations never match literally, but each IP belongs to a
|
||||
// peer with a stable hostname.
|
||||
saasAddrs := saasAddrsByPeer(want, clients)
|
||||
hsAddrs := hsAddrsByPeer(clients)
|
||||
|
||||
// Compare destination prefixes per rule — subnet CIDRs like
|
||||
// 10.44.0.0/16 are stable between Tailscale SaaS and headscale.
|
||||
// Source IPs are re-keyed per peer identity before comparison.
|
||||
// Source IPs differ entirely (different CGNAT allocation), so
|
||||
// we only validate that sources exist (non-empty), not their
|
||||
// exact count or values.
|
||||
for i := range wantFilterRules {
|
||||
wantRule := wantFilterRules[i]
|
||||
gotMatch := got.PacketFilter[i]
|
||||
|
||||
wantSrcIdents := canonicaliseSrcStrings(t, wantRule.SrcIPs, saasAddrs, i)
|
||||
gotSrcIdents := canonicaliseSrcPrefixes(t, gotMatch.Srcs, hsAddrs, i)
|
||||
|
||||
assert.Equalf(t, wantSrcIdents, gotSrcIdents,
|
||||
"PacketFilter[%d]: source peer identities mismatch", i)
|
||||
// Verify both rules have sources (don't compare exact
|
||||
// count — IP allocation differs between SaaS and headscale).
|
||||
if len(wantRule.SrcIPs) > 0 {
|
||||
assert.NotEmptyf(t, gotMatch.Srcs,
|
||||
"PacketFilter[%d]: golden has %d SrcIPs but headscale has none",
|
||||
i, len(wantRule.SrcIPs))
|
||||
}
|
||||
|
||||
// Destination prefixes: extract non-Tailscale-IP CIDRs
|
||||
// from both golden and headscale rules and compare.
|
||||
var wantDstPrefixes []string
|
||||
|
||||
for _, dp := range wantRule.DstPorts {
|
||||
pfx, err := parsePrefixOrAddr(dp.IP)
|
||||
require.NoErrorf(t, err,
|
||||
"golden DstPorts[%d].IP %q should parse as prefix or addr", i, dp.IP)
|
||||
pfx, err := netip.ParsePrefix(dp.IP)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !isTailscaleIP(pfx) {
|
||||
wantDstPrefixes = append(wantDstPrefixes, pfx.String())
|
||||
@@ -429,222 +483,12 @@ func compareNetmap(
|
||||
}
|
||||
}
|
||||
|
||||
// saasAddrsByPeer builds a map from SaaS Tailscale address to peer
|
||||
// hostname using each capture's SelfNode.Addresses. Peers not in
|
||||
// clients are skipped.
|
||||
func saasAddrsByPeer(
|
||||
want testcapture.Node,
|
||||
clients map[string]*servertest.TestClient,
|
||||
) map[netip.Addr]string {
|
||||
out := map[netip.Addr]string{}
|
||||
|
||||
if want.Netmap == nil {
|
||||
return out
|
||||
}
|
||||
|
||||
// Walk peers listed in this netmap.
|
||||
for _, peer := range want.Netmap.Peers {
|
||||
name := extractHostname(peer.Name())
|
||||
if _, isOurs := clients[name]; !isOurs {
|
||||
continue
|
||||
}
|
||||
|
||||
for i := range peer.Addresses().Len() {
|
||||
pfx := peer.Addresses().At(i)
|
||||
if isTailscaleIP(pfx) {
|
||||
out[pfx.Addr()] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The viewer's own SelfNode addresses also appear as possible src.
|
||||
if want.Netmap.SelfNode.Valid() {
|
||||
name := extractHostname(want.Netmap.SelfNode.Name())
|
||||
|
||||
if _, isOurs := clients[name]; isOurs {
|
||||
addrs := want.Netmap.SelfNode.Addresses()
|
||||
for i := range addrs.Len() {
|
||||
pfx := addrs.At(i)
|
||||
if isTailscaleIP(pfx) {
|
||||
out[pfx.Addr()] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// hsAddrsByPeer builds a map from headscale Tailscale address to peer
|
||||
// hostname by walking each live client's self addresses.
|
||||
func hsAddrsByPeer(clients map[string]*servertest.TestClient) map[netip.Addr]string {
|
||||
out := map[netip.Addr]string{}
|
||||
|
||||
for name, cl := range clients {
|
||||
nm := cl.Netmap()
|
||||
if nm == nil || !nm.SelfNode.Valid() {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs := nm.SelfNode.Addresses()
|
||||
for i := range addrs.Len() {
|
||||
pfx := addrs.At(i)
|
||||
if isTailscaleIP(pfx) {
|
||||
out[pfx.Addr()] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// canonicaliseSrcStrings converts a SrcIPs slice (as produced by the
|
||||
// SaaS wire format) into a sorted list of canonical identifiers: "*"
|
||||
// for wildcard, "peer:<name>" for each Tailscale address or prefix
|
||||
// that resolves to a known peer, or the raw CIDR string for
|
||||
// non-Tailscale prefixes. A Tailscale prefix wider than /32 (IPv4)
|
||||
// or /128 (IPv6) expands to the union of its contained peers.
|
||||
// Unresolvable Tailscale-range sources fail the test.
|
||||
func canonicaliseSrcStrings(
|
||||
t *testing.T,
|
||||
srcs []string,
|
||||
addrToPeer map[netip.Addr]string,
|
||||
ruleIndex int,
|
||||
) []string {
|
||||
t.Helper()
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
|
||||
for _, src := range srcs {
|
||||
if src == "*" {
|
||||
seen["*"] = struct{}{}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
pfx, err := parsePrefixOrAddr(src)
|
||||
require.NoErrorf(t, err,
|
||||
"PacketFilter[%d]: unparseable SrcIP %q", ruleIndex, src)
|
||||
|
||||
addIdentsForSrc(t, pfx, addrToPeer, ruleIndex, seen)
|
||||
}
|
||||
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
// canonicaliseSrcPrefixes is the headscale-side counterpart of
|
||||
// canonicaliseSrcStrings, reading already-parsed netip.Prefix values
|
||||
// from tailcfg.Match.Srcs.
|
||||
func canonicaliseSrcPrefixes(
|
||||
t *testing.T,
|
||||
srcs []netip.Prefix,
|
||||
addrToPeer map[netip.Addr]string,
|
||||
ruleIndex int,
|
||||
) []string {
|
||||
t.Helper()
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
|
||||
for _, pfx := range srcs {
|
||||
if pfx.Bits() == 0 && pfx.Addr().IsUnspecified() {
|
||||
seen["*"] = struct{}{}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
addIdentsForSrc(t, pfx, addrToPeer, ruleIndex, seen)
|
||||
}
|
||||
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
// addIdentsForSrc resolves one source prefix into canonical identity
|
||||
// tokens and inserts them into seen. A non-Tailscale prefix passes
|
||||
// through literally; a Tailscale-range prefix expands to the union
|
||||
// of peer names whose addresses fall within it.
|
||||
func addIdentsForSrc(
|
||||
t *testing.T,
|
||||
pfx netip.Prefix,
|
||||
addrToPeer map[netip.Addr]string,
|
||||
ruleIndex int,
|
||||
seen map[string]struct{},
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
if !prefixInTailscaleRange(pfx) {
|
||||
seen[pfx.String()] = struct{}{}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
matched := false
|
||||
|
||||
for addr, name := range addrToPeer {
|
||||
if pfx.Contains(addr) {
|
||||
seen["peer:"+name] = struct{}{}
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
|
||||
require.Truef(t, matched,
|
||||
"PacketFilter[%d]: Tailscale-range SrcIP %s does not cover any known peer; addrToPeer=%v",
|
||||
ruleIndex, pfx, addrToPeer)
|
||||
}
|
||||
|
||||
// prefixInTailscaleRange reports whether a prefix lies entirely
|
||||
// within the Tailscale CGNAT range (100.64.0.0/10) or Tailscale ULA
|
||||
// range (fd7a:115c:a1e0::/48), regardless of prefix length.
|
||||
func prefixInTailscaleRange(p netip.Prefix) bool {
|
||||
addr := p.Addr()
|
||||
|
||||
if addr.Is4() {
|
||||
return addr.As4()[0] == 100 && (addr.As4()[1]&0xC0) == 64
|
||||
}
|
||||
|
||||
if addr.Is6() {
|
||||
b := addr.As16()
|
||||
|
||||
return b[0] == 0xfd && b[1] == 0x7a && b[2] == 0x11 && b[3] == 0x5c //nolint:gosec // As16 returns [16]byte, indexing [0..3] is safe
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]struct{}) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
|
||||
slices.Sort(out)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
type peerSummary struct {
|
||||
TailscaleIPs []netip.Prefix // Tailscale address entries from AllowedIPs
|
||||
RoutePrefixes []string // non-Tailscale-IP AllowedIPs (sorted)
|
||||
PrimaryRoutes []string // sorted
|
||||
}
|
||||
|
||||
// parsePrefixOrAddr parses a string as a netip.Prefix. If the string
|
||||
// is a bare IP address (no slash), it is converted to a single-host
|
||||
// prefix (/32 for IPv4, /128 for IPv6). Golden data DstPorts.IP can
|
||||
// contain either form.
|
||||
func parsePrefixOrAddr(s string) (netip.Prefix, error) {
|
||||
pfx, err := netip.ParsePrefix(s)
|
||||
if err == nil {
|
||||
return pfx, nil
|
||||
}
|
||||
|
||||
addr, addrErr := netip.ParseAddr(s)
|
||||
if addrErr != nil {
|
||||
return netip.Prefix{}, err // return original prefix error
|
||||
}
|
||||
|
||||
return netip.PrefixFrom(addr, addr.BitLen()), nil
|
||||
}
|
||||
|
||||
// isTailscaleIP returns true if the prefix is a single-host Tailscale
|
||||
// address (/32 for IPv4 in CGNAT range, /128 for IPv6 in Tailscale ULA).
|
||||
func isTailscaleIP(prefix netip.Prefix) bool {
|
||||
@@ -665,36 +509,37 @@ func isTailscaleIP(prefix netip.Prefix) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// extractRoutePrefixesView returns the non-Tailscale-IP entries from
|
||||
// a typed AllowedIPs view (subnet routes, exit routes, etc.).
|
||||
func extractRoutePrefixesView(allowedIPs interface {
|
||||
Len() int
|
||||
At(i int) netip.Prefix
|
||||
},
|
||||
) []string {
|
||||
// extractRoutePrefixes returns the non-Tailscale-IP entries from an
|
||||
// AllowedIPs list (subnet routes, exit routes, etc.).
|
||||
func extractRoutePrefixes(allowedIPs []string) []string {
|
||||
var routes []string
|
||||
|
||||
for i := range allowedIPs.Len() {
|
||||
pfx := allowedIPs.At(i)
|
||||
if !isTailscaleIP(pfx) {
|
||||
routes = append(routes, pfx.String())
|
||||
for _, aip := range allowedIPs {
|
||||
prefix, err := netip.ParsePrefix(aip)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !isTailscaleIP(prefix) {
|
||||
routes = append(routes, aip)
|
||||
}
|
||||
}
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
// countTailscaleIPsView returns the number of Tailscale IP entries
|
||||
// in a typed AllowedIPs view.
|
||||
func countTailscaleIPsView(allowedIPs interface {
|
||||
Len() int
|
||||
At(i int) netip.Prefix
|
||||
},
|
||||
) int {
|
||||
// countTailscaleIPs returns the number of Tailscale IP entries in an
|
||||
// AllowedIPs list.
|
||||
func countTailscaleIPs(allowedIPs []string) int {
|
||||
count := 0
|
||||
|
||||
for i := range allowedIPs.Len() {
|
||||
if isTailscaleIP(allowedIPs.At(i)) {
|
||||
for _, aip := range allowedIPs {
|
||||
prefix, err := netip.ParsePrefix(aip)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if isTailscaleIP(prefix) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
@@ -703,17 +548,16 @@ func countTailscaleIPsView(allowedIPs interface {
|
||||
}
|
||||
|
||||
// inferNodeRoutes determines which routes each node should advertise.
|
||||
// If the topology has explicit routable_ips, those are used. Otherwise
|
||||
// routes are inferred from the netmap peer AllowedIPs and packet
|
||||
// filter destination prefixes.
|
||||
func inferNodeRoutes(t *testing.T, c *testcapture.Capture) map[string][]netip.Prefix {
|
||||
t.Helper()
|
||||
|
||||
// If the golden topology has explicit advertised_routes, those are used.
|
||||
// Otherwise, routes are inferred from the golden netmap data: if a node
|
||||
// appears as a peer with route prefixes in AllowedIPs, it should
|
||||
// advertise those routes.
|
||||
func inferNodeRoutes(gf goldenFile) map[string][]netip.Prefix {
|
||||
result := map[string][]netip.Prefix{}
|
||||
|
||||
// First use explicit routable_ips from topology.
|
||||
for name, node := range c.Topology.Nodes {
|
||||
for _, r := range node.RoutableIPs {
|
||||
// First use explicit advertised_routes from topology.
|
||||
for name, node := range gf.Topology.Nodes {
|
||||
for _, r := range node.AdvertisedRoutes {
|
||||
result[name] = append(result[name], netip.MustParsePrefix(r))
|
||||
}
|
||||
}
|
||||
@@ -725,45 +569,58 @@ func inferNodeRoutes(t *testing.T, c *testcapture.Capture) map[string][]netip.Pr
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2: infer from each capture's netmap — scan peers with
|
||||
// route prefixes in AllowedIPs. If node X appears as a peer with
|
||||
// route prefix 10.44.0.0/16, then X should advertise that route.
|
||||
for _, node := range c.Captures {
|
||||
if node.Netmap == nil {
|
||||
// Tier 2: Infer from the golden netmap — scan all captures for
|
||||
// peers with route prefixes in AllowedIPs. If node X appears as
|
||||
// a peer with route prefix 10.44.0.0/16, then X should advertise
|
||||
// that route.
|
||||
for _, capture := range gf.Captures {
|
||||
if capture.Netmap == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, peer := range node.Netmap.Peers {
|
||||
peerName := extractHostname(peer.Name())
|
||||
for _, peer := range capture.Netmap.Peers {
|
||||
peerName := extractHostname(peer.Name)
|
||||
routes := extractRoutePrefixes(peer.AllowedIPs)
|
||||
|
||||
for i := range peer.AllowedIPs().Len() {
|
||||
pfx := peer.AllowedIPs().At(i)
|
||||
if isTailscaleIP(pfx) {
|
||||
for _, r := range routes {
|
||||
prefix, err := netip.ParsePrefix(r)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !slices.Contains(result[peerName], pfx) {
|
||||
result[peerName] = append(result[peerName], pfx)
|
||||
if !slices.Contains(result[peerName], prefix) {
|
||||
result[peerName] = append(result[peerName], prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3: infer from packet_filter_rules DstPorts — secondary HA
|
||||
// routers whose routes don't appear in AllowedIPs (only the
|
||||
// primary gets the route in AllowedIPs) DO receive filter rules
|
||||
// for those routes.
|
||||
for nodeName, node := range c.Captures {
|
||||
for _, rule := range node.PacketFilterRules {
|
||||
// Tier 3: Infer from packet_filter_rules DstPorts — if a node
|
||||
// receives filter rules with non-Tailscale-IP destination
|
||||
// prefixes, it must be advertising those routes. This catches
|
||||
// secondary HA routers whose routes don't appear in AllowedIPs
|
||||
// (only the primary gets the route in AllowedIPs) but DO receive
|
||||
// filter rules for those routes.
|
||||
for nodeName, capture := range gf.Captures {
|
||||
if len(capture.PacketFilterRules) == 0 ||
|
||||
string(capture.PacketFilterRules) == "null" {
|
||||
continue
|
||||
}
|
||||
|
||||
var rules []tailcfg.FilterRule
|
||||
|
||||
err := json.Unmarshal(capture.PacketFilterRules, &rules)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, rule := range rules {
|
||||
for _, dp := range rule.DstPorts {
|
||||
if dp.IP == "*" {
|
||||
prefix, err := netip.ParsePrefix(dp.IP)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
prefix, err := parsePrefixOrAddr(dp.IP)
|
||||
require.NoErrorf(t, err,
|
||||
"golden DstPorts.IP %q for %s unparseable", dp.IP, nodeName)
|
||||
|
||||
if isTailscaleIP(prefix) {
|
||||
continue
|
||||
}
|
||||
@@ -789,3 +646,14 @@ func extractHostname(fqdn string) string {
|
||||
|
||||
return fqdn
|
||||
}
|
||||
|
||||
// convertViaPolicy converts Tailscale SaaS policy emails to headscale format.
|
||||
func convertViaPolicy(raw json.RawMessage) []byte {
|
||||
s := string(raw)
|
||||
// Anonymized SaaS emails as written by tscap/anonymize.
|
||||
s = strings.ReplaceAll(s, "odin@example.com", "tag-user@")
|
||||
s = strings.ReplaceAll(s, "thor@example.org", "tag-user@")
|
||||
s = strings.ReplaceAll(s, "freya@example.com", "tag-user@")
|
||||
|
||||
return []byte(s)
|
||||
}
|
||||
|
||||
@@ -1,411 +0,0 @@
|
||||
// This file implements data-driven via+HA compatibility tests using
|
||||
// golden data captured from Tailscale SaaS (v37-v46). These scenarios
|
||||
// exercise the interaction between via grant steering and HA primary
|
||||
// route election with varying combinations of shared/unique via tags,
|
||||
// regular grants, and multiple HA pairs.
|
||||
//
|
||||
// Test data source: ../policy/v2/testdata/grant_results/via-grant-v{37..46}.hujson
|
||||
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
|
||||
|
||||
package servertest_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/servertest"
|
||||
"github.com/juanfont/headscale/hscontrol/types/testcapture"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/netmap"
|
||||
)
|
||||
|
||||
// viaHACompatTests lists golden captures that exercise via+HA interactions.
|
||||
var viaHACompatTests = []struct {
|
||||
id string
|
||||
desc string
|
||||
}{
|
||||
{"via-grant-v37", "crossed same prefix, different via tags"},
|
||||
{"via-grant-v38", "HA baseline, no via grants"},
|
||||
{"via-grant-v39", "crossed via same prefix, different HA members"},
|
||||
{"via-grant-v40", "one client via, one client regular"},
|
||||
{"via-grant-v41", "via HA pair + non-via router same prefix"},
|
||||
{"via-grant-v42", "crossed via+regular across two HA pairs"},
|
||||
{"via-grant-v43", "partial via, partial HA, cross pairs"},
|
||||
{"via-grant-v44", "four-way HA, mixed via steering"},
|
||||
{"via-grant-v45", "via+regular overlap, 4-way HA"},
|
||||
{"via-grant-v46", "kitchen sink: mixed via+regular+HA"},
|
||||
}
|
||||
|
||||
// TestViaGrantHACompat loads golden captures from Tailscale SaaS that
|
||||
// test via grant steering combined with HA primary route election.
|
||||
// Each capture uses an inline topology with 4-6 nodes (instead of the
|
||||
// shared 15-node grant topology used by TestViaGrantMapCompat).
|
||||
func TestViaGrantHACompat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range viaHACompatTests {
|
||||
t.Run(tc.id, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(
|
||||
"..", "policy", "v2", "testdata", "grant_results", tc.id+".hujson",
|
||||
)
|
||||
|
||||
c, err := testcapture.Read(path)
|
||||
require.NoError(t, err, "failed to read %s", path)
|
||||
|
||||
if c.Error {
|
||||
t.Skipf("test %s is an error case", tc.id)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
runViaHACompat(t, c)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runViaHACompat(t *testing.T, c *testcapture.Capture) {
|
||||
t.Helper()
|
||||
|
||||
srv := servertest.NewServer(t)
|
||||
tagUser := srv.CreateUser(t, "tag-user")
|
||||
|
||||
policyJSON := convertCapturePolicy(t, c)
|
||||
|
||||
changed, err := srv.State().SetPolicy(policyJSON)
|
||||
require.NoError(t, err, "failed to set policy")
|
||||
|
||||
if changed {
|
||||
changes, err := srv.State().ReloadPolicy()
|
||||
require.NoError(t, err)
|
||||
srv.App.Change(changes...)
|
||||
}
|
||||
|
||||
// Create nodes in SaaS node ID order so headscale assigns
|
||||
// sequential DB IDs in the same relative order.
|
||||
clients := map[string]*servertest.TestClient{}
|
||||
order := captureNodeOrder(t, c)
|
||||
|
||||
for _, name := range order {
|
||||
topoNode, exists := c.Topology.Nodes[name]
|
||||
if !exists || len(topoNode.Tags) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, inCaptures := c.Captures[name]; !inCaptures {
|
||||
continue
|
||||
}
|
||||
|
||||
clients[name] = servertest.NewClient(t, srv, name,
|
||||
servertest.WithUser(tagUser),
|
||||
servertest.WithTags(topoNode.Tags...),
|
||||
)
|
||||
}
|
||||
|
||||
require.NotEmpty(t, clients, "no relevant nodes created")
|
||||
|
||||
// Advertise and approve routes in SaaS node ID order.
|
||||
for _, name := range order {
|
||||
cl, ok := clients[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
topoNode := c.Topology.Nodes[name]
|
||||
|
||||
var routes []netip.Prefix
|
||||
for _, r := range topoNode.RoutableIPs {
|
||||
routes = append(routes, netip.MustParsePrefix(r))
|
||||
}
|
||||
|
||||
if len(routes) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
cl.Direct().SetHostinfo(&tailcfg.Hostinfo{
|
||||
BackendLogID: "servertest-" + name,
|
||||
Hostname: name,
|
||||
RoutableIPs: routes,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
require.NoError(t, cl.Direct().SendUpdate(ctx),
|
||||
"route advertisement for %s should succeed", name)
|
||||
|
||||
cancel()
|
||||
|
||||
var approved []netip.Prefix
|
||||
for _, r := range topoNode.ApprovedRoutes {
|
||||
approved = append(approved, netip.MustParsePrefix(r))
|
||||
}
|
||||
|
||||
nodeID := findNodeID(t, srv, name)
|
||||
|
||||
_, routeChange, err := srv.State().SetApprovedRoutes(nodeID, approved)
|
||||
require.NoError(t, err)
|
||||
srv.App.Change(routeChange)
|
||||
}
|
||||
|
||||
// Wait for peers.
|
||||
for viewerName, cl := range clients {
|
||||
capture := c.Captures[viewerName]
|
||||
if capture.Netmap == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
expected := 0
|
||||
|
||||
for _, peer := range capture.Netmap.Peers {
|
||||
peerName := extractHostname(peer.Name())
|
||||
if _, isOurs := clients[peerName]; isOurs {
|
||||
expected++
|
||||
}
|
||||
}
|
||||
|
||||
if expected > 0 {
|
||||
cl.WaitForPeers(t, expected, 30*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all nodes have an initial netmap.
|
||||
for name, cl := range clients {
|
||||
cl.WaitForCondition(t, name+" initial netmap", 15*time.Second,
|
||||
func(nm *netmap.NetworkMap) bool {
|
||||
return nm != nil
|
||||
})
|
||||
}
|
||||
|
||||
// Compare each viewer's MapResponse against golden netmap.
|
||||
for viewerName, cl := range clients {
|
||||
capture := c.Captures[viewerName]
|
||||
if capture.Netmap == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
t.Run(viewerName, func(t *testing.T) {
|
||||
compareCaptureNetmap(t, cl, capture, clients)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// compareCaptureNetmap compares headscale's MapResponse against a
|
||||
// testcapture.Node's netmap data. Same logic as compareNetmap but
|
||||
// reads from typed testcapture fields instead of goldenFile strings.
|
||||
func compareCaptureNetmap(
|
||||
t *testing.T,
|
||||
viewer *servertest.TestClient,
|
||||
want testcapture.Node,
|
||||
clients map[string]*servertest.TestClient,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
nm := viewer.Netmap()
|
||||
require.NotNil(t, nm, "viewer has no netmap")
|
||||
|
||||
// Build peer summaries from golden data.
|
||||
wantPeers := map[string]capturePeerSummary{}
|
||||
|
||||
for _, peer := range want.Netmap.Peers {
|
||||
peerName := extractHostname(peer.Name())
|
||||
if _, isOurs := clients[peerName]; !isOurs {
|
||||
continue
|
||||
}
|
||||
|
||||
var (
|
||||
tsIPs []netip.Prefix
|
||||
routePrefixes []string
|
||||
)
|
||||
|
||||
for i := range peer.AllowedIPs().Len() {
|
||||
pfx := peer.AllowedIPs().At(i)
|
||||
if isTailscaleIP(pfx) {
|
||||
tsIPs = append(tsIPs, pfx)
|
||||
} else {
|
||||
routePrefixes = append(routePrefixes, pfx.String())
|
||||
}
|
||||
}
|
||||
|
||||
slices.Sort(routePrefixes)
|
||||
|
||||
var primaryRoutes []string
|
||||
|
||||
for i := range peer.PrimaryRoutes().Len() {
|
||||
primaryRoutes = append(primaryRoutes, peer.PrimaryRoutes().At(i).String())
|
||||
}
|
||||
|
||||
slices.Sort(primaryRoutes)
|
||||
|
||||
wantPeers[peerName] = capturePeerSummary{
|
||||
TailscaleIPs: tsIPs,
|
||||
RoutePrefixes: routePrefixes,
|
||||
PrimaryRoutes: primaryRoutes,
|
||||
}
|
||||
}
|
||||
|
||||
// Build peer summaries from headscale MapResponse.
|
||||
gotPeers := map[string]capturePeerSummary{}
|
||||
|
||||
for _, peer := range nm.Peers {
|
||||
peerName := extractHostname(peer.Name())
|
||||
if _, isOurs := clients[peerName]; !isOurs {
|
||||
continue
|
||||
}
|
||||
|
||||
var (
|
||||
tsIPs []netip.Prefix
|
||||
routePrefixes []string
|
||||
)
|
||||
|
||||
for i := range peer.AllowedIPs().Len() {
|
||||
pfx := peer.AllowedIPs().At(i)
|
||||
if isTailscaleIP(pfx) {
|
||||
tsIPs = append(tsIPs, pfx)
|
||||
} else {
|
||||
routePrefixes = append(routePrefixes, pfx.String())
|
||||
}
|
||||
}
|
||||
|
||||
slices.Sort(routePrefixes)
|
||||
|
||||
var primaryRoutes []string
|
||||
|
||||
for i := range peer.PrimaryRoutes().Len() {
|
||||
primaryRoutes = append(primaryRoutes, peer.PrimaryRoutes().At(i).String())
|
||||
}
|
||||
|
||||
slices.Sort(primaryRoutes)
|
||||
|
||||
gotPeers[peerName] = capturePeerSummary{
|
||||
TailscaleIPs: tsIPs,
|
||||
RoutePrefixes: routePrefixes,
|
||||
PrimaryRoutes: primaryRoutes,
|
||||
}
|
||||
}
|
||||
|
||||
// Compare peer visibility.
|
||||
for name, wantPeer := range wantPeers {
|
||||
gotPeer, visible := gotPeers[name]
|
||||
if !visible {
|
||||
t.Errorf("peer %s: visible in SaaS, missing in headscale (routes=%v)",
|
||||
name, wantPeer.RoutePrefixes)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
assert.Equalf(t, wantPeer.RoutePrefixes, gotPeer.RoutePrefixes,
|
||||
"peer %s: route prefixes in AllowedIPs mismatch", name)
|
||||
|
||||
assert.Lenf(t, gotPeer.TailscaleIPs, len(wantPeer.TailscaleIPs),
|
||||
"peer %s: Tailscale IP count mismatch", name)
|
||||
|
||||
assert.ElementsMatchf(t, wantPeer.PrimaryRoutes, gotPeer.PrimaryRoutes,
|
||||
"peer %s: PrimaryRoutes mismatch", name)
|
||||
}
|
||||
|
||||
// Check for extra peers.
|
||||
for name := range gotPeers {
|
||||
if _, expected := wantPeers[name]; !expected {
|
||||
t.Errorf("peer %s: visible in headscale but NOT in SaaS", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Baseline PacketFilter sanity: count rules. Full per-rule dst-prefix
|
||||
// comparison is done by the tailscale_routes_data compat test; here
|
||||
// we only catch gross drift.
|
||||
if len(want.PacketFilterRules) > 0 {
|
||||
gotLen := nm.PacketFilterRules.Len()
|
||||
assert.Equalf(t, len(want.PacketFilterRules), gotLen,
|
||||
"PacketFilter rule count mismatch (SaaS=%d, headscale=%d)",
|
||||
len(want.PacketFilterRules), gotLen,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type capturePeerSummary struct {
|
||||
TailscaleIPs []netip.Prefix
|
||||
RoutePrefixes []string
|
||||
PrimaryRoutes []string
|
||||
}
|
||||
|
||||
// captureNodeOrder returns node names from a testcapture.Capture
|
||||
// sorted by SaaS node creation time, for deterministic DB ID assignment.
|
||||
// SaaS elects HA primaries by registration order (first registered wins),
|
||||
// which correlates with Created timestamp, not with the random snowflake
|
||||
// node ID.
|
||||
func captureNodeOrder(t *testing.T, c *testcapture.Capture) []string {
|
||||
t.Helper()
|
||||
|
||||
type entry struct {
|
||||
name string
|
||||
created time.Time
|
||||
}
|
||||
|
||||
var entries []entry
|
||||
|
||||
for name, node := range c.Captures {
|
||||
if node.Netmap == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
created := node.Netmap.SelfNode.Created()
|
||||
if created.IsZero() {
|
||||
continue
|
||||
}
|
||||
|
||||
entries = append(entries, entry{name: name, created: created})
|
||||
}
|
||||
|
||||
require.NotEmpty(t, entries, "no captures with SelfNode.Created found")
|
||||
|
||||
slices.SortFunc(entries, func(a, b entry) int {
|
||||
return a.created.Compare(b.created)
|
||||
})
|
||||
|
||||
names := make([]string, len(entries))
|
||||
for i, e := range entries {
|
||||
names[i] = e.name
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
// convertCapturePolicy converts a testcapture's policy for headscale,
|
||||
// replacing SaaS emails with headscale user format. Fails the test if
|
||||
// none of the known SaaS emails are present: that would mean the
|
||||
// capture was regenerated with a new tag-owner identity and this
|
||||
// function needs updating.
|
||||
func convertCapturePolicy(t *testing.T, c *testcapture.Capture) []byte {
|
||||
t.Helper()
|
||||
|
||||
s := c.Input.FullPolicy
|
||||
|
||||
substituted := false
|
||||
|
||||
for _, email := range []string{
|
||||
"odin@example.com",
|
||||
"thor@example.org",
|
||||
"freya@example.com",
|
||||
} {
|
||||
if strings.Contains(s, email) {
|
||||
substituted = true
|
||||
s = strings.ReplaceAll(s, email, "tag-user@")
|
||||
}
|
||||
}
|
||||
|
||||
require.True(
|
||||
t,
|
||||
substituted,
|
||||
"%s: no known SaaS tag-owner email found in policy; update convertCapturePolicy",
|
||||
c.TestID,
|
||||
)
|
||||
|
||||
return []byte(s)
|
||||
}
|
||||
+24
-75
@@ -2,12 +2,11 @@ package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
hsdb "github.com/juanfont/headscale/hscontrol/db"
|
||||
"github.com/juanfont/headscale/hscontrol/routes"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
@@ -70,7 +69,7 @@ func (s *State) DebugOverview() string {
|
||||
sb.WriteString("=== Headscale State Overview ===\n\n")
|
||||
|
||||
// Node statistics
|
||||
fmt.Fprintf(&sb, "Nodes: %d total\n", allNodes.Len())
|
||||
sb.WriteString(fmt.Sprintf("Nodes: %d total\n", allNodes.Len()))
|
||||
|
||||
userNodeCounts := make(map[string]int)
|
||||
onlineCount := 0
|
||||
@@ -98,26 +97,26 @@ func (s *State) DebugOverview() string {
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(&sb, " - Online: %d\n", onlineCount)
|
||||
fmt.Fprintf(&sb, " - Expired: %d\n", expiredCount)
|
||||
fmt.Fprintf(&sb, " - Ephemeral: %d\n", ephemeralCount)
|
||||
sb.WriteString(fmt.Sprintf(" - Online: %d\n", onlineCount))
|
||||
sb.WriteString(fmt.Sprintf(" - Expired: %d\n", expiredCount))
|
||||
sb.WriteString(fmt.Sprintf(" - Ephemeral: %d\n", ephemeralCount))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// User statistics
|
||||
fmt.Fprintf(&sb, "Users: %d total\n", len(users))
|
||||
sb.WriteString(fmt.Sprintf("Users: %d total\n", len(users)))
|
||||
|
||||
for userName, nodeCount := range userNodeCounts {
|
||||
fmt.Fprintf(&sb, " - %s: %d nodes\n", userName, nodeCount)
|
||||
sb.WriteString(fmt.Sprintf(" - %s: %d nodes\n", userName, nodeCount))
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Policy information
|
||||
sb.WriteString("Policy:\n")
|
||||
fmt.Fprintf(&sb, " - Mode: %s\n", s.cfg.Policy.Mode)
|
||||
sb.WriteString(fmt.Sprintf(" - Mode: %s\n", s.cfg.Policy.Mode))
|
||||
|
||||
if s.cfg.Policy.Mode == types.PolicyModeFile {
|
||||
fmt.Fprintf(&sb, " - Path: %s\n", s.cfg.Policy.Path)
|
||||
sb.WriteString(fmt.Sprintf(" - Path: %s\n", s.cfg.Policy.Path))
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
@@ -125,7 +124,7 @@ func (s *State) DebugOverview() string {
|
||||
// DERP information
|
||||
derpMap := s.derpMap.Load()
|
||||
if derpMap != nil {
|
||||
fmt.Fprintf(&sb, "DERP: %d regions configured\n", len(derpMap.Regions))
|
||||
sb.WriteString(fmt.Sprintf("DERP: %d regions configured\n", len(derpMap.Regions)))
|
||||
} else {
|
||||
sb.WriteString("DERP: not configured\n")
|
||||
}
|
||||
@@ -133,14 +132,12 @@ func (s *State) DebugOverview() string {
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Route information
|
||||
primaryStr := s.PrimaryRoutesString()
|
||||
|
||||
routeCount := len(strings.Split(strings.TrimSpace(primaryStr), "\n"))
|
||||
if primaryStr == "" {
|
||||
routeCount := len(strings.Split(strings.TrimSpace(s.primaryRoutes.String()), "\n"))
|
||||
if s.primaryRoutes.String() == "" {
|
||||
routeCount = 0
|
||||
}
|
||||
|
||||
fmt.Fprintf(&sb, "Primary Routes: %d active\n", routeCount)
|
||||
sb.WriteString(fmt.Sprintf("Primary Routes: %d active\n", routeCount))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Registration cache
|
||||
@@ -166,18 +163,18 @@ func (s *State) DebugDERPMap() string {
|
||||
|
||||
sb.WriteString("=== DERP Map Configuration ===\n\n")
|
||||
|
||||
fmt.Fprintf(&sb, "Total Regions: %d\n\n", len(derpMap.Regions))
|
||||
sb.WriteString(fmt.Sprintf("Total Regions: %d\n\n", len(derpMap.Regions)))
|
||||
|
||||
for regionID, region := range derpMap.Regions {
|
||||
fmt.Fprintf(&sb, "Region %d: %s\n", regionID, region.RegionName)
|
||||
fmt.Fprintf(&sb, " - Nodes: %d\n", len(region.Nodes))
|
||||
sb.WriteString(fmt.Sprintf("Region %d: %s\n", regionID, region.RegionName))
|
||||
sb.WriteString(fmt.Sprintf(" - Nodes: %d\n", len(region.Nodes)))
|
||||
|
||||
for _, node := range region.Nodes {
|
||||
fmt.Fprintf(&sb, " - %s (%s:%d)\n",
|
||||
node.Name, node.HostName, node.DERPPort)
|
||||
sb.WriteString(fmt.Sprintf(" - %s (%s:%d)\n",
|
||||
node.Name, node.HostName, node.DERPPort))
|
||||
|
||||
if node.STUNPort != 0 {
|
||||
fmt.Fprintf(&sb, " STUN: %d\n", node.STUNPort)
|
||||
sb.WriteString(fmt.Sprintf(" STUN: %d\n", node.STUNPort))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,55 +253,9 @@ func (s *State) DebugFilter() ([]tailcfg.FilterRule, error) {
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
// DebugRoutes returns the current primary routes information as a
|
||||
// structured object built from the NodeStore snapshot.
|
||||
func (s *State) DebugRoutes() types.DebugRoutes {
|
||||
debug := types.DebugRoutes{
|
||||
AvailableRoutes: make(map[types.NodeID][]netip.Prefix),
|
||||
PrimaryRoutes: make(map[string]types.NodeID),
|
||||
}
|
||||
|
||||
for _, nv := range s.nodeStore.ListNodes().All() {
|
||||
if !nv.Valid() {
|
||||
continue
|
||||
}
|
||||
|
||||
online, known := nv.IsOnline().GetOk()
|
||||
if !known || !online {
|
||||
continue
|
||||
}
|
||||
|
||||
approved := nv.AllApprovedRoutes()
|
||||
if len(approved) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
slices.SortFunc(approved, netip.Prefix.Compare)
|
||||
debug.AvailableRoutes[nv.ID()] = approved
|
||||
}
|
||||
|
||||
for prefix, id := range s.nodeStore.PrimaryRoutes() {
|
||||
debug.PrimaryRoutes[prefix.String()] = id
|
||||
}
|
||||
|
||||
var unhealthy []types.NodeID
|
||||
|
||||
for _, nv := range s.nodeStore.ListNodes().All() {
|
||||
if !nv.Valid() {
|
||||
continue
|
||||
}
|
||||
|
||||
if !s.nodeStore.IsNodeHealthy(nv.ID()) {
|
||||
unhealthy = append(unhealthy, nv.ID())
|
||||
}
|
||||
}
|
||||
|
||||
if len(unhealthy) > 0 {
|
||||
slices.Sort(unhealthy)
|
||||
debug.UnhealthyNodes = unhealthy
|
||||
}
|
||||
|
||||
return debug
|
||||
// DebugRoutes returns the current primary routes information as a structured object.
|
||||
func (s *State) DebugRoutes() routes.DebugRoutes {
|
||||
return s.primaryRoutes.DebugJSON()
|
||||
}
|
||||
|
||||
// DebugRoutesString returns the current primary routes information as a string.
|
||||
@@ -371,10 +322,8 @@ func (s *State) DebugOverviewJSON() DebugOverviewInfo {
|
||||
}
|
||||
|
||||
// Route information
|
||||
primaryStr := s.PrimaryRoutesString()
|
||||
|
||||
routeCount := len(strings.Split(strings.TrimSpace(primaryStr), "\n"))
|
||||
if primaryStr == "" {
|
||||
routeCount := len(strings.Split(strings.TrimSpace(s.primaryRoutes.String()), "\n"))
|
||||
if s.primaryRoutes.String() == "" {
|
||||
routeCount = 0
|
||||
}
|
||||
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/types/change"
|
||||
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
|
||||
"github.com/rs/zerolog/log"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
// HAHealthProber periodically pings HA subnet router nodes and
|
||||
// triggers failover when a primary stops responding.
|
||||
type HAHealthProber struct {
|
||||
state *State
|
||||
cfg types.HARouteConfig
|
||||
serverURL string
|
||||
isConnected func(types.NodeID) bool
|
||||
}
|
||||
|
||||
// NewHAHealthProber creates a prober that uses the given State for
|
||||
// ping tracking and primary route management.
|
||||
// isConnected should return true if a node has an active map session.
|
||||
func NewHAHealthProber(
|
||||
s *State,
|
||||
cfg types.HARouteConfig,
|
||||
serverURL string,
|
||||
isConnected func(types.NodeID) bool,
|
||||
) *HAHealthProber {
|
||||
return &HAHealthProber{
|
||||
state: s,
|
||||
cfg: cfg,
|
||||
serverURL: serverURL,
|
||||
isConnected: isConnected,
|
||||
}
|
||||
}
|
||||
|
||||
// ProbeOnce pings all HA subnet router nodes. PingNode changes are
|
||||
// dispatched immediately via dispatch so nodes can respond before the
|
||||
// timeout. Health-related policy changes are also dispatched inline.
|
||||
func (p *HAHealthProber) ProbeOnce(
|
||||
ctx context.Context,
|
||||
dispatch func(...change.Change),
|
||||
) {
|
||||
haNodes := p.state.nodeStore.HANodes()
|
||||
if len(haNodes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Deduplicate node IDs across prefixes.
|
||||
seen := make(set.Set[types.NodeID])
|
||||
|
||||
var nodeIDs []types.NodeID
|
||||
|
||||
for _, nodes := range haNodes {
|
||||
for _, id := range nodes {
|
||||
if !seen.Contains(id) {
|
||||
seen.Add(id)
|
||||
nodeIDs = append(nodeIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Int("haNodes", len(nodeIDs)).
|
||||
Msg("HA health prober starting probe cycle")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
deadline := time.After(p.cfg.ProbeTimeout)
|
||||
|
||||
for _, id := range nodeIDs {
|
||||
if !p.isConnected(id) {
|
||||
log.Debug().
|
||||
Uint64(zf.NodeID, id.Uint64()).
|
||||
Msg("HA probe: skipping offline node")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
pingID, responseCh := p.state.RegisterPing(id)
|
||||
callbackURL := p.serverURL + "/machine/ping-response?id=" + pingID
|
||||
|
||||
dispatch(change.PingNode(id, &tailcfg.PingRequest{
|
||||
URL: callbackURL,
|
||||
}))
|
||||
|
||||
wg.Go(func() {
|
||||
select {
|
||||
case latency := <-responseCh:
|
||||
log.Debug().
|
||||
Uint64(zf.NodeID, id.Uint64()).
|
||||
Dur("latency", latency).
|
||||
Msg("HA probe: node responded")
|
||||
|
||||
if p.state.SetNodeUnhealthy(id, false) {
|
||||
dispatch(change.PolicyChange())
|
||||
|
||||
log.Info().
|
||||
Uint64(zf.NodeID, id.Uint64()).
|
||||
Msg("HA probe: node recovered, recalculating primaries")
|
||||
}
|
||||
|
||||
case <-deadline:
|
||||
p.state.CancelPing(pingID)
|
||||
|
||||
if !p.isConnected(id) {
|
||||
log.Debug().
|
||||
Uint64(zf.NodeID, id.Uint64()).
|
||||
Msg("HA probe: node went offline during probe, skipping")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Warn().
|
||||
Uint64(zf.NodeID, id.Uint64()).
|
||||
Dur("timeout", p.cfg.ProbeTimeout).
|
||||
Msg("HA probe: node did not respond")
|
||||
|
||||
if p.state.SetNodeUnhealthy(id, true) {
|
||||
dispatch(change.PolicyChange())
|
||||
|
||||
log.Info().
|
||||
Uint64(zf.NodeID, id.Uint64()).
|
||||
Msg("HA probe: node unhealthy, triggering failover")
|
||||
}
|
||||
|
||||
case <-ctx.Done():
|
||||
p.state.CancelPing(pingID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
+16
-374
@@ -1,12 +1,8 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -14,22 +10,8 @@ import (
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/dnsname"
|
||||
)
|
||||
|
||||
// fallbackGivenName is the DNS label used when a node is written with
|
||||
// an empty GivenName. Matches Tailscale SaaS behaviour for empty
|
||||
// sanitised labels.
|
||||
const fallbackGivenName = "node"
|
||||
|
||||
// Errors returned by SetGivenName. ErrNodeNotFound is defined in
|
||||
// state.go and reused here.
|
||||
var (
|
||||
ErrGivenNameTaken = errors.New("given name already in use by another node")
|
||||
ErrGivenNameInvalid = errors.New("given name is not a valid DNS label")
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -37,7 +19,6 @@ const (
|
||||
del = 2
|
||||
update = 3
|
||||
rebuildPeerMaps = 4
|
||||
setName = 5
|
||||
)
|
||||
|
||||
const prometheusNamespace = "headscale"
|
||||
@@ -117,7 +98,7 @@ func NewNodeStore(allNodes types.Nodes, peersFunc PeersFunc, batchSize int, batc
|
||||
nodes[n.ID] = *n
|
||||
}
|
||||
|
||||
snap := snapshotFromNodes(nodes, peersFunc, nil)
|
||||
snap := snapshotFromNodes(nodes, peersFunc)
|
||||
|
||||
store := &NodeStore{
|
||||
peersFunc: peersFunc,
|
||||
@@ -147,12 +128,6 @@ type Snapshot struct {
|
||||
peersByNode map[types.NodeID][]types.NodeView
|
||||
nodesByUser map[types.UserID][]types.NodeView
|
||||
allNodes []types.NodeView
|
||||
|
||||
// routes maps each prefix to its current primary advertiser. The
|
||||
// previous assignment is carried over when still valid so the
|
||||
// primary does not flap on every unrelated batch.
|
||||
routes map[netip.Prefix]types.NodeID
|
||||
isPrimaryRoute map[types.NodeID]bool
|
||||
}
|
||||
|
||||
// PeersFunc is a function that takes a list of nodes and returns a map
|
||||
@@ -171,9 +146,6 @@ type work struct {
|
||||
nodeResult chan types.NodeView // Channel to return the resulting node after batch application
|
||||
// For rebuildPeerMaps operation
|
||||
rebuildResult chan struct{}
|
||||
// For setName operation (admin rename, reject-on-collision path).
|
||||
name string
|
||||
errResult chan error
|
||||
}
|
||||
|
||||
// PutNode adds or updates a node in the store.
|
||||
@@ -273,49 +245,6 @@ func (s *NodeStore) DeleteNode(id types.NodeID) {
|
||||
nodeStoreOperations.WithLabelValues("delete").Inc()
|
||||
}
|
||||
|
||||
// SetGivenName sets node.GivenName on the node identified by id,
|
||||
// rejecting the write if the name is already held by another node.
|
||||
// Intended for the admin rename path, where auto-bumping a
|
||||
// user-supplied name would be surprising.
|
||||
//
|
||||
// Returns:
|
||||
// - the stored NodeView and nil on success
|
||||
// - ErrGivenNameInvalid if name is not a valid DNS label
|
||||
// - ErrGivenNameTaken if another node already holds name
|
||||
// - ErrNodeNotFound if no node with id exists
|
||||
//
|
||||
// Runs as a single writer-goroutine op, so the uniqueness check and
|
||||
// the write are atomic with respect to concurrent PutNode/UpdateNode.
|
||||
func (s *NodeStore) SetGivenName(id types.NodeID, name string) (types.NodeView, error) {
|
||||
timer := prometheus.NewTimer(nodeStoreOperationDuration.WithLabelValues("set_name"))
|
||||
defer timer.ObserveDuration()
|
||||
|
||||
w := work{
|
||||
op: setName,
|
||||
nodeID: id,
|
||||
name: name,
|
||||
result: make(chan struct{}),
|
||||
nodeResult: make(chan types.NodeView, 1),
|
||||
errResult: make(chan error, 1),
|
||||
}
|
||||
|
||||
nodeStoreQueueDepth.Inc()
|
||||
|
||||
s.writeQueue <- w
|
||||
|
||||
<-w.result
|
||||
nodeStoreQueueDepth.Dec()
|
||||
|
||||
nodeStoreOperations.WithLabelValues("set_name").Inc()
|
||||
|
||||
err := <-w.errResult
|
||||
if err != nil {
|
||||
return types.NodeView{}, err
|
||||
}
|
||||
|
||||
return <-w.nodeResult, nil
|
||||
}
|
||||
|
||||
// Start initializes the NodeStore and starts processing the write queue.
|
||||
func (s *NodeStore) Start() {
|
||||
s.writeQueue = make(chan work)
|
||||
@@ -389,31 +318,18 @@ func (s *NodeStore) applyBatch(batch []work) {
|
||||
// Track rebuildPeerMaps operations
|
||||
var rebuildOps []*work
|
||||
|
||||
// setErrResults collects per-work errors from the setName path so
|
||||
// they can be delivered after the snapshot swap, together with the
|
||||
// NodeView for that work.
|
||||
setErrResults := make(map[*work]error)
|
||||
|
||||
for i := range batch {
|
||||
w := &batch[i]
|
||||
switch w.op {
|
||||
case put:
|
||||
n := w.node
|
||||
n.GivenName = resolveGivenName(nodes, n.ID, n.GivenName)
|
||||
|
||||
nodes[w.nodeID] = n
|
||||
nodes[w.nodeID] = w.node
|
||||
if w.nodeResult != nil {
|
||||
nodeResultRequests[w.nodeID] = append(nodeResultRequests[w.nodeID], w)
|
||||
}
|
||||
case update:
|
||||
// Update the specific node identified by nodeID
|
||||
if n, exists := nodes[w.nodeID]; exists {
|
||||
oldGivenName := n.GivenName
|
||||
w.updateFn(&n)
|
||||
|
||||
if n.GivenName != oldGivenName {
|
||||
n.GivenName = resolveGivenName(nodes, n.ID, n.GivenName)
|
||||
}
|
||||
nodes[w.nodeID] = n
|
||||
}
|
||||
|
||||
@@ -426,41 +342,6 @@ func (s *NodeStore) applyBatch(batch []work) {
|
||||
if w.nodeResult != nil {
|
||||
nodeResultRequests[w.nodeID] = append(nodeResultRequests[w.nodeID], w)
|
||||
}
|
||||
case setName:
|
||||
n, exists := nodes[w.nodeID]
|
||||
if !exists {
|
||||
setErrResults[w] = ErrNodeNotFound
|
||||
nodeResultRequests[w.nodeID] = append(nodeResultRequests[w.nodeID], w)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if dnsname.ValidLabel(w.name) != nil {
|
||||
setErrResults[w] = ErrGivenNameInvalid
|
||||
nodeResultRequests[w.nodeID] = append(nodeResultRequests[w.nodeID], w)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
taken := false
|
||||
|
||||
for id, other := range nodes {
|
||||
if id != w.nodeID && other.GivenName == w.name {
|
||||
taken = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if taken {
|
||||
setErrResults[w] = ErrGivenNameTaken
|
||||
nodeResultRequests[w.nodeID] = append(nodeResultRequests[w.nodeID], w)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
n.GivenName = w.name
|
||||
nodes[w.nodeID] = n
|
||||
nodeResultRequests[w.nodeID] = append(nodeResultRequests[w.nodeID], w)
|
||||
case rebuildPeerMaps:
|
||||
// rebuildPeerMaps doesn't modify nodes, it just forces the snapshot rebuild
|
||||
// below to recalculate peer relationships using the current peersFunc
|
||||
@@ -468,8 +349,7 @@ func (s *NodeStore) applyBatch(batch []work) {
|
||||
}
|
||||
}
|
||||
|
||||
prev := s.data.Load()
|
||||
newSnap := snapshotFromNodes(nodes, s.peersFunc, prev.routes)
|
||||
newSnap := snapshotFromNodes(nodes, s.peersFunc)
|
||||
s.data.Store(&newSnap)
|
||||
|
||||
// Update node count gauge
|
||||
@@ -483,12 +363,6 @@ func (s *NodeStore) applyBatch(batch []work) {
|
||||
w.nodeResult <- nodeView
|
||||
|
||||
close(w.nodeResult)
|
||||
|
||||
if w.errResult != nil {
|
||||
w.errResult <- setErrResults[w]
|
||||
|
||||
close(w.errResult)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Node was deleted or doesn't exist
|
||||
@@ -496,12 +370,6 @@ func (s *NodeStore) applyBatch(batch []work) {
|
||||
w.nodeResult <- types.NodeView{} // Send invalid view
|
||||
|
||||
close(w.nodeResult)
|
||||
|
||||
if w.errResult != nil {
|
||||
w.errResult <- setErrResults[w]
|
||||
|
||||
close(w.errResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -519,48 +387,12 @@ func (s *NodeStore) applyBatch(batch []work) {
|
||||
}
|
||||
}
|
||||
|
||||
// resolveGivenName returns a unique DNS label for the node identified
|
||||
// by self, based on the caller-supplied base label. If base is empty
|
||||
// it falls back to fallbackGivenName ("node"). The label's own holder
|
||||
// (self) is excluded from the collision scan so an idempotent write
|
||||
// keeps the current label.
|
||||
//
|
||||
// On collision the label is bumped as base, base-1, base-2, …, first
|
||||
// unused wins. Must be called from the NodeStore writer goroutine
|
||||
// (inside applyBatch) so the nodes map reflects all earlier ops in
|
||||
// the batch and no other writer can interleave.
|
||||
func resolveGivenName(nodes map[types.NodeID]types.Node, self types.NodeID, base string) string {
|
||||
if base == "" {
|
||||
base = fallbackGivenName
|
||||
}
|
||||
|
||||
taken := make(map[string]struct{}, len(nodes))
|
||||
for id, n := range nodes {
|
||||
if id == self {
|
||||
continue
|
||||
}
|
||||
|
||||
taken[n.GivenName] = struct{}{}
|
||||
}
|
||||
|
||||
candidate := base
|
||||
for i := 1; ; i++ {
|
||||
if _, busy := taken[candidate]; !busy {
|
||||
return candidate
|
||||
}
|
||||
|
||||
candidate = base + "-" + strconv.Itoa(i)
|
||||
}
|
||||
}
|
||||
|
||||
// snapshotFromNodes builds the index maps and primary-route table for
|
||||
// a new Snapshot. prevRoutes carries forward the previous primary
|
||||
// assignment so a still-valid choice survives unrelated batches.
|
||||
func snapshotFromNodes(
|
||||
nodes map[types.NodeID]types.Node,
|
||||
peersFunc PeersFunc,
|
||||
prevRoutes map[netip.Prefix]types.NodeID,
|
||||
) Snapshot {
|
||||
// snapshotFromNodes creates a new Snapshot from the provided nodes.
|
||||
// It builds a lot of "indexes" to make lookups fast for datasets we
|
||||
// that is used frequently, like nodesByNodeKey, peersByNode, and nodesByUser.
|
||||
// This is not a fast operation, it is the "slow" part of our copy-on-write
|
||||
// structure, but it allows us to have fast reads and efficient lookups.
|
||||
func snapshotFromNodes(nodes map[types.NodeID]types.Node, peersFunc PeersFunc) Snapshot {
|
||||
timer := prometheus.NewTimer(nodeStoreSnapshotBuildDuration)
|
||||
defer timer.ObserveDuration()
|
||||
|
||||
@@ -569,8 +401,6 @@ func snapshotFromNodes(
|
||||
allNodes = append(allNodes, n.View())
|
||||
}
|
||||
|
||||
routes, isPrimaryRoute := electPrimaryRoutes(nodes, prevRoutes)
|
||||
|
||||
newSnap := Snapshot{
|
||||
nodesByID: nodes,
|
||||
allNodes: allNodes,
|
||||
@@ -588,9 +418,6 @@ func snapshotFromNodes(
|
||||
return peersFunc(allNodes)
|
||||
}(),
|
||||
nodesByUser: make(map[types.UserID][]types.NodeView),
|
||||
|
||||
routes: routes,
|
||||
isPrimaryRoute: isPrimaryRoute,
|
||||
}
|
||||
|
||||
// Build nodesByUser, nodesByNodeKey, and nodesByMachineKey maps
|
||||
@@ -617,89 +444,6 @@ func snapshotFromNodes(
|
||||
return newSnap
|
||||
}
|
||||
|
||||
// electPrimaryRoutes picks the primary advertiser for each non-exit
|
||||
// prefix. The previous primary is preserved when it is still online
|
||||
// and healthy (anti-flap); otherwise the lowest-NodeID healthy
|
||||
// advertiser wins. When every advertiser is unhealthy the previous
|
||||
// primary is preserved if still a candidate, falling back to the
|
||||
// lowest-NodeID candidate so peers see *some* primary instead of
|
||||
// none. Anti-flap in the all-unhealthy case matters under cable-pull
|
||||
// where IsOnline lags reality and a naive lowest-ID fallback churns
|
||||
// primaries to a node that is itself unreachable (issue #3203).
|
||||
func electPrimaryRoutes(
|
||||
nodes map[types.NodeID]types.Node,
|
||||
prev map[netip.Prefix]types.NodeID,
|
||||
) (map[netip.Prefix]types.NodeID, map[types.NodeID]bool) {
|
||||
ids := make([]types.NodeID, 0, len(nodes))
|
||||
for id := range nodes {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
slices.Sort(ids)
|
||||
|
||||
advertisers := make(map[netip.Prefix][]types.NodeID)
|
||||
|
||||
for _, id := range ids {
|
||||
n := nodes[id]
|
||||
if n.IsOnline == nil || !*n.IsOnline {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, p := range n.AllApprovedRoutes() {
|
||||
if tsaddr.IsExitRoute(p) {
|
||||
continue
|
||||
}
|
||||
|
||||
advertisers[p] = append(advertisers[p], id)
|
||||
}
|
||||
}
|
||||
|
||||
routes := make(map[netip.Prefix]types.NodeID, len(advertisers))
|
||||
for prefix, candidates := range advertisers {
|
||||
if cur, ok := prev[prefix]; ok &&
|
||||
slices.Contains(candidates, cur) &&
|
||||
!nodes[cur].Unhealthy {
|
||||
routes[prefix] = cur
|
||||
continue
|
||||
}
|
||||
|
||||
var (
|
||||
selected types.NodeID
|
||||
found bool
|
||||
)
|
||||
|
||||
for _, c := range candidates {
|
||||
if !nodes[c].Unhealthy {
|
||||
selected = c
|
||||
found = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found && len(candidates) >= 1 {
|
||||
if cur, ok := prev[prefix]; ok && slices.Contains(candidates, cur) {
|
||||
selected = cur
|
||||
} else {
|
||||
selected = candidates[0]
|
||||
}
|
||||
|
||||
found = true
|
||||
}
|
||||
|
||||
if found {
|
||||
routes[prefix] = selected
|
||||
}
|
||||
}
|
||||
|
||||
isPrimaryRoute := make(map[types.NodeID]bool, len(routes))
|
||||
for _, id := range routes {
|
||||
isPrimaryRoute[id] = true
|
||||
}
|
||||
|
||||
return routes, isPrimaryRoute
|
||||
}
|
||||
|
||||
// GetNode retrieves a node by its ID.
|
||||
// The bool indicates if the node exists or is available (like "err not found").
|
||||
// The NodeView might be invalid, so it must be checked with .Valid(), which must be used to ensure
|
||||
@@ -782,8 +526,8 @@ func (s *NodeStore) DebugString() string {
|
||||
sb.WriteString("=== NodeStore Debug Information ===\n\n")
|
||||
|
||||
// Basic counts
|
||||
fmt.Fprintf(&sb, "Total Nodes: %d\n", len(snapshot.nodesByID))
|
||||
fmt.Fprintf(&sb, "Users with Nodes: %d\n", len(snapshot.nodesByUser))
|
||||
sb.WriteString(fmt.Sprintf("Total Nodes: %d\n", len(snapshot.nodesByID)))
|
||||
sb.WriteString(fmt.Sprintf("Users with Nodes: %d\n", len(snapshot.nodesByUser)))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// User distribution (shows internal UserID tracking, not display owner)
|
||||
@@ -797,7 +541,7 @@ func (s *NodeStore) DebugString() string {
|
||||
userName = nodes[0].User().Name()
|
||||
}
|
||||
|
||||
fmt.Fprintf(&sb, " - User %d (%s): %d nodes\n", userID, userName, len(nodes))
|
||||
sb.WriteString(fmt.Sprintf(" - User %d (%s): %d nodes\n", userID, userName, len(nodes)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,20 +557,20 @@ func (s *NodeStore) DebugString() string {
|
||||
|
||||
totalPeers += peerCount
|
||||
if node, exists := snapshot.nodesByID[nodeID]; exists {
|
||||
fmt.Fprintf(&sb, " - Node %d (%s): %d peers\n",
|
||||
nodeID, node.Hostname, peerCount)
|
||||
sb.WriteString(fmt.Sprintf(" - Node %d (%s): %d peers\n",
|
||||
nodeID, node.Hostname, peerCount))
|
||||
}
|
||||
}
|
||||
|
||||
if len(snapshot.peersByNode) > 0 {
|
||||
avgPeers := float64(totalPeers) / float64(len(snapshot.peersByNode))
|
||||
fmt.Fprintf(&sb, " - Average peers per node: %.1f\n", avgPeers)
|
||||
sb.WriteString(fmt.Sprintf(" - Average peers per node: %.1f\n", avgPeers))
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Node key index
|
||||
fmt.Fprintf(&sb, "NodeKey Index: %d entries\n", len(snapshot.nodesByNodeKey))
|
||||
sb.WriteString(fmt.Sprintf("NodeKey Index: %d entries\n", len(snapshot.nodesByNodeKey)))
|
||||
sb.WriteString("\n")
|
||||
|
||||
return sb.String()
|
||||
@@ -852,108 +596,6 @@ func (s *NodeStore) ListPeers(id types.NodeID) views.Slice[types.NodeView] {
|
||||
return views.SliceOf(s.data.Load().peersByNode[id])
|
||||
}
|
||||
|
||||
// PrimaryRouteFor returns the current primary advertiser for prefix.
|
||||
func (s *NodeStore) PrimaryRouteFor(prefix netip.Prefix) (types.NodeID, bool) {
|
||||
id, ok := s.data.Load().routes[prefix]
|
||||
return id, ok
|
||||
}
|
||||
|
||||
// PrimaryRoutesForNode returns the prefixes for which id is the current
|
||||
// primary advertiser.
|
||||
func (s *NodeStore) PrimaryRoutesForNode(id types.NodeID) []netip.Prefix {
|
||||
snap := s.data.Load()
|
||||
if !snap.isPrimaryRoute[id] {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]netip.Prefix, 0)
|
||||
|
||||
for prefix, nodeID := range snap.routes {
|
||||
if nodeID == id {
|
||||
out = append(out, prefix)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// HANodes returns the prefixes with two or more online advertisers, the
|
||||
// candidate set the HA prober needs to monitor.
|
||||
func (s *NodeStore) HANodes() map[netip.Prefix][]types.NodeID {
|
||||
snap := s.data.Load()
|
||||
|
||||
advertisers := make(map[netip.Prefix][]types.NodeID)
|
||||
|
||||
for id, n := range snap.nodesByID {
|
||||
if n.IsOnline == nil || !*n.IsOnline {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, p := range n.AllApprovedRoutes() {
|
||||
if tsaddr.IsExitRoute(p) {
|
||||
continue
|
||||
}
|
||||
|
||||
advertisers[p] = append(advertisers[p], id)
|
||||
}
|
||||
}
|
||||
|
||||
out := make(map[netip.Prefix][]types.NodeID)
|
||||
|
||||
for p, ids := range advertisers {
|
||||
if len(ids) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
slices.Sort(ids)
|
||||
out[p] = ids
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// IsNodeHealthy reports whether the HA prober considers id healthy.
|
||||
// Unknown nodes report healthy so absence does not exclude them from
|
||||
// election.
|
||||
func (s *NodeStore) IsNodeHealthy(id types.NodeID) bool {
|
||||
n, ok := s.data.Load().nodesByID[id]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
return !n.Unhealthy
|
||||
}
|
||||
|
||||
// PrimaryRoutes returns the snapshot's prefix→primary map. The map is
|
||||
// owned by the snapshot and must not be mutated; it is safe to read
|
||||
// concurrently because snapshots are immutable once published.
|
||||
func (s *NodeStore) PrimaryRoutes() map[netip.Prefix]types.NodeID {
|
||||
return s.data.Load().routes
|
||||
}
|
||||
|
||||
// PrimaryRoutesString renders the snapshot's prefix→primary map for
|
||||
// debug output and test diagnostics.
|
||||
func (s *NodeStore) PrimaryRoutesString() string {
|
||||
snap := s.data.Load()
|
||||
if len(snap.routes) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
prefixes := make([]netip.Prefix, 0, len(snap.routes))
|
||||
for p := range snap.routes {
|
||||
prefixes = append(prefixes, p)
|
||||
}
|
||||
|
||||
slices.SortFunc(prefixes, netip.Prefix.Compare)
|
||||
|
||||
var b strings.Builder
|
||||
for _, p := range prefixes {
|
||||
fmt.Fprintf(&b, "%s: %d\n", p, snap.routes[p])
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// RebuildPeerMaps rebuilds the peer relationship map using the current peersFunc.
|
||||
// This must be called after policy changes because peersFunc uses PolicyManager's
|
||||
// filters to determine which nodes can see each other. Without rebuilding, the
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestPutNodeGivenNameCollisionBumps is the reproduction for #3188.
|
||||
// Before the hostname-cleanroom rewrite, two nodes could be written
|
||||
// with the same GivenName, producing duplicates. After the rewrite,
|
||||
// the NodeStore writer goroutine detects GivenName collisions inside
|
||||
// applyBatch and appends -1, -2, … to make the label unique.
|
||||
func TestPutNodeGivenNameCollisionBumps(t *testing.T) {
|
||||
store := NewNodeStore(nil, allowAllPeersFunc, TestBatchSize, TestBatchTimeout)
|
||||
|
||||
store.Start()
|
||||
defer store.Stop()
|
||||
|
||||
n1 := createTestNode(1, 1, "alice", "laptop")
|
||||
n2 := createTestNode(2, 1, "alice", "laptop")
|
||||
n3 := createTestNode(3, 1, "alice", "laptop")
|
||||
|
||||
got1 := store.PutNode(n1)
|
||||
got2 := store.PutNode(n2)
|
||||
got3 := store.PutNode(n3)
|
||||
|
||||
require.Equal(t, "laptop", got1.GivenName(), "first registration keeps base label")
|
||||
require.Equal(t, "laptop-1", got2.GivenName(), "second registration bumps to -1")
|
||||
require.Equal(t, "laptop-2", got3.GivenName(), "third registration bumps to -2")
|
||||
}
|
||||
|
||||
// TestPutNodeEmptyGivenNameFallsBackToNode covers the SaaS rule that
|
||||
// an empty sanitised label becomes the literal "node". Subsequent
|
||||
// empty-label registrations bump as usual.
|
||||
func TestPutNodeEmptyGivenNameFallsBackToNode(t *testing.T) {
|
||||
store := NewNodeStore(nil, allowAllPeersFunc, TestBatchSize, TestBatchTimeout)
|
||||
|
||||
store.Start()
|
||||
defer store.Stop()
|
||||
|
||||
n1 := createTestNode(1, 1, "alice", "")
|
||||
n1.GivenName = ""
|
||||
n2 := createTestNode(2, 1, "alice", "")
|
||||
n2.GivenName = ""
|
||||
|
||||
got1 := store.PutNode(n1)
|
||||
got2 := store.PutNode(n2)
|
||||
|
||||
require.Equal(t, "node", got1.GivenName())
|
||||
require.Equal(t, "node-1", got2.GivenName())
|
||||
}
|
||||
|
||||
// TestPutNodeIdempotentKeepsLabel asserts that re-putting the same
|
||||
// node (same ID, same GivenName) does not bump its own label.
|
||||
func TestPutNodeIdempotentKeepsLabel(t *testing.T) {
|
||||
store := NewNodeStore(nil, allowAllPeersFunc, TestBatchSize, TestBatchTimeout)
|
||||
|
||||
store.Start()
|
||||
defer store.Stop()
|
||||
|
||||
n := createTestNode(1, 1, "alice", "laptop")
|
||||
first := store.PutNode(n)
|
||||
require.Equal(t, "laptop", first.GivenName())
|
||||
|
||||
second := store.PutNode(n)
|
||||
require.Equal(t, "laptop", second.GivenName(), "re-put of same node must not bump its own label")
|
||||
}
|
||||
|
||||
// TestUpdateNodeBumpsOnCollision asserts that UpdateNode also runs
|
||||
// the collision-bump branch when a callback rewrites GivenName to a
|
||||
// label held by another node.
|
||||
func TestUpdateNodeBumpsOnCollision(t *testing.T) {
|
||||
store := NewNodeStore(nil, allowAllPeersFunc, TestBatchSize, TestBatchTimeout)
|
||||
|
||||
store.Start()
|
||||
defer store.Stop()
|
||||
|
||||
store.PutNode(createTestNode(1, 1, "alice", "laptop"))
|
||||
store.PutNode(createTestNode(2, 1, "alice", "phone"))
|
||||
|
||||
view, ok := store.UpdateNode(2, func(n *types.Node) {
|
||||
n.GivenName = "laptop"
|
||||
})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "laptop-1", view.GivenName(), "UpdateNode must bump on collision")
|
||||
}
|
||||
|
||||
// TestConcurrentPutNodeSameGivenNameAllUnique is the race regression
|
||||
// for the plan's safety argument: N goroutines concurrently PutNode
|
||||
// with the same GivenName and distinct IDs. All N stored labels must
|
||||
// be unique (no two nodes holding the same GivenName).
|
||||
func TestConcurrentPutNodeSameGivenNameAllUnique(t *testing.T) {
|
||||
const N = 20
|
||||
|
||||
store := NewNodeStore(nil, allowAllPeersFunc, TestBatchSize, TestBatchTimeout)
|
||||
|
||||
store.Start()
|
||||
defer store.Stop()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
results := make(chan string, N)
|
||||
for i := range N {
|
||||
wg.Add(1)
|
||||
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
|
||||
n := createTestNode(types.NodeID(id+1), 1, "alice", "laptop") //nolint:gosec // test ids
|
||||
|
||||
view := store.PutNode(n)
|
||||
results <- view.GivenName()
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
seen := make(map[string]struct{}, N)
|
||||
for label := range results {
|
||||
if _, dup := seen[label]; dup {
|
||||
t.Fatalf("duplicate label %q across concurrent PutNode", label)
|
||||
}
|
||||
|
||||
seen[label] = struct{}{}
|
||||
}
|
||||
|
||||
require.Len(t, seen, N, "all concurrent PutNodes must land with unique labels")
|
||||
|
||||
for i := range N {
|
||||
want := "laptop"
|
||||
if i > 0 {
|
||||
want = fmt.Sprintf("laptop-%d", i)
|
||||
}
|
||||
|
||||
if _, ok := seen[want]; !ok {
|
||||
t.Errorf("expected label %q in result set, got %v", want, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetGivenNameSuccess renames a node to a free label and asserts
|
||||
// the NodeView reflects the new label.
|
||||
func TestSetGivenNameSuccess(t *testing.T) {
|
||||
store := NewNodeStore(nil, allowAllPeersFunc, TestBatchSize, TestBatchTimeout)
|
||||
|
||||
store.Start()
|
||||
defer store.Stop()
|
||||
|
||||
store.PutNode(createTestNode(1, 1, "alice", "laptop"))
|
||||
|
||||
view, err := store.SetGivenName(1, "workhorse")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "workhorse", view.GivenName())
|
||||
}
|
||||
|
||||
// TestSetGivenNameRejectsTaken refuses to rename a node to a label
|
||||
// held by a different node, leaving both labels unchanged.
|
||||
func TestSetGivenNameRejectsTaken(t *testing.T) {
|
||||
store := NewNodeStore(nil, allowAllPeersFunc, TestBatchSize, TestBatchTimeout)
|
||||
|
||||
store.Start()
|
||||
defer store.Stop()
|
||||
|
||||
store.PutNode(createTestNode(1, 1, "alice", "laptop"))
|
||||
store.PutNode(createTestNode(2, 1, "alice", "phone"))
|
||||
|
||||
_, err := store.SetGivenName(2, "laptop")
|
||||
require.ErrorIs(t, err, ErrGivenNameTaken)
|
||||
|
||||
view, _ := store.GetNode(2)
|
||||
require.Equal(t, "phone", view.GivenName(), "rejected rename must not mutate state")
|
||||
}
|
||||
|
||||
// TestSetGivenNameRejectsInvalid returns ErrGivenNameInvalid for
|
||||
// labels that are not valid DNS labels.
|
||||
func TestSetGivenNameRejectsInvalid(t *testing.T) {
|
||||
store := NewNodeStore(nil, allowAllPeersFunc, TestBatchSize, TestBatchTimeout)
|
||||
|
||||
store.Start()
|
||||
defer store.Stop()
|
||||
|
||||
store.PutNode(createTestNode(1, 1, "alice", "laptop"))
|
||||
|
||||
for _, bad := range []string{"Joe's Mac", "has space", "-leading", "trailing-", "", "dot.in.label"} {
|
||||
_, err := store.SetGivenName(1, bad)
|
||||
require.ErrorIsf(t, err, ErrGivenNameInvalid, "label %q must reject as invalid", bad)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetGivenNameRejectsMissingNode returns ErrNodeNotFound.
|
||||
func TestSetGivenNameRejectsMissingNode(t *testing.T) {
|
||||
store := NewNodeStore(nil, allowAllPeersFunc, TestBatchSize, TestBatchTimeout)
|
||||
|
||||
store.Start()
|
||||
defer store.Stop()
|
||||
|
||||
_, err := store.SetGivenName(999, "something")
|
||||
require.ErrorIs(t, err, ErrNodeNotFound, "got %v", err)
|
||||
}
|
||||
|
||||
// TestSetGivenNameIdempotent renaming a node to its own current label
|
||||
// succeeds (not a collision against itself).
|
||||
func TestSetGivenNameIdempotent(t *testing.T) {
|
||||
store := NewNodeStore(nil, allowAllPeersFunc, TestBatchSize, TestBatchTimeout)
|
||||
|
||||
store.Start()
|
||||
defer store.Stop()
|
||||
|
||||
store.PutNode(createTestNode(1, 1, "alice", "laptop"))
|
||||
|
||||
view, err := store.SetGivenName(1, "laptop")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "laptop", view.GivenName())
|
||||
}
|
||||
@@ -150,7 +150,7 @@ func TestSnapshotFromNodes(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
nodes, peersFunc := tt.setupFunc()
|
||||
snapshot := snapshotFromNodes(nodes, peersFunc, nil)
|
||||
snapshot := snapshotFromNodes(nodes, peersFunc)
|
||||
tt.validate(t, nodes, snapshot)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
)
|
||||
|
||||
const pingIDLength = 16
|
||||
|
||||
// pingTracker correlates outgoing PingRequests with incoming HEAD
|
||||
// callbacks. Entries have no server-side TTL: callers are responsible
|
||||
// for cleaning up via CancelPing or by reading from the response channel
|
||||
// within their own timeout.
|
||||
type pingTracker struct {
|
||||
mu sync.Mutex
|
||||
pending map[string]*pendingPing
|
||||
}
|
||||
|
||||
type pendingPing struct {
|
||||
nodeID types.NodeID
|
||||
startTime time.Time
|
||||
responseCh chan time.Duration
|
||||
}
|
||||
|
||||
func newPingTracker() *pingTracker {
|
||||
return &pingTracker{
|
||||
pending: make(map[string]*pendingPing),
|
||||
}
|
||||
}
|
||||
|
||||
// register creates a pending ping and returns a unique ping ID and a
|
||||
// channel that receives the round-trip latency once the response
|
||||
// arrives.
|
||||
func (pt *pingTracker) register(nodeID types.NodeID) (string, <-chan time.Duration) {
|
||||
pingID, _ := util.GenerateRandomStringDNSSafe(pingIDLength)
|
||||
ch := make(chan time.Duration, 1)
|
||||
|
||||
pt.mu.Lock()
|
||||
pt.pending[pingID] = &pendingPing{
|
||||
nodeID: nodeID,
|
||||
startTime: time.Now(),
|
||||
responseCh: ch,
|
||||
}
|
||||
pt.mu.Unlock()
|
||||
|
||||
return pingID, ch
|
||||
}
|
||||
|
||||
// complete sends the measured latency on the response channel and
|
||||
// returns true. Returns false if the pingID is unknown (already
|
||||
// completed or cancelled).
|
||||
func (pt *pingTracker) complete(pingID string) bool {
|
||||
pt.mu.Lock()
|
||||
|
||||
pp, ok := pt.pending[pingID]
|
||||
if ok {
|
||||
delete(pt.pending, pingID)
|
||||
}
|
||||
pt.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
pp.responseCh <- time.Since(pp.startTime)
|
||||
|
||||
close(pp.responseCh)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// cancel removes a pending ping without completing it. Idempotent.
|
||||
func (pt *pingTracker) cancel(pingID string) {
|
||||
pt.mu.Lock()
|
||||
delete(pt.pending, pingID)
|
||||
pt.mu.Unlock()
|
||||
}
|
||||
|
||||
// drain closes every outstanding response channel and clears the map.
|
||||
// Called from State.Close to unblock any caller still waiting on a
|
||||
// channel that will never receive.
|
||||
func (pt *pingTracker) drain() {
|
||||
pt.mu.Lock()
|
||||
defer pt.mu.Unlock()
|
||||
|
||||
for id, pp := range pt.pending {
|
||||
close(pp.responseCh)
|
||||
delete(pt.pending, id)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterPing tracks a pending ping and returns its ID and a channel
|
||||
// for the latency. Callers must defer CancelPing or read the channel
|
||||
// within their own timeout; there is no server-side TTL.
|
||||
func (s *State) RegisterPing(nodeID types.NodeID) (string, <-chan time.Duration) {
|
||||
return s.pings.register(nodeID)
|
||||
}
|
||||
|
||||
// CompletePing signals that a ping response arrived. Returns true if
|
||||
// the ID was known, false otherwise.
|
||||
func (s *State) CompletePing(pingID string) bool {
|
||||
return s.pings.complete(pingID)
|
||||
}
|
||||
|
||||
// CancelPing removes a pending ping. Idempotent.
|
||||
func (s *State) CancelPing(pingID string) {
|
||||
s.pings.cancel(pingID)
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPingTracker_RegisterComplete(t *testing.T) {
|
||||
pt := newPingTracker()
|
||||
|
||||
pingID, ch := pt.register(types.NodeID(1))
|
||||
assert.NotEmpty(t, pingID)
|
||||
|
||||
// Complete in a goroutine since it sends on the channel.
|
||||
go func() {
|
||||
assert.True(t, pt.complete(pingID))
|
||||
}()
|
||||
|
||||
select {
|
||||
case latency := <-ch:
|
||||
assert.GreaterOrEqual(t, latency, time.Duration(0), "latency should be non-negative")
|
||||
assert.Less(t, latency, 5*time.Second, "latency should be reasonable")
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for ping response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingTracker_CompleteUnknown(t *testing.T) {
|
||||
pt := newPingTracker()
|
||||
assert.False(t, pt.complete("nonexistent"))
|
||||
}
|
||||
|
||||
func TestPingTracker_CancelThenComplete(t *testing.T) {
|
||||
pt := newPingTracker()
|
||||
|
||||
pingID, ch := pt.register(types.NodeID(1))
|
||||
pt.cancel(pingID)
|
||||
assert.False(t, pt.complete(pingID))
|
||||
|
||||
// Channel should never receive.
|
||||
select {
|
||||
case <-ch:
|
||||
t.Fatal("channel should not receive after cancel")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
// Expected: no response.
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingTracker_DoubleComplete(t *testing.T) {
|
||||
pt := newPingTracker()
|
||||
|
||||
pingID, ch := pt.register(types.NodeID(1))
|
||||
assert.True(t, pt.complete(pingID))
|
||||
|
||||
// Drain the channel.
|
||||
<-ch
|
||||
|
||||
// Second complete should return false.
|
||||
assert.False(t, pt.complete(pingID))
|
||||
}
|
||||
|
||||
func TestPingTracker_ConcurrentDifferentIDs(t *testing.T) {
|
||||
pt := newPingTracker()
|
||||
|
||||
const count = 10
|
||||
|
||||
ids := make([]string, count)
|
||||
chs := make([]<-chan time.Duration, count)
|
||||
|
||||
for i := range count {
|
||||
ids[i], chs[i] = pt.register(types.NodeID(i + 1))
|
||||
}
|
||||
|
||||
// Complete in reverse order concurrently.
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := count - 1; i >= 0; i-- {
|
||||
wg.Add(1)
|
||||
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
|
||||
assert.True(t, pt.complete(ids[idx]))
|
||||
}(i)
|
||||
}
|
||||
|
||||
// All channels should receive.
|
||||
for i := range count {
|
||||
select {
|
||||
case latency := <-chs[i]:
|
||||
assert.GreaterOrEqual(t, latency, time.Duration(0))
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("timed out waiting for ping %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestPingTracker_TwoToSameNode(t *testing.T) {
|
||||
pt := newPingTracker()
|
||||
nodeID := types.NodeID(42)
|
||||
|
||||
id1, ch1 := pt.register(nodeID)
|
||||
id2, ch2 := pt.register(nodeID)
|
||||
|
||||
require.NotEqual(t, id1, id2, "ping IDs should be unique")
|
||||
|
||||
// Complete only the first.
|
||||
assert.True(t, pt.complete(id1))
|
||||
|
||||
select {
|
||||
case latency := <-ch1:
|
||||
assert.GreaterOrEqual(t, latency, time.Duration(0))
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for first ping")
|
||||
}
|
||||
|
||||
// Second should still be pending.
|
||||
select {
|
||||
case <-ch2:
|
||||
t.Fatal("second channel should not have received yet")
|
||||
default:
|
||||
// Expected.
|
||||
}
|
||||
|
||||
// Now complete the second.
|
||||
assert.True(t, pt.complete(id2))
|
||||
|
||||
select {
|
||||
case latency := <-ch2:
|
||||
assert.GreaterOrEqual(t, latency, time.Duration(0))
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for second ping")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingTracker_Drain(t *testing.T) {
|
||||
pt := newPingTracker()
|
||||
|
||||
_, ch1 := pt.register(types.NodeID(1))
|
||||
_, ch2 := pt.register(types.NodeID(2))
|
||||
|
||||
pt.drain()
|
||||
|
||||
// Drained channels must be closed so blocked readers unblock.
|
||||
for i, ch := range []<-chan time.Duration{ch1, ch2} {
|
||||
select {
|
||||
case _, ok := <-ch:
|
||||
assert.False(t, ok, "channel %d should be closed, got value", i)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("channel %d not closed by drain", i)
|
||||
}
|
||||
}
|
||||
|
||||
assert.Empty(t, pt.pending, "pending map should be empty after drain")
|
||||
}
|
||||
|
||||
func TestPingTracker_LatencyNonNegative(t *testing.T) {
|
||||
pt := newPingTracker()
|
||||
|
||||
pingID, ch := pt.register(types.NodeID(1))
|
||||
assert.True(t, pt.complete(pingID))
|
||||
|
||||
select {
|
||||
case latency := <-ch:
|
||||
assert.GreaterOrEqual(t, latency, time.Duration(0), "latency should be non-negative")
|
||||
assert.Less(t, latency, 5*time.Second, "latency should be reasonable")
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out")
|
||||
}
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"pgregory.net/rapid"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
// model mirrors the expected primary-route assignment given the
|
||||
// operations applied so far. It is intentionally simple — close to
|
||||
// what computePrimaries (node_store.go) prescribes — so divergence
|
||||
// between the model and the snapshot's primaries map flags a bug in
|
||||
// the algorithm.
|
||||
//
|
||||
// The model lives in the test file because the algorithm under test
|
||||
// is the one inside snapshotFromNodes; the model's job is to predict
|
||||
// the same answer from a separate, deliberately direct implementation.
|
||||
type primariesModel struct {
|
||||
connected map[types.NodeID]bool
|
||||
prefixes map[types.NodeID][]netip.Prefix
|
||||
unhealthy map[types.NodeID]bool
|
||||
|
||||
// primary[p] is the current primary for prefix p. The
|
||||
// implementation preserves the current primary across changes to
|
||||
// avoid flapping, so the model has to track this across
|
||||
// operations rather than recompute a fresh choice each time.
|
||||
primary map[netip.Prefix]types.NodeID
|
||||
}
|
||||
|
||||
func newPrimariesModel() *primariesModel {
|
||||
return &primariesModel{
|
||||
connected: map[types.NodeID]bool{},
|
||||
prefixes: map[types.NodeID][]netip.Prefix{},
|
||||
unhealthy: map[types.NodeID]bool{},
|
||||
primary: map[netip.Prefix]types.NodeID{},
|
||||
}
|
||||
}
|
||||
|
||||
// advertisersByPrefix returns the connected nodes that announce each
|
||||
// prefix, sorted by NodeID (matches computePrimaries' iteration).
|
||||
func (m *primariesModel) advertisersByPrefix() map[netip.Prefix][]types.NodeID {
|
||||
out := map[netip.Prefix][]types.NodeID{}
|
||||
|
||||
for n, prefs := range m.prefixes {
|
||||
if !m.connected[n] {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, p := range prefs {
|
||||
out[p] = append(out[p], n)
|
||||
}
|
||||
}
|
||||
|
||||
for _, nodes := range out {
|
||||
slices.Sort(nodes)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// updatePrimaries reapplies the algorithm to recompute the primary
|
||||
// for each prefix. Called after every operation.
|
||||
func (m *primariesModel) updatePrimaries() {
|
||||
advertisers := m.advertisersByPrefix()
|
||||
|
||||
// Drop primaries for prefixes that no longer have any advertiser.
|
||||
for p := range m.primary {
|
||||
if _, ok := advertisers[p]; !ok {
|
||||
delete(m.primary, p)
|
||||
}
|
||||
}
|
||||
|
||||
for p, nodes := range advertisers {
|
||||
if cur, ok := m.primary[p]; ok {
|
||||
if slices.Contains(nodes, cur) && !m.unhealthy[cur] {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
selected types.NodeID
|
||||
found bool
|
||||
)
|
||||
|
||||
for _, n := range nodes {
|
||||
if !m.unhealthy[n] {
|
||||
selected = n
|
||||
found = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found && len(nodes) >= 1 {
|
||||
if cur, ok := m.primary[p]; ok && slices.Contains(nodes, cur) {
|
||||
selected = cur
|
||||
} else {
|
||||
selected = nodes[0]
|
||||
}
|
||||
|
||||
found = true
|
||||
}
|
||||
|
||||
if found {
|
||||
m.primary[p] = selected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// allPrefixes returns every prefix mentioned by any connected node.
|
||||
func (m *primariesModel) allPrefixes() []netip.Prefix {
|
||||
seen := map[netip.Prefix]bool{}
|
||||
|
||||
for n, prefs := range m.prefixes {
|
||||
if !m.connected[n] {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, p := range prefs {
|
||||
seen[p] = true
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]netip.Prefix, 0, len(seen))
|
||||
for p := range seen {
|
||||
out = append(out, p)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func samePrefixSet(a, b []netip.Prefix) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
|
||||
aa := slices.Clone(a)
|
||||
bb := slices.Clone(b)
|
||||
|
||||
slices.SortFunc(aa, netip.Prefix.Compare)
|
||||
slices.SortFunc(bb, netip.Prefix.Compare)
|
||||
|
||||
return slices.Equal(aa, bb)
|
||||
}
|
||||
|
||||
// checkInvariants asserts every property we expect of the snapshot's
|
||||
// primaries map given the model.
|
||||
func checkPrimariesInvariants(rt *rapid.T, ns *NodeStore, m *primariesModel, nodeIDs []types.NodeID) {
|
||||
rt.Helper()
|
||||
|
||||
expectedByNode := map[types.NodeID][]netip.Prefix{}
|
||||
for p, owner := range m.primary {
|
||||
expectedByNode[owner] = append(expectedByNode[owner], p)
|
||||
}
|
||||
|
||||
for _, id := range nodeIDs {
|
||||
got := ns.PrimaryRoutesForNode(id)
|
||||
want := expectedByNode[id]
|
||||
|
||||
if !samePrefixSet(got, want) {
|
||||
rt.Fatalf(
|
||||
"PrimaryRoutesForNode(%d) = %v, model expected %v",
|
||||
id, got, want,
|
||||
)
|
||||
}
|
||||
|
||||
if want := !m.unhealthy[id]; ns.IsNodeHealthy(id) != want {
|
||||
rt.Fatalf(
|
||||
"IsNodeHealthy(%d) = %v, want %v",
|
||||
id, ns.IsNodeHealthy(id), want,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Every prefix that has at least one connected advertiser must
|
||||
// have a primary in the snapshot. Issue #3203 manifests as a
|
||||
// prefix silently losing its primary after a disconnect/reconnect
|
||||
// cycle.
|
||||
for _, p := range m.allPrefixes() {
|
||||
want, expectExists := m.primary[p]
|
||||
if !expectExists {
|
||||
continue
|
||||
}
|
||||
|
||||
got, ok := ns.PrimaryRouteFor(p)
|
||||
if !ok {
|
||||
rt.Fatalf(
|
||||
"prefix %s has at least one advertiser in the model but no primary in NodeStore",
|
||||
p,
|
||||
)
|
||||
}
|
||||
|
||||
if want != got {
|
||||
rt.Fatalf(
|
||||
"prefix %s: snapshot primary = %d, model expected %d",
|
||||
p, got, want,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nodeForRapid builds a minimal types.Node for use in property
|
||||
// tests. Tests drive (IsOnline, Hostinfo.RoutableIPs, ApprovedRoutes,
|
||||
// Unhealthy) via UpdateNode; the rest stays fixed.
|
||||
func nodeForRapid(id types.NodeID) types.Node {
|
||||
mk := key.NewMachine()
|
||||
nk := key.NewNode()
|
||||
|
||||
return types.Node{
|
||||
ID: id,
|
||||
Hostname: fmt.Sprintf("rapid-%d", id),
|
||||
MachineKey: mk.Public(),
|
||||
NodeKey: nk.Public(),
|
||||
UserID: new(uint(1)),
|
||||
User: &types.User{Name: "rapid"},
|
||||
IsOnline: new(false),
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrimaryRoutesProperty drives NodeStore with a randomised
|
||||
// sequence of high-level operations and checks that the snapshot's
|
||||
// primaries map matches a reference model after every step.
|
||||
//
|
||||
// Background: issue #3203 reports that HA tracking enters a stuck
|
||||
// state after a sequence of disconnect/reconnect events. The narrow
|
||||
// integration and servertest reproductions written for the bug do
|
||||
// not fail on upstream/main, so this property test broadens the
|
||||
// search by letting rapid generate sequences we have not enumerated
|
||||
// by hand.
|
||||
func TestPrimaryRoutesProperty(t *testing.T) {
|
||||
rapid.Check(t, func(rt *rapid.T) {
|
||||
const numNodes = 4
|
||||
|
||||
nodeIDs := make([]types.NodeID, 0, numNodes)
|
||||
for i := 1; i <= numNodes; i++ {
|
||||
nodeIDs = append(nodeIDs, types.NodeID(i))
|
||||
}
|
||||
|
||||
prefixes := []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/24"),
|
||||
netip.MustParsePrefix("10.0.1.0/24"),
|
||||
}
|
||||
|
||||
ns := NewNodeStore(nil, allowAllPeersFunc, TestBatchSize, TestBatchTimeout)
|
||||
|
||||
ns.Start()
|
||||
defer ns.Stop()
|
||||
|
||||
for _, id := range nodeIDs {
|
||||
ns.PutNode(nodeForRapid(id))
|
||||
}
|
||||
|
||||
m := newPrimariesModel()
|
||||
|
||||
nodeGen := rapid.SampledFrom(nodeIDs)
|
||||
prefixSubsetGen := rapid.SliceOfNDistinct(
|
||||
rapid.SampledFrom(prefixes),
|
||||
0, len(prefixes),
|
||||
func(p netip.Prefix) string { return p.String() },
|
||||
)
|
||||
|
||||
opCount := rapid.IntRange(5, 60).Draw(rt, "opCount")
|
||||
for step := range opCount {
|
||||
op := rapid.IntRange(0, 4).Draw(rt, fmt.Sprintf("op_%d", step))
|
||||
id := nodeGen.Draw(rt, fmt.Sprintf("id_%d", step))
|
||||
|
||||
switch op {
|
||||
case 0: // ConnectAdvertise — Connect path clears Unhealthy.
|
||||
prefs := prefixSubsetGen.Draw(rt, fmt.Sprintf("prefs_%d", step))
|
||||
|
||||
ns.UpdateNode(id, func(n *types.Node) {
|
||||
n.IsOnline = new(true)
|
||||
n.Unhealthy = false
|
||||
n.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: prefs}
|
||||
n.ApprovedRoutes = prefs
|
||||
})
|
||||
|
||||
if len(prefs) == 0 {
|
||||
delete(m.connected, id)
|
||||
delete(m.prefixes, id)
|
||||
} else {
|
||||
m.connected[id] = true
|
||||
m.prefixes[id] = prefs
|
||||
}
|
||||
|
||||
delete(m.unhealthy, id)
|
||||
|
||||
case 1: // Disconnect — IsOnline=false; ApprovedRoutes persists.
|
||||
ns.UpdateNode(id, func(n *types.Node) {
|
||||
n.IsOnline = new(false)
|
||||
})
|
||||
delete(m.connected, id)
|
||||
|
||||
case 2: // ProbeUnhealthy — HA prober marks node bad.
|
||||
ns.UpdateNode(id, func(n *types.Node) {
|
||||
n.Unhealthy = true
|
||||
})
|
||||
m.unhealthy[id] = true
|
||||
|
||||
case 3: // ProbeHealthy — HA prober marks node good.
|
||||
ns.UpdateNode(id, func(n *types.Node) {
|
||||
n.Unhealthy = false
|
||||
})
|
||||
delete(m.unhealthy, id)
|
||||
|
||||
case 4: // ApprovedRoutesChange — change advertised prefs without touching health.
|
||||
prefs := prefixSubsetGen.Draw(rt, fmt.Sprintf("prefs_%d", step))
|
||||
|
||||
ns.UpdateNode(id, func(n *types.Node) {
|
||||
n.IsOnline = new(true)
|
||||
n.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: prefs}
|
||||
n.ApprovedRoutes = prefs
|
||||
})
|
||||
|
||||
if len(prefs) == 0 {
|
||||
delete(m.connected, id)
|
||||
delete(m.prefixes, id)
|
||||
} else {
|
||||
m.connected[id] = true
|
||||
m.prefixes[id] = prefs
|
||||
}
|
||||
}
|
||||
|
||||
m.updatePrimaries()
|
||||
|
||||
checkPrimariesInvariants(rt, ns, m, nodeIDs)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// mp wraps netip.MustParsePrefix.
|
||||
func mp(prefix string) netip.Prefix {
|
||||
return netip.MustParsePrefix(prefix)
|
||||
}
|
||||
|
||||
// primariesFixture builds a NodeStore with the requested node IDs
|
||||
// pre-registered (offline, no routes) and provides terse helpers for
|
||||
// driving the kinds of state transitions the algorithm cares about.
|
||||
type primariesFixture struct {
|
||||
t *testing.T
|
||||
ns *NodeStore
|
||||
}
|
||||
|
||||
func newPrimariesFixture(t *testing.T, ids ...types.NodeID) *primariesFixture {
|
||||
t.Helper()
|
||||
|
||||
ns := NewNodeStore(nil, allowAllPeersFunc, TestBatchSize, TestBatchTimeout)
|
||||
ns.Start()
|
||||
t.Cleanup(ns.Stop)
|
||||
|
||||
for _, id := range ids {
|
||||
ns.PutNode(nodeForRapid(id))
|
||||
}
|
||||
|
||||
return &primariesFixture{t: t, ns: ns}
|
||||
}
|
||||
|
||||
// advertise mirrors State.Connect: marks the node online, clears
|
||||
// Unhealthy, and sets approved + announced routes to prefs. An empty
|
||||
// prefs argument leaves the node online but advertising nothing.
|
||||
func (f *primariesFixture) advertise(id types.NodeID, prefs ...netip.Prefix) {
|
||||
f.t.Helper()
|
||||
f.ns.UpdateNode(id, func(n *types.Node) {
|
||||
n.IsOnline = new(true)
|
||||
n.Unhealthy = false
|
||||
n.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: prefs}
|
||||
n.ApprovedRoutes = prefs
|
||||
})
|
||||
}
|
||||
|
||||
// approveRoutes mirrors State.SetApprovedRoutes / Hostinfo updates:
|
||||
// it changes the node's announced + approved set without touching
|
||||
// Unhealthy.
|
||||
func (f *primariesFixture) approveRoutes(id types.NodeID, prefs ...netip.Prefix) {
|
||||
f.t.Helper()
|
||||
f.ns.UpdateNode(id, func(n *types.Node) {
|
||||
n.IsOnline = new(true)
|
||||
n.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: prefs}
|
||||
n.ApprovedRoutes = prefs
|
||||
})
|
||||
}
|
||||
|
||||
// disconnect mirrors State.Disconnect: marks the node offline. The
|
||||
// snapshot rebuild treats an offline node as a non-advertiser.
|
||||
func (f *primariesFixture) disconnect(id types.NodeID) {
|
||||
f.t.Helper()
|
||||
f.ns.UpdateNode(id, func(n *types.Node) {
|
||||
n.IsOnline = new(false)
|
||||
})
|
||||
}
|
||||
|
||||
// unhealthy mirrors State.SetNodeUnhealthy(id, true).
|
||||
func (f *primariesFixture) unhealthy(id types.NodeID) {
|
||||
f.t.Helper()
|
||||
f.ns.UpdateNode(id, func(n *types.Node) {
|
||||
n.Unhealthy = true
|
||||
})
|
||||
}
|
||||
|
||||
// healthy mirrors State.SetNodeUnhealthy(id, false).
|
||||
func (f *primariesFixture) healthy(id types.NodeID) {
|
||||
f.t.Helper()
|
||||
f.ns.UpdateNode(id, func(n *types.Node) {
|
||||
n.Unhealthy = false
|
||||
})
|
||||
}
|
||||
|
||||
// requirePrimary asserts that prefix has node id as its primary.
|
||||
func (f *primariesFixture) requirePrimary(prefix netip.Prefix, id types.NodeID) {
|
||||
f.t.Helper()
|
||||
got, ok := f.ns.PrimaryRouteFor(prefix)
|
||||
require.True(f.t, ok, "expected a primary for %s, got none", prefix)
|
||||
require.Equal(f.t, id, got, "primary for %s", prefix)
|
||||
}
|
||||
|
||||
// requireNoPrimary asserts that prefix has no primary at all.
|
||||
func (f *primariesFixture) requireNoPrimary(prefix netip.Prefix) {
|
||||
f.t.Helper()
|
||||
_, ok := f.ns.PrimaryRouteFor(prefix)
|
||||
require.False(f.t, ok, "expected no primary for %s", prefix)
|
||||
}
|
||||
|
||||
// requireNodeRoutes asserts the set of prefixes for which id is the
|
||||
// primary, regardless of order.
|
||||
func (f *primariesFixture) requireNodeRoutes(id types.NodeID, want ...netip.Prefix) {
|
||||
f.t.Helper()
|
||||
got := f.ns.PrimaryRoutesForNode(id)
|
||||
|
||||
gotSorted := slices.Clone(got)
|
||||
wantSorted := slices.Clone(want)
|
||||
|
||||
slices.SortFunc(gotSorted, netip.Prefix.Compare)
|
||||
slices.SortFunc(wantSorted, netip.Prefix.Compare)
|
||||
|
||||
require.Equal(f.t, wantSorted, gotSorted, "primary routes for node %d", id)
|
||||
}
|
||||
|
||||
func TestPrimaries_SingleNodeSingleRoute(t *testing.T) {
|
||||
f := newPrimariesFixture(t, 1)
|
||||
f.advertise(1, mp("192.168.1.0/24"))
|
||||
|
||||
f.requirePrimary(mp("192.168.1.0/24"), 1)
|
||||
f.requireNodeRoutes(1, mp("192.168.1.0/24"))
|
||||
}
|
||||
|
||||
func TestPrimaries_TwoNodesDifferentRoutes(t *testing.T) {
|
||||
f := newPrimariesFixture(t, 1, 2)
|
||||
f.advertise(1, mp("192.168.1.0/24"))
|
||||
f.advertise(2, mp("192.168.2.0/24"))
|
||||
|
||||
f.requirePrimary(mp("192.168.1.0/24"), 1)
|
||||
f.requirePrimary(mp("192.168.2.0/24"), 2)
|
||||
}
|
||||
|
||||
func TestPrimaries_OverlappingRoutesLowerIDWins(t *testing.T) {
|
||||
f := newPrimariesFixture(t, 1, 2)
|
||||
f.advertise(1, mp("192.168.1.0/24"))
|
||||
f.advertise(2, mp("192.168.1.0/24"))
|
||||
|
||||
f.requirePrimary(mp("192.168.1.0/24"), 1)
|
||||
f.requireNodeRoutes(1, mp("192.168.1.0/24"))
|
||||
f.requireNodeRoutes(2)
|
||||
}
|
||||
|
||||
func TestPrimaries_AntiFlapPreservesCurrentPrimary(t *testing.T) {
|
||||
// A primary that disappears (advertiser leaves the set) should
|
||||
// trigger failover. When the original primary returns, the new
|
||||
// primary keeps the assignment — anti-flap.
|
||||
f := newPrimariesFixture(t, 1, 2)
|
||||
f.advertise(1, mp("192.168.1.0/24"))
|
||||
f.advertise(2, mp("192.168.1.0/24"))
|
||||
f.requirePrimary(mp("192.168.1.0/24"), 1)
|
||||
|
||||
f.disconnect(1)
|
||||
f.requirePrimary(mp("192.168.1.0/24"), 2)
|
||||
|
||||
f.advertise(1, mp("192.168.1.0/24"))
|
||||
f.requirePrimary(mp("192.168.1.0/24"), 2)
|
||||
}
|
||||
|
||||
func TestPrimaries_ClearRoutesDropsPrimary(t *testing.T) {
|
||||
f := newPrimariesFixture(t, 1)
|
||||
f.advertise(1, mp("192.168.1.0/24"))
|
||||
f.requirePrimary(mp("192.168.1.0/24"), 1)
|
||||
|
||||
f.approveRoutes(1)
|
||||
f.requireNoPrimary(mp("192.168.1.0/24"))
|
||||
}
|
||||
|
||||
func TestPrimaries_DisconnectDropsLastAdvertiserPrimary(t *testing.T) {
|
||||
f := newPrimariesFixture(t, 1)
|
||||
f.advertise(1, mp("192.168.1.0/24"))
|
||||
f.requirePrimary(mp("192.168.1.0/24"), 1)
|
||||
|
||||
f.disconnect(1)
|
||||
f.requireNoPrimary(mp("192.168.1.0/24"))
|
||||
}
|
||||
|
||||
func TestPrimaries_UnhealthyTriggersFailover(t *testing.T) {
|
||||
f := newPrimariesFixture(t, 1, 2)
|
||||
f.advertise(1, mp("192.168.1.0/24"))
|
||||
f.advertise(2, mp("192.168.1.0/24"))
|
||||
f.requirePrimary(mp("192.168.1.0/24"), 1)
|
||||
|
||||
f.unhealthy(1)
|
||||
f.requirePrimary(mp("192.168.1.0/24"), 2)
|
||||
}
|
||||
|
||||
func TestPrimaries_RecoveryFromUnhealthyNoFlap(t *testing.T) {
|
||||
f := newPrimariesFixture(t, 1, 2)
|
||||
f.advertise(1, mp("192.168.1.0/24"))
|
||||
f.advertise(2, mp("192.168.1.0/24"))
|
||||
f.unhealthy(1)
|
||||
f.requirePrimary(mp("192.168.1.0/24"), 2)
|
||||
|
||||
f.healthy(1)
|
||||
f.requirePrimary(mp("192.168.1.0/24"), 2)
|
||||
}
|
||||
|
||||
func TestPrimaries_AllUnhealthyKeepsAPrimary(t *testing.T) {
|
||||
// Anti-blackhole: when every advertiser is unhealthy the
|
||||
// algorithm keeps *some* primary so peers can recover once one
|
||||
// flips healthy. The specific node is the prev primary when
|
||||
// reachable (see PreservesPrevious); this test only pins the
|
||||
// existence invariant.
|
||||
prefix := mp("192.168.1.0/24")
|
||||
f := newPrimariesFixture(t, 1, 2)
|
||||
f.advertise(1, prefix)
|
||||
f.advertise(2, prefix)
|
||||
f.unhealthy(1)
|
||||
f.unhealthy(2)
|
||||
|
||||
_, ok := f.ns.PrimaryRouteFor(prefix)
|
||||
require.True(t, ok, "all-unhealthy must still produce some primary")
|
||||
}
|
||||
|
||||
func TestPrimaries_AllUnhealthyPreservesPrevious(t *testing.T) {
|
||||
// Issue #3203: once a failover has moved primary to a higher-ID
|
||||
// node, a subsequent all-unhealthy state must NOT churn primary
|
||||
// back to the lowest-ID candidate. Under cable-pull semantics
|
||||
// both nodes can linger as IsOnline=true (half-open TCP) and
|
||||
// both go Unhealthy — naive `candidates[0]` would flap the
|
||||
// primary to a node that is itself unreachable.
|
||||
prefix := mp("10.0.0.0/24")
|
||||
f := newPrimariesFixture(t, 1, 2)
|
||||
f.advertise(1, prefix)
|
||||
f.advertise(2, prefix)
|
||||
f.requirePrimary(prefix, 1)
|
||||
|
||||
f.unhealthy(1)
|
||||
f.requirePrimary(prefix, 2)
|
||||
|
||||
f.unhealthy(2)
|
||||
f.requirePrimary(prefix, 2)
|
||||
}
|
||||
|
||||
func TestPrimaries_ExitRouteNotElected(t *testing.T) {
|
||||
// Exit routes (0.0.0.0/0, ::/0) are not subject to HA primary
|
||||
// election — every approved exit-route advertiser keeps it.
|
||||
f := newPrimariesFixture(t, 1)
|
||||
exitV4 := mp("0.0.0.0/0")
|
||||
f.advertise(1, exitV4)
|
||||
|
||||
f.requireNoPrimary(exitV4)
|
||||
}
|
||||
|
||||
func TestPrimaries_RegressionIssue3203_BothOfflineThenOneReturns(t *testing.T) {
|
||||
// Issue #3203: with two HA advertisers, dropping both then
|
||||
// bringing one back used to leave the prefix without any
|
||||
// primary. After the refactor the snapshot recomputes primaries
|
||||
// on every NodeStore write, so the returning advertiser must
|
||||
// be elected.
|
||||
prefix := mp("10.0.0.0/24")
|
||||
f := newPrimariesFixture(t, 1, 2)
|
||||
f.advertise(1, prefix)
|
||||
f.advertise(2, prefix)
|
||||
f.requirePrimary(prefix, 1)
|
||||
|
||||
f.disconnect(1)
|
||||
f.requirePrimary(prefix, 2)
|
||||
|
||||
f.disconnect(2)
|
||||
f.requireNoPrimary(prefix)
|
||||
|
||||
f.advertise(2, prefix)
|
||||
f.requirePrimary(prefix, 2)
|
||||
}
|
||||
|
||||
func TestPrimaries_HANodes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*primariesFixture)
|
||||
want map[netip.Prefix][]types.NodeID
|
||||
}{
|
||||
{
|
||||
name: "single-node-not-ha",
|
||||
setup: func(f *primariesFixture) {
|
||||
f.advertise(1, mp("192.168.1.0/24"))
|
||||
},
|
||||
want: map[netip.Prefix][]types.NodeID{},
|
||||
},
|
||||
{
|
||||
name: "two-nodes-same-prefix-is-ha",
|
||||
setup: func(f *primariesFixture) {
|
||||
f.advertise(1, mp("192.168.1.0/24"))
|
||||
f.advertise(2, mp("192.168.1.0/24"))
|
||||
},
|
||||
want: map[netip.Prefix][]types.NodeID{
|
||||
mp("192.168.1.0/24"): {1, 2},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "two-nodes-different-prefixes-not-ha",
|
||||
setup: func(f *primariesFixture) {
|
||||
f.advertise(1, mp("192.168.1.0/24"))
|
||||
f.advertise(2, mp("192.168.2.0/24"))
|
||||
},
|
||||
want: map[netip.Prefix][]types.NodeID{},
|
||||
},
|
||||
{
|
||||
name: "three-nodes-two-share-prefix",
|
||||
setup: func(f *primariesFixture) {
|
||||
f.advertise(1, mp("192.168.1.0/24"))
|
||||
f.advertise(2, mp("192.168.1.0/24"))
|
||||
f.advertise(3, mp("10.0.0.0/8"))
|
||||
},
|
||||
want: map[netip.Prefix][]types.NodeID{
|
||||
mp("192.168.1.0/24"): {1, 2},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "three-nodes-all-share",
|
||||
setup: func(f *primariesFixture) {
|
||||
f.advertise(1, mp("192.168.1.0/24"))
|
||||
f.advertise(2, mp("192.168.1.0/24"))
|
||||
f.advertise(3, mp("192.168.1.0/24"))
|
||||
},
|
||||
want: map[netip.Prefix][]types.NodeID{
|
||||
mp("192.168.1.0/24"): {1, 2, 3},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
setup: func(*primariesFixture) {
|
||||
},
|
||||
want: map[netip.Prefix][]types.NodeID{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newPrimariesFixture(t, 1, 2, 3)
|
||||
tt.setup(f)
|
||||
|
||||
got := f.ns.HANodes()
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
+211
-311
@@ -1,12 +1,5 @@
|
||||
// Package state provides core state management for Headscale,
|
||||
// coordinating between subsystems like database, IP allocation,
|
||||
// policy management, and DERP routing.
|
||||
//
|
||||
// The central type State owns a copy-on-write NodeStore
|
||||
// (node_store.go), a PrimaryRoutes HA ledger, the PolicyManager, and a
|
||||
// pingTracker for PingRequest correlation. Cross-subsystem operations
|
||||
// (node updates, policy evaluation, IP allocation) go through State
|
||||
// rather than directly to the database.
|
||||
// Package state provides core state management for Headscale, coordinating
|
||||
// between subsystems like database, IP allocation, policy management, and DERP routing.
|
||||
|
||||
package state
|
||||
|
||||
@@ -15,10 +8,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -28,6 +19,7 @@ import (
|
||||
hsdb "github.com/juanfont/headscale/hscontrol/db"
|
||||
"github.com/juanfont/headscale/hscontrol/policy"
|
||||
"github.com/juanfont/headscale/hscontrol/policy/matcher"
|
||||
"github.com/juanfont/headscale/hscontrol/routes"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/types/change"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
@@ -39,7 +31,6 @@ import (
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/dnsname"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -146,8 +137,14 @@ type State struct {
|
||||
// via the eviction callback so any waiting goroutines wake.
|
||||
authCache *expirable.LRU[types.AuthID, *types.AuthRequest]
|
||||
|
||||
// pings tracks pending ping requests and their response channels.
|
||||
pings *pingTracker
|
||||
// primaryRoutes tracks primary route assignments for nodes
|
||||
primaryRoutes *routes.PrimaryRoutes
|
||||
|
||||
// connectGen tracks a per-node monotonic generation counter so stale
|
||||
// Disconnect() calls from old poll sessions are rejected. Connect()
|
||||
// increments the counter and returns the current value; Disconnect()
|
||||
// only proceeds when the generation it carries matches the latest.
|
||||
connectGen sync.Map // types.NodeID → *atomic.Uint64
|
||||
|
||||
// sshCheckAuth tracks when source nodes last completed SSH check auth.
|
||||
//
|
||||
@@ -155,7 +152,7 @@ type State struct {
|
||||
// destination — keyed by (src, Dst=0) where 0 is a sentinel meaning "any".
|
||||
// Ref: "Once re-authenticated to a destination, the user can access the
|
||||
// device and any other device in the tailnet without re-verification
|
||||
// for the next 12 hours." — https://tailscale.com/docs/features/tailscale-ssh
|
||||
// for the next 12 hours." — https://tailscale.com/kb/1193/tailscale-ssh
|
||||
//
|
||||
// For rules with explicit checkPeriod, auth covers only that specific
|
||||
// destination — keyed by (src, dst).
|
||||
@@ -253,12 +250,12 @@ func NewState(cfg *types.Config) (*State, error) {
|
||||
return &State{
|
||||
cfg: cfg,
|
||||
|
||||
db: db,
|
||||
ipAlloc: ipAlloc,
|
||||
polMan: polMan,
|
||||
authCache: authCache,
|
||||
nodeStore: nodeStore,
|
||||
pings: newPingTracker(),
|
||||
db: db,
|
||||
ipAlloc: ipAlloc,
|
||||
polMan: polMan,
|
||||
authCache: authCache,
|
||||
primaryRoutes: routes.New(),
|
||||
nodeStore: nodeStore,
|
||||
|
||||
sshCheckAuth: make(map[sshCheckPair]time.Time),
|
||||
}, nil
|
||||
@@ -266,7 +263,6 @@ func NewState(cfg *types.Config) (*State, error) {
|
||||
|
||||
// Close gracefully shuts down the State instance and releases all resources.
|
||||
func (s *State) Close() error {
|
||||
s.pings.drain()
|
||||
s.nodeStore.Stop()
|
||||
|
||||
err := s.db.Close()
|
||||
@@ -549,88 +545,127 @@ func (s *State) DeleteNode(node types.NodeView) (change.Change, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Connect marks a node connected and returns the resulting changes
|
||||
// plus a session epoch. The caller must pass the epoch back to
|
||||
// Disconnect so deferred grace-period disconnects from a previous
|
||||
// poll session are dropped (see poll.go).
|
||||
// Connect marks a node as connected and updates its primary routes in the state.
|
||||
// It returns the list of changes and a generation number. The generation number
|
||||
// must be passed to Disconnect() so that stale disconnects from old poll sessions
|
||||
// are rejected (see the grace period logic in poll.go).
|
||||
func (s *State) Connect(id types.NodeID) ([]change.Change, uint64) {
|
||||
prevRoutes := s.nodeStore.PrimaryRoutes()
|
||||
// Increment the connect generation for this node. This ensures that any
|
||||
// in-flight Disconnect() from a previous session will see a stale generation
|
||||
// and become a no-op.
|
||||
gen := s.nextConnectGen(id)
|
||||
|
||||
// Reconnecting clears Unhealthy: the node just proved basic
|
||||
// connectivity by completing the Noise handshake.
|
||||
var epoch uint64
|
||||
// Update online status in NodeStore before creating change notification
|
||||
// so the NodeStore already reflects the correct state when other nodes
|
||||
// process the NodeCameOnline change for full map generation.
|
||||
node, ok := s.nodeStore.UpdateNode(id, func(n *types.Node) {
|
||||
n.SessionEpoch++
|
||||
epoch = n.SessionEpoch
|
||||
n.IsOnline = new(true)
|
||||
n.Unhealthy = false
|
||||
// n.LastSeen = ptr.To(now)
|
||||
})
|
||||
if !ok {
|
||||
return nil, 0
|
||||
return nil, gen
|
||||
}
|
||||
|
||||
c := []change.Change{change.NodeOnlineFor(node)}
|
||||
|
||||
log.Info().EmbedObject(node).Msg("node connected")
|
||||
|
||||
if !maps.Equal(prevRoutes, s.nodeStore.PrimaryRoutes()) {
|
||||
// Use the node's current routes for primary route update.
|
||||
// AllApprovedRoutes() returns only the intersection of announced and approved routes.
|
||||
routeChange := s.primaryRoutes.SetRoutes(id, node.AllApprovedRoutes()...)
|
||||
|
||||
if routeChange {
|
||||
c = append(c, change.NodeAdded(id))
|
||||
}
|
||||
|
||||
// Coming online may re-enable cap/relay grants and identity-based
|
||||
// aliases targeting this node, so peers need a fresh netmap.
|
||||
// Mirror Disconnect: a node coming online may (re)enable cap/relay
|
||||
// grants targeting it, reintroduce identity-based aliases that
|
||||
// resolve to its tags/IPs, and so on. Always trigger a PolicyChange
|
||||
// so peers can recompute their netmap and pick up any policy
|
||||
// elements that depend on this node being present.
|
||||
c = append(c, change.PolicyChange())
|
||||
|
||||
return c, epoch
|
||||
return c, gen
|
||||
}
|
||||
|
||||
// Disconnect marks the node offline. epoch must match the value
|
||||
// Connect returned for this session; otherwise the call no-ops so a
|
||||
// deferred disconnect from an older session cannot overwrite state set
|
||||
// by a newer Connect. The check and the IsOnline write share an
|
||||
// UpdateNode closure, making them atomic against concurrent Connects.
|
||||
func (s *State) Disconnect(id types.NodeID, epoch uint64) ([]change.Change, error) {
|
||||
var stale bool
|
||||
// nextConnectGen atomically increments and returns the connect generation for a node.
|
||||
func (s *State) nextConnectGen(id types.NodeID) uint64 {
|
||||
val, _ := s.connectGen.LoadOrStore(id, &atomic.Uint64{})
|
||||
|
||||
counter, ok := val.(*atomic.Uint64)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
|
||||
return counter.Add(1)
|
||||
}
|
||||
|
||||
// connectGeneration returns the current connect generation for a node.
|
||||
func (s *State) connectGeneration(id types.NodeID) uint64 {
|
||||
val, ok := s.connectGen.Load(id)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
|
||||
counter, ok := val.(*atomic.Uint64)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
|
||||
return counter.Load()
|
||||
}
|
||||
|
||||
// Disconnect marks a node as disconnected and updates its primary routes in the state.
|
||||
// The gen parameter is the generation returned by Connect(). If a newer Connect() has
|
||||
// been called since the session that is disconnecting, the generation will not match
|
||||
// and this call becomes a no-op, preventing stale disconnects from overwriting the
|
||||
// online status set by a newer session.
|
||||
func (s *State) Disconnect(id types.NodeID, gen uint64) ([]change.Change, error) {
|
||||
// Check if this disconnect is stale. A newer Connect() will have incremented
|
||||
// the generation, so if ours doesn't match, a newer session owns this node.
|
||||
if current := s.connectGeneration(id); current != gen {
|
||||
log.Debug().
|
||||
Uint64("disconnect_gen", gen).
|
||||
Uint64("current_gen", current).
|
||||
Msg("stale disconnect rejected, newer session active")
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
node, ok := s.nodeStore.UpdateNode(id, func(n *types.Node) {
|
||||
if n.SessionEpoch != epoch {
|
||||
stale = true
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
n.LastSeen = &now
|
||||
// NodeStore is the source of truth for all node state including online status.
|
||||
n.IsOnline = new(false)
|
||||
// Offline nodes are not HA candidates; drop any stale
|
||||
// Unhealthy bit so it does not surface in DebugRoutes.
|
||||
n.Unhealthy = false
|
||||
})
|
||||
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %d", ErrNodeNotFound, id)
|
||||
}
|
||||
|
||||
if stale {
|
||||
log.Debug().
|
||||
Uint64("disconnect_epoch", epoch).
|
||||
Uint64("current_epoch", node.SessionEpoch()).
|
||||
Msg("stale disconnect rejected, newer session active")
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
log.Info().EmbedObject(node).Msg("node disconnected")
|
||||
|
||||
// Persist LastSeen best-effort: NodeStore already reflects offline
|
||||
// and peers still need the change notifications below.
|
||||
// Special error handling for disconnect - we log errors but continue
|
||||
// because NodeStore is already updated and we need to notify peers
|
||||
_, c, err := s.persistNodeToDB(node)
|
||||
if err != nil {
|
||||
// Log error but don't fail the disconnection - NodeStore is already updated
|
||||
// and we need to send change notifications to peers
|
||||
log.Error().Err(err).EmbedObject(node).Msg("failed to update last seen in database")
|
||||
|
||||
c = change.Change{}
|
||||
}
|
||||
|
||||
// Going offline can affect policy compilation beyond subnet routes
|
||||
// (cap/relay grants, tag/group aliases, via routes), so peers need
|
||||
// a fresh netmap regardless of whether the primary moved.
|
||||
// The node is disconnecting so make sure that none of the routes it
|
||||
// announced are served to any nodes.
|
||||
s.primaryRoutes.SetRoutes(id)
|
||||
|
||||
// A node going offline can affect policy compilation in ways beyond
|
||||
// subnet routes: cap/relay grants targeting this node, identity-based
|
||||
// aliases (tags, groups, users) that reference its tags/IPs, via
|
||||
// routes steered through it, and so on. Always trigger a PolicyChange
|
||||
// so peers receive a recomputed netmap and drop any cached state
|
||||
// derived from this node (including peer relay allocations).
|
||||
//
|
||||
// TODO(kradalby): fires one full netmap recompute per peer on
|
||||
// every connect/disconnect. Coalesce in mapper/batcher.go:addToBatch.
|
||||
@@ -664,59 +699,6 @@ func (s *State) GetNodeByMachineKey(machineKey key.MachinePublic, userID types.U
|
||||
return s.nodeStore.GetNodeByMachineKey(machineKey, userID)
|
||||
}
|
||||
|
||||
// ResolveNode looks up a node by numeric ID, IPv4/IPv6 address, given
|
||||
// name, or hostname. It tries ID first, then IP, then GivenName
|
||||
// (unique per tailnet), then Hostname (client-reported, may collide).
|
||||
// Within the name passes, the lowest NodeID wins so repeated calls
|
||||
// are deterministic across snapshot iterations.
|
||||
func (s *State) ResolveNode(query string) (types.NodeView, bool) {
|
||||
// Try numeric ID first.
|
||||
id, idErr := types.ParseNodeID(query)
|
||||
if idErr == nil {
|
||||
return s.GetNodeByID(id)
|
||||
}
|
||||
|
||||
// Try IP address.
|
||||
addr, addrErr := netip.ParseAddr(query)
|
||||
if addrErr == nil {
|
||||
var match types.NodeView
|
||||
|
||||
for _, n := range s.ListNodes().All() {
|
||||
if !slices.Contains(n.IPs(), addr) {
|
||||
continue
|
||||
}
|
||||
|
||||
if !match.Valid() || n.ID() < match.ID() {
|
||||
match = n
|
||||
}
|
||||
}
|
||||
|
||||
return match, match.Valid()
|
||||
}
|
||||
|
||||
// Try GivenName then Hostname, each with a stable tie-break on
|
||||
// lowest NodeID.
|
||||
var givenMatch, hostMatch types.NodeView
|
||||
|
||||
for _, n := range s.ListNodes().All() {
|
||||
if n.GivenName() == query {
|
||||
if !givenMatch.Valid() || n.ID() < givenMatch.ID() {
|
||||
givenMatch = n
|
||||
}
|
||||
} else if n.Hostname() == query {
|
||||
if !hostMatch.Valid() || n.ID() < hostMatch.ID() {
|
||||
hostMatch = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if givenMatch.Valid() {
|
||||
return givenMatch, true
|
||||
}
|
||||
|
||||
return hostMatch, hostMatch.Valid()
|
||||
}
|
||||
|
||||
// ListNodes retrieves specific nodes by ID, or all nodes if no IDs provided.
|
||||
func (s *State) ListNodes(nodeIDs ...types.NodeID) views.Slice[types.NodeView] {
|
||||
if len(nodeIDs) == 0 {
|
||||
@@ -898,16 +880,8 @@ func (s *State) SetApprovedRoutes(nodeID types.NodeID, routes []netip.Prefix) (t
|
||||
// TODO(kradalby): In principle we should call the AutoApprove logic here
|
||||
// because even if the CLI removes an auto-approved route, it will be added
|
||||
// back automatically.
|
||||
prevRoutes := s.nodeStore.PrimaryRoutes()
|
||||
|
||||
n, ok := s.nodeStore.UpdateNode(nodeID, func(node *types.Node) {
|
||||
node.ApprovedRoutes = routes
|
||||
// A node with no approved routes is no longer an HA
|
||||
// candidate; drop any stale Unhealthy bit (mirrors the
|
||||
// legacy routes.SetRoutes(empty) auto-clear).
|
||||
if len(node.AllApprovedRoutes()) == 0 {
|
||||
node.Unhealthy = false
|
||||
}
|
||||
})
|
||||
|
||||
if !ok {
|
||||
@@ -920,9 +894,13 @@ func (s *State) SetApprovedRoutes(nodeID types.NodeID, routes []netip.Prefix) (t
|
||||
return types.NodeView{}, change.Change{}, err
|
||||
}
|
||||
|
||||
// PolicyChange fans out a fresh netmap whenever the new approved
|
||||
// set shifted a primary advertiser.
|
||||
routeChange := !maps.Equal(prevRoutes, s.nodeStore.PrimaryRoutes())
|
||||
// Update primary routes table based on SubnetRoutes (intersection of announced and approved).
|
||||
// The primary routes table is what the mapper uses to generate network maps, so updating it
|
||||
// here ensures that route changes are distributed to peers.
|
||||
routeChange := s.primaryRoutes.SetRoutes(nodeID, nodeView.AllApprovedRoutes()...)
|
||||
|
||||
// If routes changed or the changeset isn't already a full update, trigger a policy change
|
||||
// to ensure all nodes get updated network maps
|
||||
if routeChange || !c.IsFull() {
|
||||
c = change.PolicyChange()
|
||||
}
|
||||
@@ -930,29 +908,34 @@ func (s *State) SetApprovedRoutes(nodeID types.NodeID, routes []netip.Prefix) (t
|
||||
return nodeView, c, nil
|
||||
}
|
||||
|
||||
// RenameNode changes the display name of a node. The admin supplies
|
||||
// the exact DNS label they want; malformed input is rejected (no
|
||||
// auto-sanitisation) and collisions error out rather than silently
|
||||
// bumping a user-facing label. See HOSTNAME.md for the CLI contract.
|
||||
// RenameNode changes the display name of a node.
|
||||
func (s *State) RenameNode(nodeID types.NodeID, newName string) (types.NodeView, change.Change, error) {
|
||||
err := dnsname.ValidLabel(newName)
|
||||
err := util.ValidateHostname(newName)
|
||||
if err != nil {
|
||||
return types.NodeView{}, change.Change{}, fmt.Errorf("renaming node: %w", err)
|
||||
}
|
||||
|
||||
view, err := s.nodeStore.SetGivenName(nodeID, newName)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ErrGivenNameTaken):
|
||||
// Check name uniqueness against NodeStore
|
||||
allNodes := s.nodeStore.ListNodes()
|
||||
for i := range allNodes.Len() {
|
||||
node := allNodes.At(i)
|
||||
if node.ID() != nodeID && node.AsStruct().GivenName == newName {
|
||||
return types.NodeView{}, change.Change{}, fmt.Errorf("%w: %s", ErrNodeNameNotUnique, newName)
|
||||
case errors.Is(err, ErrNodeNotFound):
|
||||
return types.NodeView{}, change.Change{}, fmt.Errorf("%w: %d", ErrNodeNotInNodeStore, nodeID)
|
||||
default:
|
||||
return types.NodeView{}, change.Change{}, fmt.Errorf("renaming node: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return s.persistNodeToDB(view)
|
||||
// Update NodeStore before database to ensure consistency. The NodeStore update is
|
||||
// blocking and will be the source of truth for the batcher. The database update must
|
||||
// make the exact same change.
|
||||
n, ok := s.nodeStore.UpdateNode(nodeID, func(node *types.Node) {
|
||||
node.GivenName = newName
|
||||
})
|
||||
|
||||
if !ok {
|
||||
return types.NodeView{}, change.Change{}, fmt.Errorf("%w: %d", ErrNodeNotInNodeStore, nodeID)
|
||||
}
|
||||
|
||||
return s.persistNodeToDB(n)
|
||||
}
|
||||
|
||||
// BackfillNodeIPs assigns IP addresses to nodes that don't have them.
|
||||
@@ -1108,9 +1091,20 @@ func (s *State) SetPolicyInDB(data string) (*types.Policy, error) {
|
||||
return s.db.SetPolicy(data)
|
||||
}
|
||||
|
||||
// SetNodeRoutes sets the primary routes for a node.
|
||||
func (s *State) SetNodeRoutes(nodeID types.NodeID, routes ...netip.Prefix) change.Change {
|
||||
if s.primaryRoutes.SetRoutes(nodeID, routes...) {
|
||||
// Route changes affect packet filters for all nodes, so trigger a policy change
|
||||
// to ensure filters are regenerated across the entire network
|
||||
return change.PolicyChange()
|
||||
}
|
||||
|
||||
return change.Change{}
|
||||
}
|
||||
|
||||
// GetNodePrimaryRoutes returns the primary routes for a node.
|
||||
func (s *State) GetNodePrimaryRoutes(nodeID types.NodeID) []netip.Prefix {
|
||||
return s.nodeStore.PrimaryRoutesForNode(nodeID)
|
||||
return s.primaryRoutes.PrimaryRoutes(nodeID)
|
||||
}
|
||||
|
||||
// RoutesForPeer computes the routes a peer should advertise in a viewer's
|
||||
@@ -1118,114 +1112,50 @@ func (s *State) GetNodePrimaryRoutes(nodeID types.NodeID) []netip.Prefix {
|
||||
// routes, and via grant steering.
|
||||
//
|
||||
// Approved exit routes (0.0.0.0/0, ::/0) are included alongside subnet
|
||||
// routes — they appear in every peer's AllowedIPs, while unapproved
|
||||
// ones do not.
|
||||
// routes. SaaS golden data (routes-ea* captures) confirms that approved
|
||||
// exit routes appear in every peer's AllowedIPs, while unapproved ones
|
||||
// (routes-b9) do not. TestRoutesCompatPeerAllowedIPs validates this
|
||||
// against all captured netmaps.
|
||||
func (s *State) RoutesForPeer(
|
||||
viewer, peer types.NodeView,
|
||||
matchers []matcher.Match,
|
||||
) []netip.Prefix {
|
||||
viaResult := s.polMan.ViaRoutesForPeer(viewer, peer)
|
||||
globalPrimaries := s.nodeStore.PrimaryRoutesForNode(peer.ID())
|
||||
globalPrimaries := s.primaryRoutes.PrimaryRoutes(peer.ID())
|
||||
exitRoutes := peer.ExitRoutes()
|
||||
|
||||
var reduced []netip.Prefix
|
||||
|
||||
// Fast path: no via grants affect this pair.
|
||||
if len(viaResult.Include) == 0 && len(viaResult.Exclude) == 0 {
|
||||
allRoutes := slices.Concat(globalPrimaries, exitRoutes)
|
||||
|
||||
reduced = policy.ReduceRoutes(viewer, allRoutes, matchers)
|
||||
} else {
|
||||
// Slow path: drop excluded routes, reduce, append via-included.
|
||||
routes := make([]netip.Prefix, 0, len(globalPrimaries)+len(exitRoutes))
|
||||
for _, p := range slices.Concat(globalPrimaries, exitRoutes) {
|
||||
if !slices.Contains(viaResult.Exclude, p) {
|
||||
routes = append(routes, p)
|
||||
}
|
||||
}
|
||||
return policy.ReduceRoutes(viewer, allRoutes, matchers)
|
||||
}
|
||||
|
||||
reduced = policy.ReduceRoutes(viewer, routes, matchers)
|
||||
|
||||
// Append via-included routes. The via grant IS the authorization
|
||||
// (no matcher filter needed), but HA primary election applies
|
||||
// when a regular (non-via) grant also covers the same prefix.
|
||||
//
|
||||
// Rules:
|
||||
// - Peer is HA primary → always include
|
||||
// - Peer is NOT primary, no regular grant → include
|
||||
// (per-viewer via steering)
|
||||
// - Peer is NOT primary, regular grant exists → exclude
|
||||
// (HA primary wins)
|
||||
for _, p := range viaResult.Include {
|
||||
if slices.Contains(reduced, p) {
|
||||
continue
|
||||
}
|
||||
|
||||
if slices.Contains(globalPrimaries, p) {
|
||||
reduced = append(reduced, p)
|
||||
} else if !slices.Contains(viaResult.UsePrimary, p) {
|
||||
reduced = append(reduced, p)
|
||||
}
|
||||
// Slow path: drop excluded routes, reduce, append via-included.
|
||||
routes := make([]netip.Prefix, 0, len(globalPrimaries)+len(exitRoutes))
|
||||
for _, p := range slices.Concat(globalPrimaries, exitRoutes) {
|
||||
if !slices.Contains(viaResult.Exclude, p) {
|
||||
routes = append(routes, p)
|
||||
}
|
||||
}
|
||||
|
||||
// Co-router visibility: when the viewer advertises the same prefix
|
||||
// that the peer is HA primary for, the viewer must see that route
|
||||
// regardless of matcher authorization. HA secondaries need this to
|
||||
// know which peer is primary for their shared prefix.
|
||||
viewerSubnets := viewer.SubnetRoutes()
|
||||
if len(viewerSubnets) > 0 {
|
||||
for _, p := range globalPrimaries {
|
||||
if slices.Contains(viewerSubnets, p) && !slices.Contains(reduced, p) {
|
||||
reduced = append(reduced, p)
|
||||
}
|
||||
reduced := policy.ReduceRoutes(viewer, routes, matchers)
|
||||
|
||||
// Append via-included routes directly — the via grant IS the
|
||||
// authorization, so these must not be filtered by the viewer's
|
||||
// matchers.
|
||||
for _, p := range viaResult.Include {
|
||||
if !slices.Contains(reduced, p) {
|
||||
reduced = append(reduced, p)
|
||||
}
|
||||
}
|
||||
|
||||
return reduced
|
||||
}
|
||||
|
||||
// PrimaryRoutesString renders the current prefix→primary assignment
|
||||
// for diagnostics.
|
||||
// PrimaryRoutesString returns a string representation of all primary routes.
|
||||
func (s *State) PrimaryRoutesString() string {
|
||||
return s.nodeStore.PrimaryRoutesString()
|
||||
}
|
||||
|
||||
// IsNodeHealthy reports the HA prober's view of id. Unknown nodes
|
||||
// report healthy.
|
||||
func (s *State) IsNodeHealthy(id types.NodeID) bool {
|
||||
return s.nodeStore.IsNodeHealthy(id)
|
||||
}
|
||||
|
||||
// SetNodeUnhealthy flips the runtime Unhealthy bit and reports whether
|
||||
// the resulting primary route assignment changed, so the HA prober can
|
||||
// decide whether to fan out a PolicyChange.
|
||||
//
|
||||
// A request to mark a node Unhealthy is dropped if the node is no
|
||||
// longer an HA candidate (offline or no approved routes). The prober
|
||||
// reads HANodes() at the start of a probe cycle and writes back after
|
||||
// the timeout fires; in that window the node may have left the
|
||||
// candidate set, and the bit would just be stale. The check happens
|
||||
// inside the writer goroutine so it serialises against the
|
||||
// SetApprovedRoutes / Disconnect that removed candidacy.
|
||||
func (s *State) SetNodeUnhealthy(id types.NodeID, unhealthy bool) bool {
|
||||
prevRoutes := s.nodeStore.PrimaryRoutes()
|
||||
|
||||
_, ok := s.nodeStore.UpdateNode(id, func(n *types.Node) {
|
||||
if unhealthy {
|
||||
online := n.IsOnline != nil && *n.IsOnline
|
||||
if !online || len(n.AllApprovedRoutes()) == 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
n.Unhealthy = unhealthy
|
||||
})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return !maps.Equal(prevRoutes, s.nodeStore.PrimaryRoutes())
|
||||
return s.primaryRoutes.String()
|
||||
}
|
||||
|
||||
// ValidateAPIKey checks if an API key is valid and active.
|
||||
@@ -1694,11 +1624,14 @@ func (s *State) createAndSaveNewNode(params newNodeParams) (types.NodeView, erro
|
||||
nodeToRegister.IPv4 = ipv4
|
||||
nodeToRegister.IPv6 = ipv6
|
||||
|
||||
// Seed GivenName from the sanitised raw hostname. NodeStore.PutNode
|
||||
// bumps on collision and falls back to "node" if the sanitised
|
||||
// result is empty (pure non-ASCII / punctuation input).
|
||||
// Ensure unique given name if not set
|
||||
if nodeToRegister.GivenName == "" {
|
||||
nodeToRegister.GivenName = dnsname.SanitizeHostname(nodeToRegister.Hostname)
|
||||
givenName, err := hsdb.EnsureUniqueGivenName(s.db.DB, nodeToRegister.Hostname)
|
||||
if err != nil {
|
||||
return types.NodeView{}, fmt.Errorf("ensuring unique given name: %w", err)
|
||||
}
|
||||
|
||||
nodeToRegister.GivenName = givenName
|
||||
}
|
||||
|
||||
// New node - database first to get ID, then NodeStore
|
||||
@@ -1861,12 +1794,10 @@ func (s *State) HandleNodeFromAuthPath(
|
||||
// the initial Hostinfo from the cached client-supplied Hostinfo (or
|
||||
// an empty stub if the client did not send one).
|
||||
hostname := regData.Hostname
|
||||
|
||||
hostinfo := &tailcfg.Hostinfo{}
|
||||
if regData.Hostinfo != nil {
|
||||
hostinfo = regData.Hostinfo.Clone()
|
||||
}
|
||||
|
||||
hostinfo.Hostname = hostname
|
||||
|
||||
// Lookup existing nodes
|
||||
@@ -2088,13 +2019,12 @@ func (s *State) HandleNodeFromPreAuthKey(
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve the raw hostname as reported by the client. Sanitisation
|
||||
// for the DNS label lives on node.GivenName, not on node.Hostname;
|
||||
// see HOSTNAME.md.
|
||||
var hostname string
|
||||
if regReq.Hostinfo != nil {
|
||||
hostname = regReq.Hostinfo.Hostname
|
||||
}
|
||||
// Ensure we have a valid hostname - handle nil/empty cases
|
||||
hostname := util.EnsureHostname(
|
||||
regReq.Hostinfo.View(),
|
||||
machineKey.String(),
|
||||
regReq.NodeKey.String(),
|
||||
)
|
||||
|
||||
// Ensure we have valid hostinfo
|
||||
validHostinfo := cmp.Or(regReq.Hostinfo, &tailcfg.Hostinfo{})
|
||||
@@ -2401,33 +2331,7 @@ func (s *State) autoApproveNodes() ([]change.Change, error) {
|
||||
return cs, nil
|
||||
}
|
||||
|
||||
// isAutoDerivedGivenName reports whether given matches what
|
||||
// dnsname.SanitizeHostname(hostname) would produce, optionally with a
|
||||
// NodeStore collision-bump "-N" suffix. It is used to detect whether a
|
||||
// GivenName has been admin-renamed (in which case it must not be
|
||||
// overwritten by client-side hostname changes).
|
||||
func isAutoDerivedGivenName(given, hostname string) bool {
|
||||
base := dnsname.SanitizeHostname(hostname)
|
||||
if given == base {
|
||||
return true
|
||||
}
|
||||
|
||||
suffix, ok := strings.CutPrefix(given, base+"-")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
_, err := strconv.Atoi(suffix)
|
||||
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// UpdateNodeFromMapRequest is the sync point where Hostinfo changes,
|
||||
// endpoint updates, and route advertisements from a MapRequest land in
|
||||
// the NodeStore. It produces a change.Change summarising what actually
|
||||
// moved so downstream subsystems (mapper, policy, primary routes) can
|
||||
// react accordingly.
|
||||
//
|
||||
// UpdateNodeFromMapRequest processes a MapRequest and updates the node.
|
||||
// TODO(kradalby): This is essentially a patch update that could be sent directly to nodes,
|
||||
// which means we could shortcut the whole change thing if there are no other important updates.
|
||||
// When a field is added to this function, remember to also add it to:
|
||||
@@ -2449,10 +2353,6 @@ func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest
|
||||
endpointChanged bool
|
||||
derpChanged bool
|
||||
)
|
||||
// Snapshot the primary assignment so we can tell whether the
|
||||
// Hostinfo + auto-approval that follows shifted any prefix.
|
||||
prevRoutes := s.nodeStore.PrimaryRoutes()
|
||||
|
||||
// We need to ensure we update the node as it is in the NodeStore at
|
||||
// the time of the request.
|
||||
updatedNode, ok := s.nodeStore.UpdateNode(id, func(currentNode *types.Node) {
|
||||
@@ -2533,18 +2433,7 @@ func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest
|
||||
// before we take the changes.
|
||||
// NetInfo preservation has already been handled above before early return check
|
||||
currentNode.Hostinfo = req.Hostinfo
|
||||
if req.Hostinfo != nil && req.Hostinfo.Hostname != "" {
|
||||
// Preserve an admin-renamed GivenName: only auto-derive when the
|
||||
// current GivenName is still what SanitizeHostname of the old
|
||||
// Hostname would produce (possibly with a "-N" collision bump).
|
||||
autoDerived := isAutoDerivedGivenName(currentNode.GivenName, currentNode.Hostname)
|
||||
|
||||
currentNode.Hostname = req.Hostinfo.Hostname
|
||||
if autoDerived {
|
||||
currentNode.GivenName = dnsname.SanitizeHostname(req.Hostinfo.Hostname)
|
||||
// NodeStore.UpdateNode auto-bumps GivenName on collision.
|
||||
}
|
||||
}
|
||||
currentNode.ApplyHostnameFromHostInfo(req.Hostinfo)
|
||||
|
||||
if routeChange {
|
||||
// Apply pre-calculated route approval
|
||||
@@ -2560,14 +2449,6 @@ func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest
|
||||
Msg("applying route approval results")
|
||||
}
|
||||
}
|
||||
|
||||
// AllApprovedRoutes is announced ∩ approved; a Hostinfo
|
||||
// update that shrinks the announced set can drop the node
|
||||
// out of HA candidacy without touching ApprovedRoutes.
|
||||
// Clear any stale Unhealthy bit in that case.
|
||||
if len(currentNode.AllApprovedRoutes()) == 0 {
|
||||
currentNode.Unhealthy = false
|
||||
}
|
||||
})
|
||||
|
||||
if !ok {
|
||||
@@ -2592,22 +2473,10 @@ func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest
|
||||
}
|
||||
} // Continue with the rest of the processing using the updated node
|
||||
|
||||
// SubnetRoutes = announced ∩ approved, so a Hostinfo update can
|
||||
// move a primary without ever touching ApprovedRoutes. The pre/post
|
||||
// snapshot diff catches that.
|
||||
nodeRouteChange := change.Change{}
|
||||
|
||||
if !maps.Equal(prevRoutes, s.nodeStore.PrimaryRoutes()) {
|
||||
log.Debug().
|
||||
Caller().
|
||||
Uint64(zf.NodeID, id.Uint64()).
|
||||
Strs(zf.RoutesAnnounced, util.PrefixesToString(updatedNode.AnnouncedRoutes())).
|
||||
Strs(zf.ApprovedRoutes, util.PrefixesToString(updatedNode.ApprovedRoutes().AsSlice())).
|
||||
Strs(zf.AllApprovedRoutes, util.PrefixesToString(updatedNode.AllApprovedRoutes())).
|
||||
Msg("primary route assignment shifted after MapRequest")
|
||||
|
||||
nodeRouteChange = change.PolicyChange()
|
||||
}
|
||||
// Handle route changes after NodeStore update.
|
||||
// Update routes if announced routes changed (even if approved routes stayed the same)
|
||||
// because SubnetRoutes is the intersection of announced AND approved routes.
|
||||
nodeRouteChange := s.maybeUpdateNodeRoutes(id, updatedNode, hostinfoChanged, needsRouteApproval, routeChange, req.Hostinfo)
|
||||
|
||||
_, policyChange, err := s.persistNodeToDB(updatedNode)
|
||||
if err != nil {
|
||||
@@ -2701,3 +2570,34 @@ func peerChangeEmpty(peerChange tailcfg.PeerChange) bool {
|
||||
peerChange.LastSeen == nil &&
|
||||
peerChange.KeyExpiry == nil
|
||||
}
|
||||
|
||||
// maybeUpdateNodeRoutes updates node routes if announced routes changed but approved routes didn't.
|
||||
// This is needed because SubnetRoutes is the intersection of announced AND approved routes.
|
||||
func (s *State) maybeUpdateNodeRoutes(
|
||||
id types.NodeID,
|
||||
node types.NodeView,
|
||||
hostinfoChanged, needsRouteApproval, routeChange bool,
|
||||
hostinfo *tailcfg.Hostinfo,
|
||||
) change.Change {
|
||||
// Only update if announced routes changed without approval change
|
||||
if !hostinfoChanged || !needsRouteApproval || routeChange || hostinfo == nil {
|
||||
return change.Change{}
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Caller().
|
||||
Uint64(zf.NodeID, id.Uint64()).
|
||||
Msg("updating routes because announced routes changed but approved routes did not")
|
||||
|
||||
// SetNodeRoutes sets the active/distributed routes using AllApprovedRoutes()
|
||||
// which returns only the intersection of announced AND approved routes.
|
||||
log.Debug().
|
||||
Caller().
|
||||
Uint64(zf.NodeID, id.Uint64()).
|
||||
Strs(zf.RoutesAnnounced, util.PrefixesToString(node.AnnouncedRoutes())).
|
||||
Strs(zf.ApprovedRoutes, util.PrefixesToString(node.ApprovedRoutes().AsSlice())).
|
||||
Strs(zf.AllApprovedRoutes, util.PrefixesToString(node.AllApprovedRoutes())).
|
||||
Msg("updating node routes for distribution")
|
||||
|
||||
return s.SetNodeRoutes(id, node.AllApprovedRoutes()...)
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"github.com/chasefleming/elem-go"
|
||||
)
|
||||
|
||||
// AuthErrorResult contains the text content for an error page shown
|
||||
// to users in their browser when a browser-facing operation fails
|
||||
// (OIDC callback, SSH check, registration confirmation, etc.).
|
||||
type AuthErrorResult struct {
|
||||
// Title is the browser tab / page title,
|
||||
// e.g. "Headscale - Error".
|
||||
Title string
|
||||
|
||||
// Heading is the bold red text inside the error box,
|
||||
// e.g. "Forbidden".
|
||||
Heading string
|
||||
|
||||
// Message is the actionable user-facing message shown below
|
||||
// the heading, e.g. "You are not authorized. Please contact
|
||||
// your administrator."
|
||||
Message string
|
||||
}
|
||||
|
||||
// AuthError renders a styled error page for browser-facing failures.
|
||||
// The caller controls every user-visible string via [AuthErrorResult].
|
||||
func AuthError(result AuthErrorResult) *elem.Element {
|
||||
box := errorBox(
|
||||
result.Heading,
|
||||
elem.Text(result.Message),
|
||||
)
|
||||
|
||||
return HtmlStructure(
|
||||
elem.Title(nil, elem.Text(result.Title)),
|
||||
mdTypesetBody(
|
||||
headscaleLogo(),
|
||||
box,
|
||||
pageFooter(),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func AuthSuccess(result AuthSuccessResult) *elem.Element {
|
||||
externalLink("https://headscale.net/stable/", "Headscale documentation"),
|
||||
),
|
||||
elem.Li(nil,
|
||||
externalLink("https://tailscale.com/docs", "Tailscale docs"),
|
||||
externalLink("https://tailscale.com/kb/", "Tailscale knowledge base"),
|
||||
),
|
||||
),
|
||||
pageFooter(),
|
||||
|
||||
+59
-130
@@ -39,10 +39,6 @@ const (
|
||||
// Success colors.
|
||||
colorSuccess = "#059669" //nolint:unused // Success states
|
||||
colorSuccessLight = "#d1fae5" //nolint:unused // Success backgrounds
|
||||
|
||||
// Error colors.
|
||||
colorError = "#dc2626" //nolint:unused // Error states (red-600)
|
||||
colorErrorLight = "#fee2e2" //nolint:unused // Error backgrounds (red-100)
|
||||
)
|
||||
|
||||
// Spacing System
|
||||
@@ -108,11 +104,15 @@ func responsiveContainer(children ...elem.Node) *elem.Element {
|
||||
func card(title string, children ...elem.Node) *elem.Element {
|
||||
cardContent := children
|
||||
if title != "" {
|
||||
// Prepend title as H3 if provided
|
||||
cardContent = append([]elem.Node{
|
||||
elem.H3(attrs.Props{
|
||||
attrs.Style: styles.Props{
|
||||
styles.MarginTop: "0",
|
||||
styles.MarginBottom: spaceM,
|
||||
styles.FontSize: fontSizeH3,
|
||||
styles.LineHeight: lineHeightH3, // 1.5 - H3 line height
|
||||
styles.Color: colorTextSecondary,
|
||||
}.ToInline(),
|
||||
}, elem.Text(title)),
|
||||
}, children...)
|
||||
@@ -120,12 +120,12 @@ func card(title string, children ...elem.Node) *elem.Element {
|
||||
|
||||
return elem.Div(attrs.Props{
|
||||
attrs.Style: styles.Props{
|
||||
styles.Background: "var(--hs-bg)",
|
||||
styles.Border: "1px solid var(--hs-border)",
|
||||
styles.BorderRadius: "0.5rem",
|
||||
styles.Padding: "clamp(1rem, 3vw, 1.5rem)",
|
||||
styles.Background: colorBackgroundCard,
|
||||
styles.Border: "1px solid " + colorBorderLight,
|
||||
styles.BorderRadius: "0.5rem", // 8px rounded corners
|
||||
styles.Padding: "clamp(1rem, 3vw, 1.5rem)", // Responsive padding
|
||||
styles.MarginBottom: spaceL,
|
||||
styles.BoxShadow: "0 1px 3px rgba(0,0,0,0.1)",
|
||||
styles.BoxShadow: "0 1px 3px rgba(0,0,0,0.1)", // Subtle shadow
|
||||
}.ToInline(),
|
||||
}, cardContent...)
|
||||
}
|
||||
@@ -329,12 +329,6 @@ func inlineCode(code string) *elem.Element {
|
||||
//
|
||||
//nolint:unused // Used in apple.go template.
|
||||
func orDivider() *elem.Element {
|
||||
lineStyle := styles.Props{
|
||||
styles.Flex: "1",
|
||||
styles.Height: "1px",
|
||||
styles.BackgroundColor: "var(--hs-border)",
|
||||
}.ToInline()
|
||||
|
||||
return elem.Div(attrs.Props{
|
||||
attrs.Style: styles.Props{
|
||||
styles.Display: "flex",
|
||||
@@ -345,17 +339,29 @@ func orDivider() *elem.Element {
|
||||
styles.Width: "100%",
|
||||
}.ToInline(),
|
||||
},
|
||||
elem.Div(attrs.Props{attrs.Style: lineStyle}),
|
||||
elem.Div(attrs.Props{
|
||||
attrs.Style: styles.Props{
|
||||
styles.Flex: "1",
|
||||
styles.Height: "1px",
|
||||
styles.BackgroundColor: colorBorderLight,
|
||||
}.ToInline(),
|
||||
}),
|
||||
elem.Strong(attrs.Props{
|
||||
attrs.Style: styles.Props{
|
||||
styles.Color: "var(--md-default-fg-color--light)",
|
||||
styles.Color: colorTextSecondary,
|
||||
styles.FontSize: fontSizeBase,
|
||||
styles.FontWeight: "500",
|
||||
"text-transform": "uppercase",
|
||||
"letter-spacing": "0.05em",
|
||||
}.ToInline(),
|
||||
}, elem.Text("or")),
|
||||
elem.Div(attrs.Props{attrs.Style: lineStyle}),
|
||||
elem.Div(attrs.Props{
|
||||
attrs.Style: styles.Props{
|
||||
styles.Flex: "1",
|
||||
styles.Height: "1px",
|
||||
styles.BackgroundColor: colorBorderLight,
|
||||
}.ToInline(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -365,30 +371,26 @@ func orDivider() *elem.Element {
|
||||
//
|
||||
//nolint:unused // Used in auth_success.go template.
|
||||
func successBox(heading string, children ...elem.Node) *elem.Element {
|
||||
return elem.Div(
|
||||
attrs.Props{
|
||||
attrs.Style: styles.Props{
|
||||
styles.Display: "flex",
|
||||
styles.AlignItems: "center",
|
||||
styles.Gap: spaceM,
|
||||
styles.Padding: spaceL,
|
||||
styles.BackgroundColor: "var(--hs-success-bg)",
|
||||
styles.Border: "1px solid var(--hs-success)",
|
||||
styles.BorderRadius: "0.5rem",
|
||||
styles.MarginBottom: spaceXL,
|
||||
}.ToInline(),
|
||||
attrs.Role: "status",
|
||||
"aria-live": "polite",
|
||||
},
|
||||
return elem.Div(attrs.Props{
|
||||
attrs.Style: styles.Props{
|
||||
styles.Display: "flex",
|
||||
styles.AlignItems: "center",
|
||||
styles.Gap: spaceM,
|
||||
styles.Padding: spaceL,
|
||||
styles.BackgroundColor: colorSuccessLight,
|
||||
styles.Border: "1px solid " + colorSuccess,
|
||||
styles.BorderRadius: "0.5rem",
|
||||
styles.MarginBottom: spaceXL,
|
||||
}.ToInline(),
|
||||
},
|
||||
checkboxIcon(),
|
||||
elem.Div(nil,
|
||||
append([]elem.Node{
|
||||
elem.Strong(attrs.Props{
|
||||
attrs.Style: styles.Props{
|
||||
styles.Display: "block",
|
||||
styles.Color: "var(--hs-success)",
|
||||
styles.Color: colorSuccess,
|
||||
styles.FontSize: fontSizeH3,
|
||||
styles.FontWeight: "700",
|
||||
styles.MarginBottom: spaceXS,
|
||||
}.ToInline(),
|
||||
}, elem.Text(heading)),
|
||||
@@ -399,54 +401,8 @@ func successBox(heading string, children ...elem.Node) *elem.Element {
|
||||
|
||||
// checkboxIcon returns the success checkbox SVG icon as raw HTML.
|
||||
func checkboxIcon() elem.Node {
|
||||
return elem.Raw(`<svg id="checkbox" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 512 512" style="flex-shrink:0">
|
||||
<path fill="currentColor" d="M256 32C132.3 32 32 132.3 32 256s100.3 224 224 224 224-100.3 224-224S379.7 32 256 32zm114.9 149.1L231.8 359.6c-1.1 1.1-2.9 3.5-5.1 3.5-2.3 0-3.8-1.6-5.1-2.9-1.3-1.3-78.9-75.9-78.9-75.9l-1.5-1.5c-.6-.9-1.1-2-1.1-3.2 0-1.2.5-2.3 1.1-3.2.4-.4.7-.7 1.1-1.2 7.7-8.1 23.3-24.5 24.3-25.5 1.3-1.3 2.4-3 4.8-3 2.5 0 4.1 2.1 5.3 3.3 1.2 1.2 45 43.3 45 43.3l111.3-143c1-.8 2.2-1.4 3.5-1.4 1.3 0 2.5.5 3.5 1.3l30.6 24.1c.8 1 1.3 2.2 1.3 3.5.1 1.3-.4 2.4-1 3.3z"></path>
|
||||
</svg>`)
|
||||
}
|
||||
|
||||
// errorBox creates a red error feedback box with an X-circle icon.
|
||||
// The heading is displayed as bold red text, and children are rendered below it.
|
||||
// Pairs with successBox for consistent feedback styling.
|
||||
//
|
||||
//nolint:unused // Used in auth_error.go template.
|
||||
func errorBox(heading string, children ...elem.Node) *elem.Element {
|
||||
return elem.Div(
|
||||
attrs.Props{
|
||||
attrs.Style: styles.Props{
|
||||
styles.Display: "flex",
|
||||
styles.AlignItems: "center",
|
||||
styles.Gap: spaceM,
|
||||
styles.Padding: spaceL,
|
||||
styles.BackgroundColor: "var(--hs-error-bg)",
|
||||
styles.Border: "1px solid var(--hs-error)",
|
||||
styles.BorderRadius: "0.5rem",
|
||||
styles.MarginBottom: spaceXL,
|
||||
}.ToInline(),
|
||||
attrs.Role: "alert",
|
||||
"aria-live": "assertive",
|
||||
},
|
||||
errorIcon(),
|
||||
elem.Div(nil,
|
||||
append([]elem.Node{
|
||||
elem.Strong(attrs.Props{
|
||||
attrs.Style: styles.Props{
|
||||
styles.Display: "block",
|
||||
styles.Color: "var(--hs-error)",
|
||||
styles.FontSize: fontSizeH3,
|
||||
styles.FontWeight: "700",
|
||||
styles.MarginBottom: spaceXS,
|
||||
}.ToInline(),
|
||||
}, elem.Text(heading)),
|
||||
}, children...)...,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// errorIcon returns the error X-circle SVG icon as raw HTML.
|
||||
func errorIcon() elem.Node {
|
||||
return elem.Raw(`<svg id="error-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" style="flex-shrink:0">
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor"/>
|
||||
<path d="M15 9l-6 6M9 9l6 6" stroke="var(--hs-error-bg, #fee2e2)" stroke-width="2" stroke-linecap="round"/>
|
||||
return elem.Raw(`<svg id="checkbox" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 512 512">
|
||||
<path d="M256 32C132.3 32 32 132.3 32 256s100.3 224 224 224 224-100.3 224-224S379.7 32 256 32zm114.9 149.1L231.8 359.6c-1.1 1.1-2.9 3.5-5.1 3.5-2.3 0-3.8-1.6-5.1-2.9-1.3-1.3-78.9-75.9-78.9-75.9l-1.5-1.5c-.6-.9-1.1-2-1.1-3.2 0-1.2.5-2.3 1.1-3.2.4-.4.7-.7 1.1-1.2 7.7-8.1 23.3-24.5 24.3-25.5 1.3-1.3 2.4-3 4.8-3 2.5 0 4.1 2.1 5.3 3.3 1.2 1.2 45 43.3 45 43.3l111.3-143c1-.8 2.2-1.4 3.5-1.4 1.3 0 2.5.5 3.5 1.3l30.6 24.1c.8 1 1.3 2.2 1.3 3.5.1 1.3-.4 2.4-1 3.3z"></path>
|
||||
</svg>`)
|
||||
}
|
||||
|
||||
@@ -460,25 +416,29 @@ func warningBox(title, message string) *elem.Element {
|
||||
styles.AlignItems: "flex-start",
|
||||
styles.Gap: spaceM,
|
||||
styles.Padding: spaceL,
|
||||
styles.BackgroundColor: "var(--hs-warning-bg)",
|
||||
styles.Border: "1px solid var(--hs-warning-border)",
|
||||
styles.BackgroundColor: "#fef3c7", // yellow-100
|
||||
styles.Border: "1px solid #f59e0b", // yellow-500
|
||||
styles.BorderRadius: "0.5rem",
|
||||
styles.MarginTop: spaceL,
|
||||
styles.MarginBottom: spaceL,
|
||||
}.ToInline(),
|
||||
attrs.Role: "note",
|
||||
},
|
||||
elem.Raw(`<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--hs-warning-border)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink: 0; margin-top: 2px;"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>`),
|
||||
elem.Raw(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink: 0; margin-top: 2px;"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>`),
|
||||
elem.Div(nil,
|
||||
elem.Strong(attrs.Props{
|
||||
attrs.Style: styles.Props{
|
||||
styles.Display: "block",
|
||||
styles.Color: "var(--hs-warning-text)",
|
||||
styles.Color: "#92400e", // yellow-800
|
||||
styles.FontSize: fontSizeH3,
|
||||
styles.MarginBottom: spaceXS,
|
||||
}.ToInline(),
|
||||
}, elem.Text(title)),
|
||||
elem.Div(nil, elem.Text(message)),
|
||||
elem.Div(attrs.Props{
|
||||
attrs.Style: styles.Props{
|
||||
styles.Color: colorTextPrimary,
|
||||
styles.FontSize: fontSizeBase,
|
||||
}.ToInline(),
|
||||
}, elem.Text(message)),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -491,18 +451,16 @@ func downloadButton(href, text string) *elem.Element {
|
||||
attrs.Href: href,
|
||||
attrs.Download: "headscale_macos.mobileconfig",
|
||||
attrs.Style: styles.Props{
|
||||
styles.Display: "inline-flex",
|
||||
styles.AlignItems: "center",
|
||||
styles.Display: "inline-block",
|
||||
styles.Padding: "0.75rem 1.5rem",
|
||||
styles.BackgroundColor: "var(--md-primary-fg-color)",
|
||||
styles.BackgroundColor: "#3b82f6", // blue-500
|
||||
styles.Color: "#ffffff",
|
||||
styles.TextDecoration: "none",
|
||||
styles.BorderRadius: "0.375rem",
|
||||
styles.BorderRadius: "0.5rem",
|
||||
styles.FontWeight: "500",
|
||||
styles.Transition: "background-color 150ms ease-out",
|
||||
styles.Transition: "background-color 0.2s",
|
||||
styles.MarginRight: spaceM,
|
||||
styles.MarginBottom: spaceM,
|
||||
"min-height": "44px",
|
||||
}.ToInline(),
|
||||
}, elem.Text(text))
|
||||
}
|
||||
@@ -517,6 +475,10 @@ func externalLink(href, text string) *elem.Element {
|
||||
attrs.Href: href,
|
||||
attrs.Rel: "noreferrer noopener",
|
||||
attrs.Target: "_blank",
|
||||
attrs.Style: styles.Props{
|
||||
styles.Color: colorPrimaryAccent, // #4051b5 - base link color
|
||||
styles.TextDecoration: "none",
|
||||
}.ToInline(),
|
||||
}, elem.Text(text))
|
||||
}
|
||||
|
||||
@@ -533,39 +495,6 @@ func instructionStep(_ int, text string) *elem.Element {
|
||||
}, elem.Text(text))
|
||||
}
|
||||
|
||||
// detailsBox creates a collapsible <details>/<summary> section.
|
||||
// Styled to match the card/box component family (border, radius, CSS variables).
|
||||
// Collapsed by default; the user clicks the summary to expand.
|
||||
//
|
||||
//nolint:unused // Used in ping.go template.
|
||||
func detailsBox(summary string, children ...elem.Node) *elem.Element {
|
||||
return elem.Details(attrs.Props{
|
||||
attrs.Style: styles.Props{
|
||||
styles.Background: "var(--hs-bg)",
|
||||
styles.Border: "1px solid var(--hs-border)",
|
||||
styles.BorderRadius: "0.5rem",
|
||||
styles.Padding: spaceS + " " + spaceM,
|
||||
styles.MarginTop: spaceL,
|
||||
styles.MarginBottom: spaceL,
|
||||
}.ToInline(),
|
||||
},
|
||||
elem.Summary(attrs.Props{
|
||||
attrs.Style: styles.Props{
|
||||
"cursor": "pointer",
|
||||
styles.FontWeight: "500",
|
||||
styles.Color: "var(--md-default-fg-color--light)",
|
||||
styles.Padding: spaceS + " 0",
|
||||
}.ToInline(),
|
||||
}, elem.Text(summary)),
|
||||
elem.Div(attrs.Props{
|
||||
attrs.Style: styles.Props{
|
||||
styles.PaddingTop: spaceS,
|
||||
styles.Color: "var(--md-default-fg-color)",
|
||||
}.ToInline(),
|
||||
}, children...),
|
||||
)
|
||||
}
|
||||
|
||||
// Status Message Component
|
||||
// For displaying success/error/info messages with appropriate styling.
|
||||
//
|
||||
@@ -575,8 +504,8 @@ func statusMessage(message string, isSuccess bool) *elem.Element {
|
||||
textColor := colorSuccess
|
||||
|
||||
if !isSuccess {
|
||||
bgColor = colorErrorLight
|
||||
textColor = colorError
|
||||
bgColor = "#fee2e2" // red-100
|
||||
textColor = "#dc2626" // red-600
|
||||
}
|
||||
|
||||
return elem.Div(attrs.Props{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user