Main Pull - #1
Open
Kraken-OffSec wants to merge 954 commits into
Open
Conversation
Kraken-OffSec
commented
Sep 2, 2025
- have read the CONTRIBUTING.md file
- raised a GitHub issue or discussed it on the projects chat beforehand
- added unit tests
- added integration tests
- updated documentation if needed
- updated CHANGELOG.md
…e-ipv4 cap When a node carries the disable-ipv4 nodeAttr documented at https://tailscale.com/docs/reference/troubleshooting/network-configuration/cgnat-conflicts, SaaS stops sending the node's CGNAT IPv4 prefix in MapResponse. The allocator keeps assigning IPv4 server-side; only the wire-shape delivery is filtered. Subnet routes the node advertises -- including IPv4 prefixes -- survive in AllowedIPs and PrimaryRoutes. TailNode now drops Is4 prefixes from Addresses and from the node's own /32 slot in AllowedIPs when selfPolicyCaps carries disable-ipv4. Mapper.buildTailPeers passes each peer's policy CapMap so the filter applies in viewer netmaps too; the CapMap merge that follows is overwritten by PeerCapMap so only the address filter survives on the peer path. Two captures land in testdata/nodeattrs_results to anchor the behaviour: - nodeattrs-attr-c15-disable-ipv4 (on tag:client) - nodeattrs-attr-c16-disable-ipv4-router (on tag:router, which advertises 10.33.0.0/16, confirming subnet routes survive)
The guard added for #2862 in handleRegister checked node.Expiry().Valid() before preserving node state on Auth=nil + Expiry=zero registration requests. Valid() returns false when node.Expiry is nil, the default for tagged nodes and for untagged nodes registered against a preauth key with no default node.expiry configured. Both fell through to handleLogout, which wrote &time.Time{} (0001-01-01T00:00:00Z) over the original nil — the user-visible 0001-01-01 expiry that `headscale nodes list` reports after restart. IsExpired() already returns false for both nil and zero-time, so the Valid() check was redundant. Drop it so all nil-expiry nodes are covered by the same preservation path. Fixes #3170 Fixes #3262
Trim whitespace on action, users, src, dst; reject empty/wildcard users; reject empty acceptEnv; reject negative and over-max checkPeriod; reject hosts-table aliases as SSH dst; reject non-ASCII tag names; tolerate tag-owner cycles; match group-nesting wording.
Adds SSHPolicyTest plus parse-time validation: empty src/dst, port/CIDR/autogroup-internet destinations, and tag references missing from tagOwners are rejected. Engine evaluation comes in a follow-up.
SetPolicy and policy check now compile per-dst SSH rules and replay each sshTests entry. The accept assertion treats check-action rules as reachable; the check assertion requires HoldAndDelegate on the matching rule. Boot reload warns and continues.
Replays recorded policy responses for the sshTests block. 200 captures must evaluate; non-200 captures must reject with the recorded body as a substring of the headscale error. Divergences are listed in knownSSHTesterDivergences.
Endpoints, Tags and ApprovedRoutes serialize as JSON on Node. GORM's
struct Updates path skips fields it considers zero, and reflect treats
a nil slice as zero — clearing any of these columns via the State
persist path would leave the previous value in the database.
Introduce Strings, Prefixes and AddrPorts as named slice types whose
IsZero() always reports false, so GORM keeps the column in the UPDATE
regardless of the slice being nil or empty. JSON marshalling is
unchanged: nil serializes to null, empty to []. List() returns the
underlying unnamed slice for callers (mainly testify assertions over
reflect.DeepEqual) that distinguish the named type from its base.
Regenerated types_clone.go and types_view.go follow the field-type
swap. Test assertions across hscontrol/{db,state,servertest} updated
to call .List() where reflect.DeepEqual previously matched the raw
slice type.
Fixes #3110
Drives the persist path for ApprovedRoutes, Tags and Endpoints — seed a non-empty value, clear to nil, read the column back from disk, then close the State and reopen one against the same sqlite file to simulate a server restart. Pins the contract the named IsZero slice types enforce so future changes to the persist path cannot silently drop a cleared slice column. Updates #3110
Both helpers existed to write the literal "[]" when clearing a slice column — a workaround for GORM's struct-Updates skipping nil slices. The State path goes exclusively through persistNodeToDB, which is now correct end-to-end thanks to the named IsZero slice types, so the helpers are dead in production. The remaining callers were tests. TestSetTags is dropped — TestSetTags_* in hscontrol/grpcv1_test.go already covers the State path that production uses. TestAutoApproveRoutes now writes routes via DB.Save on the loaded node, which is the path gRPC SetApprovedRoutes drives in production. Updates #3110
slices.Contains required exact equality between grant dst and the advertised subnet route. Any non-identical pair was rejected, so a via grant with broader (or narrower) dst emitted no filter rule and added no route to the viewer's AllowedIPs. Tailscale SaaS uses containment in either direction. Switch to slices.ContainsFunc(routes, dst.Overlaps) for filter rule emission (keep dst literal in DstPorts), and append overlapping advertised routes to ViaRoutesForPeer.Include / Exclude. Rewrite the multi-router HA election and regular-grant overlap detection to key off the matched routes rather than the dst. Resolve *Host aliases to *Prefix once in compileOneViaGrant and at the top of ViaRoutesForPeer so the switch arms reach them. Fixes #3267
Captures from Tailscale SaaS exercising broader, narrower, host alias, disjoint, and 4via6 grant destinations against advertised subnet routes. TestGrantsCompat replays them. Updates #3267
TestGrantViaSubnetFilterRules pins exact-equality dst. Add a sibling for the broader-dst case so the regression sits at the server level alongside the policy-engine unit test. Updates #3267
Used by the trusted_proxies middleware for safe X-Forwarded-For parsing with rightmost-trusted-range semantics.
chi middleware.RealIP was mounted unconditionally on both the
public router and the noise router, so any client could send
X-Real-IP or X-Forwarded-For and have the spoofed value land in
r.RemoteAddr and the access-log remote= field.
Add a top-level trusted_proxies config option (list of CIDRs) and
replace middleware.RealIP with a gated middleware that:
- honours True-Client-IP / X-Real-IP / X-Forwarded-For only when
r.RemoteAddr is inside one of the configured prefixes;
- strips those three headers from every request whose peer is
not trusted, so downstream handlers cannot read them.
X-Forwarded-For is parsed via realclientip-go's
RightmostTrustedRangeStrategy so a prepended value cannot win in a
proxy chain. trustedProxies() rejects 0.0.0.0/0 and ::/0 at config
load.
Empty trusted_proxies (the default) skips the mount entirely;
r.RemoteAddr is the directly-connecting TCP peer.
Cover the option in config-example.yaml, the reverse-proxy integration guide, and the 0.29.0 CHANGELOG.
Docker 29 itself works; the breakage on the GHA runner image was the overlayfs default. Setting storage-driver=overlay2 restores the long-standing default and lets the suite run on the current Docker without the apt downgrade dance. Fixes #3094
Anonymous Hub pulls trip the 100/6h IP cap on shared CI runners, turning into singleton FAIL reports whenever the runner egress IP crosses the quota. Route every pull through Docker Hub credentials when present, and retry transient errors with backoff. tsic and hi use the same helper so both surfaces honour ~/.docker/config.json and the GHA secrets.
DisconnectContainerFromNetwork and ReconnectContainerToNetwork returned as soon as the docker API call completed, but libnetwork bridge reprogramming continued for several seconds after. The HA disconnect tests then raced and bounced between healthy and broken bridges. Poll until the container's endpoint is gone (on disconnect) or reconciled (on reconnect), and on the "conflicts with existing route" surface clear the stale subnet route from the netns and retry. Settle is now baked into the primitive so every caller benefits.
Categorised timeouts in integrationutil/timeouts.go remove the drift opportunity between same-purpose budgets repeated across the suite. The auth, cli, dns, derp, ssh, and tags tests are swept; acl, route, and general tests follow in later commits alongside their other ergonomic fixes.
tsic.Curl returned ("", nil) when curl exited 0 with a zero-byte body —
the usual signature of a mid-stream reset — so EventuallyWithT could
not retry. Return an error on empty body instead.
Replace the 56 inline curl + assert.Len(13) blocks with
assertCurlDockerHostname so the empty-body fix benefits every callsite
without further touch-ups.
Gate ACL waits on actual filter visibility: snapshotClientFilters +
waitForClientFilterChange ensure the new PacketFilter has reached the
client before assertions fire; SyncOption + WithPreBarrier feeds a
server-side policy-loaded check into WaitForTailscaleSyncPerUser.
Move advertise-routes mutation out of EventuallyWithT in route_test
(cmd/hi/README forbids retrying state-mutating calls). Pace the
TestNodeOnlineStatus outer loop with a Ticker, not a Sleep.
policyChangeResponse already includes everything else; carry DNSConfig
too so the client's netmap DNS is anchored on every policy change
rather than relying on the previous snapshot.
Send the MagicDNS root domains as empty non-nil Resolver slices instead
of nil values. tailcfg.DNSConfig.Clone and net/dns.Config.Clone in
tailscale drop map entries whose value is nil (tailcfg_clone.go and
dns_clone.go both contain `if sv == nil { continue }`). On a major
LinkChange the client's wgengine handler clones lastDNSConfig and
re-applies it; with nil values the cloned config has Routes:{}, dns.Set
wipes Nameservers in /etc/resolv.conf, and curl-by-FQDN fails until
the next route-changing netmap, typically about six minutes later.
Empty slice survives Clone and carries the same "resolve locally"
semantics for Routes entries.
The HA prober dispatches a PingRequest, waits ProbeTimeout (5s), and marks the node unhealthy if no callback arrives. A node that bounced its poll session between probe cycles satisfies two conditions that conspire to fail TestHASubnetRouterFailover: a probe queued against the previous session is silently dropped when the worker writes to the closed connection (timeout always fires), and a probe sent immediately after reconnect lands while wgengine is still rebuilding magicsock state from the new netmap. Either path installs a spurious unhealthy bit, which sends the preserved-primary anti-flap the wrong way. Record the session observed at dispatch time and drop the timeout path if the node reconnected since. Require the session to survive a full probe cycle before a timeout can drive a failover.
requirePrimaryStable in TestHASubnetRouterFailoverDockerDisconnect Phase 5a (simultaneous cable-pull of both routers) intermittently caught the primary flipping to the offline r1. Both probe goroutines mark their target unhealthy back-to-back; SetNodeUnhealthy publishes a fresh NodeStore snapshot each call, so the intermediate snapshot — r1 unhealthy, r2 still healthy — runs the election with one healthy candidate left and picks it. The next snapshot then enters the all-unhealthy preserve-prev path, which preserves the wrong choice. Collect probe results from the cycle and apply them through a new NodeStore.UpdateNodes batched op so the election only runs once, with the cycle's final health state. PolicyChange dispatch moves outside the wg.Go goroutines and fires once if the primary assignment actually changed.
electPrimaryRoutes' all-unhealthy fallback picked candidates[0] when the previous primary was no longer a candidate. The Phase-5 simultaneous dual-disconnect path in TestHASubnetRouterFailoverDocker Disconnect hits this asymmetrically: a batched probe cycle marks both routers unhealthy with prev=r2 preserved, then the grace-period Disconnect for r2 drops it from candidates. With prev gone and the remaining r1 still carrying its Unhealthy bit, the fallback pointed peers at the cable-pulled r1 — flapping primary to an unreachable node and tripping requirePrimaryStable. Leave the prefix unmapped when prev is gone and every candidate is unhealthy. Peers see no advertiser instead of an unreachable one, which is honest: the next probe cycle re-evaluates and picks whichever node responds. The property-test model that mirrored the old behaviour is updated to match.
Expand TestPrimaryRoutesProperty (5 -> 9 ops). New ops mirror the production shapes the failure cases hit: BatchProbeResults via UpdateNodes, SimultaneousDisconnect via UpdateNodes, SetApprovedRoutes that leaves announced RoutableIPs intact, OfflineExpiry that keeps Unhealthy set. The model now tracks announced and approved separately and recomputes the intersection. Strengthen the per-op assertions to cover invariants the model alone cannot prove: every primary must be online, every primary must currently advertise its prefix, no flap onto an unhealthy candidate when a healthy one was available, no flap off a previous primary that remains a healthy candidate. The check now takes a pre-op snapshot so the anti-flap rule has a stable reference. Add TestHAProberProperty in servertest. It drives a real TestServer with three HA-route-advertising clients through rapid-drawn sequences of ClientDisconnect / ClientReconnect / ProberTick / WaitForSnapshot ops and re-checks the same shape invariants after every step. Document the system in hscontrol/state/HA_INVARIANTS.md: a state machine over (Healthy+Online, Unhealthy+Online, Offline, OfflineExpired), fifteen numbered invariants with predicates and violation paths, and a coverage matrix mapping each invariant to its unit, servertest, and integration tests. Three rows pin the recent fixes to the invariants they enforce.
Run the real operator in a single-container k3s cluster against an in-test Headscale over plain HTTP. k3sic exposes reusable building blocks (InstallOperator, DeployConnector, DeployEchoServer, ExposeServiceToTailnet, DeployProxyGroup); the test covers operator registration, an egress connector, ingress connectivity from a tailnet node, and proxy groups. tls-ca-baking.md records the private-CA TLS variant. Updates #1202
Factor the hs-/ts-/derp-/k3s- prefix set into one helper used by cleanup and docker; add a doctor check for the ghcr k3s image.
It needs no special runner (every integration container already runs privileged), so it joins the generated matrix; load br_netfilter only for that test. Updates #1202
One exclusive mutex serialized every policy read, so a mass reconnect on autogroup:self/via/relay policies stalled clients into "unexpected EOF" retries. Per-node caches become xsync.Maps for lazy population under RLock. Fixes #3346
A peer whose GivenName fails GetFQDN aborted the whole map for every node that could see it. Drop and log it; SSH policy errors degrade too. Fixes #3346
A bare return sent an empty 200 the client read as "unexpected EOF" and retried forever; emit a real error instead. Updates #3346
A valid label can still overflow 255 chars under a long base_domain; gate RenameNode with the new types.ValidateGivenName. Updates #3346
Scan a node-health check registry at boot and log each node whose name can't form a valid FQDN, with the rename fix. Log-only, no mutation. Updates #3346
The chi migration dropped GET; JS/WASM control clients open /ts2021 as a WebSocket GET and were rejected with 405 before reaching NoiseUpgradeHandler. Fixes #3357
A raw coder/websocket dial and the real tailscale.com js/wasm control client under Node, both against headscale alongside normal Tailscale clients. Updates #3357
Generated by gh-action-integration-generator. Updates #3357
Signed-off-by: alaningtrump <alaningtrump@outlook.com>
Tailscale HEAD go.mod now requires go >= 1.26.5; build images pinned 1.26.4. Bump go.mod, the four Go Dockerfiles, and the nixpkgs pin (staging-next-26.05 ships go_1_26 1.26.5; unstable still lags).
Point the action at the repo root, which is the canonical entry point in v2; the /action subpath is now a deprecated shim. Closes #3356
Keep the existing search provider instead (and the separator) option.
waitForFollowup returns nodeToRegisterResponse for a completed registration without checking that the Noise session polling for the result was started with the machine key that opened the registration. That response carries the registering user's User and Login, so the auth ID in the followup URL is the only thing protecting it. handleRegister and handleLogout both call machineKeyMismatch before handing back a node, so this is the one path of the three that does not. The key is already available: HandleNodeFromAuthPath resolves the node from the MachineKey cached in RegistrationData, so on the normal path the node and the session agree and the check is a no-op. The auth ID is 96 bits of randomness and is not guessable, so this is not reachable by brute force. It is logged at info level when a registration is created, which makes log access the realistic way to obtain one. The existing followup_registration_success case built its node with CreateNodeForTest, which picks a random machine key that no real registration would produce. Set the registering machine key so the fixture matches the production path. Signed-off-by: Arpit Jain <arpitjain099@gmail.com>
/key handed out the Noise public key for any v>=39, a floor unrelated to the handshake's capver.MinSupportedCapabilityVersion. Reject below the supported floor, matching /ts2021, and drop the stale constant. Fixes #3380
Tagged nodes never expire, but handleLogout stamped a past expiry on them, leaving them stuck expired and unable to re-authenticate. Fixes #3371
Re-registering a tagged node with a different key discarded the new key's tags and left a stale auth-key reference; retag on key change and persist it. Fixes #3370
Re-authenticating a tagged node with --advertise-tags checked the tag-owned node, not the authenticating user, so every tag was rejected. Fixes #3374
…marshaling error Noticed this while testing out #3394.
With node.ephemeral.inactivity_timeout set, ephemeral nodes are usually deleted after they go offline, but under reconnect churn some departed nodes stayed in the node list as disconnected indefinitely until removed manually or until Headscale restarted. Ephemeral cleanup is timer-based via EphemeralGarbageCollector, not a periodic LastSeen scan. serveLongPoll cancelled any pending GC timer at the very start of a long-poll attempt and only rescheduled on a clean disconnect after Connect. If a reconnect cancelled the timer and then failed before Connect (for example an UpdateNodeFromMapRequest error), the deferred cleanup saw connectGen == 0 and returned without Schedule. The node remained offline with no deletion timer and no reconciler to recover it. Cancel the ephemeral GC timer only after a successful Connect, so a failed reconnect leaves an already-armed inactivity timer intact. Successful reconnects still cancel GC once the node is online, and a later disconnect reschedules as before. Add TestFailedReconnectDoesNotCancelEphemeralGC to lock in the ordering, plus IsScheduled and DeleteNodeFromStoreForTest helpers for the test. Fixes #3382 Co-authored-by: Cursor <cursoragent@cursor.com>
Fixes #3401 Signed-off-by: Simon Law <sfllaw@tailscale.com>
waitForFollowup selected on ctx.Done() and the verdict channel with equal priority; when both were ready, select picked at random and discarded a successful registration as a spurious 401 timeout. Check for a completed verdict first, race the deadline only if none is ready. Fixes #3385
Updates #3385
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.