Main Pull#1
Open
Kraken-OffSec wants to merge 943 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
- Mention policy as generic term that covers ACLs or Grants - Refresh routes policy examples - Remove Headscale specific exit node separation. Use via instead. Fixes: #3087
ACL policies now accept a top-level nodeAttrs block. Each entry hands a list of tailcfg node capabilities to every node matching target. Accepted target forms are the same as acls.src and grants.src: users, groups, tags, hosts, prefixes, autogroup:member, autogroup:tagged, and *. autogroup:self, autogroup:internet, and autogroup:danger-all are rejected at validate time because none describes a stable identity set a node-level attribute can attach to. NodeAttrGrant carries Targets, Attrs, and IPPool. IPPool is parsed but rejected at validate time -- the allocator that consumes it is not yet implemented. nodeAttrUnsupportedCaps lists caps SaaS accepts that headscale cannot act on (funnel today) and rejects them with a tracking-issue link in the error. compileNodeAttrs resolves each entry's targets, then maps every targeted node to a tailcfg.NodeCapMap of the entry's attrs. Per-node IPs are cached once per call so the inner attr loop is O(grants) instead of O(grants * nodes) IP allocations. PolicyManager grows NodeCapMap (per-node), NodeCapMaps (snapshot for batched callers), and NodesWithChangedCapMap (drain buffer for the self-broadcast diff). refreshNodeAttrsLocked appends to the drain rather than overwriting so a SetUsers/SetNodes between SetPolicy and the drain cannot lose the policy-reload diff.
…ns DoH rewrite
WithSelfNode and buildTailPeers merge each node's policy CapMap
into the tailcfg.Node.CapMap they emit. State.NodeCapMap and
State.NodeCapMaps wrap the policy manager: NodeCapMap returns a
defensive clone per call; NodeCapMaps snapshots the full per-node
map once for batched callers, amortising pm.mu acquisition across
a peer build.
generateDNSConfig grew a per-node CapMap argument so it can apply
nodeAttr-driven DNS overlays. The nextdns DoH rewrite hardens against
policy-controlled inputs:
- nextDNSDoHHost anchors the prefix match instead of substring,
so a hostile resolver URL cannot smuggle a nextdns hostname in
a path or query.
- nextDNSProfileFromCapMap accepts only profile names matching
[A-Za-z0-9._-]{1,64} and picks the lexicographically first when
multiple are granted -- deterministic, no shell metacharacters
or URL fragments through.
- addNextDNSMetadata composes the rewritten URL via url.Parse +
url.Values rather than fmt.Sprintf, so existing query strings
on the resolver URL survive and metadata cannot inject a new
component.
WithTaildropEnabled in servertest controls cfg.Taildrop.Enabled per
test so cap/file-sharing emission can be toggled in tests that need
to verify the off path.
…y file Tailscale models the randomize-client-port toggle as a top-level field on the ACL policy. Headscale now matches that shape: the server-config randomize_client_port key is removed, the toggle lives in the policy file as randomizeClientPort, and per-node opt-in via nodeAttrs is also supported. Operators upgrading from a config-set randomize_client_port hit depr.fatalWithHint at startup, which prints the deprecation message and points at the new policy field rather than silently dropping the toggle. The default carries over (false) so operators who never set it are unaffected. config-example.yaml ships a REMOVED stanza showing the migration. types/node.go drops the cfg.RandomizeClientPort read from TailNode -- the cap is now policy-driven through compileNodeAttrs and the tail_test.go expectations follow.
Adds a data-driven test that loads testdata/nodeattrs_results/*.hujson and diffs the captured SaaS-rendered netmaps against headscale's compileNodeAttrs output. Each capture is one scenario the SaaS control plane has rendered against the same policy headscale is asked to compile -- the test enforces shape parity per node. tailnet_state_caps.go enumerates the caps SaaS emits where headscale has no equivalent concept yet (user-role admin/owner, tailnet lock, services host, app connectors, internal magicsock and SSH tuning, tailnet-state metadata) plus the always-on baseline (admin, ssh, file-sharing) and the taildrive pair. stripUnmodelledTailnetStateCaps filters both sides of cmp.Diff so the comparison focuses on the policy-driven caps. PeerCapMap encodes which caps the Tailscale client reads from the peer view (suggest-exit-node when exit routes are approved, etc.) for use by the mapper. testcapture switches to typed tailcfg/netmap/filtertype/apitype values so schema drift between the capture tool and headscale becomes a compile error rather than a silent test failure. Existing compat suites (acl, grants, routes, ssh, issue_3212) move to the typed shape. The 53 SelfNode netmap captures and the 7 anonymizer-corrupted suggest-charmander -> suggest-exit-node restorations in routes_results / issue_3212 ride along.
…outes approved The Tailscale client surfaces 'use this peer as your exit node' when the peer's CapMap carries the tailcfg.NodeAttrSuggestExitNode cap. SaaS emits it only on peers whose advertised exit routes are approved -- not every peer that just has the cap in its own nodeAttrs slot. policyv2.PeerCapMap encodes that emission rule: it walks the peer's own self-CapMap (built from compileNodeAttrs) and surfaces the gated entries (today just suggest-exit-node when the peer IsExitNode). Mapper.buildTailPeers calls it for each peer instead of merging the peer's full nodeAttrs CapMap onto its peer view. allCapMaps snapshots the full per-node CapMap once per peer-list build so pm.mu is acquired once rather than per peer.
types.NodeView.TailNode takes a selfPolicyCaps tailcfg.NodeCapMap parameter and merges it into the baseline. The mapper's WithSelfNode hands it the policy result via state.NodeCapMap; peer-path callers pass nil because peer-side CapMap is set downstream via policyv2.PeerCapMap. The nodeAttrs compat test now diffs the full TailNode self-view output against captured SaaS netmaps. Before this change the test compared compileNodeAttrs alone -- the policy-only output -- and needed a strip list to compensate for the missing baseline. With TailNode on the diff path, baseline emission is exercised end-to-end by every capture; a regression in TailNode breaks the suite. unmodelledTailnetStateCaps drops cap/ssh and cap/file-sharing now that both sides emit them identically. The file header is rewritten to read as 'caps SaaS emits where headscale has no equivalent yet' rather than the more confusing 'shape divergence' framing.
Taildrive (drive:share and drive:access) is policy-driven per Tailscale's documented behaviour (https://tailscale.com/docs/features/taildrive). The previous always-on baseline emission diverged from SaaS for every node not targeted by a drive nodeAttr -- a real semantic divergence that the compat suite caught once the test moved to comparing TailNode output against the captured netmaps. types.Node.TailNode no longer stamps the drive pair. Operators wanting taildrive add a nodeAttrs entry: "nodeAttrs": [ { "target": ["*"], "attr": ["drive:share", "drive:access"] } ] unmodelledTailnetStateCaps shrinks accordingly. The baseline-divergence group is gone; every entry left in the list is genuinely unmodelled (user-role caps, unimplemented features, tailnet metadata, internal tuning). servertest's TestNodeAttrsBaselineCapsAlwaysOn expects the smaller baseline (admin + ssh + file-sharing). Integration TestGrantCapDrive grants the drive caps explicitly via NodeAttrs to exercise the policy-driven emission path.
…enabled Tailscale stamps tailcfg.NodeAttrDefaultAutoUpdate on every node's CapMap with a JSON bool reflecting the tailnet-wide auto-update default. Headscale grows an auto_update.enabled config option and emits the cap accordingly from TailNode -- the cap leaves the unmodelledTailnetStateCaps strip list and is compared in full by the nodeAttrs compat suite. testNodeAttrsSuccess drives cfg.AutoUpdate.Enabled from tf.Input.Tailnet.Settings.DevicesAutoUpdatesOn so each capture's expected emission matches the SaaS state it was taken under. Two captures cover both branches: - nodeattrs-tailnet-devices-auto-updates-on -> [true] - nodeattrs-tailnet-devices-auto-updates-off -> [false] The Tailscale v2 TailnetSettings API does not expose the Send Files toggle, so the compat suite cannot vary cfg.Taildrop.Enabled per capture. TestTaildropDisabledWithholdsFileSharingCap covers the off path directly in servertest.
…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
Add the OAuth client type, its database storage, the scope grant package, policy tag-ownership exposure, and the state operations backing the v2 OAuth client-credentials flow.
…h-clients command Serve the v2 API over the local unix socket for the CLI and add the oauth-clients command that drives it.
…ients CLI Add scope-enforcement coverage and integration tests for the oauth-clients CLI across the test matrix.
Defence-in-depth fixes to the OIDC login flow. Set the state/nonce cookie Secure flag from the configured server_url scheme rather than req.TLS, so the cookies stay Secure behind a TLS-terminating reverse proxy where the proxy-to-Headscale hop is plain HTTP. Deriving it from config avoids trusting a spoofable X-Forwarded-Proto header. Make the OIDC state single-use: consume it from the cache on the callback and clear the state/nonce cookies once validated, so a replayed callback cannot resolve the same session and the cookies do not linger until expiry. Bound OIDC discovery to the caller's context so a slow or unreachable issuer fails startup within the timeout instead of hanging, and validate the issuer URL and required client_id/client_secret at config load so an unworkable setup fails fast.
Lexical sort placed v1.100 before v1.98; cmpver compares numerically.
Sets TS_DEBUG_DERP_WS_CLIENT + TS_DEBUG_USE_DERP_HTTP so a client can reach hsic's plain-HTTP embedded DERP over websockets; the counterpart to hsic.WithoutTLS.
Mints an admin API key and creates a keyType=client OAuth credential through gen/client/v2, exposed on ControlServer. Reusable by tests needing OAuth client credentials.
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.
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.