Commit Graph

4203 Commits

Author SHA1 Message Date
Kristoffer Dalby
58a85b68b3 CHANGELOG: bump 0.29.0 minimum tailscale client to v1.80.0
Reflects the capver floor move from 109 (v1.78) to 113 (v1.80) that
ships with this release.
v0.29.0-beta.1
2026-05-22 11:22:01 +02:00
Kristoffer Dalby
eb23c125fa capver, types: bump to tailscale v1.98, drop LegacyDERPString
Regenerate capver for tailscale v1.98 — `MinSupportedCapabilityVersion`
slides 109 → 113 (v1.78 → v1.80). v1.80+ clients understand
`Node.HomeDERP`, which superseded `LegacyDERPString` upstream at capver
111. Drops the emit, plumbing, the trip-wire that caught this cleanup,
the golden test fixtures, and the integration check on
`peer.LegacyDERPString()` — the sibling `HomeDERP` check covers intent.

`v1.98` was added by hand to `capver_generated.go` because tailscale
skipped publishing v1.97/v1.98 to ghcr until late in the cycle; a clean
regeneration produces the same content.
2026-05-22 11:22:01 +02:00
Kristoffer Dalby
570735f204 gen: regenerate grpc stubs with protoc-gen-go-grpc v1.6.2 2026-05-22 11:22:01 +02:00
Kristoffer Dalby
78570c754f Dockerfile: bump base images
Bump golang to 1.26.3, alpine to 3.23, rust to 1.95 across the
integration, derper, tailscale-HEAD, and tailscale-rs Dockerfiles.
2026-05-22 11:22:01 +02:00
Kristoffer Dalby
25adfaf341 flake.nix, flake.lock: bump nixpkgs and pinned tools
nixpkgs: bump unstable revision. Pinned tools: grpc-gateway 2.28.0 ->
2.29.0 (matches the Go dep), golangci-lint 2.11.4 -> 2.12.2. Disable
gomodguard in .golangci.yaml since 2.12 deprecated it.
2026-05-22 11:22:01 +02:00
Kristoffer Dalby
be90910d33 go.mod, go.sum: bump dependencies for v0.29.0
Refresh direct deps (tailscale v1.98.1, modernc.org/sqlite v1.50.1 +
libc v1.72.3 lockstep, otel cluster v1.43.0, x/* family, pgx v5.9.2,
go-jose v3.0.5, grpc v1.81.1, grpc-gateway v2.29.0) and bulk-update
remaining direct deps. tailscale held at v1.98.1 since v1.98.2 demands
go 1.26.3 which nixpkgs unstable does not yet ship.
2026-05-22 11:22:01 +02:00
Kristoffer Dalby
575d8ecbfd changelog: normalise 0.29.0 BREAKING and Changes sections
Move HA subnet router health probing above BREAKING so the layout
matches every other release. Drop **User deletion**: / **Node Expiry**:
bold prefixes redundant with the #### subgrouping. Fill missing PR
refs: #3202 (hostname rewrite), #3263 (sshTests + SSH rule validation),
#3194 (HA probe), #3251 (randomize_client_port removal), #3268
(trusted_proxies).
2026-05-20 14:17:24 +02:00
Kristoffer Dalby
e4e742c776 noise: pin outer RemoteAddr onto tunnel requests
The HTTP/2 server inside the Noise tunnel fills r.RemoteAddr from the
hijacked TCP socket, so /machine/register and /machine/map logged the
reverse proxy's loopback peer (e.g. 127.0.0.1:44388) even with
trusted_proxies set. The outer router's realIPMiddleware had already
resolved the client IP onto req.RemoteAddr; that value never crossed
the hijack.

Replace the inner realIPMiddleware mount — dead inside the encrypted
tunnel — with overrideRemoteAddr(req.RemoteAddr) so requests served
over the tunnel report the outer-resolved client IP.
2026-05-20 11:30:41 +02:00
Kristoffer Dalby
4cca63155d all: apply godoc [Name] link conventions across comments
Every Go-identifier reference in // and /* */ comments now uses
godoc's [Name] linking syntax so pkg.go.dev and `go doc` render
them as clickable cross-references. No behaviour change.

Pattern applied across the tree:
  In-package         [Foo], [Foo.Bar]
  Cross-package      [pkg.Foo], [pkg.Foo.Bar]
  Stdlib             [netip.Prefix], [errors.Is], [context.Context]
  Tailscale          [tailcfg.MapResponse], [tailcfg.Node.CapMap],
                     [tailcfg.NodeAttrSuggestExitNode]

Skip rules:
  - File:line refs left as plain text
  - HuJSON wire keys inside backtick raw strings untouched
  - ACL/policy syntax tokens (tag:foo, autogroup:self, ...) not Go
    symbols, left as plain text
  - JSON/OIDC wire keys, gorm tags, RFC IPv6 placeholders, markdown
    link tags, decorative dividers — all left as-is
2026-05-19 09:55:22 +02:00
Kristoffer Dalby
17236fd284 all: annotate complex functions with gocyclo rationale
Splitting these functions does not buy clarity — each has been
extracted before and put back. Pin the //nolint:gocyclo on each
with the reason their shape resists clean decomposition.

  policy/v2/policy.go     ViaRoutesForPeer        — three-pass
                                                    via-grant resolution
  policy/v2/filter.go     compileSSHPolicy        — per-rule
                                                    branches with
                                                    intertwined
                                                    autogroup:self
                                                    handling
                                                    (annotated in the
                                                    earlier nil-error
                                                    commit)
  state/state.go          HandleNodeFromPreAuthKey — security-
                                                    sensitive
                                                    sequential
                                                    validation order
  servertest/routes_test  TestRoutes              — table-driven
                                                    test driver with
                                                    many independent
                                                    subtests

Also: //nolint:recvcheck on policy/v2.SSHUser — UnmarshalJSON
requires a pointer receiver; the other methods on this string
newtype use value receivers by convention.
2026-05-19 09:55:22 +02:00
Kristoffer Dalby
3e2aa5814e all: annotate gosec false positives with rationale
Each //nolint:gosec carries the gosec code and one line on why
the finding is a false positive or already mitigated.

  G124 cookies (oidc.go x3, oidc_confirm_test.go)
    Secure is set conditionally on req.TLS != nil; HttpOnly and
    SameSiteStrictMode already on. gosec misses the conditional.
    Test fixture cookie is explicitly a test fixture.

  G705 (debug.go)
    templates.PingPage(...).Render() is a templ component that
    auto-escapes user input.

  G706 (scenario.go)
    Integration log emits trusted scenario state. The pre-built
    image G706 sites in hsic.go / tsic.go ride along with the
    earlier constants commit.

  G710 (app.go, tailsql.go)
    Redirect target is "trusted ServerURL prefix + path". gosec
    cannot see past the prefix.
2026-05-19 09:55:22 +02:00
Kristoffer Dalby
f905d58292 all: mechanical lint fixes
hscontrol/debug.go — pre-size nodes []nodeStatus to len(debugInfo)
    so the loop does not grow under append.
  hscontrol/mapper/batcher_test.go — testing.TB parameter on
    setupBatcherWithTestData renamed t → tb so thelper sees the
    expected name.
  hscontrol/db/text_serialiser.go — reflect.Ptr → reflect.Pointer
    (deprecated alias).
2026-05-19 09:55:22 +02:00
Kristoffer Dalby
e00c899219 cmd, templates, integration: extract shared production constants
Constants the operator/test reader benefits from centralising.
Tests stay verbatim (the .golangci.yaml goconst tune skips them);
extraction here applies only where the same literal acts as
shared vocabulary across files.

  cmd/headscale/cli/strings.go (new)
    Cobra subcommand verbs and aliases shared by every list / show
    / new / delete / expire command across api_key, nodes, policy,
    preauthkeys, users — plus the Result / Created / Expiration
    column headers used in printOutput maps.

  hscontrol/templates/design.go
    cssBorderHS, cssBreakWord, cssCenter, cssOverflowWrap — shared
    styles applied across design.go, ping.go, register_confirm.go.
    spaceS already existed; switch raw "0.5rem" literals to it.

  integration/hsic/hsic.go
    binHeadscale, flagOutput, acceptJSON — names invoked across
    hsic.go and config.go.

  integration/tsic/tsic.go
    tailscaleBin — used across docker exec call sites.
2026-05-19 09:55:22 +02:00
Kristoffer Dalby
64c398f2c2 metrics, policy/v2: drop unused scaffolding + nil-error returns
Two cleanups in the same package boundary:

policy/v2.Policy.compileFilterRules and compileFilterRulesForNode
returned (rules, error) where the error was always nil. Drop the
error return and the dead error handling at 15 call sites in the
compat tests.

Delete code with no callers:
  hscontrol/metrics.go
    prometheusMiddleware + respWriterProm — custom HTTP timer
    middleware that was never registered. /metrics stays mounted
    via debug.go and noise.go; mapresponse_* counters keep emitting.
    httpDuration and httpCounter — backing metrics for the deleted
    middleware.
  hscontrol/policy/v2/filter.go
    resolvedAddrsToPrincipals — superseded by ipSetToPrincipals.
    ipSetToPrefixStringList — superseded by inline prefix
    formatting at the surviving callers.
  hscontrol/policy/v2/tailscale_{acl,grants}_data_compat_test.go
    setupACLCompatNodes / setupGrantsCompatNodes — the data-driven
    tests switched to per-scenario topology reconstruction.
2026-05-19 09:55:22 +02:00
Kristoffer Dalby
7f02210863 .golangci: ignore tests for goconst, raise occurrence threshold
Test fixtures repeat strings (IPs, tags, hostnames, user/email
names) by their nature; extracting each into a named constant
adds indirection without adding clarity. Keep goconst strict on
production code, off on tests.

  ignore-tests: true       drop test-fixture noise entirely
  min-occurrences: 5       raise from default 3 to filter
                           "happens thrice" non-vocabulary cases
  min-len: 6               skip short literals like "set", "get",
                           "new" that read better at call sites
2026-05-19 09:55:22 +02:00
Florian Preinstorfer
e07b39108f Quote autogroup:self in the CHANGELOG 2026-05-18 17:21:58 +02:00
Florian Preinstorfer
e285f3c932 The headscale service is enabled by default 2026-05-18 17:21:58 +02:00
Florian Preinstorfer
355733342f Update config-example links 2026-05-18 17:21:58 +02:00
Florian Preinstorfer
f3f84a5a63 Add docs for policy-wide options and node attributes 2026-05-18 17:21:58 +02:00
Florian Preinstorfer
4eb5899154 Add taildrive, tests, sshTests as supported features 2026-05-18 17:21:58 +02:00
Kristoffer Dalby
e2f2f9211f state, servertest: property-test HA election + invariant catalogue
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.
2026-05-18 17:18:08 +02:00
Kristoffer Dalby
c7630b505b state: leave prefix unmapped when all primary candidates unhealthy
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.
2026-05-18 17:18:08 +02:00
Kristoffer Dalby
de6be71a86 state: batch HA probe results so dual-disconnect cannot flap primary
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.
2026-05-18 17:18:08 +02:00
Kristoffer Dalby
fb8eecae25 state: defer HA failover when probe target reconnected mid-cycle
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.
2026-05-18 17:18:08 +02:00
Kristoffer Dalby
a345a22a3b mapper, app: ship MagicDNS Routes as empty slices, not nil
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.
2026-05-18 17:18:08 +02:00
Kristoffer Dalby
dfcc96d808 integration: harden ACL test ergonomics
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.
2026-05-18 17:18:08 +02:00
Kristoffer Dalby
78fd6efb38 integration: replace ad-hoc test timeouts with named constants
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.
2026-05-18 17:18:08 +02:00
Kristoffer Dalby
eec3844f24 integration/dockertestutil: wait for libnetwork settle on reconnect
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.
2026-05-18 17:18:08 +02:00
Kristoffer Dalby
98e9ff4d36 integration: authenticate Docker Hub pulls and retry transient errors
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.
2026-05-18 17:18:08 +02:00
Kristoffer Dalby
4d3b567149 ci: use overlay2 storage driver instead of pinning docker v28
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
2026-05-18 17:18:08 +02:00
Kristoffer Dalby
963daf8908 docs: document trusted_proxies config option
Cover the option in config-example.yaml, the reverse-proxy
integration guide, and the 0.29.0 CHANGELOG.
2026-05-18 17:17:55 +02:00
Kristoffer Dalby
c6c29c05e5 hscontrol: gate proxy header trust on trusted_proxies
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.
2026-05-18 17:17:55 +02:00
Kristoffer Dalby
1f48ebb376 go.mod: add github.com/realclientip/realclientip-go
Used by the trusted_proxies middleware for safe X-Forwarded-For
parsing with rightmost-trusted-range semantics.
2026-05-18 17:17:55 +02:00
Kristoffer Dalby
b5b786f519 servertest: cover broader-dst via grant in filter test
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
2026-05-18 14:02:00 +02:00
Kristoffer Dalby
2cb914df59 policy/v2: add SaaS goldens for via-grant prefix containment
Captures from Tailscale SaaS exercising broader, narrower, host
alias, disjoint, and 4via6 grant destinations against advertised
subnet routes. TestGrantsCompat replays them.

Updates #3267
2026-05-18 14:02:00 +02:00
Kristoffer Dalby
e5fcd01ee6 policy/v2: match via-grant destinations by prefix overlap
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
2026-05-18 14:02:00 +02:00
Kristoffer Dalby
af7e7a4560 db: remove unused SetApprovedRoutes and SetTags helpers
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
2026-05-15 11:21:58 +02:00
Kristoffer Dalby
b1196baf6d state: add regression test for Node slice persistence
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
2026-05-15 11:21:58 +02:00
Kristoffer Dalby
7a20db9f49 types: persist Node JSON slices via named IsZero types
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
2026-05-15 11:21:58 +02:00
Kristoffer Dalby
e78a24b892 CHANGELOG: document sshTests evaluation (beta) 2026-05-13 21:10:13 +02:00
Kristoffer Dalby
574a61852a integration: reject failing sshTests at headscale policy set 2026-05-13 21:10:13 +02:00
Kristoffer Dalby
92a9accfcb cmd/headscale/cli: mention sshTests in policy check help 2026-05-13 21:10:13 +02:00
Kristoffer Dalby
26eebcea5a policy/v2: add sshtester compat runner
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.
2026-05-13 21:10:13 +02:00
Kristoffer Dalby
013dea4f40 policy/v2: evaluate sshTests at write boundary
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.
2026-05-13 21:10:13 +02:00
Kristoffer Dalby
6a0a297c7f policy/v2: validate sshTests at parse
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.
2026-05-13 21:10:13 +02:00
Kristoffer Dalby
d600090f2c policy/v2: align SSH rule validation with Tailscale
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.
2026-05-13 21:10:13 +02:00
Kristoffer Dalby
4ad200ab73 hscontrol: preserve nil expiry on tailscaled restart
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
2026-05-13 17:06:16 +02:00
Kristoffer Dalby
5d502bfb88 types/node, mapper: strip own IPv4 from emission when node has disable-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)
2026-05-13 14:22:30 +02:00
Kristoffer Dalby
64d13f77e8 types/config, types/node: model default-auto-update from auto_update.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.
2026-05-13 14:22:30 +02:00
Kristoffer Dalby
408f4022e4 CHANGELOG: document nodeAttrs feature and migrations 2026-05-13 14:22:30 +02:00