Compare commits

...

87 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.
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
Kristoffer Dalby 8ea4cd3faa types/node, policy/v2: drop taildrive caps from baseline emission
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.
2026-05-13 14:22:30 +02:00
Kristoffer Dalby 5ebc53c29e types/node, mapper, policy/v2: assemble self CapMap inside TailNode
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.
2026-05-13 14:22:30 +02:00
Kristoffer Dalby b3f795f0b4 mapper, policy/v2: stamp suggest-exit-node on Peer.CapMap when exit routes 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.
2026-05-13 14:22:30 +02:00
Kristoffer Dalby 078b9e308f policy/v2: SaaS-derived compat tests for nodeAttrs
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.
2026-05-13 14:22:30 +02:00
Kristoffer Dalby 3f73ed5404 config, types: move randomize_client_port from server config to policy 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.
2026-05-13 14:22:30 +02:00
Kristoffer Dalby 6fcff9e352 mapper, state: deliver nodeAttrs through MapResponse and harden nextdns 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.
2026-05-13 14:22:30 +02:00
Kristoffer Dalby a4f05b0962 policy/v2: parse, validate, and compile nodeAttrs
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.
2026-05-13 14:22:30 +02:00
Florian Preinstorfer c4ab267c36 Refresh features page 2026-05-12 14:12:29 +02:00
Florian Preinstorfer 109bfc404c Refresh docs for Grants
- 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
2026-05-12 14:12:29 +02:00
Florian Preinstorfer 1a64d950fd Document supported autogroups once 2026-05-12 14:12:29 +02:00
Florian Preinstorfer edb7ad0f81 Rewrite ACL docs as policy
- Rename from acl.md to policy.md and setup redirect links
- Mention both ACLs and Grants
- Remove most old ACL docs and replace with links to Tailscale docs
- Add "Getting started" section
- Add section about notable differences
2026-05-12 14:12:29 +02:00
Florian Preinstorfer 892ffffc4a Remove misleading comment 2026-05-12 14:12:29 +02:00
Florian Preinstorfer e13f0458bb Remove redundant prefix 2026-05-12 14:12:29 +02:00
Florian Preinstorfer 68b0014871 Use distroless without quotes 2026-05-12 14:12:29 +02:00
Florian Preinstorfer 484462898b Remove link to sqlite
Other mentions of SQLite don't link either.
2026-05-12 14:12:29 +02:00
Florian Preinstorfer 45b698dbac Shorten container introduction 2026-05-12 14:12:29 +02:00
Florian Preinstorfer 14ce7e9106 Remove link to Arch AUR headscale-git
Its outdated and unmaintained.
2026-05-12 14:12:29 +02:00
Florian Preinstorfer 84c7f0d450 Link to development builds 2026-05-12 14:12:29 +02:00
Florian Preinstorfer c7f221dd0a Fix typo and wording 2026-05-12 14:12:29 +02:00
Florian Preinstorfer 163363a12a Use docs instead of KB 2026-05-12 14:12:29 +02:00
Kristoffer Dalby f03d41ea9a CHANGELOG: document policy tests (beta)
Fixes #1803
2026-05-12 11:54:54 +01:00
Kristoffer Dalby d5b2837231 policy/v2: match default proto set for tests with no proto
The policy `tests` block lets entries omit `proto`. Tailscale's client
maps that to the default protocol set {TCP, UDP, ICMP, ICMPv6} — the
captured packet_filter_matches show all four IANA numbers explicitly
when no proto is set — and a rule restricted to any one of them
satisfies an empty-proto reachability test.

srcReachesDst was passing the empty Protocol through unchanged, which
landed an empty []int in ruleMatchesProto. The matcher then short-
circuited to "no match" for every rule with a non-empty IPProto
restriction, including TCP-only grants compiled from `ip: ["tcp:80"]`.
The bug surfaced in the captured allpass-acls-and-grants-mixed
scenario: the grant `tag:client → webserver:80` was reachable in the
compiled filter but the empty-proto test could not see it.

Expand the empty Protocol to the default set at the call site so
ruleMatchesProto's intersection check sees the right requested
protocols. Drop the now-dead empty-requestedProtos branch from the
matcher. The last divergence drops out of knownPolicyTesterDivergences
as a result.

Updates #1803
2026-05-12 11:54:54 +01:00
Kristoffer Dalby e4e209f919 policy/v2: canonicalize Protocol form during unmarshal
Tailscale accepts both named ("tcp") and numeric IANA ("6") protocol
forms wherever a Protocol value is allowed. Headscale stored whichever
form the user wrote, leaving downstream code with two equivalents to
handle separately. validateProtocolPortCompatibility only recognised
the named constants and rejected the numeric form, so a policy with
`proto: "6", dst: ["host:443"]` was rejected at parse time even though
SaaS accepts it.

Resolve the disagreement by normalising to the named form during
Protocol.UnmarshalJSON. Every downstream consumer now sees one form
regardless of what the user wrote, so layered guards like
`|| protocol == "6"` in the validator are unnecessary.

Updates #1803
2026-05-12 11:54:54 +01:00
Kristoffer Dalby f172dba0e3 policy/v2: validate tests block at parse boundary
A `tests` entry describes one connection attempt to one specific
host on one specific port over a connection-oriented protocol, and
asserts whether it is allowed or denied. Five shape rules follow —
single-port dst, proto in {tcp, udp, sctp, ""}, no
autogroup:internet dst, no CIDR-typed dst (raw `/N` or hosts:-alias
to a multi-host prefix), at least one of accept/deny — and every
one was previously silently accepted by headscale even though
Tailscale SaaS rejects them as "test(s) failed".

Enforce them in one pass over `pol.Tests` from `Policy.validate()`,
reusing the existing parse-time multierr aggregation. The same
shapes remain valid inside ACL or Grant destinations where the rule
does not apply; the validator only walks the tests array.

The compat runner now treats parse-time errors equivalently to
SetPolicy errors so the captured Tailscale body still matches via
substring regardless of which step surfaces the rejection. Nine
divergences resolved by this validation pass drop out of
knownPolicyTesterDivergences.

Updates #1803
2026-05-12 11:54:54 +01:00
Kristoffer Dalby c0774a739b policy/v2: add policytester captures recorded from Tailscale SaaS
57 captures covering the alias × outcome matrix for the tests block,
recorded against a real Tailscale SaaS tailnet. Replayed by
TestPolicyTesterCompat.

Bump the check-added-large-files pre-commit threshold to 1024 KB —
captures include verbose per-node netmaps and one is 620 KB.

Updates #1803
2026-05-12 11:54:54 +01:00
Kristoffer Dalby 7bc701179b policy/v2: add policytester compat test runner
Pin headscale's accept/reject decision and error body against
Tailscale SaaS by replaying captures recorded from a real tailnet.
Mirrors the tailscale_grants_compat_test.go pattern: glob over
testdata/policytest_results/, one t.Run per file, parse-or-SetPolicy
error must contain the captured api_response_body.message.

errPolicyTestsFailed is "test(s) failed" — Tailscale's literal body —
so substring match works against captured response bodies. Per-test
detail (src, dst, expected vs got) is preserved below the prefix for
the CLI / config-reload paths that don't have an audit endpoint.

knownPolicyTesterDivergences gates the 12 mismatches the captures
will surface so the suite stays green; engine fixes in follow-up
commits drop the entries as each is resolved.

Updates #1803
2026-05-12 11:54:54 +01:00
Kristoffer Dalby b29ae25356 policy/v2: evaluate the tests block on user-initiated writes
v2 silently dropped policy.tests, so a policy that contradicted its
own assertions still applied. Resolve src/dst via the existing Alias
machinery, walk the compiled global filter rules (acls and grants
both contribute), and run on every user-write boundary: SetPolicy,
the file watcher, and `headscale policy check`. A failing test
rejects the write before it mutates live state.

Boot-time reload skips evaluation; an already-stored policy that
references a deleted user shouldn't lock the server out.

`headscale policy check` is a thin frontend for the new CheckPolicy
gRPC method. The server-side handler builds a fresh PolicyManager
from the request bytes and the state's live users/nodes, runs
SetPolicy on the sandbox so the tests block executes, and returns
the result through gRPC status. No persistence, no policy_mode
coupling. --bypass-grpc-and-access-database-directly opens the DB
directly when the server is not running.

cmd/headscale/cli/root.go no longer special-cases `policy check` in
init() (the early return from PR #2580 broke --config registration
and viper priming for --bypass).

integration/cli_policy_test.go covers policy_mode={file,database} x
fixture={acl-only, acl+passing-tests, acl+failing-tests} x
bypass={false,true} = 12 rows.

Updates #1803

Co-authored-by: Janis Jansons <janhouse@gmail.com>
2026-05-12 11:54:54 +01:00
Kristoffer Dalby 56146de377 proto: add CheckPolicy RPC
CheckPolicy validates a candidate policy against a running server's
live users and nodes (running its tests block) without persisting
anything. Used by 'headscale policy check' to replace the in-process
validation path the CLI runs today, which would otherwise need its
own database connection.

Updates #1803
2026-05-12 11:54:54 +01:00
Kristoffer Dalby c3df84e354 policy/matcher: include CapGrant.Dsts in match destinations
MatchFromFilterRule only read DstPorts[].IP into the destination
IPSet. Cap-grant-only filter rules (e.g. tailscale.com/cap/relay)
carry their destinations in CapGrant[].Dsts, so the derived matchers
had empty dest sets and BuildPeerMap / ReduceNodes never exposed the
cap target to its source nodes. Without a companion IP-level grant
the relay node stayed invisible, so clients never tried to use it
and connections sat on DERP.

Union CapGrant[].Dsts into the destination IPSet alongside DstPorts.
Restores peer-visibility for any cap-grant-only relationship; the
peer-relay flow is the most visible instance.

Fixes #3256
2026-05-11 14:55:06 +01:00
Kristoffer Dalby 795a1efe9b ci: fetch full history in golangci-lint job
revgrep needs pull_request.base.sha in the local clone to compute
the diff against new code. With fetch-depth: 2, only HEAD and one
parent are fetched, so a stale base SHA (when main moves between
PR syncs) is not reachable and revgrep falls through, surfacing
pre-existing issues outside the PR scope.
2026-05-11 10:34:58 +01:00
Kristoffer Dalby dc733767c4 Dockerfile.tailscale-HEAD,Dockerfile.derper: bump golang to 1.26.3
tailscale upstream go.mod now requires 1.26.3.
2026-05-11 10:34:58 +01:00
Lealem Amedie 542091e82b Add unit test 2026-05-11 09:25:26 +01:00
Lealem Amedie 6cd919d411 mapper: include UserProfiles in policy-change MapResponses 2026-05-11 09:25:26 +01:00
Kristoffer Dalby 2f907edf87 hscontrol/types: regenerate types_clone.go for viewer bump
cmd/viewer in tailscale.com/cmd v1.97.0-pre emits new(*x) instead
of ptr.To(*x). No behaviour change.
2026-05-11 08:46:12 +01:00
Kristoffer Dalby 9621a97ebe ci, pre-commit: validate vendor hash via vendorhash check
Replace the grep/awk hash extraction in build.yml with a structured
vendorhash check step; the PR review comment now reads expected/
actual values directly from $GITHUB_OUTPUT instead of scraping Nix
stderr. Add a prek hook so divergence is caught locally before push.
2026-05-11 08:46:12 +01:00
Kristoffer Dalby e470774f6a cmd/vendorhash: track vendor SRI in flakehashes.json
Move the headscale vendorHash out of flake.nix into a content-
addressed flakehashes.json maintained by a small Go tool. The
schema and goModFingerprint algorithm mirror upstream tailscale's
tool/updateflakes so a future shared library extraction is trivial.

vendorhash check verifies flakehashes.json against the current
go.mod/go.sum. Hot path is a sha256 over those two files, so
re-runs without input change are essentially free; only an actual
fingerprint drift triggers go mod vendor + nardump.SRI.

vendorhash update recomputes both fields and rewrites the JSON.
The nix-vendor-sri devShell shim now wraps it.
2026-05-11 08:46:12 +01:00
Kristoffer Dalby 980622e9a5 flake.nix, go.mod: bump tailscale.com to v1.97.0-pre
Pulls in the cmd/nardump library split (tailscale/tailscale#19551)
so flakehashes.json tooling can import nardump.SRI directly.

Side effects: Go directive bumps to 1.26.2 and the nixpkgs lock
advances to a revision shipping go 1.26.2.
2026-05-11 08:46:12 +01:00
460 changed files with 3129930 additions and 4202 deletions
+14 -15
View File
@@ -38,24 +38,19 @@ jobs:
'**/flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
- name: Run nix build
id: build
- name: Check vendor hash
id: vendorhash
if: steps.changed-files.outputs.files == 'true'
run: |
nix build |& tee build-result
BUILD_STATUS="${PIPESTATUS[0]}"
nix develop --command -- go run ./cmd/vendorhash check | tee check-result
{
grep '^expected_sri=' check-result || true
grep '^actual_sri=' check-result || true
} >> "$GITHUB_OUTPUT"
OLD_HASH=$(cat build-result | grep specified: | awk -F ':' '{print $2}' | sed 's/ //g')
NEW_HASH=$(cat build-result | grep got: | awk -F ':' '{print $2}' | sed 's/ //g')
echo "OLD_HASH=$OLD_HASH" >> $GITHUB_OUTPUT
echo "NEW_HASH=$NEW_HASH" >> $GITHUB_OUTPUT
exit $BUILD_STATUS
- name: Nix gosum diverging
- name: Vendor hash diverging
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
if: failure() && steps.build.outcome == 'failure'
if: failure() && steps.vendorhash.outcome == 'failure'
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
@@ -63,9 +58,13 @@ jobs:
pull_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Nix build failed with wrong gosum, please update "vendorSha256" (${{ steps.build.outputs.OLD_HASH }}) for the "headscale" package in flake.nix with the new SHA: ${{ steps.build.outputs.NEW_HASH }}'
body: 'Vendor hash in `flakehashes.json` is stale (was `${{ steps.vendorhash.outputs.expected_sri }}`, should be `${{ steps.vendorhash.outputs.actual_sri }}`). Run `go run ./cmd/vendorhash update` and commit the result.'
})
- name: Run nix build
if: steps.changed-files.outputs.files == 'true'
run: nix build
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
if: steps.changed-files.outputs.files == 'true'
with:
@@ -14,7 +14,7 @@ import (
// Key is the test function name, value is a list of subtest prefixes.
// Each prefix becomes a separate CI job as "TestName/prefix".
//
// Example: TestAutoApproveMultiNetwork has subtests like:
// Example: [TestAutoApproveMultiNetwork] has subtests like:
// - TestAutoApproveMultiNetwork/authkey-tag-advertiseduringup-false-pol-database
// - TestAutoApproveMultiNetwork/webauth-user-advertiseduringup-true-pol-file
//
+18 -15
View File
@@ -67,24 +67,21 @@ jobs:
with:
name: postgres-image
path: /tmp/artifacts
- name: Pin Docker to v28 (avoid v29 breaking changes)
- name: Force overlay2 storage driver
run: |
# Docker 29 breaks docker build via Go client libraries and
# docker load/save with certain tarball formats.
# Pin to Docker 28.x until our tooling is updated.
# https://github.com/actions/runner-images/issues/13474
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
| sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update -qq
VERSION=$(apt-cache madison docker-ce | grep '28\.5' | head -1 | awk '{print $3}')
sudo apt-get install -y --allow-downgrades \
"docker-ce=${VERSION}" "docker-ce-cli=${VERSION}"
sudo mkdir -p /etc/docker
echo '{"storage-driver":"overlay2"}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
docker version
- name: Login to Docker Hub
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_CI_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_CI_TOKEN }}
if: env.DOCKERHUB_USERNAME != ''
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Load Docker images, Go cache, and prepare binary
run: |
gunzip -c /tmp/artifacts/headscale-image.tar.gz | docker load
@@ -105,6 +102,12 @@ jobs:
HEADSCALE_INTEGRATION_POSTGRES_IMAGE: ${{ inputs.postgres_flag == '--postgres=1' && format('postgres:{0}', github.sha) || '' }}
HEADSCALE_INTEGRATION_GO_CACHE: /tmp/go-cache/go
HEADSCALE_INTEGRATION_GO_BUILD_CACHE: /tmp/go-cache/.cache/go-build
# Mirror the docker/login-action secrets into env so the
# dockertestutil.Credentials resolver picks them up directly
# (otherwise it falls back to parsing ~/.docker/config.json,
# which works but is one step further from the source).
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_CI_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_CI_TOKEN }}
run: /tmp/artifacts/hi run --stats --ts-memory-limit=300 --hs-memory-limit=1500 "^${{ inputs.test }}$" \
--timeout=120m \
${{ inputs.postgres_flag }}
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 2
fetch-depth: 0
- name: Get changed files
id: changed-files
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
+29 -29
View File
@@ -69,25 +69,26 @@ jobs:
name: go-cache
path: go-cache.tar.gz
retention-days: 10
- name: Pin Docker to v28 (avoid v29 breaking changes)
- name: Force overlay2 storage driver
if: steps.changed-files.outputs.files == 'true'
run: |
# Docker 29 breaks docker build via Go client libraries and
# docker load/save with certain tarball formats.
# Pin to Docker 28.x until our tooling is updated.
# Docker 29 runner images default to overlayfs, which breaks
# docker build via Go SDK libraries and docker save/load
# tarball formats. overlay2 is the long-standing default.
# https://github.com/actions/runner-images/issues/13474
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
| sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update -qq
VERSION=$(apt-cache madison docker-ce | grep '28\.5' | head -1 | awk '{print $3}')
sudo apt-get install -y --allow-downgrades \
"docker-ce=${VERSION}" "docker-ce-cli=${VERSION}"
sudo mkdir -p /etc/docker
echo '{"storage-driver":"overlay2"}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
docker version
- name: Login to Docker Hub
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_CI_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_CI_TOKEN }}
if: env.DOCKERHUB_USERNAME != ''
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Build headscale image
if: steps.changed-files.outputs.files == 'true'
run: |
@@ -123,24 +124,21 @@ jobs:
needs: build
if: needs.build.outputs.files-changed == 'true'
steps:
- name: Pin Docker to v28 (avoid v29 breaking changes)
- name: Force overlay2 storage driver
run: |
# Docker 29 breaks docker build via Go client libraries and
# docker load/save with certain tarball formats.
# Pin to Docker 28.x until our tooling is updated.
# https://github.com/actions/runner-images/issues/13474
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
| sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update -qq
VERSION=$(apt-cache madison docker-ce | grep '28\.5' | head -1 | awk '{print $3}')
sudo apt-get install -y --allow-downgrades \
"docker-ce=${VERSION}" "docker-ce-cli=${VERSION}"
sudo mkdir -p /etc/docker
echo '{"storage-driver":"overlay2"}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
docker version
- name: Login to Docker Hub
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_CI_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_CI_TOKEN }}
if: env.DOCKERHUB_USERNAME != ''
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Pull and save postgres image
run: |
docker pull postgres:latest
@@ -202,6 +200,8 @@ jobs:
- TestAuthWebFlowAuthenticationPingAll
- TestAuthWebFlowLogoutAndReloginSameUser
- TestAuthWebFlowLogoutAndReloginNewUser
- TestPolicyCheckCommand
- TestSSHTestsRejectFailingPolicy
- TestUserCommand
- TestPreAuthKeyCommand
- TestPreAuthKeyCommandWithoutExpiry
+12
View File
@@ -13,6 +13,7 @@ linters:
- gochecknoinits
- gocognit
- godox
- gomodguard
- interfacebloat
- ireturn
- lll
@@ -30,6 +31,17 @@ linters:
- wrapcheck
- wsl
settings:
goconst:
# Test fixtures repeat strings (IPs, tags, hostnames) by their
# nature; extracting them obscures the test rather than helping.
# Production code stays strict.
ignore-tests: true
# Default is 3. Bump so "happens thrice" cases that are not part
# of a shared vocabulary do not get extracted.
min-occurrences: 5
# Default is 3. Short literals ("set", "get", "new") read better
# at call sites than behind a named constant.
min-len: 6
forbidigo:
forbid:
# Forbid time.Sleep everywhere with context-appropriate alternatives
+9
View File
@@ -13,6 +13,7 @@ repos:
rev: v6.0.0
hooks:
- id: check-added-large-files
args: [--maxkb=1024]
- id: check-case-conflict
- id: check-executables-have-shebangs
- id: check-json
@@ -60,3 +61,11 @@ repos:
language: system
types: [go]
pass_filenames: false
# vendor-hash keeps flakehashes.json in sync with go.mod/go.sum.
- id: vendor-hash
name: vendor-hash
entry: nix develop --command -- go run ./cmd/vendorhash check
language: system
files: ^(go\.mod|go\.sum|flakehashes\.json)$
pass_filenames: false
+158 -37
View File
@@ -2,7 +2,7 @@
## 0.29.0 (202x-xx-xx)
**Minimum supported Tailscale client version: v1.76.0**
**Minimum supported Tailscale client version: v1.80.0**
### Tailscale ACL compatibility improvements
@@ -27,6 +27,50 @@ A new `headscale auth` CLI command group supports the approval flow:
[#1850](https://github.com/juanfont/headscale/pull/1850)
[#3180](https://github.com/juanfont/headscale/pull/3180)
### Policy tests (beta)
Headscale now evaluates the `tests` block in a policy file. Tests assert reachability between
named sources and destinations and cover the whole policy — both `acls` and `grants` rules
contribute. They run on user-initiated writes via `headscale policy set`, on SIGHUP reload
(`systemctl reload headscale` / `kill -HUP $(pidof headscale)`), and on `headscale policy check`.
A failing test rejects the write before it is applied, with the same error message Tailscale SaaS
would return for the same policy.
At boot a stored policy whose tests no longer pass — for example because a referenced user was
deleted while the server was offline — logs a warning and the server keeps running. Fix the
policy and reload.
This feature is **beta** while behavioural coverage against Tailscale SaaS broadens.
[#3229](https://github.com/juanfont/headscale/pull/3229)
### SSH policy tests (beta)
Headscale now evaluates the `sshTests` block in a policy file. Each entry names a source, one or
more destination hosts, and three optional user lists: `accept` asserts the listed login users
reach every destination via an accept- or check-action SSH rule, `deny` asserts none of them
reach any destination, and `check` requires reachability specifically through a check-action
rule. Tests run on `headscale policy set`, on SIGHUP reload (`systemctl reload headscale` /
`kill -HUP $(pidof headscale)`), and on `headscale policy check`. A failing test rejects the
write before it is applied, with the same error message Tailscale SaaS would return for the same
policy.
At boot a stored policy whose sshTests no longer pass — for example because a referenced user was
deleted while the server was offline — logs a warning and the server keeps running. Fix the
policy and reload.
This feature is **beta** while behavioural coverage against Tailscale SaaS broadens.
[#3263](https://github.com/juanfont/headscale/pull/3263)
### SSH rule validation
SSH rule parsing now trims surrounding whitespace on `action`, `users`, `src`, and `dst`,
rejects empty or wildcard entries in `users`, rejects empty `acceptEnv`, and rejects negative
`checkPeriod`. `hosts:` aliases are rejected as SSH destinations, non-ASCII tag names are
rejected at parse time, and the wording for group-nesting cycles matches Tailscale SaaS.
[#3263](https://github.com/juanfont/headscale/pull/3263)
### Grants
We now support [Tailscale grants](https://tailscale.com/docs/features/access-control/grants)
@@ -36,33 +80,82 @@ field steers traffic through specific tagged subnet routers or exit nodes. The `
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
As part of this, we added `autogroup:danger-all`. It resolves to `0.0.0.0/0` and `::/0`, all IP
addresses, including those outside the tailnet. This replaces the old behaviour where `*` matched
all IPs (see BREAKING below). The name is intentionally scary: accepting traffic from the entire
all IPs (see BREAKING below). The name is intentional: 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)
### Node attributes (`nodeAttrs`)
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)).
ACL policies now accept a `nodeAttrs` block. Each entry hands a list of
Tailscale node capabilities to every node matching `target`. The accepted
target forms are the same as `acls.src` and `grants.src`: users, groups,
tags, hosts, prefixes, `autogroup:member`, `autogroup:tagged`, and `*`.
What changed:
```jsonc
{
"randomizeClientPort": true,
"nodeAttrs": [
{ "target": ["autogroup:tagged"], "attr": ["disable-captive-portal-detection"] },
{ "target": ["alice@example.com"], "attr": ["nextdns:abc123"] },
],
}
```
- 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).
Frequently requested capabilities this unlocks include `magicdns-aaaa`,
`disable-relay-server`, `disable-captive-portal-detection`,
`nextdns:<profile>` / `nextdns:no-device-info`, `randomize-client-port`,
and the Taildrive `drive:share` / `drive:access` pair. The set is not
limited to these, any string-only cap an operator places in policy
reaches clients unchanged.
`randomizeClientPort` also lands as a top-level policy field that toggles
the default for every node, replacing the old server-config knob.
A new `auto_update.enabled` config option controls the tailnet-wide
default for client auto-update. When true, every node's CapMap carries
`default-auto-update: [true]` so fresh clients pick up the default
unless they make a local opt-in / opt-out choice.
Policies that use the `funnel` cap, `ipPool` blocks, or
`autogroup:admin` / `autogroup:owner` targets are rejected at load —
those features depend on machinery headscale does not yet ship.
[#3251](https://github.com/juanfont/headscale/pull/3251)
### Taildrive
Taildrive ([file-sync between
nodes](https://tailscale.com/docs/features/taildrive)) is now
configurable through policy. Grant `drive:share` to the node that
hosts files and `drive:access` to nodes that read or write them; pair
with a `tailscale.com/cap/drive` grant to set the per-share access
mode:
```jsonc
{
"nodeAttrs": [
{ "target": ["tag:fileserver"], "attr": ["drive:share"] },
{ "target": ["autogroup:member"], "attr": ["drive:access"] },
],
"grants": [
{
"src": ["autogroup:member"],
"dst": ["tag:fileserver"],
"app": {
"tailscale.com/cap/drive": [{ "shares": ["*"], "access": "rw" }],
},
},
],
}
```
A wildcard `nodeAttrs` (`"target": ["*"]`) hands the caps to every
node when fine-grained control is not needed.
### Hostname sanitisation
Hostnames are now santised using Tailscales `magicdns` sanitisation rules, matching Tailscale SaaS behavior. This means that hostnames with non-ASCII characters, special characters, or reserved DNS label characters are now transformed into valid DNS labels for MagicDNS. This improves our previously too strict sanitisation that rejected hostnames based on our guesswork and not based on the Tailscale upstream behaviour.
Examples that previously regressed and now work:
@@ -75,11 +168,24 @@ Examples that previously regressed and now work:
| `My-PC!` | `My-PC!` | `my-pc` |
| `我的电脑` | `我的电脑` | `node` |
[#3202](https://github.com/juanfont/headscale/pull/3202)
### 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.
[#3194](https://github.com/juanfont/headscale/pull/3194)
### 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.
- 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. [#3202](https://github.com/juanfont/headscale/pull/3202)
#### ACL Policy
@@ -88,8 +194,8 @@ Examples that previously regressed and now work:
- 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)
- Policies using tags, hosts, or IP addresses as sources for autogroup:self destinations will now fail validation
- 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
@@ -101,21 +207,32 @@ Examples that previously regressed and now work:
- Downgrading to a previous minor version is blocked
- Patch version changes within the same minor are always allowed
#### Configuration
- The `randomize_client_port` server-config key was removed; the
toggle now lives in the policy file as a top-level
`randomizeClientPort` field, matching the Tailscale-hosted schema. [#3251](https://github.com/juanfont/headscale/pull/3251)
Headscale refuses to start when the old key is set. Move it to the
policy file referenced by `policy.path`:
```jsonc
{
"randomizeClientPort": true,
}
```
If you do not have a policy file yet, create one with that minimal
content and point `policy.path` at it. The default carries over —
empty / absent policy means `randomizeClientPort: false`, matching
the previous behaviour for operators who never set the key. Per-node
opt-in via `nodeAttrs` is also supported and stacks on top of the
global default.
#### 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
@@ -134,6 +251,7 @@ 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)
- Reject policies whose `user@` tokens match multiple DB users; rename the duplicate via `headscale users rename` to load [#3160](https://github.com/juanfont/headscale/issues/3160)
- Evaluate the policy `tests` block on user-initiated writes across both `acls` and `grants`; reject policies whose tests fail (beta) [#1803](https://github.com/juanfont/headscale/issues/1803)
#### Grants
@@ -155,7 +273,8 @@ connected" routers that maintain their control session but cannot route packets.
- `headscale policy check --bypass-grpc-and-access-database-directly` validates `user@` tokens against the live user database [#3160](https://github.com/juanfont/headscale/issues/3160)
- Remove deprecated `--namespace` flag from `nodes list`, `nodes register`, and `debug create-node` commands (use `--user` instead) [#3093](https://github.com/juanfont/headscale/pull/3093)
- Remove deprecated `namespace`/`ns` command aliases for `users` and `machine`/`machines` aliases for `nodes` [#3093](https://github.com/juanfont/headscale/pull/3093)
- **User deletion**: Fix `DestroyUser` deleting all pre-auth keys in the database instead of only the target user's keys [#3155](https://github.com/juanfont/headscale/pull/3155)
- Fix `DestroyUser` deleting all pre-auth keys in the database instead of only the target user's keys [#3155](https://github.com/juanfont/headscale/pull/3155)
- `headscale policy check` evaluates the `tests` block when invoked with `--bypass-grpc-and-access-database-directly`; without the flag it warns instead of running the tests against empty data [#1803](https://github.com/juanfont/headscale/issues/1803)
#### API
@@ -174,6 +293,7 @@ connected" routers that maintain their control session but cannot route packets.
- 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`
- Add `trusted_proxies` to gate `True-Client-IP` / `X-Real-IP` / `X-Forwarded-For` (previously honoured from any client) [#3268](https://github.com/juanfont/headscale/pull/3268)
#### Debug
@@ -185,8 +305,9 @@ connected" routers that maintain their control session but cannot route packets.
- Remove old migrations for the debian package [#3185](https://github.com/juanfont/headscale/pull/3185)
- Install `config-example.yaml` as example for the debian package [#3186](https://github.com/juanfont/headscale/pull/3186)
- **Node Expiry**: Fix user owned re registration with zero client expiry and no default storing `0001-01-01 00:00:00` in the database instead of NULL [#3199](https://github.com/juanfont/headscale/pull/3199)
- Fix user-owned re-registration with zero client expiry and no default storing `0001-01-01 00:00:00` in the database instead of `NULL` [#3199](https://github.com/juanfont/headscale/pull/3199)
- Pre-existing rows with `0001-01-01 00:00:00` are not backfilled; they clear themselves the next time the node re-registers
- Fix `tailscaled` restart on a node with no expiry resetting `NULL` to `0001-01-01 00:00:00` in the database, affecting both tagged and untagged nodes [#3197](https://github.com/juanfont/headscale/pull/3197)
## 0.28.0 (2026-02-04)
+2 -2
View File
@@ -1,6 +1,6 @@
# For testing purposes only
FROM golang:1.26.2-alpine AS build-env
FROM golang:1.26.3-alpine AS build-env
WORKDIR /go/src
@@ -12,7 +12,7 @@ WORKDIR /go/src/tailscale
ARG TARGETARCH
RUN GOARCH=$TARGETARCH go install -v ./cmd/derper
FROM alpine:3.22
FROM alpine:3.23
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables curl
COPY --from=build-env /go/bin/* /usr/local/bin/
+1 -1
View File
@@ -2,7 +2,7 @@
# and are in no way endorsed by Headscale's maintainers as an
# official nor supported release or distribution.
FROM docker.io/golang:1.26.1-trixie AS builder
FROM docker.io/golang:1.26.3-trixie AS builder
ARG VERSION=dev
ENV GOPATH /go
WORKDIR /go/src/headscale
+2 -2
View File
@@ -4,7 +4,7 @@
# This Dockerfile is more or less lifted from tailscale/tailscale
# to ensure a similar build process when testing the HEAD of tailscale.
FROM golang:1.26.2-alpine AS build-env
FROM golang:1.26.3-alpine AS build-env
WORKDIR /go/src
@@ -36,7 +36,7 @@ RUN GOARCH=$TARGETARCH go install -tags="${BUILD_TAGS}" -ldflags="\
-X tailscale.com/version.gitCommitStamp=$VERSION_GIT_HASH" \
-v ./cmd/tailscale ./cmd/tailscaled ./cmd/containerboot
FROM alpine:3.22
FROM alpine:3.23
# Upstream: ca-certificates ip6tables iptables iproute2
# Tests: curl python3 (traceroute via BusyBox)
RUN apk add --no-cache ca-certificates curl ip6tables iptables iproute2 python3
+2 -2
View File
@@ -1,4 +1,4 @@
FROM rust:1.94-bookworm AS builder
FROM rust:1.95-trixie AS builder
ARG TAILSCALE_RS_REPO=https://github.com/tailscale/tailscale-rs.git
ARG TAILSCALE_RS_REF=main
@@ -15,7 +15,7 @@ RUN sed -i '/^axum = \["dep:axum"\]/a insecure-keyfetch = ["ts_control/insecure-
RUN cargo build --release --features axum,insecure-keyfetch --example axum
FROM debian:bookworm-slim
FROM debian:trixie-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends \
+9 -10
View File
@@ -41,9 +41,9 @@ var apiKeysCmd = &cobra.Command{
}
var listAPIKeys = &cobra.Command{
Use: "list",
Use: cmdList,
Short: "List the Api keys for headscale",
Aliases: []string{"ls", "show"},
Aliases: []string{"ls", cmdShow},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
response, err := client.ListApiKeys(ctx, &v1.ListApiKeysRequest{})
if err != nil {
@@ -51,9 +51,8 @@ var listAPIKeys = &cobra.Command{
}
return printListOutput(cmd, response.GetApiKeys(), func() error {
tableData := pterm.TableData{
{"ID", "Prefix", "Expiration", "Created"},
}
tableData := make(pterm.TableData, 1, 1+len(response.GetApiKeys()))
tableData[0] = []string{"ID", "Prefix", colExpiration, colCreated}
for _, key := range response.GetApiKeys() {
expiration := "-"
@@ -82,7 +81,7 @@ var createAPIKeyCmd = &cobra.Command{
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.`,
Aliases: []string{"c", "new"},
Aliases: []string{"c", cmdNew},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
expiration, err := expirationFromFlag(cmd)
if err != nil {
@@ -117,9 +116,9 @@ func apiKeyIDOrPrefix(cmd *cobra.Command) (uint64, string, error) {
}
var expireAPIKeyCmd = &cobra.Command{
Use: "expire",
Use: cmdExpire,
Short: "Expire an ApiKey",
Aliases: []string{"revoke", "exp", "e"},
Aliases: []string{"revoke", aliasExp, "e"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
id, prefix, err := apiKeyIDOrPrefix(cmd)
if err != nil {
@@ -139,9 +138,9 @@ var expireAPIKeyCmd = &cobra.Command{
}
var deleteAPIKeyCmd = &cobra.Command{
Use: "delete",
Use: cmdDelete,
Short: "Delete an ApiKey",
Aliases: []string{"remove", "del"},
Aliases: []string{"remove", aliasDel},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
id, prefix, err := apiKeyIDOrPrefix(cmd)
if err != nil {
+15 -16
View File
@@ -89,9 +89,9 @@ var registerNodeCmd = &cobra.Command{
}
var listNodesCmd = &cobra.Command{
Use: "list",
Use: cmdList,
Short: "List nodes",
Aliases: []string{"ls", "show"},
Aliases: []string{"ls", cmdShow},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
user, _ := cmd.Flags().GetString("user")
@@ -101,7 +101,7 @@ var listNodesCmd = &cobra.Command{
}
return printListOutput(cmd, response.GetNodes(), func() error {
tableData, err := nodesToPtables(user, response.GetNodes())
tableData, err := nodesToPtables(response.GetNodes())
if err != nil {
return fmt.Errorf("converting to table: %w", err)
}
@@ -145,12 +145,12 @@ var listNodeRoutesCmd = &cobra.Command{
}
var expireNodeCmd = &cobra.Command{
Use: "expire",
Use: cmdExpire,
Short: "Expire (log out) a node in your network",
Long: `Expiring a node will keep the node in the database and force it to reauthenticate.
Use --disable to disable key expiry (node will never expire).`,
Aliases: []string{"logout", "exp", "e"},
Aliases: []string{"logout", aliasExp, "e"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
identifier, _ := cmd.Flags().GetUint64("identifier")
disableExpiry, _ := cmd.Flags().GetBool("disable")
@@ -229,9 +229,9 @@ var renameNodeCmd = &cobra.Command{
}
var deleteNodeCmd = &cobra.Command{
Use: "delete",
Use: cmdDelete,
Short: "Delete a node",
Aliases: []string{"del"},
Aliases: []string{aliasDel},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
identifier, _ := cmd.Flags().GetUint64("identifier")
@@ -252,7 +252,7 @@ var deleteNodeCmd = &cobra.Command{
"Do you want to remove the node %s?",
getResponse.GetNode().GetName(),
)) {
return printOutput(cmd, map[string]string{"Result": "Node not deleted"}, "Node not deleted")
return printOutput(cmd, map[string]string{colResult: "Node not deleted"}, "Node not deleted")
}
_, err = client.DeleteNode(ctx, deleteRequest)
@@ -262,7 +262,7 @@ var deleteNodeCmd = &cobra.Command{
return printOutput(
cmd,
map[string]string{"Result": "Node deleted"},
map[string]string{colResult: "Node deleted"},
"Node deleted",
)
}),
@@ -304,10 +304,7 @@ be assigned to nodes.`,
},
}
func nodesToPtables(
currentUser string,
nodes []*v1.Node,
) (pterm.TableData, error) {
func nodesToPtables(nodes []*v1.Node) (pterm.TableData, error) {
tableHeader := []string{
"ID",
"Hostname",
@@ -319,11 +316,12 @@ func nodesToPtables(
"IP addresses",
"Ephemeral",
"Last seen",
"Expiration",
colExpiration,
"Connected",
"Expired",
}
tableData := pterm.TableData{tableHeader}
tableData := make(pterm.TableData, 1, 1+len(nodes))
tableData[0] = tableHeader
for _, node := range nodes {
var ephemeral bool
@@ -447,7 +445,8 @@ func nodeRoutesToPtables(
"Available",
"Serving (Primary)",
}
tableData := pterm.TableData{tableHeader}
tableData := make(pterm.TableData, 1, 1+len(nodes))
tableData[0] = tableHeader
for _, node := range nodes {
nodeData := []string{
+43 -14
View File
@@ -48,7 +48,7 @@ func init() {
policyCmd.AddCommand(setPolicy)
checkPolicy.Flags().StringP("file", "f", "", "Path to a policy file in HuJSON format")
checkPolicy.Flags().BoolP(bypassFlag, "", false, "Uses the headscale config to directly access the database, bypassing gRPC and does not require the server to be running. Required to validate that user@ tokens resolve against the user database; without it, the check is syntax-only.")
checkPolicy.Flags().BoolP(bypassFlag, "", false, "Open the database directly (no gRPC, no running server) to resolve user references and to evaluate the policy's tests and sshTests blocks. Required when those checks are needed.")
mustMarkRequired(checkPolicy, "file")
policyCmd.AddCommand(checkPolicy)
}
@@ -61,7 +61,7 @@ var policyCmd = &cobra.Command{
var getPolicy = &cobra.Command{
Use: "get",
Short: "Print the current ACL Policy",
Aliases: []string{"show", "view", "fetch"},
Aliases: []string{cmdShow, "view", "fetch"},
RunE: func(cmd *cobra.Command, args []string) error {
var policyData string
@@ -171,6 +171,11 @@ var setPolicy = &cobra.Command{
var checkPolicy = &cobra.Command{
Use: "check",
Short: "Check the Policy file for errors",
Long: `
Check validates the policy against the server's live users and nodes,
running any "tests" or "sshTests" block. By default the command is a
thin frontend for a gRPC call to a running headscale; pass --` + bypassFlag + ` to
open the database directly when headscale is not running.`,
RunE: func(cmd *cobra.Command, args []string) error {
policyPath, _ := cmd.Flags().GetString("file")
@@ -179,8 +184,6 @@ var checkPolicy = &cobra.Command{
return fmt.Errorf("reading policy file: %w", err)
}
var users []types.User
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
@@ -192,23 +195,49 @@ var checkPolicy = &cobra.Command{
}
defer d.Close()
users, err = d.ListUsers()
users, err := d.ListUsers()
if err != nil {
return fmt.Errorf("loading users for policy validation: %w", err)
return fmt.Errorf("loading users: %w", err)
}
}
_, err = policy.NewPolicyManager(policyBytes, users, views.Slice[types.NodeView]{})
if err != nil {
return fmt.Errorf("parsing policy file: %w", err)
}
nodes, err := d.ListNodes()
if err != nil {
return fmt.Errorf("loading nodes: %w", err)
}
// [policy.NewPolicyManager] validates structure and user references
// but intentionally skips test evaluation (boot path).
// [policy.PolicyManager.SetPolicy] is the user-write boundary and is what runs the
// tests and sshTests blocks.
pm, err := policy.NewPolicyManager(policyBytes, users, nodes.ViewSlice())
if err != nil {
return fmt.Errorf("parsing policy file: %w", err)
}
_, err = pm.SetPolicy(policyBytes)
if err != nil {
return err
}
if users == nil {
fmt.Println("Policy syntax is valid (run with --" + bypassFlag + " to also validate user references against the database)")
} else {
fmt.Println("Policy is valid")
return nil
}
ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig()
if err != nil {
return fmt.Errorf("connecting to headscale: %w", err)
}
defer cancel()
defer conn.Close()
_, err = client.CheckPolicy(ctx, &v1.CheckPolicyRequest{Policy: string(policyBytes)})
if err != nil {
return err
}
fmt.Println("Policy is valid")
return nil
},
}
+17 -18
View File
@@ -42,9 +42,9 @@ var preauthkeysCmd = &cobra.Command{
}
var listPreAuthKeys = &cobra.Command{
Use: "list",
Use: cmdList,
Short: "List all preauthkeys",
Aliases: []string{"ls", "show"},
Aliases: []string{"ls", cmdShow},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
response, err := client.ListPreAuthKeys(ctx, &v1.ListPreAuthKeysRequest{})
if err != nil {
@@ -52,17 +52,16 @@ var listPreAuthKeys = &cobra.Command{
}
return printListOutput(cmd, response.GetPreAuthKeys(), func() error {
tableData := pterm.TableData{
{
"ID",
"Key/Prefix",
"Reusable",
"Ephemeral",
"Used",
"Expiration",
"Created",
"Owner",
},
tableData := make(pterm.TableData, 1, 1+len(response.GetPreAuthKeys()))
tableData[0] = []string{
"ID",
"Key/Prefix",
"Reusable",
"Ephemeral",
"Used",
colExpiration,
colCreated,
"Owner",
}
for _, key := range response.GetPreAuthKeys() {
@@ -100,7 +99,7 @@ var listPreAuthKeys = &cobra.Command{
var createPreAuthKeyCmd = &cobra.Command{
Use: "create",
Short: "Creates a new preauthkey",
Aliases: []string{"c", "new"},
Aliases: []string{"c", cmdNew},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
user, _ := cmd.Flags().GetUint64("user")
reusable, _ := cmd.Flags().GetBool("reusable")
@@ -130,9 +129,9 @@ var createPreAuthKeyCmd = &cobra.Command{
}
var expirePreAuthKeyCmd = &cobra.Command{
Use: "expire",
Use: cmdExpire,
Short: "Expire a preauthkey",
Aliases: []string{"revoke", "exp", "e"},
Aliases: []string{"revoke", aliasExp, "e"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
id, _ := cmd.Flags().GetUint64("id")
@@ -154,9 +153,9 @@ var expirePreAuthKeyCmd = &cobra.Command{
}
var deletePreAuthKeyCmd = &cobra.Command{
Use: "delete",
Use: cmdDelete,
Short: "Delete a preauthkey",
Aliases: []string{"del", "rm", "d"},
Aliases: []string{aliasDel, "rm", "d"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
id, _ := cmd.Flags().GetUint64("id")
+1 -7
View File
@@ -3,7 +3,6 @@ package cli
import (
"os"
"runtime"
"slices"
"strings"
"github.com/juanfont/headscale/hscontrol/types"
@@ -22,11 +21,6 @@ func init() {
return
}
if slices.Contains(os.Args, "policy") && slices.Contains(os.Args, "check") {
zerolog.SetGlobalLevel(zerolog.Disabled)
return
}
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().
StringVarP(&cfgFile, "config", "c", "", "config file (default is /etc/headscale/config.yaml)")
@@ -36,7 +30,7 @@ func init() {
Bool("force", false, "Disable prompts and forces the execution")
// Re-enable usage output only for flag-parsing errors; runtime errors
// from RunE should never dump usage text.
// from [cobra.Command.RunE] should never dump usage text.
rootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error {
cmd.SilenceUsage = false
+57 -44
View File
@@ -4,6 +4,19 @@ import (
"testing"
)
const (
v23 = "0.23.0"
v23Alpha1 = "0.23.0-alpha.1"
v23Beta1 = "0.23.0-beta.1"
v23RC1 = "0.23.0-rc.1"
v23Dev = "0.23.0-dev"
v231 = "0.23.1"
v24Alpha1Tag = "v0.24.0-alpha.1"
v24RCTag = "v0.24.0-rc.1"
v24Tag = "v0.24.0"
)
func TestFilterPreReleasesIfStable(t *testing.T) {
tests := []struct {
name string
@@ -14,64 +27,64 @@ func TestFilterPreReleasesIfStable(t *testing.T) {
}{
{
name: "stable version filters alpha tag",
currentVersion: "0.23.0",
tag: "v0.24.0-alpha.1",
currentVersion: v23,
tag: v24Alpha1Tag,
expectedFilter: true,
description: "When on stable release, alpha tags should be filtered",
},
{
name: "stable version filters beta tag",
currentVersion: "0.23.0",
currentVersion: v23,
tag: "v0.24.0-beta.2",
expectedFilter: true,
description: "When on stable release, beta tags should be filtered",
},
{
name: "stable version filters rc tag",
currentVersion: "0.23.0",
tag: "v0.24.0-rc.1",
currentVersion: v23,
tag: v24RCTag,
expectedFilter: true,
description: "When on stable release, rc tags should be filtered",
},
{
name: "stable version allows stable tag",
currentVersion: "0.23.0",
tag: "v0.24.0",
currentVersion: v23,
tag: v24Tag,
expectedFilter: false,
description: "When on stable release, stable tags should not be filtered",
},
{
name: "alpha version allows alpha tag",
currentVersion: "0.23.0-alpha.1",
currentVersion: v23Alpha1,
tag: "v0.24.0-alpha.2",
expectedFilter: false,
description: "When on alpha release, alpha tags should not be filtered",
},
{
name: "alpha version allows beta tag",
currentVersion: "0.23.0-alpha.1",
currentVersion: v23Alpha1,
tag: "v0.24.0-beta.1",
expectedFilter: false,
description: "When on alpha release, beta tags should not be filtered",
},
{
name: "alpha version allows rc tag",
currentVersion: "0.23.0-alpha.1",
tag: "v0.24.0-rc.1",
currentVersion: v23Alpha1,
tag: v24RCTag,
expectedFilter: false,
description: "When on alpha release, rc tags should not be filtered",
},
{
name: "alpha version allows stable tag",
currentVersion: "0.23.0-alpha.1",
tag: "v0.24.0",
currentVersion: v23Alpha1,
tag: v24Tag,
expectedFilter: false,
description: "When on alpha release, stable tags should not be filtered",
},
{
name: "beta version allows alpha tag",
currentVersion: "0.23.0-beta.1",
tag: "v0.24.0-alpha.1",
currentVersion: v23Beta1,
tag: v24Alpha1Tag,
expectedFilter: false,
description: "When on beta release, alpha tags should not be filtered",
},
@@ -84,28 +97,28 @@ func TestFilterPreReleasesIfStable(t *testing.T) {
},
{
name: "beta version allows rc tag",
currentVersion: "0.23.0-beta.1",
tag: "v0.24.0-rc.1",
currentVersion: v23Beta1,
tag: v24RCTag,
expectedFilter: false,
description: "When on beta release, rc tags should not be filtered",
},
{
name: "beta version allows stable tag",
currentVersion: "0.23.0-beta.1",
tag: "v0.24.0",
currentVersion: v23Beta1,
tag: v24Tag,
expectedFilter: false,
description: "When on beta release, stable tags should not be filtered",
},
{
name: "rc version allows alpha tag",
currentVersion: "0.23.0-rc.1",
tag: "v0.24.0-alpha.1",
currentVersion: v23RC1,
tag: v24Alpha1Tag,
expectedFilter: false,
description: "When on rc release, alpha tags should not be filtered",
},
{
name: "rc version allows beta tag",
currentVersion: "0.23.0-rc.1",
currentVersion: v23RC1,
tag: "v0.24.0-beta.1",
expectedFilter: false,
description: "When on rc release, beta tags should not be filtered",
@@ -119,78 +132,78 @@ func TestFilterPreReleasesIfStable(t *testing.T) {
},
{
name: "rc version allows stable tag",
currentVersion: "0.23.0-rc.1",
tag: "v0.24.0",
currentVersion: v23RC1,
tag: v24Tag,
expectedFilter: false,
description: "When on rc release, stable tags should not be filtered",
},
{
name: "stable version with patch filters alpha",
currentVersion: "0.23.1",
tag: "v0.24.0-alpha.1",
currentVersion: v231,
tag: v24Alpha1Tag,
expectedFilter: true,
description: "Stable version with patch number should filter alpha tags",
},
{
name: "stable version with patch allows stable",
currentVersion: "0.23.1",
tag: "v0.24.0",
currentVersion: v231,
tag: v24Tag,
expectedFilter: false,
description: "Stable version with patch number should allow stable tags",
},
{
name: "tag with alpha substring in version number",
currentVersion: "0.23.0",
currentVersion: v23,
tag: "v1.0.0-alpha.1",
expectedFilter: true,
description: "Tags with alpha in version string should be filtered on stable",
},
{
name: "tag with beta substring in version number",
currentVersion: "0.23.0",
currentVersion: v23,
tag: "v1.0.0-beta.1",
expectedFilter: true,
description: "Tags with beta in version string should be filtered on stable",
},
{
name: "tag with rc substring in version number",
currentVersion: "0.23.0",
currentVersion: v23,
tag: "v1.0.0-rc.1",
expectedFilter: true,
description: "Tags with rc in version string should be filtered on stable",
},
{
name: "empty tag on stable version",
currentVersion: "0.23.0",
currentVersion: v23,
tag: "",
expectedFilter: false,
description: "Empty tags should not be filtered",
},
{
name: "dev version allows all tags",
currentVersion: "0.23.0-dev",
tag: "v0.24.0-alpha.1",
currentVersion: v23Dev,
tag: v24Alpha1Tag,
expectedFilter: false,
description: "Dev versions should not filter any tags (pre-release allows all)",
},
{
name: "stable version filters dev tag",
currentVersion: "0.23.0",
currentVersion: v23,
tag: "v0.24.0-dev",
expectedFilter: true,
description: "When on stable release, dev tags should be filtered",
},
{
name: "dev version allows dev tag",
currentVersion: "0.23.0-dev",
currentVersion: v23Dev,
tag: "v0.24.0-dev.1",
expectedFilter: false,
description: "When on dev release, dev tags should not be filtered",
},
{
name: "dev version allows stable tag",
currentVersion: "0.23.0-dev",
tag: "v0.24.0",
currentVersion: v23Dev,
tag: v24Tag,
expectedFilter: false,
description: "When on dev release, stable tags should not be filtered",
},
@@ -222,25 +235,25 @@ func TestIsPreReleaseVersion(t *testing.T) {
}{
{
name: "stable version",
version: "0.23.0",
version: v23,
expected: false,
description: "Stable version should not be pre-release",
},
{
name: "alpha version",
version: "0.23.0-alpha.1",
version: v23Alpha1,
expected: true,
description: "Alpha version should be pre-release",
},
{
name: "beta version",
version: "0.23.0-beta.1",
version: v23Beta1,
expected: true,
description: "Beta version should be pre-release",
},
{
name: "rc version",
version: "0.23.0-rc.1",
version: v23RC1,
expected: true,
description: "RC version should be pre-release",
},
@@ -258,7 +271,7 @@ func TestIsPreReleaseVersion(t *testing.T) {
},
{
name: "dev version",
version: "0.23.0-dev",
version: v23Dev,
expected: true,
description: "Dev version should be pre-release",
},
@@ -270,7 +283,7 @@ func TestIsPreReleaseVersion(t *testing.T) {
},
{
name: "version with patch number",
version: "0.23.1",
version: v231,
expected: false,
description: "Stable version with patch should not be pre-release",
},
+23
View File
@@ -0,0 +1,23 @@
package cli
// Shared CLI vocabulary used across multiple command definitions in this
// package. Centralising the strings prevents goconst drift and ensures a
// typo in a subcommand name fails to compile rather than silently
// breaking the binding.
const (
// Subcommand verbs (cobra Use field).
cmdList = "list"
cmdShow = "show"
cmdNew = "new"
cmdDelete = "delete"
cmdExpire = "expire"
// Subcommand aliases.
aliasDel = "del"
aliasExp = "exp"
// Output table column headers and printOutput map keys.
colResult = "Result"
colCreated = "Created"
colExpiration = "Expiration"
)
+8 -6
View File
@@ -70,7 +70,7 @@ var userCmd = &cobra.Command{
var createUserCmd = &cobra.Command{
Use: "create NAME",
Short: "Creates a new user",
Aliases: []string{"c", "new"},
Aliases: []string{"c", cmdNew},
Args: func(cmd *cobra.Command, args []string) error {
if len(args) < 1 {
return errMissingParameter
@@ -115,7 +115,7 @@ var createUserCmd = &cobra.Command{
var destroyUserCmd = &cobra.Command{
Use: "destroy --identifier ID or --name NAME",
Short: "Destroys a user",
Aliases: []string{"delete"},
Aliases: []string{cmdDelete},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
id, username, err := usernameAndIDFromFlag(cmd)
if err != nil {
@@ -142,7 +142,7 @@ var destroyUserCmd = &cobra.Command{
"Do you want to remove the user %q (%d) and any associated preauthkeys?",
user.GetName(), user.GetId(),
)) {
return printOutput(cmd, map[string]string{"Result": "User not destroyed"}, "User not destroyed")
return printOutput(cmd, map[string]string{colResult: "User not destroyed"}, "User not destroyed")
}
deleteRequest := &v1.DeleteUserRequest{Id: user.GetId()}
@@ -157,9 +157,9 @@ var destroyUserCmd = &cobra.Command{
}
var listUsersCmd = &cobra.Command{
Use: "list",
Use: cmdList,
Short: "List all the users",
Aliases: []string{"ls", "show"},
Aliases: []string{"ls", cmdShow},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
request := &v1.ListUsersRequest{}
@@ -183,7 +183,9 @@ var listUsersCmd = &cobra.Command{
}
return printListOutput(cmd, response.GetUsers(), func() error {
tableData := pterm.TableData{{"ID", "Name", "Username", "Email", "Created"}}
tableData := make(pterm.TableData, 1, 1+len(response.GetUsers()))
tableData[0] = []string{"ID", "Name", "Username", "Email", colCreated}
for _, user := range response.GetUsers() {
tableData = append(
tableData,
+7 -7
View File
@@ -67,9 +67,9 @@ func newHeadscaleServerWithConfig() (*hscontrol.Headscale, error) {
return app, nil
}
// grpcRunE wraps a cobra RunE func, injecting a ready gRPC client and
// context. Connection lifecycle is managed by the wrapper — callers
// never see the underlying conn or cancel func.
// grpcRunE wraps a cobra [cobra.Command.RunE] func, injecting a ready
// gRPC client and context. Connection lifecycle is managed by the
// wrapper — callers never see the underlying conn or cancel func.
func grpcRunE(
fn func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error,
) func(*cobra.Command, []string) error {
@@ -103,7 +103,7 @@ func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *g
address := cfg.CLI.Address
// If the address is not set, we assume that we are on the server hosting hscontrol.
// If the address is not set, we assume that we are on the server hosting [hscontrol].
if address == "" {
log.Debug().
Str("socket", cfg.UnixSocket).
@@ -112,9 +112,9 @@ func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *g
address = cfg.UnixSocket
// Try to give the user better feedback if we cannot write to the headscale
// socket. Note: os.OpenFile on a Unix domain socket returns ENXIO on
// socket. Note: [os.OpenFile] on a Unix domain socket returns ENXIO on
// Linux which is expected — only permission errors are actionable here.
// The actual gRPC connection uses net.Dial which handles sockets properly.
// The actual gRPC connection uses [net.Dial] which handles sockets properly.
socket, err := os.OpenFile(cfg.UnixSocket, os.O_WRONLY, SocketWritePermissions) //nolint
if err != nil {
if os.IsPermission(err) {
@@ -269,7 +269,7 @@ func printListOutput(
// printError writes err to stderr, formatting it as JSON/YAML when the
// --output flag requests machine-readable output. Used exclusively by
// Execute() so that every error surfaces in the format the caller asked for.
// [Execute] so that every error surfaces in the format the caller asked for.
func printError(err error, outputFormat string) {
type errOutput struct {
Error string `json:"error"`
-1
View File
@@ -73,5 +73,4 @@ func TestConfigLoading(t *testing.T) {
assert.Equal(t, "HTTP-01", viper.GetString("tls_letsencrypt_challenge_type"))
assert.Equal(t, fs.FileMode(0o770), util.GetFileMode("unix_socket_permission"))
assert.False(t, viper.GetBool("logtail.enabled"))
assert.False(t, viper.GetBool("randomize_client_port"))
}
+48 -17
View File
@@ -14,6 +14,7 @@ import (
"strings"
"time"
"github.com/cenkalti/backoff/v5"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/mount"
@@ -522,9 +523,9 @@ func checkImageAvailableLocally(ctx context.Context, cli *client.Client, imageNa
return true, nil
}
// ensureImageAvailable checks if the image is available locally first, then pulls if needed.
// ensureImageAvailable pulls imageName if missing, using Docker Hub
// credentials and retrying transient errors.
func ensureImageAvailable(ctx context.Context, cli *client.Client, imageName string, verbose bool) error {
// First check if image is available locally
available, err := checkImageAvailableLocally(ctx, cli, imageName)
if err != nil {
return fmt.Errorf("checking local image availability: %w", err)
@@ -538,34 +539,64 @@ func ensureImageAvailable(ctx context.Context, cli *client.Client, imageName str
return nil
}
// Image not available locally, try to pull it
if verbose {
log.Printf("Image %s not found locally, pulling...", imageName)
}
reader, err := cli.ImagePull(ctx, imageName, image.PullOptions{})
registryAuth, err := dockertestutil.RegistryAuth()
if err != nil {
return fmt.Errorf("pulling image %s: %w", imageName, err)
return fmt.Errorf("resolving registry auth: %w", err)
}
defer reader.Close()
if verbose {
_, err = io.Copy(os.Stdout, reader)
if err != nil {
return fmt.Errorf("reading pull output: %w", err)
}
} else {
_, err = io.Copy(io.Discard, reader)
if err != nil {
return fmt.Errorf("reading pull output: %w", err)
}
_, err = backoff.Retry(
ctx,
func() (struct{}, error) {
reader, pullErr := cli.ImagePull(ctx, imageName, image.PullOptions{RegistryAuth: registryAuth})
if pullErr != nil {
if isPermanentDockerPullError(pullErr) {
return struct{}{}, backoff.Permanent(pullErr)
}
return struct{}{}, fmt.Errorf("pulling image %s: %w", imageName, pullErr)
}
defer reader.Close()
sink := io.Discard
if verbose {
sink = os.Stdout
}
_, copyErr := io.Copy(sink, reader)
if copyErr != nil {
return struct{}{}, fmt.Errorf("reading pull output: %w", copyErr)
}
return struct{}{}, nil
},
backoff.WithBackOff(backoff.NewExponentialBackOff()),
backoff.WithMaxElapsedTime(60*time.Second),
)
if err != nil {
return err
}
if !verbose {
log.Printf("Image %s pulled successfully", imageName)
}
return nil
}
func isPermanentDockerPullError(err error) bool {
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "manifest unknown") ||
strings.Contains(msg, "manifest not found") ||
strings.Contains(msg, "repository does not exist") ||
strings.Contains(msg, "name unknown") ||
strings.Contains(msg, "no such image")
}
// listControlFiles displays the headscale test artifacts created in the control logs directory.
func listControlFiles(logsDir string) {
entries, err := os.ReadDir(logsDir)
@@ -799,7 +830,7 @@ func extractContainerLogs(ctx context.Context, cli *client.Client, containerID,
// extractContainerFiles extracts database file and directories from headscale containers.
// Note: The actual file extraction is now handled by the integration tests themselves
// via SaveProfile, SaveMapResponses, and SaveDatabase functions in hsic.go.
// via [SaveProfile], [SaveMapResponses], and [SaveDatabase] functions in hsic.go.
func extractContainerFiles(ctx context.Context, cli *client.Client, containerID, containerName, logsDir string, verbose bool) error {
// Files are now extracted directly by the integration tests
// This function is kept for potential future use or other file types
+84 -45
View File
@@ -7,6 +7,20 @@ import (
"log"
"os/exec"
"strings"
"github.com/juanfont/headscale/integration/dockertestutil"
)
const (
statusPass = "PASS"
statusFail = "FAIL"
statusWarn = "WARN"
nameDockerDaemon = "Docker Daemon"
nameDockerContext = "Docker Context"
nameDockerSocket = "Docker Socket"
nameGolangImage = "Golang Image"
nameGoInstall = "Go Installation"
)
var ErrSystemChecksFailed = errors.New("system checks failed")
@@ -31,9 +45,10 @@ func runDoctorCheck(ctx context.Context) error {
results = append(results, dockerResult)
// If Docker is available, run additional checks
if dockerResult.Status == "PASS" {
if dockerResult.Status == statusPass {
results = append(results, checkDockerContext(ctx))
results = append(results, checkDockerSocket(ctx))
results = append(results, checkDockerHubCredentials())
results = append(results, checkGolangImage(ctx))
}
@@ -51,7 +66,7 @@ func runDoctorCheck(ctx context.Context) error {
// Return error if any critical checks failed
for _, result := range results {
if result.Status == "FAIL" {
if result.Status == statusFail {
return fmt.Errorf("%w - see details above", ErrSystemChecksFailed)
}
}
@@ -67,7 +82,7 @@ func checkDockerBinary() DoctorResult {
if err != nil {
return DoctorResult{
Name: "Docker Binary",
Status: "FAIL",
Status: statusFail,
Message: "Docker binary not found in PATH",
Suggestions: []string{
"Install Docker: https://docs.docker.com/get-docker/",
@@ -79,7 +94,7 @@ func checkDockerBinary() DoctorResult {
return DoctorResult{
Name: "Docker Binary",
Status: "PASS",
Status: statusPass,
Message: "Docker binary found",
}
}
@@ -89,8 +104,8 @@ func checkDockerDaemon(ctx context.Context) DoctorResult {
cli, err := createDockerClient(ctx)
if err != nil {
return DoctorResult{
Name: "Docker Daemon",
Status: "FAIL",
Name: nameDockerDaemon,
Status: statusFail,
Message: fmt.Sprintf("Cannot create Docker client: %v", err),
Suggestions: []string{
"Start Docker daemon/service",
@@ -105,8 +120,8 @@ func checkDockerDaemon(ctx context.Context) DoctorResult {
_, err = cli.Ping(ctx)
if err != nil {
return DoctorResult{
Name: "Docker Daemon",
Status: "FAIL",
Name: nameDockerDaemon,
Status: statusFail,
Message: fmt.Sprintf("Cannot ping Docker daemon: %v", err),
Suggestions: []string{
"Ensure Docker daemon is running",
@@ -117,8 +132,8 @@ func checkDockerDaemon(ctx context.Context) DoctorResult {
}
return DoctorResult{
Name: "Docker Daemon",
Status: "PASS",
Name: nameDockerDaemon,
Status: statusPass,
Message: "Docker daemon is running and accessible",
}
}
@@ -128,8 +143,8 @@ func checkDockerContext(ctx context.Context) DoctorResult {
contextInfo, err := getCurrentDockerContext(ctx)
if err != nil {
return DoctorResult{
Name: "Docker Context",
Status: "WARN",
Name: nameDockerContext,
Status: statusWarn,
Message: "Could not detect Docker context, using default settings",
Suggestions: []string{
"Check: docker context ls",
@@ -140,15 +155,15 @@ func checkDockerContext(ctx context.Context) DoctorResult {
if contextInfo == nil {
return DoctorResult{
Name: "Docker Context",
Status: "PASS",
Name: nameDockerContext,
Status: statusPass,
Message: "Using default Docker context",
}
}
return DoctorResult{
Name: "Docker Context",
Status: "PASS",
Name: nameDockerContext,
Status: statusPass,
Message: "Using Docker context: " + contextInfo.Name,
}
}
@@ -158,8 +173,8 @@ func checkDockerSocket(ctx context.Context) DoctorResult {
cli, err := createDockerClient(ctx)
if err != nil {
return DoctorResult{
Name: "Docker Socket",
Status: "FAIL",
Name: nameDockerSocket,
Status: statusFail,
Message: fmt.Sprintf("Cannot access Docker socket: %v", err),
Suggestions: []string{
"Check Docker socket permissions",
@@ -173,8 +188,8 @@ func checkDockerSocket(ctx context.Context) DoctorResult {
info, err := cli.Info(ctx)
if err != nil {
return DoctorResult{
Name: "Docker Socket",
Status: "FAIL",
Name: nameDockerSocket,
Status: statusFail,
Message: fmt.Sprintf("Cannot get Docker info: %v", err),
Suggestions: []string{
"Check Docker daemon status",
@@ -184,19 +199,43 @@ func checkDockerSocket(ctx context.Context) DoctorResult {
}
return DoctorResult{
Name: "Docker Socket",
Status: "PASS",
Name: nameDockerSocket,
Status: statusPass,
Message: fmt.Sprintf("Docker socket accessible (Server: %s)", info.ServerVersion),
}
}
// checkDockerHubCredentials warns when pulls would be anonymous and
// therefore rate-limited.
func checkDockerHubCredentials() DoctorResult {
_, _, source := dockertestutil.Credentials()
if source == dockertestutil.CredentialSourceAnonymous {
return DoctorResult{
Name: "Docker Hub Credentials",
Status: "WARN",
Message: "No Docker Hub credentials found — pulls will be rate-limited (100/6h per IP)",
Suggestions: []string{
"Run: docker login",
"Or export DOCKERHUB_USERNAME and DOCKERHUB_TOKEN",
"In CI: ensure the docker/login-action step is configured with secrets",
},
}
}
return DoctorResult{
Name: "Docker Hub Credentials",
Status: "PASS",
Message: fmt.Sprintf("Credentials available (source: %s)", source),
}
}
// checkGolangImage verifies the golang Docker image is available locally or can be pulled.
func checkGolangImage(ctx context.Context) DoctorResult {
cli, err := createDockerClient(ctx)
if err != nil {
return DoctorResult{
Name: "Golang Image",
Status: "FAIL",
Name: nameGolangImage,
Status: statusFail,
Message: "Cannot create Docker client for image check",
}
}
@@ -209,8 +248,8 @@ func checkGolangImage(ctx context.Context) DoctorResult {
available, err := checkImageAvailableLocally(ctx, cli, imageName)
if err != nil {
return DoctorResult{
Name: "Golang Image",
Status: "FAIL",
Name: nameGolangImage,
Status: statusFail,
Message: fmt.Sprintf("Cannot check golang image %s: %v", imageName, err),
Suggestions: []string{
"Check Docker daemon status",
@@ -221,8 +260,8 @@ func checkGolangImage(ctx context.Context) DoctorResult {
if available {
return DoctorResult{
Name: "Golang Image",
Status: "PASS",
Name: nameGolangImage,
Status: statusPass,
Message: fmt.Sprintf("Golang image %s is available locally", imageName),
}
}
@@ -231,8 +270,8 @@ func checkGolangImage(ctx context.Context) DoctorResult {
err = ensureImageAvailable(ctx, cli, imageName, false)
if err != nil {
return DoctorResult{
Name: "Golang Image",
Status: "FAIL",
Name: nameGolangImage,
Status: statusFail,
Message: fmt.Sprintf("Golang image %s not available locally and cannot pull: %v", imageName, err),
Suggestions: []string{
"Check internet connectivity",
@@ -244,8 +283,8 @@ func checkGolangImage(ctx context.Context) DoctorResult {
}
return DoctorResult{
Name: "Golang Image",
Status: "PASS",
Name: nameGolangImage,
Status: statusPass,
Message: fmt.Sprintf("Golang image %s is now available", imageName),
}
}
@@ -255,8 +294,8 @@ func checkGoInstallation(ctx context.Context) DoctorResult {
_, err := exec.LookPath("go")
if err != nil {
return DoctorResult{
Name: "Go Installation",
Status: "FAIL",
Name: nameGoInstall,
Status: statusFail,
Message: "Go binary not found in PATH",
Suggestions: []string{
"Install Go: https://golang.org/dl/",
@@ -270,8 +309,8 @@ func checkGoInstallation(ctx context.Context) DoctorResult {
output, err := cmd.Output()
if err != nil {
return DoctorResult{
Name: "Go Installation",
Status: "FAIL",
Name: nameGoInstall,
Status: statusFail,
Message: fmt.Sprintf("Cannot get Go version: %v", err),
}
}
@@ -279,8 +318,8 @@ func checkGoInstallation(ctx context.Context) DoctorResult {
version := strings.TrimSpace(string(output))
return DoctorResult{
Name: "Go Installation",
Status: "PASS",
Name: nameGoInstall,
Status: statusPass,
Message: version,
}
}
@@ -293,7 +332,7 @@ func checkGitRepository(ctx context.Context) DoctorResult {
if err != nil {
return DoctorResult{
Name: "Git Repository",
Status: "FAIL",
Status: statusFail,
Message: "Not in a Git repository",
Suggestions: []string{
"Run from within the headscale git repository",
@@ -304,7 +343,7 @@ func checkGitRepository(ctx context.Context) DoctorResult {
return DoctorResult{
Name: "Git Repository",
Status: "PASS",
Status: statusPass,
Message: "Running in Git repository",
}
}
@@ -331,7 +370,7 @@ func checkRequiredFiles(ctx context.Context) DoctorResult {
if len(missingFiles) > 0 {
return DoctorResult{
Name: "Required Files",
Status: "FAIL",
Status: statusFail,
Message: "Missing required files: " + strings.Join(missingFiles, ", "),
Suggestions: []string{
"Ensure you're in the headscale project root directory",
@@ -343,7 +382,7 @@ func checkRequiredFiles(ctx context.Context) DoctorResult {
return DoctorResult{
Name: "Required Files",
Status: "PASS",
Status: statusPass,
Message: "All required files found",
}
}
@@ -357,11 +396,11 @@ func displayDoctorResults(results []DoctorResult) {
var icon string
switch result.Status {
case "PASS":
case statusPass:
icon = "✅"
case "WARN":
case statusWarn:
icon = "⚠️"
case "FAIL":
case statusFail:
icon = "❌"
default:
icon = "❓"
+1 -1
View File
@@ -94,7 +94,7 @@ func detectGoVersion() string {
return "1.26.1"
}
// splitLines splits a string into lines without using strings.Split.
// splitLines splits a string into lines without using [strings.Split].
func splitLines(s string) []string {
var (
lines []string
+3 -3
View File
@@ -160,7 +160,7 @@ func (sc *StatsCollector) monitorDockerEvents(ctx context.Context, runID string,
continue
}
// Convert to types.Container format for consistency
// Convert to [types.Container] format for consistency
cont := types.Container{ //nolint:staticcheck // SA1019: use container.Summary
ID: containerInfo.ID,
Names: []string{containerInfo.Name},
@@ -256,7 +256,7 @@ func (sc *StatsCollector) collectStatsForContainer(ctx context.Context, containe
err := decoder.Decode(&stats)
if err != nil {
// EOF is expected when container stops or stream ends
// [io.EOF] is expected when container stops or stream ends
if err.Error() != "EOF" && verbose {
log.Printf("Failed to decode stats for container %s: %v", containerID[:12], err)
}
@@ -312,7 +312,7 @@ func calculateCPUPercent(prevStats, stats *container.Stats) float64 { //nolint:s
// Calculate CPU percentage: (container CPU delta / system CPU delta) * number of CPUs * 100
numCPUs := float64(len(stats.CPUStats.CPUUsage.PercpuUsage))
if numCPUs == 0 {
// Fallback: if PercpuUsage is not available, assume 1 CPU
// Fallback: if [PercpuUsage] is not available, assume 1 CPU
numCPUs = 1.0
}
+221
View File
@@ -0,0 +1,221 @@
// vendorhash maintains the Nix SRI hash for the Go module vendor tree
// and stores it in flakehashes.json alongside a content fingerprint of
// go.mod and go.sum.
//
// Each block records its input fingerprint (goModSum) so that re-runs
// with no input change are essentially free: the fast path is just a
// sha256 over two small files. The vendor tree is only re-walked when
// the fingerprint actually drifts.
//
// Subcommands:
//
// vendorhash check exit non-zero if flakehashes.json is stale
// vendorhash update recompute and rewrite flakehashes.json
//
// The JSON schema and [goModFingerprint] algorithm mirror upstream
// tailscale's tool/updateflakes so a future shared library extraction
// is straightforward.
package main
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"tailscale.com/cmd/nardump/nardump"
)
const (
hashesFile = "flakehashes.json"
goModFile = "go.mod"
goSumFile = "go.sum"
)
type FlakeHashes struct {
Vendor VendorBlock `json:"vendor"`
}
type VendorBlock struct {
GoModSum string `json:"goModSum"`
SRI string `json:"sri"`
}
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
ctx := context.Background()
var err error
switch os.Args[1] {
case "check":
err = cmdCheck(ctx)
case "update":
err = cmdUpdate(ctx)
case "-h", "--help", "help":
usage()
return
default:
usage()
os.Exit(2)
}
if err != nil {
if errors.Is(err, errStale) {
os.Exit(1)
}
fmt.Fprintln(os.Stderr, "vendorhash:", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: vendorhash <check|update>")
}
// errStale signals to [main] that the check found a mismatch; it has
// already printed a remediation message, so [main] should exit 1
// silently.
var errStale = errors.New("vendor hash stale")
// cmdCheck verifies that flakehashes.json matches the current
// go.mod/go.sum. The fast path (fingerprint unchanged) costs only
// a sha256 over the two files. On mismatch, it computes the actual
// SRI so the failure message gives the developer the value to paste
// (or to run `vendorhash update`).
func cmdCheck(ctx context.Context) error {
hashes, err := loadHashes()
if err != nil {
return err
}
curFP, err := goModFingerprint()
if err != nil {
return err
}
if curFP == hashes.Vendor.GoModSum {
return nil
}
curSRI, err := hashVendor(ctx)
if err != nil {
return err
}
fmt.Fprintln(os.Stderr, "vendor hash is stale.")
fmt.Fprintf(os.Stderr, " expected goModSum: %s\n", hashes.Vendor.GoModSum)
fmt.Fprintf(os.Stderr, " actual goModSum: %s\n", curFP)
fmt.Fprintf(os.Stderr, " expected sri: %s\n", hashes.Vendor.SRI)
fmt.Fprintf(os.Stderr, " actual sri: %s\n", curSRI)
fmt.Fprintln(os.Stderr, "run: go run ./cmd/vendorhash update")
// Also emit machine-parseable lines so CI can pick them up.
fmt.Printf("expected_sri=%s\n", hashes.Vendor.SRI)
fmt.Printf("actual_sri=%s\n", curSRI)
return errStale
}
func cmdUpdate(ctx context.Context) error {
fp, err := goModFingerprint()
if err != nil {
return err
}
sri, err := hashVendor(ctx)
if err != nil {
return err
}
return writeHashes(FlakeHashes{
Vendor: VendorBlock{
GoModSum: fp,
SRI: sri,
},
})
}
// goModFingerprint returns a content fingerprint of go.mod and go.sum
// that changes whenever either file changes. The byte layout matches
// upstream tailscale's tool/updateflakes.
func goModFingerprint() (string, error) {
h := sha256.New()
for _, f := range []string{goModFile, goSumFile} {
b, err := os.ReadFile(f)
if err != nil {
return "", err
}
fmt.Fprintf(h, "%s %d\n", f, len(b))
h.Write(b)
}
return "sha256-" + base64.StdEncoding.EncodeToString(h.Sum(nil)), nil
}
// hashVendor runs `go mod vendor` into a temporary directory and
// returns the Nix SRI hash of the resulting tree.
func hashVendor(ctx context.Context) (string, error) {
out, err := os.MkdirTemp("", "nar-vendor-")
if err != nil {
return "", err
}
// `go mod vendor -o` requires the destination to not already exist.
err = os.Remove(out)
if err != nil {
return "", err
}
defer os.RemoveAll(out)
cmd := exec.CommandContext(ctx, "go", "mod", "vendor", "-o", out)
cmd.Env = append(os.Environ(), "GOWORK=off")
cmd.Stderr = os.Stderr
err = cmd.Run()
if err != nil {
return "", fmt.Errorf("go mod vendor: %w", err)
}
return nardump.SRI(os.DirFS(out))
}
func loadHashes() (FlakeHashes, error) {
var h FlakeHashes
b, err := os.ReadFile(hashesFile)
if err != nil {
return h, err
}
err = json.Unmarshal(b, &h)
if err != nil {
return h, fmt.Errorf("%s: %w", hashesFile, err)
}
return h, nil
}
func writeHashes(h FlakeHashes) error {
b, err := json.MarshalIndent(h, "", " ")
if err != nil {
return err
}
b = append(b, '\n')
// flakehashes.json is committed source read by Nix during evaluation;
// world-readable matches every other tracked file in the repo.
return os.WriteFile(hashesFile, b, 0o644) //nolint:gosec
}
+35 -24
View File
@@ -39,6 +39,13 @@ grpc_listen_addr: 127.0.0.1:50443
# are doing.
grpc_allow_insecure: false
# CIDR(s) of reverse proxies (e.g. 127.0.0.1/32) whose
# True-Client-IP, X-Real-IP and X-Forwarded-For headers should
# be honoured. Empty (default) ignores those headers; setting
# this without a proxy in front lets clients spoof their logged
# source IP.
trusted_proxies: []
# The Noise section includes specific configuration for the
# TS2021 Noise protocol
noise:
@@ -80,8 +87,7 @@ prefixes:
# connection cannot be established.
# https://tailscale.com/blog/how-tailscale-works/#encrypted-tcp-relays-derp
#
# headscale needs a list of DERP servers that can be presented
# to the clients.
# Headscale needs a list of DERP servers that can be presented to the clients.
derp:
server:
# If enabled, runs the embedded DERP server and merges it into the rest of the DERP config
@@ -126,9 +132,9 @@ derp:
# Locally available DERP map files encoded in YAML
#
# This option is mostly interesting for people hosting
# their own DERP servers:
# This option is mostly interesting for people hosting their own DERP servers:
# https://tailscale.com/docs/reference/derp-servers/custom-derp-servers
# https://headscale.net/stable/ref/derp/
#
# paths:
# - /etc/headscale/derp-example.yaml
@@ -262,7 +268,7 @@ tls_letsencrypt_cache_dir: /var/lib/headscale/cache
# Type of ACME challenge to use, currently supported types:
# HTTP-01 or TLS-ALPN-01
# See: docs/ref/tls.md for more information
# See: https://headscale.net/stable/ref/tls/
tls_letsencrypt_challenge_type: HTTP-01
# When HTTP-01 challenge is chosen, letsencrypt must set up a
# verification endpoint, and it will be listening on:
@@ -281,23 +287,22 @@ log:
format: text
## 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
# Headscale supports a wide range of Tailscale policy features such as ACLs and
# Grants. Please have a look at their docs to better understand the concepts:
# ACLs: https://tailscale.com/docs/features/access-control/acls
# Grants: https://tailscale.com/docs/features/access-control/grants
policy:
# The mode can be "file" or "database" that defines
# where the ACL policies are stored and read from.
# where the policies are stored and read from.
mode: file
# If the mode is set to "file", the path to a
# HuJSON file containing ACL policies.
# If the mode is set to "file", the path to a HuJSON file containing policies.
path: ""
## DNS
#
# headscale supports Tailscale's DNS configuration and MagicDNS.
# Please have a look to their KB to better understand the concepts:
# Please have a look to their docs 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
#
@@ -314,7 +319,7 @@ policy:
# 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
magic_dns: true
# Defines the base domain to create the hostnames for MagicDNS.
@@ -355,7 +360,7 @@ dns:
# Extra DNS records
# so far only A and AAAA records are supported (on the tailscale side)
# See: docs/ref/dns.md
# See: https://headscale.net/stable/ref/dns/
extra_records: []
# - name: "grafana.myvpn.example.com"
# type: "A"
@@ -374,6 +379,7 @@ unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"
# OpenID Connect
# https://headscale.net/stable/ref/oidc/
# oidc:
# # Block startup until the identity provider is available and healthy.
# only_start_if_oidc_is_available: true
@@ -452,19 +458,24 @@ logtail:
# disabled by default. Enabling this will make your clients send logs to Tailscale Inc.
enabled: false
# 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.
randomize_client_port: false
# Taildrop configuration
# Taildrop is the file sharing feature of Tailscale, allowing nodes to send files to each other.
# Taildrop is the file sharing feature of Tailscale, allowing nodes to
# send files to each other.
# https://tailscale.com/docs/features/taildrop
taildrop:
# Enable or disable Taildrop for all nodes.
# When enabled, nodes can send files to other nodes owned by the same user.
# Tagged devices and cross-user transfers are not permitted by Tailscale clients.
# Enable or disable Taildrop tailnet-wide. When disabled, headscale
# withholds `https://tailscale.com/cap/file-sharing` from every
# node's CapMap.
enabled: true
# Default node auto-update behaviour. When enabled, every node's
# CapMap carries `default-auto-update: [true]` so clients that have
# not made a local opt-in / opt-out choice run auto-updates by
# default. Setting it back to false flips the default for future
# clients; clients that already stored the value locally keep their
# choice.
auto_update:
enabled: false
# Advanced performance tuning parameters.
# The defaults are carefully chosen and should rarely need adjustment.
# Only modify these if you have identified a specific performance issue.
+3 -3
View File
@@ -134,7 +134,7 @@ help to the community.
Running headscale on a machine that is also in the tailnet can cause problems with subnet routers, traffic relay nodes, and MagicDNS. It might work, but it is not supported.
## Why do two nodes see each other in their status, even if an ACL allows traffic only in one direction?
## Why do two nodes see each other in their status, even if a policy rule allows traffic only in one direction?
A frequent use case is to allow traffic only from one node to another, but not the other way around. For example, the
workstation of an administrator should be able to connect to all nodes but the nodes themselves shouldn't be able to
@@ -142,7 +142,7 @@ connect back to the administrator's node. Why do all nodes see the administrator
`tailscale status`?
This is essentially how Tailscale works. If traffic is allowed to flow in one direction, then both nodes see each other
in their output of `tailscale status`. Traffic is still filtered according to the ACL, with the exception of
in their output of `tailscale status`. Traffic is still filtered according to the policy, with the exception of
`tailscale ping` which is always allowed in either direction.
See also <https://tailscale.com/docs/concepts/device-visibility>.
@@ -191,7 +191,7 @@ following steps can be used to migrate from unsupported IP prefixes back to the
SET ipv4=concat('100.64.', id/256, '.', id%256),
ipv6=concat('fd7a:115c:a1e0::', format('%x', id));
```
- Update the [policy](../ref/acls.md) to reflect the IP address changes (if any)
- Update the [policy](../ref/policy.md) to reflect the IP address changes (if any)
- Start Headscale
Nodes should reconnect within a few seconds and pickup their newly assigned IP addresses.
+12 -6
View File
@@ -13,23 +13,29 @@ provides on overview of Headscale's feature and compatibility with the Tailscale
- [x] [Global and restricted nameservers (split DNS)](https://tailscale.com/docs/reference/dns-in-tailscale#nameservers)
- [x] [search domains](https://tailscale.com/docs/reference/dns-in-tailscale#search-domains)
- [x] [Extra DNS records (Headscale only)](../ref/dns.md#setting-extra-dns-records)
- [x] [Taildrop (File Sharing)](https://tailscale.com/docs/features/taildrop)
- [x] File sharing
- [x] [Taildrive](https://tailscale.com/docs/features/taildrive)
- [x] [Taildrop](https://tailscale.com/docs/features/taildrop)
- [x] [Tags](../ref/tags.md)
- [x] [Routes](../ref/routes.md)
- [x] [Subnet routers](../ref/routes.md#subnet-router)
- [x] [Exit nodes](../ref/routes.md#exit-node)
- [x] [Route filtering with Via](https://tailscale.com/docs/features/access-control/grants/grants-via)
- [x] Dual stack (IPv4 and IPv6)
- [x] Ephemeral nodes
- [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] [Peer relays](https://tailscale.com/docs/features/peer-relay)
- [x] [Policy](../ref/policy.md) ([GitHub label "policy"](https://github.com/juanfont/headscale/labels/policy%20%F0%9F%93%9D))
- [x] ACLs
- [x] Grants
- [x] Some [Autogroups](../ref/policy.md#autogroups)
- [x] [Auto approvers](https://tailscale.com/docs/reference/syntax/policy-file#auto-approvers) for [subnet
routers](../ref/routes.md#automatically-approve-routes-of-a-subnet-router) and [exit
nodes](../ref/routes.md#automatically-approve-an-exit-node-with-auto-approvers)
- [x] [Tailscale SSH](https://tailscale.com/docs/features/tailscale-ssh)
- [x] [Node attributes](../ref/policy.md#node-attributes)
- [x] [Tests](https://tailscale.com/docs/reference/syntax/policy-file#tests) and
[sshTests](https://tailscale.com/docs/reference/syntax/policy-file#ssh-tests)
- [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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

+1 -1
View File
@@ -8,7 +8,7 @@ hide:
Headscale is an open source, self-hosted implementation of the Tailscale control server.
This page contains the documentation for the latest version of headscale. Please also check our [FAQ](./about/faq.md).
This page contains the documentation for the latest version of headscale. Please also check our [FAQ](about/faq.md).
Join our [Discord server](https://discord.gg/c84AZQhmpx) for a chat and community support.
-295
View File
@@ -1,295 +0,0 @@
Headscale implements the same policy ACLs as Tailscale.com, adapted to the self-hosted environment.
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.
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
long as the ACL's permits this exchange.
## ACL Setup
To enable and configure ACLs in Headscale, you need to specify the path to your ACL policy file in the `policy.path` key in `config.yaml`.
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).
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
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.
```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.
```json
{
"acls": []
}
```
## Complex Example
Let's build a more complex example use case for a small business (It may be the place where
ACL's are the most useful).
We have a small company with a boss, an admin, two developers and an intern.
The boss should have access to all servers but not to the user's hosts. Admin
should also have access to all hosts except that their permissions should be
limited to maintaining the hosts (for example purposes). The developers can do
anything they want on dev hosts but only watch on productions hosts. Intern
can only interact with the development servers.
There's an additional server that acts as a router, connecting the VPN users
to an internal network `10.20.0.0/16`. Developers must have access to those
internal resources.
Each user have at least a device connected to the network and we have some
servers.
- database.prod
- database.dev
- app-server1.prod
- app-server1.dev
- billing.internal
- router.internal
![ACL implementation example](../assets/images/headscale-acl-network.png)
When [registering the servers](../usage/getting-started.md#register-a-node) we
will need to add the flag `--advertise-tags=tag:<tag1>,tag:<tag2>`, and the user
that is registering the server should be allowed to do it. Since anyone can add
tags to a server they can register, the check of the tags is done on headscale
server and only valid tags are applied. A tag is valid if the user that is
registering it is allowed to do it.
Here are the ACL's to implement the same permissions as above:
```json title="acl.json"
{
// groups are collections of users having a common scope. A user can be in multiple groups
// groups cannot be composed of groups
"groups": {
"group:boss": ["boss@"],
"group:dev": ["dev1@", "dev2@"],
"group:admin": ["admin1@"],
"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)
// and explained [here](https://tailscale.com/blog/rbac-like-it-was-meant-to-be/)
"tagOwners": {
// the administrators can add servers in production
"tag:prod-databases": ["group:admin"],
"tag:prod-app-servers": ["group:admin"],
// the boss can tag any server as internal
"tag:internal": ["group:boss"],
// dev can add servers for dev purposes as well as admins
"tag:dev-databases": ["group:admin", "group:dev"],
"tag:dev-app-servers": ["group:admin", "group:dev"]
// interns cannot add servers
},
// hosts should be defined using its IP addresses and a subnet mask.
// to define a single host, use a /32 mask. You cannot use DNS entries here,
// as they're prone to be hijacked by replacing their IP addresses.
// see https://github.com/tailscale/tailscale/issues/3800 for more information.
"hosts": {
"postgresql.internal": "10.20.0.2/32",
"webservers.internal": "10.20.10.1/29"
},
"acls": [
// boss have access to all servers
{
"action": "accept",
"src": ["group:boss"],
"dst": [
"tag:prod-databases:*",
"tag:prod-app-servers:*",
"tag:internal:*",
"tag:dev-databases:*",
"tag:dev-app-servers:*"
]
},
// admin have only access to administrative ports of the servers, in tcp/22
{
"action": "accept",
"src": ["group:admin"],
"proto": "tcp",
"dst": [
"tag:prod-databases:22",
"tag:prod-app-servers:22",
"tag:internal:22",
"tag:dev-databases:22",
"tag:dev-app-servers:22"
]
},
// we also allow admin to ping the servers
{
"action": "accept",
"src": ["group:admin"],
"proto": "icmp",
"dst": [
"tag:prod-databases:*",
"tag:prod-app-servers:*",
"tag:internal:*",
"tag:dev-databases:*",
"tag:dev-app-servers:*"
]
},
// developers have access to databases servers and application servers on all ports
// they can only view the applications servers in prod and have no access to databases servers in production
{
"action": "accept",
"src": ["group:dev"],
"dst": [
"tag:dev-databases:*",
"tag:dev-app-servers:*",
"tag:prod-app-servers:80,443"
]
},
// developers have access to the internal network through the router.
// the internal network is composed of HTTPS endpoints and Postgresql
// database servers.
{
"action": "accept",
"src": ["group:dev"],
"dst": ["10.20.0.0/16:443,5432"]
},
// servers should be able to talk to database in tcp/5432. Database should not be able to initiate connections to
// applications servers
{
"action": "accept",
"src": ["tag:dev-app-servers"],
"proto": "tcp",
"dst": ["tag:dev-databases:5432"]
},
{
"action": "accept",
"src": ["tag:prod-app-servers"],
"dst": ["tag:prod-databases:5432"]
},
// interns have access to dev-app-servers only in reading mode
{
"action": "accept",
"src": ["group:intern"],
"dst": ["tag:dev-app-servers:80,443"]
},
// Allow users to access their own devices using autogroup:self (see below for more details about performance impact)
{
"action": "accept",
"src": ["autogroup:member"],
"dst": ["autogroup:self:*"]
}
]
}
```
## Autogroups
Headscale supports several autogroups that automatically include users, destinations, or devices with specific properties. Autogroups provide a convenient way to write ACL rules without manually listing individual users or devices.
### `autogroup:internet`
Allows access to the internet through [exit nodes](routes.md#exit-node). Can only be used in ACL destinations.
```json
{
"action": "accept",
"src": ["group:users"],
"dst": ["autogroup:internet:*"]
}
```
### `autogroup:member`
Includes all [personal (untagged) devices](registration.md/#identity-model).
```json
{
"action": "accept",
"src": ["autogroup:member"],
"dst": ["tag:prod-app-servers:80,443"]
}
```
### `autogroup:tagged`
Includes all devices that [have at least one tag](registration.md/#identity-model).
```json
{
"action": "accept",
"src": ["autogroup:tagged"],
"dst": ["tag:monitoring:9090"]
}
```
### `autogroup:self`
!!! warning "The current implementation of `autogroup:self` is inefficient"
Includes devices where the same user is authenticated on both the source and destination. Does not include tagged devices. Can only be used in ACL destinations.
```json
{
"action": "accept",
"src": ["autogroup:member"],
"dst": ["autogroup:self:*"]
}
```
*Using `autogroup:self` may cause performance degradation on the Headscale coordinator server in large deployments, as filter rules must be compiled per-node rather than globally and the current implementation is not very efficient.*
If you experience performance issues, consider using more specific ACL rules or limiting the use of `autogroup:self`.
```json
{
// The following rules allow internal users to communicate with their
// own nodes in case autogroup:self is causing performance issues.
{ "action": "accept", "src": ["boss@"], "dst": ["boss@:*"] },
{ "action": "accept", "src": ["dev1@"], "dst": ["dev1@:*"] },
{ "action": "accept", "src": ["dev2@"], "dst": ["dev2@:*"] },
{ "action": "accept", "src": ["admin1@"], "dst": ["admin1@:*"] },
{ "action": "accept", "src": ["intern1@"], "dst": ["intern1@:*"] }
}
```
### `autogroup:nonroot`
Used in Tailscale SSH rules to allow access to any user except root. Can only be used in the `users` field of SSH rules.
```json
{
"action": "accept",
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"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).
+3 -3
View File
@@ -53,19 +53,19 @@ Headscale provides a metrics and debug endpoint. It allows to introspect differe
- Information about the Go runtime, memory usage and statistics
- Connected nodes and pending registrations
- Active ACLs, filters and SSH policy
- Active policy, filters and SSH policy
- Current DERPMap
- Prometheus metrics
!!! warning "Keep the metrics and debug endpoint private"
The listen address and port can be configured with the `metrics_listen_addr` variable in the [configuration
file](./configuration.md). By default it listens on localhost, port 9090.
file](configuration.md). By default it listens on localhost, port 9090.
Keep the metrics and debug endpoint private to your internal network and don't expose it to the Internet.
The metrics and debug interface can be disabled completely by setting `metrics_listen_addr: null` in the
[configuration file](./configuration.md).
[configuration file](configuration.md).
Query metrics via <http://localhost:9090/metrics> and get an overview of available debug information via
<http://localhost:9090/debug/>. Metrics may be queried from outside localhost but the debug interface is subject to
+4 -4
View File
@@ -6,8 +6,8 @@ DERP server to ensure seamless connectivity between nodes.
## Configuration
DERP related settings are configured within the `derp` section of the [configuration file](./configuration.md). The
following sections only use a few of the available settings, check the [example configuration](./configuration.md) for
DERP related settings are configured within the `derp` section of the [configuration file](configuration.md). The
following sections only use a few of the available settings, check the [example configuration](configuration.md) for
all available configuration options.
### Enable embedded DERP
@@ -163,7 +163,7 @@ Any Tailscale client may be used to introspect the DERP map and to check for con
- Check connectivity with the embedded DERP[^1]:`tailscale debug derp headscale`
Additional DERP related metrics and information is available via the [metrics and debug
endpoint](./debug.md#metrics-and-debug-endpoint).
endpoint](debug.md#metrics-and-debug-endpoint).
## Limitations
@@ -171,4 +171,4 @@ endpoint](./debug.md#metrics-and-debug-endpoint).
endpoint via HTTP on port tcp/80.
- There are no speed or throughput optimisations, the main purpose is to assist in node connectivity.
[^1]: This assumes that the default region code of the [configuration file](./configuration.md) is used.
[^1]: This assumes that the default region code of the [configuration file](configuration.md) is used.
+5 -5
View File
@@ -1,19 +1,19 @@
# DNS
Headscale supports [most DNS features](../about/features.md) from Tailscale. DNS related settings can be configured
within the `dns` section of the [configuration file](./configuration.md).
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:
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
- 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
configuration require a restart of Headscale.
- For dynamic DNS records that may be added, updated or removed while Headscale is running or DNS records that are
generated by scripts the option `dns.extra_records_path` in the [configuration file](./configuration.md) is useful.
generated by scripts the option `dns.extra_records_path` in the [configuration file](configuration.md) is useful.
Set it to the absolute path of the JSON file containing DNS records and Headscale processes this file as it detects
changes.
@@ -66,7 +66,7 @@ hostname and port combination "http://hostname-in-magic-dns.myvpn.example.com:30
!!! tip "Good to know"
- The `dns.extra_records_path` option in the [configuration file](./configuration.md) needs to reference the
- The `dns.extra_records_path` option in the [configuration file](configuration.md) needs to reference the
JSON file containing extra DNS records.
- Be sure to "sort keys" and produce a stable output in case you generate the JSON file with a script.
Headscale uses a checksum to detect changes to the file and a stable output avoids unnecessary processing.
+24
View File
@@ -31,6 +31,30 @@ tls_cert_path: ""
tls_key_path: ""
```
### Trusted proxies
Headscale ignores `True-Client-IP`, `X-Real-IP` and `X-Forwarded-For`
unless the request's TCP peer matches `trusted_proxies`. Set this to
the CIDR(s) your reverse proxy connects from so the real client IP
appears in access logs:
```yaml title="config.yaml"
trusted_proxies:
- 127.0.0.1/32
- ::1/128
```
The reverse proxy must also strip any client-supplied
`True-Client-IP` / `X-Real-IP` / `X-Forwarded-For` on inbound requests
and set its own values. nginx's `$proxy_add_x_forwarded_for` only
appends to whatever the client sent — pair it with
`proxy_set_header X-Real-IP $remote_addr;` and clear the inbound XFF
yourself if your nginx version does not do so.
Leaving `trusted_proxies` empty when there is no proxy in front is
safe: the headers are dropped from every request and the access log
shows the directly-connecting TCP peer.
## nginx
The following example configuration can be used in your nginx setup, substituting values as necessary. `<IP:PORT>` should be the IP address and port where headscale is running. In most cases, this will be `http://localhost:8080`.
+1 -1
View File
@@ -19,7 +19,7 @@ Headscale doesn't provide a built-in web interface but users may pick one from t
it offers Local (`docker exec`) and API Mode
- [headscale-console](https://github.com/rickli-cloud/headscale-console) - WebAssembly-based client supporting SSH, VNC
and RDP with optional self-service capabilities
- [headscale-piying](https://github.com/wszgrcy/headscale-piying) - headscale web ui,support visual ACL configuration
- [headscale-piying](https://github.com/wszgrcy/headscale-piying) - headscale web ui, support visual ACL configuration
- [HeadControl](https://github.com/ahmadzip/HeadControl) - Minimal Headscale admin dashboard, built with Go and HTMX
- [Headscale Manager](https://github.com/hkdone/headscalemanager) - Headscale UI for Android
- [Headscale UI](https://github.com/MunMunMiao/headscale-ui) - Headscale UI online and Self-hosting
+7 -7
View File
@@ -214,14 +214,14 @@ You may refer to users in the Headscale policy via:
{
"groups": {
"group:alice": [
"https://soo.example.com/oauth2/openid/59ac9125-c31b-46c5-814e-06242908cf57@"
"https://sso.example.com/oauth2/openid/59ac9125-c31b-46c5-814e-06242908cf57@"
]
},
"acls": [
"grants": [
{
"action": "accept",
"src": ["group:alice"],
"dst": ["*:*"]
"dst": ["*"],
"ip": ["*"]
}
]
}
@@ -246,7 +246,7 @@ endpoint.
- Support for OpenID Connect aims to be generic and vendor independent. It offers only limited support for quirks of
specific identity providers.
- OIDC groups cannot be used in ACLs.
- OIDC groups cannot be used in policy rules.
- The username provided by the identity provider needs to adhere to this pattern:
- The username must be at least two characters long.
- It must only contain letters, digits, hyphens, dots, underscores, and up to a single `@`.
@@ -283,9 +283,9 @@ Authelia is fully supported by Headscale.
### Google OAuth
!!! warning "No username due to missing preferred_username"
!!! warning "No username due to missing preferred_username claim"
Google OAuth does not send the `preferred_username` claim when the scope `profile` is requested. The username in
Google OAuth does not send the `preferred_username` claim when the `profile` scope is requested. The username in
Headscale will be blank/not set.
In order to integrate Headscale with Google, you'll need to have a [Google Cloud
+249
View File
@@ -0,0 +1,249 @@
# Policy
Headscale implements a large portion of Tailscale's [policy
features](https://tailscale.com/docs/features/tailnet-policy-file), most notably access control based on
[ACLs](https://tailscale.com/docs/features/access-control/acls) and
[Grants](https://tailscale.com/docs/features/access-control/grants) or [Tailscale
SSH](https://tailscale.com/docs/features/tailscale-ssh). See [limitations](#limitations) to learn about missing features
and notable implementation differences between Headscale and Tailscale.
Headscale uses the same [huJSON](https://github.com/tailscale/hujson) based file format as Tailscale. By default, no
policy is loaded which means that Headscale allows all traffic between nodes. To start using a policy file[^1], specify
its path in the `policy.path` key in the [configuration file](configuration.md).
Headscale needs to be reloaded to pick up changes to the policy file. Either reload Headscale via its systemd service
(`sudo systemctl reload headscale`) or by sending a SIGHUP signal (`sudo kill -HUP $(pidof headscale)`) to the main
process. Headscale logs the result of policy processing after each reload.
Please have a look at Tailscale's policy related documentation to learn more:
- [Tailscale policy file](https://tailscale.com/docs/features/tailnet-policy-file): A description of supported sections
within the policy file along with links to syntax references for each section.
- [ACLs](https://tailscale.com/docs/features/access-control/acls): How to configure access control using ACLs.
- [Grants](https://tailscale.com/docs/features/access-control/grants): Introduction to Grants with links to [syntax
reference](https://tailscale.com/docs/reference/syntax/grants),
[examples](https://tailscale.com/docs/reference/examples/grants) and a [migration guide from ACLs to
Grants](https://tailscale.com/docs/reference/migrate-acls-grants).
## Getting started
Headscale supports both [ACLs](https://tailscale.com/docs/features/access-control/acls) and
[Grants](https://tailscale.com/docs/features/access-control/grants) to write an access control policy. We recommend the
use of Grants since ACLs are considered legacy and will not receive new features by Tailscale.
### Allow All
If you define a policy file but completely omit the `"acls"` or `"grants"` section, Headscale will default to an [allow
all](https://tailscale.com/docs/reference/examples/acls#allow-all-default-acl) policy. This means all devices connected
to your tailnet will be able to communicate freely with each other.
```json title="policy.json"
{}
```
### Deny All
To [prevent all communication within your tailnet](https://tailscale.com/docs/reference/examples/acls#deny-all), you can
include an empty array for the `"grants"` section in your policy file.
```json title="policy.json"
{
"grants": []
}
```
### More examples
- See our documentation on [subnet routers](routes.md#subnet-router) and [exit nodes](routes.md#exit-node) to learn how
to restrict their use or how to automatically approve them.
- The Tailscale documentation provides a large collection of configuration examples:
- [ACL examples](https://tailscale.com/docs/reference/examples/acls)
- [Grants examples](https://tailscale.com/docs/reference/examples/grants)
- [SSH configuration](https://tailscale.com/docs/features/tailscale-ssh#configure-tailscale-ssh)
- [Define a tag](https://tailscale.com/docs/features/tags#define-a-tag)
______________________________________________________________________
## Limitations
- [Device postures](https://tailscale.com/docs/features/device-posture) and the related sections such as `postures` or
`srcPosture` aren't supported.
- [IP sets](https://tailscale.com/docs/features/tailnet-policy-file/ip-sets) aren't supported.
- A subset of [Autogroups](#autogroups) are available.
## Autogroups
Headscale supports several [Autogroups](https://tailscale.com/docs/reference/targets-and-selectors#autogroups) that
automatically include users, destinations, or devices with specific properties. Autogroups provide a convenient way to
write policy rules without manually listing individual users or devices.
### [`autogroup:internet`](https://tailscale.com/docs/reference/targets-and-selectors#autogroupinternet)
Allows access to the internet through [exit nodes](routes.md#exit-node). Can only be used in policy destinations.
```json title="policy.json"
{
"grants": [
{
"src": ["alice@"],
"dst": ["autogroup:internet"],
"ip": ["*"]
}
]
}
```
### [`autogroup:member`](https://tailscale.com/docs/reference/targets-and-selectors#autogrouprole)
Includes all [personal (untagged) devices](registration.md/#identity-model).
```json title="policy.json"
{
"grants": [
{
"src": ["autogroup:member"],
"dst": ["tag:prod-app-servers"],
"ip": ["80,443"]
}
]
}
```
### [`autogroup:tagged`](https://tailscale.com/docs/reference/targets-and-selectors#autogrouptagged)
Includes all devices that [have at least one tag](registration.md/#identity-model).
```json title="policy.json"
{
"grants": [
{
"src": ["autogroup:tagged"],
"dst": ["tag:monitoring"],
"ip": ["9090"]
}
]
}
```
### [`autogroup:self`](https://tailscale.com/docs/reference/targets-and-selectors#autogroupself)
Includes devices where the same user is authenticated on both the source and destination. Does not include tagged
devices. Can only be used in policy destinations.
```json title="policy.json"
{
"grants": [
{
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"ip": ["*"]
}
]
}
```
!!! warning "The current implementation of `autogroup:self` is inefficient"
Using `autogroup:self` may cause performance degradation on the Headscale coordinator server in large deployments,
as filter rules must be compiled per-node rather than globally and the current implementation is not very efficient.
If you experience performance issues, consider using more specific policy rules or limiting the use of
`autogroup:self`.
```json title="policy.json"
{
"grants": [
// The following rules allow internal users to communicate with their
// own nodes in case autogroup:self is causing performance issues.
{
"src": ["boss@"],
"dst": ["boss@"],
"ip": "*"
},
{
"src": ["dev1@"],
"dst": ["dev1@"],
"ip": "*"
},
{
"src": ["intern1@"],
"dst": ["intern1@"],
"ip": "*"
}
]
}
```
### [`autogroup:nonroot`](https://tailscale.com/docs/reference/targets-and-selectors#other-built-in-targets)
Used in Tailscale SSH rules to allow access to any user except root. Can only be used in the `users` field of SSH rules.
```json title="policy.json"
{
"ssh": [
{
"action": "accept",
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"users": ["autogroup:nonroot"]
}
]
}
```
### [`autogroup:danger-all`](https://tailscale.com/docs/reference/targets-and-selectors#autogroupdanger-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.
## Node Attributes
[Node attributes](https://tailscale.com/docs/reference/syntax/policy-file#node-attributes) allow for device-specific
configuration and attributes. At least the following node attributes are currently supported by Headscale[^2]:
- `drive:access`, `drive:share`: [Taildrive support](https://tailscale.com/docs/features/taildrive).
- `nextdns:<profile>`, `nextdns:no-device-info`: [NextDNS integration](https://tailscale.com/docs/integrations/nextdns).
Be sure to set NextDNS as global resolver in the [configuration](configuration.md).
- `magicdns-aaaa`: Respond to AAAA queries on the local [MagicDNS](https://tailscale.com/docs/features/magicdns)
resolver at 100.100.100.100.
- `disable-ipv4`: Selectively disable IPv4 for specfic nodes. This is may be useful to workaround [CGNat
conflicts](https://tailscale.com/docs/reference/troubleshooting/network-configuration/cgnat-conflicts).
- `randomize-client-port`: Allocate a [random port for WireGuard
traffic](https://tailscale.com/docs/reference/syntax/policy-file#randomizeclientport) instead of the static default
port 41641.
- `disable-captive-portal-detection`: [Disable automatic captive portal
detection](https://tailscale.com/docs/integrations/captive-portals#disable-captive-portal-detection).
```json title="policy.json"
{
"nodeAttrs": [
{
// Enable MagicDNS AAAA records for all nodes
"target": ["*"]
"attr": ["magicdns-aaaa"]
}
]
}
```
## Network-wide policy options
The following options are applied for the entire tailnet. Consider [node attributes](#node-attributes) for a more
fine-grained configuration instead.
- `randomizeClientPort`: Allocate a [random port for WireGuard
traffic](https://tailscale.com/docs/reference/syntax/policy-file#randomizeclientport) instead of the static default
port 41641.
```json title="policy.json"
{
// Use a random WireGuard port for the entire tailnet
"randomizeClientPort": true
}
```
[^1]: Headscale also allows to store the policy in the database. This is typically only required in case a [web
interface](integration/web-ui.md) is used.
[^2]: Other key-only node attributes can be used as well. Find them in the client source code with `grep -E '^\s+NodeAttr\w+' tailcfg/tailcfg.go` or by using [GitHub code search (requires
login)](https://github.com/search?q=repo%3Atailscale%2Ftailscale%20language%3Ago%20path%3Atailcfg%2Ftailcfg.go%20symbol%3A%2FNodeAttr%5Cw%2B%2F&type=code).
+2 -2
View File
@@ -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/docs/reference/syntax/policy-file#tag-owners) section of the
[policy](policy.md). A simple example looks like this:
```json title="The user alice can register nodes tagged with tag:server"
{
+38 -42
View File
@@ -76,29 +76,29 @@ 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.
### Restrict the use of a subnet router with ACL
### Restrict the use of a subnet router with a policy
The routes announced by subnet routers are available to the nodes in a tailnet. By default, without an ACL enabled, all
nodes can accept and use such routes. Configure an ACL to explicitly manage who can use routes.
The routes announced by subnet routers are available to the nodes in a tailnet. By default, without a policy enabled,
all nodes can accept and use such routes. Configure a policy to explicitly manage who can use routes.
The ACL snippet below defines three hosts, a subnet router `router`, a regular node `node` and `service.example.net` as
internal service that can be reached via a route on the subnet router `router`. It allows the node `node` to access
The policy snippet below defines three hosts, a subnet router `router`, a regular node `node` and `service.example.net`
as internal service that can be reached via a route on the subnet router `router`. It allows the node `node` to access
`service.example.net` on port 80 and 443 which is reachable via the subnet router. Access to the subnet router itself is
denied.
```json title="Access the routes of a subnet router without the subnet router itself"
{
"hosts": {
// the router is not referenced but announces 192.168.0.0/24"
// the router is not referenced but announces 192.168.0.0/24
"router": "100.64.0.1/32",
"node": "100.64.0.2/32",
"service.example.net": "192.168.0.1/32"
},
"acls": [
"grants": [
{
"action": "accept",
"src": ["node"],
"dst": ["service.example.net:80,443"]
"dst": ["service.example.net"],
"ip": ["80,443"]
}
]
}
@@ -107,10 +107,10 @@ denied.
### Automatically approve routes of a subnet router
The initial setup of a subnet router usually requires manual approval of their announced routes on the control server
before they can be used by a node in a tailnet. Headscale supports the `autoApprovers` section of an ACL to automate the
approval of routes served with a subnet router.
before they can be used by a node in a tailnet. Headscale supports the `autoApprovers` section in a policy to automate
the approval of routes served with a subnet router.
The ACL snippet below defines the tag `tag:router` owned by the user `alice`. This tag is used for `routes` in the
The policy snippet below defines the tag `tag:router` owned by the user `alice`. This tag is used for `routes` in the
`autoApprovers` section. The IPv4 route `192.168.0.0/24` is automatically approved once announced by a subnet router
that advertises the tag `tag:router`.
@@ -124,7 +124,7 @@ that advertises the tag `tag:router`.
"192.168.0.0/24": ["tag:router"]
}
},
"acls": [
"grants": [
// more rules
]
}
@@ -204,19 +204,19 @@ $ 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.
### Restrict the use of an exit node with ACL
### Restrict the use of an exit node with a policy
An exit node is offered to all nodes in a tailnet. By default, without an ACL enabled, all nodes in a tailnet can select
and use an exit node. Configure `autogroup:internet` in an ACL rule to restrict who can use _any_ of the available exit
nodes.
An exit node is offered to all nodes in a tailnet. By default, without a policy enabled, all nodes in a tailnet can
select and use an exit node. Configure `autogroup:internet` in a policy rule to restrict who can use _any_ of the
available exit nodes.
```json title="Example use of autogroup:internet"
{
"acls": [
"grants": [
{
"action": "accept",
"src": ["..."],
"dst": ["autogroup:internet:*"]
"dst": ["autogroup:internet"],
"ip": ["*"]
}
]
}
@@ -224,45 +224,41 @@ nodes.
### Restrict access to exit nodes per user or group
A user can use _any_ of the available exit nodes with `autogroup:internet`. Alternatively, the ACL snippet below assigns
each user a specific exit node while hiding all other exit nodes. The user `alice` can only use exit node `exit1` while
user `bob` can only use exit node `exit2`.
A user can use _any_ of the available exit nodes with `autogroup:internet`. Alternatively, the policy snippet below
assigns each user a specific exit node while hiding all other exit nodes. The user `alice` can only use an exit node
tagged with `tag:exit1` while user `bob` can only use an exit node tagged with `tag:exit2`.
```json title="Assign each user a dedicated exit node"
{
"hosts": {
"exit1": "100.64.0.1/32",
"exit2": "100.64.0.2/32"
"tagOwners": {
"tag:exit1": ["alice@"],
"tag:exit2": ["bob@"]
},
"acls": [
"grants": [
{
"action": "accept",
"src": ["alice@"],
"dst": ["exit1:*"]
"dst": ["autogroup:internet"],
"via": ["tag:exit1"],
"ip": ["*"]
},
{
"action": "accept",
"src": ["bob@"],
"dst": ["exit2:*"]
"dst": ["autogroup:internet"],
"via": ["tag:exit2"],
"ip": ["*"]
}
]
}
```
!!! warning
- The above implementation is Headscale specific and will likely be removed once [support for
`via`](https://github.com/juanfont/headscale/issues/2409) is available.
- Beware that a user can also connect to any port of the exit node itself.
### Automatically approve an exit node with auto approvers
The initial setup of an exit node usually requires manual approval on the control server before it can be used by a node
in a tailnet. Headscale supports the `autoApprovers` section of an ACL to automate the approval of a new exit node as
in a tailnet. Headscale supports the `autoApprovers` section in a policy to automate the approval of a new exit node as
soon as it joins the tailnet.
The ACL snippet below defines the tag `tag:exit` owned by the user `alice`. This tag is used for `exitNode` in the
`autoApprovers` section. A new exit node that advertises the tag `tag:exit` is automatically approved:
The policy snippet below defines the tag `tag:exit` owned by the user `alice`. This tag is used for the `exitNode` entry
in the `autoApprovers` section. A new exit node that advertises the tag `tag:exit` is automatically approved:
```json title="Exit nodes tagged with tag:exit are automatically approved"
{
@@ -272,7 +268,7 @@ The ACL snippet below defines the tag `tag:exit` owned by the user `alice`. This
"autoApprovers": {
"exitNode": ["tag:exit"]
},
"acls": [
"grants": [
// more rules
]
}
@@ -295,7 +291,7 @@ 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.
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.
`node.routes.ha` in the [configuration file](configuration.md) for details.
## Troubleshooting
+3 -6
View File
@@ -1,7 +1,7 @@
# Community packages
Several Linux distributions and community members provide packages for headscale. Those packages may be used instead of
the [official releases](./official.md) provided by the headscale maintainers. Such packages offer improved integration
the [official releases](official.md) provided by the headscale maintainers. Such packages offer improved integration
for their targeted operating system and usually:
- setup a dedicated local user account to run headscale
@@ -10,8 +10,8 @@ for their targeted operating system and usually:
!!! warning "Community packages might be outdated"
The packages mentioned on this page might be outdated or unmaintained. Use the [official releases](./official.md) to
get the current stable version or to test pre-releases.
The packages mentioned on this page might be outdated or unmaintained. Use the [official releases](official.md) to
get the current stable version or to [test pre-releases](main.md).
[![Packaging status](https://repology.org/badge/vertical-allrepos/headscale.svg)](https://repology.org/project/headscale/versions)
@@ -23,9 +23,6 @@ Arch Linux offers a package for headscale, install via:
pacman -S headscale
```
The [AUR package `headscale-git`](https://aur.archlinux.org/packages/headscale-git) can be used to build the current
development version.
## Fedora, RHEL, CentOS
A third-party repository for various RPM based distributions is available at:
+4 -5
View File
@@ -7,9 +7,8 @@
**It might be outdated and it might miss necessary steps**.
This documentation has the goal of showing a user how-to set up and run headscale in a container. A container runtime
such as [Docker](https://www.docker.com) or [Podman](https://podman.io) is required. The container image can be found on
[Docker Hub](https://hub.docker.com/r/headscale/headscale) and [GitHub Container
A container runtime such as [Docker](https://www.docker.com) or [Podman](https://podman.io) is required. The container
image can be found on [Docker Hub](https://hub.docker.com/r/headscale/headscale) and [GitHub Container
Registry](https://github.com/juanfont/headscale/pkgs/container/headscale). The container image URLs are:
- [Docker Hub](https://hub.docker.com/r/headscale/headscale): `docker.io/headscale/headscale:<VERSION>`
@@ -18,7 +17,7 @@ Registry](https://github.com/juanfont/headscale/pkgs/container/headscale). The c
## Configure and run headscale
1. Create a directory on the container host to store headscale's [configuration](../../ref/configuration.md) and the [SQLite](https://www.sqlite.org/) database:
1. Create a directory on the container host to store headscale's [configuration](../../ref/configuration.md) and the SQLite database:
```shell
mkdir -p ./headscale/{config,lib}
@@ -98,7 +97,7 @@ Continue on the [getting started page](../../usage/getting-started.md) to regist
## Debugging headscale running in Docker
The Headscale container image is based on a "distroless" image that does not contain a shell or any other debug tools. If you need to debug headscale running in the Docker container, you can use the `-debug` variant, for example `docker.io/headscale/headscale:x.x.x-debug`.
The Headscale container image is based on a distroless image that does not contain a shell or any other debug tools. If you need to debug headscale running in the Docker container, you can use the `-debug` variant, for example `docker.io/headscale/headscale:x.x.x-debug`.
### Running the debug Docker container
+2 -2
View File
@@ -39,7 +39,7 @@ docker run \
serve
```
See [Running headscale in a container](./container.md) for full container setup instructions.
See [Running headscale in a container](container.md) for full container setup instructions.
## Binaries
@@ -54,5 +54,5 @@ via [nightly.link](https://nightly.link/juanfont/headscale/workflows/container-m
| macOS | arm64 | [headscale-darwin-arm64](https://nightly.link/juanfont/headscale/workflows/container-main/main/headscale-darwin-arm64.zip) |
After downloading and extracting the archive, make the binary executable and follow the
[standalone binary installation](./official.md#using-standalone-binaries-advanced)
[standalone binary installation](official.md#using-standalone-binaries-advanced)
instructions for setting up the service.
+3 -3
View File
@@ -31,10 +31,10 @@ distributions are Ubuntu 22.04 or newer, Debian 12 or newer.
sudo nano /etc/headscale/config.yaml
```
1. Enable and start the headscale service:
1. Restart headscale to pick up configuration changes:
```shell
sudo systemctl enable --now headscale
sudo systemctl restart headscale
```
1. Verify that headscale is running as intended:
@@ -51,7 +51,7 @@ Continue on the [getting started page](../../usage/getting-started.md) to regist
This installation method is considered advanced as one needs to take care of the local user and the systemd
service themselves. If possible, use the [DEB packages](#using-packages-for-debianubuntu-recommended) or a
[community package](./community.md) instead.
[community package](community.md) instead.
This section describes the installation of headscale according to the [Requirements and
assumptions](../requirements.md#assumptions). Headscale is run by a dedicated local user and the service itself is
+1 -1
View File
@@ -40,7 +40,7 @@ The headscale documentation and the provided examples are written with a few ass
- Headscale is running as system service via a dedicated local user `headscale`.
- The [configuration](../ref/configuration.md) is loaded from `/etc/headscale/config.yaml`.
- SQLite is used as database.
- The data directory for headscale (used for private keys, ACLs, SQLite database, …) is located in `/var/lib/headscale`.
- The data directory for headscale (used for private keys, policy, SQLite database, …) is located in `/var/lib/headscale`.
- URLs and values that need to be replaced by the user are either denoted as `<VALUE_TO_CHANGE>` or use placeholder
values such as `headscale.example.com`.
Generated
+3 -3
View File
@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1775701739,
"narHash": "sha256-2FWWY1rr/+pGUJK1npcVcsWNEblzmKs6VxD3VEvwJSs=",
"lastModified": 1779351318,
"narHash": "sha256-f+JACbTqzZ+G92DSnXOUGRhGANb8Blh7CoeYOeBF8/U=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0f7663154ff2fec150f9dbf5f81ec2785dc1e0db",
"rev": "4a29d733e8a7d5b824c3d8c958a946a9867b3eb2",
"type": "github"
},
"original": {
+10 -16
View File
@@ -27,7 +27,7 @@
let
pkgs = nixpkgs.legacyPackages.${prev.stdenv.hostPlatform.system};
buildGo = pkgs.buildGo126Module;
vendorHash = "sha256-8vTEkPEMbJ6DSOjcoQrYRyKSYI8jjcllTmJ6RXmUV9w=";
vendorHash = (builtins.fromJSON (builtins.readFile ./flakehashes.json)).vendor.sri;
in
{
headscale = buildGo {
@@ -38,8 +38,8 @@
# Only run unit tests when testing a build
checkFlags = [ "-short" ];
# When updating go.mod or go.sum, a new sha will need to be calculated,
# update this if you have a mismatch after doing a change to those files.
# vendorHash is read from flakehashes.json; refresh via:
# go run ./cmd/vendorhash update
inherit vendorHash;
subPackages = [ "cmd/headscale" ];
@@ -62,16 +62,16 @@
protoc-gen-grpc-gateway = buildGo rec {
pname = "grpc-gateway";
version = "2.28.0";
version = "2.29.0";
src = pkgs.fetchFromGitHub {
owner = "grpc-ecosystem";
repo = "grpc-gateway";
rev = "v${version}";
sha256 = "sha256-93omvHb+b+S0w4D+FGEEwYYDjgumJFDAruc1P4elfvA=";
sha256 = "sha256-d9OIIGttyMBSNgpS6mbR5JEIm13qGu2gFHJazJAexdw=";
};
vendorHash = "sha256-jVP5zfFPfHeAEApKNJzZwuZLA+DjKgkL7m2DFG72UNs=";
vendorHash = "sha256-p51yD+v8+rPs+ztlX7r0VQ4XlwUkxu+PxgknKEvH00k=";
nativeBuildInputs = [ pkgs.installShellFiles ];
@@ -97,16 +97,16 @@
# Build golangci-lint with Go 1.26 (upstream uses hardcoded Go version)
golangci-lint = buildGo rec {
pname = "golangci-lint";
version = "2.11.4";
version = "2.12.2";
src = pkgs.fetchFromGitHub {
owner = "golangci";
repo = "golangci-lint";
rev = "v${version}";
hash = "sha256-B19aLvfNRY9TOYw/71f2vpNUuSIz8OI4dL0ijGezsas=";
hash = "sha256-qR7fp1x2S+EwEAcplRHTvA3jWwLr/XSiYKSZtAwkrNU=";
};
vendorHash = "sha256-xuoj4+U4tB5gpABKq4Dbp2cxnljxdYoBbO8A7DqPM5E=";
vendorHash = "sha256-AG5wtLwWLz55bdp1oi3cW+9O3yj1W1P7MV9zxym7Pb4=";
subPackages = [ "cmd/golangci-lint" ];
@@ -223,13 +223,7 @@
"nix-vendor-sri"
''
set -eu
OUT=$(mktemp -d -t nar-hash-XXXXXX)
rm -rf "$OUT"
go mod vendor -o "$OUT"
go run tailscale.com/cmd/nardump --sri "$OUT"
rm -rf "$OUT"
exec go run ./cmd/vendorhash update "$@"
'')
(pkgs.writeShellScriptBin
+6
View File
@@ -0,0 +1,6 @@
{
"vendor": {
"goModSum": "sha256-xos6ixeJRMrEengGJaRiSsrm+3S3R0OEsnv1e85Sqhw=",
"sri": "sha256-bZod9sUUyQ67x/HzZrQ7SK+o5gAUxJhx7Rr6VdIUj1I="
}
}
+65 -60
View File
@@ -109,7 +109,7 @@ const file_headscale_v1_headscale_proto_rawDesc = "" +
"\x1cheadscale/v1/headscale.proto\x12\fheadscale.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17headscale/v1/user.proto\x1a\x1dheadscale/v1/preauthkey.proto\x1a\x17headscale/v1/node.proto\x1a\x19headscale/v1/apikey.proto\x1a\x17headscale/v1/auth.proto\x1a\x19headscale/v1/policy.proto\"\x0f\n" +
"\rHealthRequest\"E\n" +
"\x0eHealthResponse\x123\n" +
"\x15database_connectivity\x18\x01 \x01(\bR\x14databaseConnectivity2\xeb\x19\n" +
"\x15database_connectivity\x18\x01 \x01(\bR\x14databaseConnectivity2\xe0\x1a\n" +
"\x10HeadscaleService\x12h\n" +
"\n" +
"CreateUser\x12\x1f.headscale.v1.CreateUserRequest\x1a .headscale.v1.CreateUserResponse\"\x17\x82\xd3\xe4\x93\x02\x11:\x01*\"\f/api/v1/user\x12\x80\x01\n" +
@@ -144,7 +144,8 @@ const file_headscale_v1_headscale_proto_rawDesc = "" +
"\vListApiKeys\x12 .headscale.v1.ListApiKeysRequest\x1a!.headscale.v1.ListApiKeysResponse\"\x16\x82\xd3\xe4\x93\x02\x10\x12\x0e/api/v1/apikey\x12v\n" +
"\fDeleteApiKey\x12!.headscale.v1.DeleteApiKeyRequest\x1a\".headscale.v1.DeleteApiKeyResponse\"\x1f\x82\xd3\xe4\x93\x02\x19*\x17/api/v1/apikey/{prefix}\x12d\n" +
"\tGetPolicy\x12\x1e.headscale.v1.GetPolicyRequest\x1a\x1f.headscale.v1.GetPolicyResponse\"\x16\x82\xd3\xe4\x93\x02\x10\x12\x0e/api/v1/policy\x12g\n" +
"\tSetPolicy\x12\x1e.headscale.v1.SetPolicyRequest\x1a\x1f.headscale.v1.SetPolicyResponse\"\x19\x82\xd3\xe4\x93\x02\x13:\x01*\x1a\x0e/api/v1/policy\x12[\n" +
"\tSetPolicy\x12\x1e.headscale.v1.SetPolicyRequest\x1a\x1f.headscale.v1.SetPolicyResponse\"\x19\x82\xd3\xe4\x93\x02\x13:\x01*\x1a\x0e/api/v1/policy\x12s\n" +
"\vCheckPolicy\x12 .headscale.v1.CheckPolicyRequest\x1a!.headscale.v1.CheckPolicyResponse\"\x1f\x82\xd3\xe4\x93\x02\x19:\x01*\"\x14/api/v1/policy/check\x12[\n" +
"\x06Health\x12\x1b.headscale.v1.HealthRequest\x1a\x1c.headscale.v1.HealthResponse\"\x16\x82\xd3\xe4\x93\x02\x10\x12\x0e/api/v1/healthB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3"
var (
@@ -190,33 +191,35 @@ var file_headscale_v1_headscale_proto_goTypes = []any{
(*DeleteApiKeyRequest)(nil), // 26: headscale.v1.DeleteApiKeyRequest
(*GetPolicyRequest)(nil), // 27: headscale.v1.GetPolicyRequest
(*SetPolicyRequest)(nil), // 28: headscale.v1.SetPolicyRequest
(*CreateUserResponse)(nil), // 29: headscale.v1.CreateUserResponse
(*RenameUserResponse)(nil), // 30: headscale.v1.RenameUserResponse
(*DeleteUserResponse)(nil), // 31: headscale.v1.DeleteUserResponse
(*ListUsersResponse)(nil), // 32: headscale.v1.ListUsersResponse
(*CreatePreAuthKeyResponse)(nil), // 33: headscale.v1.CreatePreAuthKeyResponse
(*ExpirePreAuthKeyResponse)(nil), // 34: headscale.v1.ExpirePreAuthKeyResponse
(*DeletePreAuthKeyResponse)(nil), // 35: headscale.v1.DeletePreAuthKeyResponse
(*ListPreAuthKeysResponse)(nil), // 36: headscale.v1.ListPreAuthKeysResponse
(*DebugCreateNodeResponse)(nil), // 37: headscale.v1.DebugCreateNodeResponse
(*GetNodeResponse)(nil), // 38: headscale.v1.GetNodeResponse
(*SetTagsResponse)(nil), // 39: headscale.v1.SetTagsResponse
(*SetApprovedRoutesResponse)(nil), // 40: headscale.v1.SetApprovedRoutesResponse
(*RegisterNodeResponse)(nil), // 41: headscale.v1.RegisterNodeResponse
(*DeleteNodeResponse)(nil), // 42: headscale.v1.DeleteNodeResponse
(*ExpireNodeResponse)(nil), // 43: headscale.v1.ExpireNodeResponse
(*RenameNodeResponse)(nil), // 44: headscale.v1.RenameNodeResponse
(*ListNodesResponse)(nil), // 45: headscale.v1.ListNodesResponse
(*BackfillNodeIPsResponse)(nil), // 46: headscale.v1.BackfillNodeIPsResponse
(*AuthRegisterResponse)(nil), // 47: headscale.v1.AuthRegisterResponse
(*AuthApproveResponse)(nil), // 48: headscale.v1.AuthApproveResponse
(*AuthRejectResponse)(nil), // 49: headscale.v1.AuthRejectResponse
(*CreateApiKeyResponse)(nil), // 50: headscale.v1.CreateApiKeyResponse
(*ExpireApiKeyResponse)(nil), // 51: headscale.v1.ExpireApiKeyResponse
(*ListApiKeysResponse)(nil), // 52: headscale.v1.ListApiKeysResponse
(*DeleteApiKeyResponse)(nil), // 53: headscale.v1.DeleteApiKeyResponse
(*GetPolicyResponse)(nil), // 54: headscale.v1.GetPolicyResponse
(*SetPolicyResponse)(nil), // 55: headscale.v1.SetPolicyResponse
(*CheckPolicyRequest)(nil), // 29: headscale.v1.CheckPolicyRequest
(*CreateUserResponse)(nil), // 30: headscale.v1.CreateUserResponse
(*RenameUserResponse)(nil), // 31: headscale.v1.RenameUserResponse
(*DeleteUserResponse)(nil), // 32: headscale.v1.DeleteUserResponse
(*ListUsersResponse)(nil), // 33: headscale.v1.ListUsersResponse
(*CreatePreAuthKeyResponse)(nil), // 34: headscale.v1.CreatePreAuthKeyResponse
(*ExpirePreAuthKeyResponse)(nil), // 35: headscale.v1.ExpirePreAuthKeyResponse
(*DeletePreAuthKeyResponse)(nil), // 36: headscale.v1.DeletePreAuthKeyResponse
(*ListPreAuthKeysResponse)(nil), // 37: headscale.v1.ListPreAuthKeysResponse
(*DebugCreateNodeResponse)(nil), // 38: headscale.v1.DebugCreateNodeResponse
(*GetNodeResponse)(nil), // 39: headscale.v1.GetNodeResponse
(*SetTagsResponse)(nil), // 40: headscale.v1.SetTagsResponse
(*SetApprovedRoutesResponse)(nil), // 41: headscale.v1.SetApprovedRoutesResponse
(*RegisterNodeResponse)(nil), // 42: headscale.v1.RegisterNodeResponse
(*DeleteNodeResponse)(nil), // 43: headscale.v1.DeleteNodeResponse
(*ExpireNodeResponse)(nil), // 44: headscale.v1.ExpireNodeResponse
(*RenameNodeResponse)(nil), // 45: headscale.v1.RenameNodeResponse
(*ListNodesResponse)(nil), // 46: headscale.v1.ListNodesResponse
(*BackfillNodeIPsResponse)(nil), // 47: headscale.v1.BackfillNodeIPsResponse
(*AuthRegisterResponse)(nil), // 48: headscale.v1.AuthRegisterResponse
(*AuthApproveResponse)(nil), // 49: headscale.v1.AuthApproveResponse
(*AuthRejectResponse)(nil), // 50: headscale.v1.AuthRejectResponse
(*CreateApiKeyResponse)(nil), // 51: headscale.v1.CreateApiKeyResponse
(*ExpireApiKeyResponse)(nil), // 52: headscale.v1.ExpireApiKeyResponse
(*ListApiKeysResponse)(nil), // 53: headscale.v1.ListApiKeysResponse
(*DeleteApiKeyResponse)(nil), // 54: headscale.v1.DeleteApiKeyResponse
(*GetPolicyResponse)(nil), // 55: headscale.v1.GetPolicyResponse
(*SetPolicyResponse)(nil), // 56: headscale.v1.SetPolicyResponse
(*CheckPolicyResponse)(nil), // 57: headscale.v1.CheckPolicyResponse
}
var file_headscale_v1_headscale_proto_depIdxs = []int32{
2, // 0: headscale.v1.HeadscaleService.CreateUser:input_type -> headscale.v1.CreateUserRequest
@@ -246,37 +249,39 @@ var file_headscale_v1_headscale_proto_depIdxs = []int32{
26, // 24: headscale.v1.HeadscaleService.DeleteApiKey:input_type -> headscale.v1.DeleteApiKeyRequest
27, // 25: headscale.v1.HeadscaleService.GetPolicy:input_type -> headscale.v1.GetPolicyRequest
28, // 26: headscale.v1.HeadscaleService.SetPolicy:input_type -> headscale.v1.SetPolicyRequest
0, // 27: headscale.v1.HeadscaleService.Health:input_type -> headscale.v1.HealthRequest
29, // 28: headscale.v1.HeadscaleService.CreateUser:output_type -> headscale.v1.CreateUserResponse
30, // 29: headscale.v1.HeadscaleService.RenameUser:output_type -> headscale.v1.RenameUserResponse
31, // 30: headscale.v1.HeadscaleService.DeleteUser:output_type -> headscale.v1.DeleteUserResponse
32, // 31: headscale.v1.HeadscaleService.ListUsers:output_type -> headscale.v1.ListUsersResponse
33, // 32: headscale.v1.HeadscaleService.CreatePreAuthKey:output_type -> headscale.v1.CreatePreAuthKeyResponse
34, // 33: headscale.v1.HeadscaleService.ExpirePreAuthKey:output_type -> headscale.v1.ExpirePreAuthKeyResponse
35, // 34: headscale.v1.HeadscaleService.DeletePreAuthKey:output_type -> headscale.v1.DeletePreAuthKeyResponse
36, // 35: headscale.v1.HeadscaleService.ListPreAuthKeys:output_type -> headscale.v1.ListPreAuthKeysResponse
37, // 36: headscale.v1.HeadscaleService.DebugCreateNode:output_type -> headscale.v1.DebugCreateNodeResponse
38, // 37: headscale.v1.HeadscaleService.GetNode:output_type -> headscale.v1.GetNodeResponse
39, // 38: headscale.v1.HeadscaleService.SetTags:output_type -> headscale.v1.SetTagsResponse
40, // 39: headscale.v1.HeadscaleService.SetApprovedRoutes:output_type -> headscale.v1.SetApprovedRoutesResponse
41, // 40: headscale.v1.HeadscaleService.RegisterNode:output_type -> headscale.v1.RegisterNodeResponse
42, // 41: headscale.v1.HeadscaleService.DeleteNode:output_type -> headscale.v1.DeleteNodeResponse
43, // 42: headscale.v1.HeadscaleService.ExpireNode:output_type -> headscale.v1.ExpireNodeResponse
44, // 43: headscale.v1.HeadscaleService.RenameNode:output_type -> headscale.v1.RenameNodeResponse
45, // 44: headscale.v1.HeadscaleService.ListNodes:output_type -> headscale.v1.ListNodesResponse
46, // 45: headscale.v1.HeadscaleService.BackfillNodeIPs:output_type -> headscale.v1.BackfillNodeIPsResponse
47, // 46: headscale.v1.HeadscaleService.AuthRegister:output_type -> headscale.v1.AuthRegisterResponse
48, // 47: headscale.v1.HeadscaleService.AuthApprove:output_type -> headscale.v1.AuthApproveResponse
49, // 48: headscale.v1.HeadscaleService.AuthReject:output_type -> headscale.v1.AuthRejectResponse
50, // 49: headscale.v1.HeadscaleService.CreateApiKey:output_type -> headscale.v1.CreateApiKeyResponse
51, // 50: headscale.v1.HeadscaleService.ExpireApiKey:output_type -> headscale.v1.ExpireApiKeyResponse
52, // 51: headscale.v1.HeadscaleService.ListApiKeys:output_type -> headscale.v1.ListApiKeysResponse
53, // 52: headscale.v1.HeadscaleService.DeleteApiKey:output_type -> headscale.v1.DeleteApiKeyResponse
54, // 53: headscale.v1.HeadscaleService.GetPolicy:output_type -> headscale.v1.GetPolicyResponse
55, // 54: headscale.v1.HeadscaleService.SetPolicy:output_type -> headscale.v1.SetPolicyResponse
1, // 55: headscale.v1.HeadscaleService.Health:output_type -> headscale.v1.HealthResponse
28, // [28:56] is the sub-list for method output_type
0, // [0:28] is the sub-list for method input_type
29, // 27: headscale.v1.HeadscaleService.CheckPolicy:input_type -> headscale.v1.CheckPolicyRequest
0, // 28: headscale.v1.HeadscaleService.Health:input_type -> headscale.v1.HealthRequest
30, // 29: headscale.v1.HeadscaleService.CreateUser:output_type -> headscale.v1.CreateUserResponse
31, // 30: headscale.v1.HeadscaleService.RenameUser:output_type -> headscale.v1.RenameUserResponse
32, // 31: headscale.v1.HeadscaleService.DeleteUser:output_type -> headscale.v1.DeleteUserResponse
33, // 32: headscale.v1.HeadscaleService.ListUsers:output_type -> headscale.v1.ListUsersResponse
34, // 33: headscale.v1.HeadscaleService.CreatePreAuthKey:output_type -> headscale.v1.CreatePreAuthKeyResponse
35, // 34: headscale.v1.HeadscaleService.ExpirePreAuthKey:output_type -> headscale.v1.ExpirePreAuthKeyResponse
36, // 35: headscale.v1.HeadscaleService.DeletePreAuthKey:output_type -> headscale.v1.DeletePreAuthKeyResponse
37, // 36: headscale.v1.HeadscaleService.ListPreAuthKeys:output_type -> headscale.v1.ListPreAuthKeysResponse
38, // 37: headscale.v1.HeadscaleService.DebugCreateNode:output_type -> headscale.v1.DebugCreateNodeResponse
39, // 38: headscale.v1.HeadscaleService.GetNode:output_type -> headscale.v1.GetNodeResponse
40, // 39: headscale.v1.HeadscaleService.SetTags:output_type -> headscale.v1.SetTagsResponse
41, // 40: headscale.v1.HeadscaleService.SetApprovedRoutes:output_type -> headscale.v1.SetApprovedRoutesResponse
42, // 41: headscale.v1.HeadscaleService.RegisterNode:output_type -> headscale.v1.RegisterNodeResponse
43, // 42: headscale.v1.HeadscaleService.DeleteNode:output_type -> headscale.v1.DeleteNodeResponse
44, // 43: headscale.v1.HeadscaleService.ExpireNode:output_type -> headscale.v1.ExpireNodeResponse
45, // 44: headscale.v1.HeadscaleService.RenameNode:output_type -> headscale.v1.RenameNodeResponse
46, // 45: headscale.v1.HeadscaleService.ListNodes:output_type -> headscale.v1.ListNodesResponse
47, // 46: headscale.v1.HeadscaleService.BackfillNodeIPs:output_type -> headscale.v1.BackfillNodeIPsResponse
48, // 47: headscale.v1.HeadscaleService.AuthRegister:output_type -> headscale.v1.AuthRegisterResponse
49, // 48: headscale.v1.HeadscaleService.AuthApprove:output_type -> headscale.v1.AuthApproveResponse
50, // 49: headscale.v1.HeadscaleService.AuthReject:output_type -> headscale.v1.AuthRejectResponse
51, // 50: headscale.v1.HeadscaleService.CreateApiKey:output_type -> headscale.v1.CreateApiKeyResponse
52, // 51: headscale.v1.HeadscaleService.ExpireApiKey:output_type -> headscale.v1.ExpireApiKeyResponse
53, // 52: headscale.v1.HeadscaleService.ListApiKeys:output_type -> headscale.v1.ListApiKeysResponse
54, // 53: headscale.v1.HeadscaleService.DeleteApiKey:output_type -> headscale.v1.DeleteApiKeyResponse
55, // 54: headscale.v1.HeadscaleService.GetPolicy:output_type -> headscale.v1.GetPolicyResponse
56, // 55: headscale.v1.HeadscaleService.SetPolicy:output_type -> headscale.v1.SetPolicyResponse
57, // 56: headscale.v1.HeadscaleService.CheckPolicy:output_type -> headscale.v1.CheckPolicyResponse
1, // 57: headscale.v1.HeadscaleService.Health:output_type -> headscale.v1.HealthResponse
29, // [29:58] is the sub-list for method output_type
0, // [0:29] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
+66
View File
@@ -966,6 +966,33 @@ func local_request_HeadscaleService_SetPolicy_0(ctx context.Context, marshaler r
return msg, metadata, err
}
func request_HeadscaleService_CheckPolicy_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq CheckPolicyRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if req.Body != nil {
_, _ = io.Copy(io.Discard, req.Body)
}
msg, err := client.CheckPolicy(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_HeadscaleService_CheckPolicy_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq CheckPolicyRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.CheckPolicy(ctx, &protoReq)
return msg, metadata, err
}
func request_HeadscaleService_Health_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq HealthRequest
@@ -1533,6 +1560,26 @@ func RegisterHeadscaleServiceHandlerServer(ctx context.Context, mux *runtime.Ser
}
forward_HeadscaleService_SetPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_HeadscaleService_CheckPolicy_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/CheckPolicy", runtime.WithHTTPPathPattern("/api/v1/policy/check"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_HeadscaleService_CheckPolicy_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_HeadscaleService_CheckPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_HeadscaleService_Health_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@@ -2052,6 +2099,23 @@ func RegisterHeadscaleServiceHandlerClient(ctx context.Context, mux *runtime.Ser
}
forward_HeadscaleService_SetPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_HeadscaleService_CheckPolicy_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/CheckPolicy", runtime.WithHTTPPathPattern("/api/v1/policy/check"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_HeadscaleService_CheckPolicy_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_HeadscaleService_CheckPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_HeadscaleService_Health_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@@ -2100,6 +2164,7 @@ var (
pattern_HeadscaleService_DeleteApiKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"api", "v1", "apikey", "prefix"}, ""))
pattern_HeadscaleService_GetPolicy_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "policy"}, ""))
pattern_HeadscaleService_SetPolicy_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "policy"}, ""))
pattern_HeadscaleService_CheckPolicy_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "policy", "check"}, ""))
pattern_HeadscaleService_Health_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "health"}, ""))
)
@@ -2131,5 +2196,6 @@ var (
forward_HeadscaleService_DeleteApiKey_0 = runtime.ForwardResponseMessage
forward_HeadscaleService_GetPolicy_0 = runtime.ForwardResponseMessage
forward_HeadscaleService_SetPolicy_0 = runtime.ForwardResponseMessage
forward_HeadscaleService_CheckPolicy_0 = runtime.ForwardResponseMessage
forward_HeadscaleService_Health_0 = runtime.ForwardResponseMessage
)
+39 -1
View File
@@ -1,6 +1,6 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc-gen-go-grpc v1.6.2
// - protoc (unknown)
// source: headscale/v1/headscale.proto
@@ -46,6 +46,7 @@ const (
HeadscaleService_DeleteApiKey_FullMethodName = "/headscale.v1.HeadscaleService/DeleteApiKey"
HeadscaleService_GetPolicy_FullMethodName = "/headscale.v1.HeadscaleService/GetPolicy"
HeadscaleService_SetPolicy_FullMethodName = "/headscale.v1.HeadscaleService/SetPolicy"
HeadscaleService_CheckPolicy_FullMethodName = "/headscale.v1.HeadscaleService/CheckPolicy"
HeadscaleService_Health_FullMethodName = "/headscale.v1.HeadscaleService/Health"
)
@@ -86,6 +87,7 @@ type HeadscaleServiceClient interface {
// --- Policy start ---
GetPolicy(ctx context.Context, in *GetPolicyRequest, opts ...grpc.CallOption) (*GetPolicyResponse, error)
SetPolicy(ctx context.Context, in *SetPolicyRequest, opts ...grpc.CallOption) (*SetPolicyResponse, error)
CheckPolicy(ctx context.Context, in *CheckPolicyRequest, opts ...grpc.CallOption) (*CheckPolicyResponse, error)
// --- Health start ---
Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error)
}
@@ -368,6 +370,16 @@ func (c *headscaleServiceClient) SetPolicy(ctx context.Context, in *SetPolicyReq
return out, nil
}
func (c *headscaleServiceClient) CheckPolicy(ctx context.Context, in *CheckPolicyRequest, opts ...grpc.CallOption) (*CheckPolicyResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CheckPolicyResponse)
err := c.cc.Invoke(ctx, HeadscaleService_CheckPolicy_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *headscaleServiceClient) Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(HealthResponse)
@@ -415,6 +427,7 @@ type HeadscaleServiceServer interface {
// --- Policy start ---
GetPolicy(context.Context, *GetPolicyRequest) (*GetPolicyResponse, error)
SetPolicy(context.Context, *SetPolicyRequest) (*SetPolicyResponse, error)
CheckPolicy(context.Context, *CheckPolicyRequest) (*CheckPolicyResponse, error)
// --- Health start ---
Health(context.Context, *HealthRequest) (*HealthResponse, error)
mustEmbedUnimplementedHeadscaleServiceServer()
@@ -508,6 +521,9 @@ func (UnimplementedHeadscaleServiceServer) GetPolicy(context.Context, *GetPolicy
func (UnimplementedHeadscaleServiceServer) SetPolicy(context.Context, *SetPolicyRequest) (*SetPolicyResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetPolicy not implemented")
}
func (UnimplementedHeadscaleServiceServer) CheckPolicy(context.Context, *CheckPolicyRequest) (*CheckPolicyResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CheckPolicy not implemented")
}
func (UnimplementedHeadscaleServiceServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Health not implemented")
}
@@ -1018,6 +1034,24 @@ func _HeadscaleService_SetPolicy_Handler(srv interface{}, ctx context.Context, d
return interceptor(ctx, in, info, handler)
}
func _HeadscaleService_CheckPolicy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CheckPolicyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(HeadscaleServiceServer).CheckPolicy(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: HeadscaleService_CheckPolicy_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(HeadscaleServiceServer).CheckPolicy(ctx, req.(*CheckPolicyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _HeadscaleService_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(HealthRequest)
if err := dec(in); err != nil {
@@ -1151,6 +1185,10 @@ var HeadscaleService_ServiceDesc = grpc.ServiceDesc{
MethodName: "SetPolicy",
Handler: _HeadscaleService_SetPolicy_Handler,
},
{
MethodName: "CheckPolicy",
Handler: _HeadscaleService_CheckPolicy_Handler,
},
{
MethodName: "Health",
Handler: _HeadscaleService_Health_Handler,
+91 -6
View File
@@ -206,6 +206,86 @@ func (x *GetPolicyResponse) GetUpdatedAt() *timestamppb.Timestamp {
return nil
}
type CheckPolicyRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Policy string `protobuf:"bytes,1,opt,name=policy,proto3" json:"policy,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CheckPolicyRequest) Reset() {
*x = CheckPolicyRequest{}
mi := &file_headscale_v1_policy_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CheckPolicyRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CheckPolicyRequest) ProtoMessage() {}
func (x *CheckPolicyRequest) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_policy_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CheckPolicyRequest.ProtoReflect.Descriptor instead.
func (*CheckPolicyRequest) Descriptor() ([]byte, []int) {
return file_headscale_v1_policy_proto_rawDescGZIP(), []int{4}
}
func (x *CheckPolicyRequest) GetPolicy() string {
if x != nil {
return x.Policy
}
return ""
}
type CheckPolicyResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CheckPolicyResponse) Reset() {
*x = CheckPolicyResponse{}
mi := &file_headscale_v1_policy_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CheckPolicyResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CheckPolicyResponse) ProtoMessage() {}
func (x *CheckPolicyResponse) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_policy_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CheckPolicyResponse.ProtoReflect.Descriptor instead.
func (*CheckPolicyResponse) Descriptor() ([]byte, []int) {
return file_headscale_v1_policy_proto_rawDescGZIP(), []int{5}
}
var File_headscale_v1_policy_proto protoreflect.FileDescriptor
const file_headscale_v1_policy_proto_rawDesc = "" +
@@ -221,7 +301,10 @@ const file_headscale_v1_policy_proto_rawDesc = "" +
"\x11GetPolicyResponse\x12\x16\n" +
"\x06policy\x18\x01 \x01(\tR\x06policy\x129\n" +
"\n" +
"updated_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAtB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3"
"updated_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\",\n" +
"\x12CheckPolicyRequest\x12\x16\n" +
"\x06policy\x18\x01 \x01(\tR\x06policy\"\x15\n" +
"\x13CheckPolicyResponseB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3"
var (
file_headscale_v1_policy_proto_rawDescOnce sync.Once
@@ -235,17 +318,19 @@ func file_headscale_v1_policy_proto_rawDescGZIP() []byte {
return file_headscale_v1_policy_proto_rawDescData
}
var file_headscale_v1_policy_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
var file_headscale_v1_policy_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
var file_headscale_v1_policy_proto_goTypes = []any{
(*SetPolicyRequest)(nil), // 0: headscale.v1.SetPolicyRequest
(*SetPolicyResponse)(nil), // 1: headscale.v1.SetPolicyResponse
(*GetPolicyRequest)(nil), // 2: headscale.v1.GetPolicyRequest
(*GetPolicyResponse)(nil), // 3: headscale.v1.GetPolicyResponse
(*timestamppb.Timestamp)(nil), // 4: google.protobuf.Timestamp
(*CheckPolicyRequest)(nil), // 4: headscale.v1.CheckPolicyRequest
(*CheckPolicyResponse)(nil), // 5: headscale.v1.CheckPolicyResponse
(*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp
}
var file_headscale_v1_policy_proto_depIdxs = []int32{
4, // 0: headscale.v1.SetPolicyResponse.updated_at:type_name -> google.protobuf.Timestamp
4, // 1: headscale.v1.GetPolicyResponse.updated_at:type_name -> google.protobuf.Timestamp
6, // 0: headscale.v1.SetPolicyResponse.updated_at:type_name -> google.protobuf.Timestamp
6, // 1: headscale.v1.GetPolicyResponse.updated_at:type_name -> google.protobuf.Timestamp
2, // [2:2] is the sub-list for method output_type
2, // [2:2] is the sub-list for method input_type
2, // [2:2] is the sub-list for extension type_name
@@ -264,7 +349,7 @@ func file_headscale_v1_policy_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_policy_proto_rawDesc), len(file_headscale_v1_policy_proto_rawDesc)),
NumEnums: 0,
NumMessages: 4,
NumMessages: 6,
NumExtensions: 0,
NumServices: 0,
},
@@ -660,6 +660,38 @@
]
}
},
"/api/v1/policy/check": {
"post": {
"operationId": "HeadscaleService_CheckPolicy",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/v1CheckPolicyResponse"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/v1CheckPolicyRequest"
}
}
],
"tags": [
"HeadscaleService"
]
}
},
"/api/v1/preauthkey": {
"get": {
"operationId": "HeadscaleService_ListPreAuthKeys",
@@ -1044,6 +1076,17 @@
}
}
},
"v1CheckPolicyRequest": {
"type": "object",
"properties": {
"policy": {
"type": "string"
}
}
},
"v1CheckPolicyResponse": {
"type": "object"
},
"v1CreateApiKeyRequest": {
"type": "object",
"properties": {
+42 -42
View File
@@ -1,6 +1,6 @@
module github.com/juanfont/headscale
go 1.26.1
go 1.26.3
require (
github.com/arl/statsviz v0.8.0
@@ -8,20 +8,20 @@ require (
github.com/chasefleming/elem-go v0.31.0
github.com/coder/websocket v1.8.14
github.com/coreos/go-oidc/v3 v3.18.0
github.com/creachadair/command v0.2.2
github.com/creachadair/flax v0.0.5
github.com/creachadair/command v0.2.6
github.com/creachadair/flax v0.0.6
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
github.com/docker/docker v28.5.2+incompatible
github.com/fsnotify/fsnotify v1.9.0
github.com/fsnotify/fsnotify v1.10.1
github.com/glebarez/sqlite v1.11.0
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/metrics v0.1.1
github.com/go-gormigrate/gormigrate/v2 v2.1.5
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686
github.com/gofrs/uuid/v5 v5.4.0
github.com/google/go-cmp v0.7.0
github.com/gorilla/mux v1.8.1
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/jagottsicher/termcolor v1.0.2
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25
@@ -31,31 +31,32 @@ require (
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/common v0.67.5
github.com/pterm/pterm v0.12.83
github.com/puzpuzpuz/xsync/v4 v4.4.0
github.com/rs/zerolog v1.35.0
github.com/puzpuzpuz/xsync/v4 v4.5.0
github.com/realclientip/realclientip-go v1.0.0
github.com/rs/zerolog v1.35.1
github.com/samber/lo v1.53.0
github.com/sasha-s/go-deadlock v0.3.9
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd
github.com/tailscale/squibble v0.0.0-20260303070345-3ac5157f405e
github.com/tailscale/tailsql v0.0.0-20260322172246-3ab0c1744d9c
github.com/tailscale/squibble v0.0.0-20260411062017-141f5d618bc4
github.com/tailscale/tailsql v0.0.0-20260521144131-377d992d0d71
github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
golang.org/x/crypto v0.49.0
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
golang.org/x/net v0.52.0
golang.org/x/crypto v0.52.0
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a
golang.org/x/net v0.55.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.20.0
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d
google.golang.org/grpc v1.80.0
google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94
google.golang.org/grpc v1.81.1
google.golang.org/protobuf v1.36.11
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
pgregory.net/rapid v1.3.0
tailscale.com v1.98.3
zombiezen.com/go/postgrestest v1.0.1
)
@@ -77,10 +78,10 @@ require (
// together, e.g:
// go get modernc.org/libc@v1.55.3 modernc.org/sqlite@v1.33.1
require (
modernc.org/libc v1.70.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.48.2
modernc.org/sqlite v1.50.1
)
// NOTE: gvisor must be updated in lockstep with
@@ -129,12 +130,12 @@ require (
github.com/containerd/continuity v0.4.5 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/creachadair/mds v0.26.2 // indirect
github.com/creachadair/mds v0.28.0 // indirect
github.com/creachadair/msync v0.8.2 // indirect
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d // indirect
github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/cli v29.2.1+incompatible // indirect
github.com/docker/cli v29.4.0+incompatible // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -144,7 +145,7 @@ require (
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
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
github.com/go-jose/go-jose/v3 v3.0.5 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -168,7 +169,7 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
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
@@ -176,7 +177,7 @@ require (
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/klauspost/compress v1.18.5 // indirect
github.com/lib/pq v1.11.1 // indirect
github.com/lithammer/fuzzysearch v1.1.8 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
@@ -186,8 +187,8 @@ require (
github.com/mdlayher/socket v0.5.1 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/moby/api v1.53.0 // indirect
github.com/moby/moby/client v0.2.2 // indirect
github.com/moby/moby/api v1.54.1 // indirect
github.com/moby/moby/client v0.4.0 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/term v0.5.2 // indirect
@@ -203,7 +204,6 @@ require (
github.com/pires/go-proxyproto v0.9.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus-community/pro-bing v0.7.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@@ -215,12 +215,12 @@ require (
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // indirect
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
github.com/tailscale/setec v0.0.0-20260115174028-19d190c5556d // indirect
github.com/tailscale/setec v0.0.0-20260310221408-dcd97e42f251 // 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/tailscale/wireguard-go v0.0.0-20260427181203-e3ac4a0afb4e // 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
@@ -228,24 +228,24 @@ require (
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
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
golang.org/x/text v0.36.0 // indirect
golang.org/x/image v0.40.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.43.0 // indirect
golang.org/x/tools v0.45.0 // indirect
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
google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94 // 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
+98 -96
View File
@@ -129,12 +129,12 @@ github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creachadair/command v0.2.2 h1:4RGsUhqFf1imFC+vMWOOCiQdncThCdcdMJp0JNCjxxc=
github.com/creachadair/command v0.2.2/go.mod h1:Z6Zp6CSJcnaWWR4wHgdqzODnFdxFJAaa/DrcVkeUu3E=
github.com/creachadair/flax v0.0.5 h1:zt+CRuXQASxwQ68e9GHAOnEgAU29nF0zYMHOCrL5wzE=
github.com/creachadair/flax v0.0.5/go.mod h1:F1PML0JZLXSNDMNiRGK2yjm5f+L9QCHchyHBldFymj8=
github.com/creachadair/mds v0.26.2 h1:rCtvEV/bCRY0hGfwvvMg0p3yzKgBE8l/9OV4fjF9QQ8=
github.com/creachadair/mds v0.26.2/go.mod h1:dMBTCSy3iS3dwh4Rb1zxeZz2d7K8+N24GCTsayWtQRI=
github.com/creachadair/command v0.2.6 h1:+d4xUSZza5nqRVVsVRf5jDbix/0uKxVqFrc30F4uyRI=
github.com/creachadair/command v0.2.6/go.mod h1:HWvS3zEpVakKlNXxT/j1U8tNoDaY7By7ORYQ+m1ha2U=
github.com/creachadair/flax v0.0.6 h1:IFUdRdfKynNTyR5SRlrHMUGnxxMZZen8RVpwcDq/ftk=
github.com/creachadair/flax v0.0.6/go.mod h1:F1PML0JZLXSNDMNiRGK2yjm5f+L9QCHchyHBldFymj8=
github.com/creachadair/mds v0.28.0 h1:VS4JsBSlClsqBrHT8cYMTMqFHJdU0P/Z1ES6zjnSMXo=
github.com/creachadair/mds v0.28.0/go.mod h1:dMBTCSy3iS3dwh4Rb1zxeZz2d7K8+N24GCTsayWtQRI=
github.com/creachadair/msync v0.8.2 h1:ujvc/SVJPn+bFwmjUHucXNTTn3opVe2YbQ46mBCnP08=
github.com/creachadair/msync v0.8.2/go.mod h1:LzxqD9kfIl/O3DczkwOgJplLPqwrTbIhINlf9bHIsEY=
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
@@ -155,8 +155,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/docker/cli v29.2.1+incompatible h1:n3Jt0QVCN65eiVBoUTZQM9mcQICCJt3akW4pKAbKdJg=
github.com/docker/cli v29.2.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/cli v29.4.0+incompatible h1:+IjXULMetlvWJiuSI0Nbor36lcJ5BTcVpUmB21KBoVM=
github.com/docker/cli v29.4.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
@@ -174,8 +174,8 @@ 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=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
@@ -192,12 +192,12 @@ github.com/go-chi/metrics v0.1.1 h1:CXhbnkAVVjb0k73EBRQ6Z2YdWFnbXZgNtg1Mboguibk=
github.com/go-chi/metrics v0.1.1/go.mod h1:mcGTM1pPalP7WCtb+akNYFO/lwNwBBLCuedepqjoPn4=
github.com/go-gormigrate/gormigrate/v2 v2.1.5 h1:1OyorA5LtdQw12cyJDEHuTrEV3GiXiIhS4/QTTa/SM8=
github.com/go-gormigrate/gormigrate/v2 v2.1.5/go.mod h1:mj9ekk/7CPF3VjopaFvWKN2v7fN3D9d3eEOAXRhi/+M=
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ=
github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 h1:NZBJxCpbHS1gzS6xAmyxbJznosZIIPk9IB42v62UvKA=
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -258,8 +258,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
@@ -280,8 +280,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jagottsicher/termcolor v1.0.2 h1:fo0c51pQSuLBN1+yVX2ZE+hE+P7ULb/TY8eRowJnrsM=
@@ -303,8 +303,8 @@ 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/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
@@ -351,10 +351,10 @@ github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.53.0 h1:PihqG1ncw4W+8mZs69jlwGXdaYBeb5brF6BL7mPIS/w=
github.com/moby/moby/api v1.53.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc=
github.com/moby/moby/client v0.2.2 h1:Pt4hRMCAIlyjL3cr8M5TrXCwKzguebPAc2do2ur7dEM=
github.com/moby/moby/client v0.2.2/go.mod h1:2EkIPVNCqR05CMIzL1mfA07t0HvVUUOl85pasRz/GmQ=
github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
@@ -404,8 +404,6 @@ github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Q
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA=
github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
@@ -423,15 +421,17 @@ github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5b
github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s=
github.com/pterm/pterm v0.12.83 h1:ie+YmGmA727VuhxBlyGr74Ks+7McV6kT99IB8EU80aA=
github.com/pterm/pterm v0.12.83/go.mod h1:xlgc6bFWyJIMtmLJvGim+L7jhSReilOlOnodeIYe4Tk=
github.com/puzpuzpuz/xsync/v4 v4.4.0 h1:vlSN6/CkEY0pY8KaB0yqo/pCLZvp9nhdbBdjipT4gWo=
github.com/puzpuzpuz/xsync/v4 v4.4.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
github.com/puzpuzpuz/xsync/v4 v4.5.0 h1:vOSWu6b57/emh+L/Cw0BeQfvxa/cogFywXHeGUxQxAg=
github.com/puzpuzpuz/xsync/v4 v4.5.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
github.com/realclientip/realclientip-go v1.0.0 h1:+yPxeC0mEaJzq1BfCt2h4BxlyrvIIBzR6suDc3BEF1U=
github.com/realclientip/realclientip-go v1.0.0/go.mod h1:CXnUdVwFRcXFJIRb/dTYqbT7ud48+Pi2pFm80bxDmcI=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/safchain/ethtool v0.7.0 h1:rlJzfDetsVvT61uz8x1YIcFn12akMfuPulHtZjtb7Is=
github.com/safchain/ethtool v0.7.0/go.mod h1:MenQKEjXdfkjD3mp2QdCk8B/hwvkrlOTm/FD4gTpFxQ=
@@ -473,8 +473,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ=
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d h1:JcGKBZAL7ePLwOhUdN8qGQZlP5GueEiIZwY7R62pejE=
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
github.com/tailscale/gliderssh v0.3.4-0.20260330083525-c1389c70ff89 h1:glgVc1ZYMjwN1Q/ITWeuSQyl029uayagaR2sjsifehc=
github.com/tailscale/gliderssh v0.3.4-0.20260330083525-c1389c70ff89/go.mod h1:wn16Km1EZOX4UEAyaZa3dBwfFGOJ7neck40NcwosJUw=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869 h1:SRL6irQkKGQKKLzvQP/ke/2ZuB7Py5+XuqtOgSj+iMM=
@@ -485,18 +487,18 @@ github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA=
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
github.com/tailscale/setec v0.0.0-20260115174028-19d190c5556d h1:N+TtzIaGYREbLbKZB0WU0vVnMSfaqUkSf3qMEi03hwE=
github.com/tailscale/setec v0.0.0-20260115174028-19d190c5556d/go.mod h1:6NU8H/GLPVX2TnXAY1duyy9ylLaHwFpr0X93UPiYmNI=
github.com/tailscale/squibble v0.0.0-20260303070345-3ac5157f405e h1:4yfp5/YDr+TzbUME/PalYJVXAsp7zA2Gv2xQMZ9Qors=
github.com/tailscale/squibble v0.0.0-20260303070345-3ac5157f405e/go.mod h1:xJkMmR3t+thnUQhA3Q4m2VSlS5pcOq+CIjmU/xfKKx4=
github.com/tailscale/tailsql v0.0.0-20260322172246-3ab0c1744d9c h1:7lJQ/zycbk1E9e0nUiMuwIDYprFTLpWXUwiPdi+tRlI=
github.com/tailscale/tailsql v0.0.0-20260322172246-3ab0c1744d9c/go.mod h1:bpNmZdvZKmBstrZunT+NXL6hmrFw5AsuT7MGiYS8sRc=
github.com/tailscale/setec v0.0.0-20260310221408-dcd97e42f251 h1:kNnJlwxSzue+VRJuDdZ/yebcMM2q9KqFmK9xQqH1YRc=
github.com/tailscale/setec v0.0.0-20260310221408-dcd97e42f251/go.mod h1:6NU8H/GLPVX2TnXAY1duyy9ylLaHwFpr0X93UPiYmNI=
github.com/tailscale/squibble v0.0.0-20260411062017-141f5d618bc4 h1:1ghkd9YIC4J7umZuu9jZz8afWJSj1hCRSzfvdI2Q3Vo=
github.com/tailscale/squibble v0.0.0-20260411062017-141f5d618bc4/go.mod h1:EVp9PDh7v69Do6aNzFfLvL0fdCph3XskGMi+WcS/uOM=
github.com/tailscale/tailsql v0.0.0-20260521144131-377d992d0d71 h1:IXwFgoMvxnXLCmQldzyuOO+b8Ko1cxqeuz5o3oaMkj8=
github.com/tailscale/tailsql v0.0.0-20260521144131-377d992d0d71/go.mod h1:N2Dm+UzRWz01zPMDl5XHkSVq91kNPWU13uUUibcOm+c=
github.com/tailscale/web-client-prebuilt v0.0.0-20251127225136-f19339b67368 h1:0tpDdAj9sSfSZg4gMwNTdqMP592sBrq2Sm0w6ipnh7k=
github.com/tailscale/web-client-prebuilt v0.0.0-20251127225136-f19339b67368/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y=
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw=
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/wireguard-go v0.0.0-20260427181203-e3ac4a0afb4e h1:GexFR7ak1iz26fxg8HWCpOEqAOL8UEZJ7J3JxeCalDs=
github.com/tailscale/wireguard-go v0.0.0-20260427181203-e3ac4a0afb4e/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
@@ -528,24 +530,24 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
@@ -559,25 +561,25 @@ go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/W
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8=
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -603,8 +605,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -612,24 +614,24 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
@@ -637,12 +639,12 @@ golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d h1:/aDRtSZJjyLQzm75d+a1wOJaqyKBMvIAfeQmoa3ORiI=
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:etfGUgejTiadZAUaEP14NP97xi1RGeawqkjDARA/UOs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94 h1:DddG61lE5LkX6144z22i0gma9BMBs5aZ9B8lZLobxyw=
google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:1dCETSCY2YKZNXQE3h4fun3TYwF5p8jejRKZgfWAgAY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94 h1:eZCjr/aAF8c5ccm5pb6T4EXgIei5MlAAPWPJk+5ArfY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -672,10 +674,10 @@ 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=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
@@ -684,29 +686,29 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
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=
pgregory.net/rapid v1.3.0 h1:vBvO0VSqti75J1jjYqpgPNBLKMd1+gxa9fYo7vk/Exc=
pgregory.net/rapid v1.3.0/go.mod h1:dPlE4OBBxgXPqkP79flB6sJL1dx5azpI7HQ9MY9Z7uk=
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=
tailscale.com v1.96.5/go.mod h1:/3lnZBYb2UEwnN0MNu2SDXUtT06AGd5k0s+OWx3WmcY=
tailscale.com v1.98.3 h1:caAbG4UfkKfKPE6b1fj5t4ep5qrwEis5AJu91ruvePw=
tailscale.com v1.98.3/go.mod h1:U23ZwbZlKJMNU7CScy+lCVVlece/S5n09q0nyudncBI=
zombiezen.com/go/postgrestest v1.0.1 h1:aXoADQAJmZDU3+xilYVut0pHhgc0sF8ZspPW9gFNwP4=
zombiezen.com/go/postgrestest v1.0.1/go.mod h1:marlZezr+k2oSJrvXHnZUs1olHqpE9czlz8ZYkVxliQ=
+33 -6
View File
@@ -99,6 +99,10 @@ type Headscale struct {
DERPServer *derpServer.DERPServer
// realIPMiddleware is nil when cfg.TrustedProxies is empty; the
// router skips the mount and r.RemoteAddr stays as the TCP peer.
realIPMiddleware func(http.Handler) http.Handler
// Things that generate changes
extraRecordMan *dns.ExtraRecordsMan
authProvider AuthProvider
@@ -140,6 +144,13 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) {
state: s,
}
if len(cfg.TrustedProxies) > 0 {
app.realIPMiddleware, err = trustedProxyRealIP(cfg.TrustedProxies)
if err != nil {
return nil, fmt.Errorf("building trusted_proxies middleware: %w", err)
}
}
// Initialize ephemeral garbage collector
ephemeralGC := db.NewEphemeralGarbageCollector(func(ni types.NodeID) {
node, ok := app.state.GetNodeByID(ni)
@@ -208,7 +219,19 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) {
}
for _, d := range magicDNSDomains {
app.cfg.TailcfgDNSConfig.Routes[d.WithoutTrailingDot()] = nil
// Empty non-nil slice rather than nil: tailcfg.DNSConfig.Clone
// and dns.Config.Clone in tailscale drop map entries whose
// value is nil (see tailscale.com/tailcfg/tailcfg_clone.go and
// tailscale.com/net/dns/dns_clone.go: `if sv == nil { continue }`).
// Sending nil here caused the client's wgengine LinkChange:major
// handler to clobber /etc/resolv.conf on every tunnel-IP rebind
// — the handler reapplies a Clone of lastDNSConfig and the magic
// DNS routes vanish, taking the resolver with them for ~6 min
// until the next route-changing netmap. Empty slice survives
// Clone and carries the same "resolve locally" semantics
// (tailscale.com/ipn/ipnlocal/node_backend.go:869 documents the
// empty-resolver Routes form for Issue 2706).
app.cfg.TailcfgDNSConfig.Routes[d.WithoutTrailingDot()] = []*dnstype.Resolver{}
}
}
@@ -251,7 +274,7 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) {
// Redirect to our TLS url.
func (h *Headscale) redirect(w http.ResponseWriter, req *http.Request) {
target := h.cfg.ServerURL + req.URL.RequestURI()
http.Redirect(w, req, target, http.StatusFound)
http.Redirect(w, req, target, http.StatusFound) //nolint:gosec // G710: target prefixed by trusted ServerURL
}
func (h *Headscale) scheduledTasks(ctx context.Context) {
@@ -512,7 +535,11 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux {
},
}))
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
if h.realIPMiddleware != nil {
r.Use(h.realIPMiddleware)
}
r.Use(middleware.RequestLogger(&zerologRequestLogger{}))
r.Use(middleware.Recoverer)
r.Use(securityHeaders)
@@ -553,7 +580,7 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux {
r.HandleFunc("/v1/*", grpcMux.ServeHTTP)
})
// Ping response endpoint: receives HEAD from clients responding
// to a PingRequest. The unguessable ping ID serves as authentication.
// to a [tailcfg.PingRequest]. The unguessable ping ID serves as authentication.
r.Head("/machine/ping-response", h.PingResponseHandler)
r.Get("/favicon.ico", FaviconHandler)
@@ -1117,7 +1144,7 @@ func (h *Headscale) Change(cs ...change.Change) {
h.mapBatcher.AddWork(cs...)
}
// HTTPHandler returns an http.Handler for the Headscale control server.
// HTTPHandler returns an [http.Handler] for the [Headscale] control server.
// The handler serves the Tailscale control protocol including the /key
// endpoint and /ts2021 Noise upgrade path.
func (h *Headscale) HTTPHandler() http.Handler {
@@ -1197,7 +1224,7 @@ func (l *acmeLogger) RoundTrip(req *http.Request) (*http.Response, error) {
return resp, nil
}
// zerologRequestLogger implements chi's middleware.LogFormatter
// [zerologRequestLogger] implements chi's [middleware.LogFormatter]
// to route HTTP request logs through zerolog.
type zerologRequestLogger struct{}
+10 -11
View File
@@ -73,8 +73,8 @@ func (h *Headscale) handleRegister(
// the Noise session's machine key matches the cached node.
// Without this check anyone holding a target's NodeKey could
// open a Noise session with a throwaway machine key and read
// the owner's User/Login back through nodeToRegisterResponse.
// handleLogout enforces the same check on its own path.
// the owner's User/Login back through [nodeToRegisterResponse].
// [Headscale.handleLogout] enforces the same check on its own path.
if node.MachineKey() != machineKey {
return nil, NewHTTPError(
http.StatusUnauthorized,
@@ -83,10 +83,9 @@ func (h *Headscale) handleRegister(
)
}
// When tailscaled restarts, it sends RegisterRequest with Auth=nil and Expiry=zero.
// When tailscaled restarts, it sends [tailcfg.RegisterRequest] with Auth=nil and Expiry=zero.
// Return the current node state without modification.
// See: https://github.com/juanfont/headscale/issues/2862
if req.Expiry.IsZero() && node.Expiry().Valid() && !node.IsExpired() {
if req.Expiry.IsZero() && !node.IsExpired() {
return nodeToRegisterResponse(node), nil
}
@@ -192,7 +191,7 @@ func (h *Headscale) handleLogout(
}
// If the request expiry is in the past, we consider it a logout.
// Zero expiry is handled in handleRegister() before calling this function.
// Zero expiry is handled in [Headscale.handleRegister] before calling this function.
if req.Expiry.Before(time.Now()) {
log.Debug().
EmbedObject(node).
@@ -254,7 +253,7 @@ func nodeToRegisterResponse(node types.NodeView) *tailcfg.RegisterResponse {
MachineAuthorized: true,
}
// For tagged nodes, use the TaggedDevices special user
// For tagged nodes, use the [types.TaggedDevices] special user
// For user-owned nodes, include User and Login information from the actual user
if node.IsTagged() {
resp.User = types.TaggedDevices.View().TailscaleUser()
@@ -303,8 +302,8 @@ func (h *Headscale) waitForFollowup(
}
// reqToNewRegisterResponse refreshes the registration flow by creating a new
// registration ID and returning the corresponding AuthURL so the client can
// restart the authentication process.
// registration ID and returning the corresponding [tailcfg.RegisterResponse.AuthURL]
// so the client can restart the authentication process.
func (h *Headscale) reqToNewRegisterResponse(
req tailcfg.RegisterRequest,
machineKey key.MachinePublic,
@@ -326,8 +325,8 @@ func (h *Headscale) reqToNewRegisterResponse(
}, nil
}
// registrationDataFromRequest builds the RegistrationData payload stored
// in the auth cache for a pending registration. The original Hostinfo is
// registrationDataFromRequest builds the [types.RegistrationData] payload stored
// in the auth cache for a pending registration. The original [tailcfg.Hostinfo] is
// retained so that consumers (auth callback, observability) see the
// fields the client originally announced; the bounded-LRU cap on the
// cache is what bounds the unauthenticated cache-fill DoS surface.
+152 -10
View File
@@ -1,6 +1,7 @@
package hscontrol
import (
"context"
"testing"
"time"
@@ -59,7 +60,7 @@ func createTestAppWithNodeExpiry(t *testing.T, nodeExpiry time.Duration) *Headsc
// a tagged node with:
// - Tags from the PreAuthKey
// - Nil UserID (tagged nodes are owned by tags, not a user)
// - IsTagged() returns true.
// - [types.Node.IsTagged] returns true.
func TestTaggedPreAuthKeyCreatesTaggedNode(t *testing.T) {
app := createTestApp(t)
@@ -112,7 +113,7 @@ func TestTaggedPreAuthKeyCreatesTaggedNode(t *testing.T) {
// authentication. This is critical for the container restart scenario (#2830).
//
// NOTE: This test verifies that re-authentication preserves the node's current tags
// without testing tag modification via SetNodeTags (which requires ACL policy setup).
// without testing tag modification via [state.State.SetNodeTags] (which requires ACL policy setup).
func TestReAuthDoesNotReapplyTags(t *testing.T) {
app := createTestApp(t)
@@ -179,7 +180,7 @@ func TestReAuthDoesNotReapplyTags(t *testing.T) {
}
// NOTE: TestSetTagsOnUserOwnedNode functionality is covered by gRPC tests in grpcv1_test.go
// which properly handle ACL policy setup. The test verifies that SetTags can convert
// which properly handle ACL policy setup. The test verifies that [headscaleV1APIServer.SetTags] can convert
// user-owned nodes to tagged nodes while preserving UserID.
// TestCannotRemoveAllTags tests that attempting to remove all tags from a
@@ -669,10 +670,151 @@ func TestTaggedNodeReauthPreservesDisabledExpiry(t *testing.T) {
"Tagged node should have expiry PRESERVED as disabled after re-auth")
}
// TestTaggedNodeRestartPreservesNilExpiry tests that a tagged node whose
// tailscaled restarts (sending Auth=nil, Expiry=zero) keeps its nil expiry.
//
// The handleRegister guard required node.Expiry().Valid(), false for the
// nil expiry tagged nodes are created with. The request fell through to
// handleLogout, which wrote &time.Time{} over the original nil and flipped
// the API representation from null to "0001-01-01T00:00:00Z".
func TestTaggedNodeRestartPreservesNilExpiry(t *testing.T) {
app := createTestApp(t)
user := app.state.CreateUserForTest("tag-restart")
tags := []string{"tag:agent"}
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, tags)
require.NoError(t, err)
machineKey := key.NewMachine()
nodeKey := key.NewNode()
regReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{
AuthKey: pak.Key,
},
NodeKey: nodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: "tagged-restart-test",
},
}
resp, err := app.handleRegisterWithAuthKey(regReq, machineKey.Public())
require.NoError(t, err)
require.True(t, resp.MachineAuthorized)
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
require.True(t, node.IsTagged())
require.False(t, node.Expiry().Valid(), "tagged node should have nil expiry after registration")
require.False(t, node.IsExpired(), "tagged node with nil expiry should not be expired")
// tailscaled restart: RegisterRequest with Auth=nil and Expiry=time.Time{}
// (the Go zero value) is what the client sends when it restarts with
// persisted state.
restartReq := tailcfg.RegisterRequest{
Auth: nil,
NodeKey: nodeKey.Public(),
Expiry: time.Time{},
}
restartResp, err := app.handleRegister(context.Background(), restartReq, machineKey.Public())
require.NoError(t, err)
require.True(t, restartResp.MachineAuthorized,
"restart should not require re-authorization")
require.False(t, restartResp.NodeKeyExpired,
"restart should not mark node key as expired")
require.Equal(t, types.TaggedDevices.View().TailscaleUser(), restartResp.User,
"response should identify node as tagged device")
nodeAfterRestart, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
assert.True(t, nodeAfterRestart.IsTagged(), "node should still be tagged")
assert.False(t, nodeAfterRestart.IsExpired(), "node should not be expired after restart")
assert.False(t, nodeAfterRestart.Expiry().Valid(),
"tagged node expiry must remain nil (not zero-time) after restart")
var dbNode types.Node
require.NoError(t,
app.state.DB().DB.First(&dbNode, nodeAfterRestart.ID().Uint64()).Error)
assert.Nil(t, dbNode.Expiry,
"database expiry column must be NULL after restart, not a pointer to zero-time")
}
// TestUntaggedNodeRestartPreservesNilExpiry tests that an untagged node
// registered against a preauth key with no default node.expiry keeps its
// nil expiry when tailscaled restarts. Same root cause as the tagged
// variant: the dropped node.Expiry().Valid() check covers any nil-expiry
// node, regardless of ownership.
func TestUntaggedNodeRestartPreservesNilExpiry(t *testing.T) {
app := createTestAppWithNodeExpiry(t, 0)
user := app.state.CreateUserForTest("untagged-restart")
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, nil)
require.NoError(t, err)
machineKey := key.NewMachine()
nodeKey := key.NewNode()
regReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{
AuthKey: pak.Key,
},
NodeKey: nodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: "untagged-restart-test",
},
Expiry: time.Time{},
}
resp, err := app.handleRegisterWithAuthKey(regReq, machineKey.Public())
require.NoError(t, err)
require.True(t, resp.MachineAuthorized)
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
require.False(t, node.IsTagged(), "node should be user-owned, not tagged")
require.False(t, node.Expiry().Valid(),
"untagged node with no default expiry should have nil expiry after registration")
require.False(t, node.IsExpired())
restartReq := tailcfg.RegisterRequest{
Auth: nil,
NodeKey: nodeKey.Public(),
Expiry: time.Time{},
}
restartResp, err := app.handleRegister(context.Background(), restartReq, machineKey.Public())
require.NoError(t, err)
require.True(t, restartResp.MachineAuthorized,
"restart should not require re-authorization")
require.False(t, restartResp.NodeKeyExpired,
"restart should not mark node key as expired")
nodeAfterRestart, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
assert.False(t, nodeAfterRestart.IsTagged(), "node should still be user-owned")
assert.False(t, nodeAfterRestart.IsExpired(), "node should not be expired after restart")
assert.False(t, nodeAfterRestart.Expiry().Valid(),
"untagged node expiry must remain nil (not zero-time) after restart")
var dbNode types.Node
require.NoError(t,
app.state.DB().DB.First(&dbNode, nodeAfterRestart.ID().Uint64()).Error)
assert.Nil(t, dbNode.Expiry,
"database expiry column must be NULL after restart, not a pointer to zero-time "+
"(this is what `sqlite3 ... 'select expiry from nodes'` sees)")
}
// TestExpiryDuringPersonalToTaggedConversion tests that when a personal node
// is converted to tagged via reauth with RequestTags, the expiry is cleared to nil.
// BUG #3048: Previously expiry was NOT cleared because expiry handling ran
// BEFORE processReauthTags.
// Previously expiry was NOT cleared because expiry handling ran
// BEFORE [state.State.processReauthTags].
func TestExpiryDuringPersonalToTaggedConversion(t *testing.T) {
app := createTestApp(t)
user := app.state.CreateUserForTest("expiry-test-user")
@@ -744,8 +886,8 @@ func TestExpiryDuringPersonalToTaggedConversion(t *testing.T) {
// TestExpiryDuringTaggedToPersonalConversion tests that when a tagged node
// is converted to personal via reauth with empty RequestTags, expiry is set
// from the client request.
// BUG #3048: Previously expiry was NOT set because expiry handling ran
// BEFORE processReauthTags (node was still tagged at check time).
// Previously expiry was NOT set because expiry handling ran
// BEFORE [state.State.processReauthTags] (node was still tagged at check time).
func TestExpiryDuringTaggedToPersonalConversion(t *testing.T) {
app := createTestApp(t)
user := app.state.CreateUserForTest("expiry-test-user2")
@@ -1003,7 +1145,7 @@ func TestNodeExpiryZeroDisablesDefault(t *testing.T) {
assert.False(t, node.IsExpired(), "node should not be expired")
// With node.expiry=0 and zero client expiry, the node gets a zero expiry
// which IsExpired() treats as "never expires" — backwards compatible.
// which [types.Node.IsExpired] treats as "never expires" — backwards compatible.
if node.Expiry().Valid() {
assert.True(t, node.Expiry().Get().IsZero(),
"with node.expiry=0 and zero client expiry, expiry should be zero time")
@@ -1124,11 +1266,11 @@ func TestReregistrationAppliesDefaultExpiry(t *testing.T) {
// re-registers with zero client expiry and node.expiry is disabled (0),
// the node's expiry stays nil rather than being set to a pointer to zero
// time. Regression test for the else branch introduced in commit 6337a3db
// which assigned `&regReq.Expiry` (pointer to time.Time{}) instead of nil,
// which assigned `&regReq.Expiry` (pointer to [time.Time]{}) instead of nil,
// causing the database row to hold `0001-01-01 00:00:00` instead of NULL.
//
// The same !regReq.Expiry.IsZero() gate at state.go:2221-2228 is shared by
// the tags-only PreAuthKey path (createAndSaveNewNode also receives nil
// the tags-only PreAuthKey path ([state.State.createAndSaveNewNode] also receives nil
// when the client sends zero expiry), so this regression is covered for
// tagged nodes by inspection.
func TestReregistrationZeroExpiryStaysNil(t *testing.T) {
+34 -34
View File
@@ -31,7 +31,7 @@ type interactiveStep struct {
stepType string // stepTypeInitialRequest, stepTypeAuthCompletion, or stepTypeFollowupRequest
expectAuthURL bool
expectCacheEntry bool
callAuthPath bool // Real call to HandleNodeFromAuthPath, not mocked
callAuthPath bool // Real call to [state.State.HandleNodeFromAuthPath], not mocked
}
//nolint:gocyclo // comprehensive test function with many scenarios
@@ -140,7 +140,7 @@ func TestAuthenticationFlows(t *testing.T) {
return "", err
}
// Wait for node to be available in NodeStore
// Wait for node to be available in [state.NodeStore]
require.EventuallyWithT(t, func(c *assert.CollectT) {
_, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(c, found, "node should be available in NodeStore")
@@ -209,7 +209,7 @@ func TestAuthenticationFlows(t *testing.T) {
return "", err
}
// Wait for node to be available in NodeStore
// Wait for node to be available in [state.NodeStore]
require.EventuallyWithT(t, func(c *assert.CollectT) {
_, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(c, found, "node should be available in NodeStore")
@@ -409,7 +409,7 @@ func TestAuthenticationFlows(t *testing.T) {
t.Logf("Setup registered node: %+v", resp)
// Wait for node to be available in NodeStore with debug info
// Wait for node to be available in [state.NodeStore] with debug info
var attemptCount int
require.EventuallyWithT(t, func(c *assert.CollectT) {
@@ -470,7 +470,7 @@ func TestAuthenticationFlows(t *testing.T) {
return "", err
}
// Wait for node to be available in NodeStore
// Wait for node to be available in [state.NodeStore]
require.EventuallyWithT(t, func(c *assert.CollectT) {
_, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(c, found, "node should be available in NodeStore")
@@ -520,7 +520,7 @@ func TestAuthenticationFlows(t *testing.T) {
return "", err
}
// Wait for node to be available in NodeStore
// Wait for node to be available in [state.NodeStore]
require.EventuallyWithT(t, func(c *assert.CollectT) {
_, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(c, found, "node should be available in NodeStore")
@@ -637,7 +637,7 @@ func TestAuthenticationFlows(t *testing.T) {
return "", err
}
// Wait for node to be available in NodeStore
// Wait for node to be available in [state.NodeStore]
require.EventuallyWithT(t, func(c *assert.CollectT) {
_, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(c, found, "node should be available in NodeStore")
@@ -687,7 +687,7 @@ func TestAuthenticationFlows(t *testing.T) {
app.state.SetAuthCacheEntry(regID, nodeToRegister)
// Simulate successful registration
// handleRegister will receive the value when it starts waiting
// [Headscale.handleRegister] will receive the value when it starts waiting
go func() {
user := app.state.CreateUserForTest("followup-user")
@@ -826,7 +826,7 @@ func TestAuthenticationFlows(t *testing.T) {
},
// TEST: Nil hostinfo is handled with defensive code
// WHAT: Tests that nil hostinfo in register request is handled gracefully
// INPUT: Register request with Hostinfo field set to nil
// INPUT: Register request with [tailcfg.Hostinfo] field set to nil
// EXPECTED: Node registers successfully with generated hostname starting with "node-"
// WHY: Defensive code prevents nil pointer panics; creates valid default hostinfo
{
@@ -856,7 +856,7 @@ 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
// With nil [tailcfg.Hostinfo] the raw hostname stays empty and GivenName
// falls back to the literal "node" per the SaaS spec.
node, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(t, found)
@@ -954,7 +954,7 @@ func TestAuthenticationFlows(t *testing.T) {
// TEST: PreAuthKey registration rejects client-provided RequestTags
// WHAT: Tests that PreAuthKey registrations cannot use client-provided tags
// INPUT: PreAuthKey registration with RequestTags in Hostinfo
// INPUT: PreAuthKey registration with [tailcfg.Hostinfo.RequestTags] set
// EXPECTED: Registration fails with "requested tags [...] are invalid or not permitted" error
// WHY: PreAuthKey nodes get their tags from the key itself, not from client requests
{
@@ -1240,7 +1240,7 @@ func TestAuthenticationFlows(t *testing.T) {
// TEST: Zero-time expiry is handled correctly
// WHAT: Tests registration with expiry set to zero time value
// INPUT: Register request with Expiry set to time.Time{} (zero value)
// INPUT: Register request with Expiry set to [time.Time]{} (zero value)
// EXPECTED: Node registers successfully; zero time treated as no expiry
// WHY: Zero time is valid Go default; should be handled gracefully
{
@@ -1280,7 +1280,7 @@ func TestAuthenticationFlows(t *testing.T) {
},
// TEST: Malformed hostinfo with very long hostname is truncated
// WHAT: Tests that excessively long hostname is truncated to DNS label limit
// INPUT: Hostinfo with 110-character hostname (exceeds 63-char DNS limit)
// INPUT: [tailcfg.Hostinfo] with 110-character hostname (exceeds 63-char DNS limit)
// EXPECTED: Node registers successfully; hostname truncated to 63 characters
// WHY: Defensive code enforces DNS label limit (RFC 1123); prevents errors
{
@@ -1845,7 +1845,7 @@ func TestAuthenticationFlows(t *testing.T) {
},
// TEST: Logout with expiry exactly at current time
// WHAT: Tests logout when expiry is set to exact current time (boundary case)
// INPUT: Existing node sends request with expiry=time.Now() (not past, not future)
// INPUT: Existing node sends request with expiry=[time.Now]() (not past, not future)
// EXPECTED: Node is logged out (treated as expired)
// WHY: Edge case: current time should be treated as expired
{
@@ -2225,7 +2225,7 @@ func TestAuthenticationFlows(t *testing.T) {
},
// TEST: Interactive workflow with nil hostinfo
// WHAT: Tests interactive registration when request has nil hostinfo
// INPUT: Interactive registration request with Hostinfo=nil
// INPUT: Interactive registration request with [tailcfg.Hostinfo]=nil
// EXPECTED: Node registers successfully with generated default hostname
// WHY: Defensive code handles nil hostinfo in interactive flow
{
@@ -2761,7 +2761,7 @@ func TestNodeStoreLookup(t *testing.T) {
t.Logf("Registered node successfully: %+v", resp)
// Wait for node to be available in NodeStore
// Wait for node to be available in [state.NodeStore]
var node types.NodeView
require.EventuallyWithT(t, func(c *assert.CollectT) {
@@ -3072,7 +3072,7 @@ func TestWebFlowReauthDifferentUser(t *testing.T) {
})
t.Run("returned_node_is_user2_new_node", func(t *testing.T) {
// The node returned from HandleNodeFromAuthPath should be user2's NEW node
// The node returned from [state.State.HandleNodeFromAuthPath] should be user2's NEW node
assert.Equal(t, user2.ID, node.UserID().Get(), "Returned node should belong to user2")
assert.NotEqual(t, user1NodeID, node.ID(), "Returned node should be NEW, not transferred from user1")
t.Logf("✓ HandleNodeFromAuthPath returned user2's new node (ID: %d)", node.ID())
@@ -3166,7 +3166,7 @@ func createTestApp(t *testing.T) *Headscale {
// 1. Node registers successfully with a single-use pre-auth key
// 2. Node is running fine
// 3. Node restarts (e.g., after headscale upgrade or tailscale container restart)
// 4. Node sends RegisterRequest with the same pre-auth key
// 4. Node sends [tailcfg.RegisterRequest] with the same pre-auth key
// 5. BUG: Headscale rejects the request with "authkey expired" or "authkey already used"
//
// Expected behavior:
@@ -3223,7 +3223,7 @@ func TestGitHubIssue2830_NodeRestartWithUsedPreAuthKey(t *testing.T) {
require.NoError(t, err)
assert.True(t, usedPak.Used, "pre-auth key should be marked as used after initial registration")
// STEP 2: Simulate node restart - node sends RegisterRequest again with same pre-auth key
// STEP 2: Simulate node restart - node sends [tailcfg.RegisterRequest] again with same pre-auth key
// This happens when:
// - Tailscale container restarts
// - Tailscaled service restarts
@@ -3508,7 +3508,7 @@ func TestGitHubIssue2830_ExistingNodeCanReregisterWithUsedPreAuthKey(t *testing.
// WITHOUT THE FIX: This would fail with "authkey already used" error
// WITH THE FIX: This succeeds because it's the same node re-registering with its own key
// Simulate sending the same RegisterRequest again (same MachineKey, same AuthKey)
// Simulate sending the same [tailcfg.RegisterRequest] again (same MachineKey, same AuthKey)
// This is exactly what happens when a container restarts
reregisterReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{
@@ -3787,15 +3787,15 @@ func TestAuthKeyTaggedToUserOwnedViaReauth(t *testing.T) {
nodeAfterReauth.IsTagged(), nodeAfterReauth.UserID().Get())
}
// TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate tests that when a PreAuthKey is deleted,
// subsequent node updates (like those triggered by MapRequests) do not recreate the key.
// TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate tests that when a [types.PreAuthKey] is deleted,
// subsequent node updates (like those triggered by [tailcfg.MapRequest]s) do not recreate the key.
//
// This reproduces the bug where:
// 1. Create a tagged preauthkey and register a node
// 2. Delete the preauthkey (confirmed gone from pre_auth_keys DB table)
// 3. Node sends MapRequest (e.g., after tailscaled restart)
// 3. Node sends [tailcfg.MapRequest] (e.g., after tailscaled restart)
// 4. BUG: The preauthkey reappears because GORM's Updates() upserts the stale AuthKey
// data that still exists in the NodeStore's in-memory cache.
// data that still exists in the [state.NodeStore]'s in-memory cache.
//
// The fix is to use Omit("AuthKey") on all node Updates() calls to prevent GORM
// from touching the AuthKey association.
@@ -3864,11 +3864,11 @@ func TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate(t *testing.T) {
require.Nil(t, dbNode.AuthKeyID, "node's AuthKeyID should be NULL after PreAuthKey deletion")
t.Log("Node's AuthKeyID is NULL in database")
// The NodeStore may still have stale AuthKey data in memory.
// Now simulate what happens when the node sends a MapRequest after a tailscaled restart.
// This triggers persistNodeToDB which calls GORM's Updates().
// The [state.NodeStore] may still have stale AuthKey data in memory.
// Now simulate what happens when the node sends a [tailcfg.MapRequest] after a tailscaled restart.
// This triggers [state.State.persistNodeToDB] which calls GORM's Updates().
// Simulate a MapRequest by updating the node through the state layer
// Simulate a [tailcfg.MapRequest] by updating the node through the state layer
// This mimics what poll.go does when processing MapRequests
mapReq := tailcfg.MapRequest{
NodeKey: nodeKey.Public(),
@@ -3879,8 +3879,8 @@ func TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate(t *testing.T) {
},
}
// Process the MapRequest-like update
// This calls UpdateNodeFromMapRequest which eventually calls persistNodeToDB
// Process the [tailcfg.MapRequest]-like update
// This calls [state.State.UpdateNodeFromMapRequest] which eventually calls [state.State.persistNodeToDB]
_, err = app.state.UpdateNodeFromMapRequest(node.ID(), mapReq)
require.NoError(t, err, "UpdateNodeFromMapRequest should succeed")
t.Log("Simulated MapRequest update completed")
@@ -3943,7 +3943,7 @@ func TestTaggedNodeWithoutUserToDifferentUser(t *testing.T) {
alice := app.state.CreateUserForTest("alice")
require.NotNil(t, alice, "Alice user should be created")
// Step 4: Re-register the node to alice via HandleNodeFromAuthPath
// Step 4: Re-register the node to alice via [state.State.HandleNodeFromAuthPath]
// This is what happens when running: headscale auth register --auth-id <id> --user alice
nodeKey2 := key.NewNode()
registrationID := types.MustAuthID()
@@ -3960,7 +3960,7 @@ func TestTaggedNodeWithoutUserToDifferentUser(t *testing.T) {
// This should NOT panic - before the fix, this would panic with:
// panic: runtime error: invalid memory address or nil pointer dereference
// at UserView.Name() because the existing node has no User
// at [types.UserView.Name] because the existing node has no User
nodeAfterReauth, _, err := app.state.HandleNodeFromAuthPath(
registrationID,
types.UserID(alice.ID),
@@ -3977,8 +3977,8 @@ func TestTaggedNodeWithoutUserToDifferentUser(t *testing.T) {
require.False(t, nodeAfterReauth.IsTagged(), "Node should no longer be tagged")
require.Empty(t, nodeAfterReauth.Tags().AsSlice(), "Node should have no tags")
// Verify Owner() works without panicking - this is what the mapper's
// generateUserProfiles calls, and it would panic with a nil pointer
// Verify [types.NodeView.Owner] works without panicking - this is what the mapper's
// [generateUserProfiles] calls, and it would panic with a nil pointer
// dereference if node.User was not set during the tag→user conversion.
owner := nodeAfterReauth.Owner()
require.True(t, owner.Valid(), "Owner should be valid after conversion (mapper would panic if nil)")
+11 -18
View File
@@ -12,24 +12,17 @@ import (
"tailscale.com/util/set"
)
const (
// minVersionParts is the minimum number of version parts needed for major.minor.
minVersionParts = 2
// minVersionParts is the minimum number of version parts needed for major.minor.
const minVersionParts = 2
// legacyDERPCapVer is the capability version when LegacyDERP can be cleaned up.
legacyDERPCapVer = 111
)
// CanOldCodeBeCleanedUp is intended to be called on startup to see if
// there are old code that can ble cleaned up, entries should contain
// a CapVer where something can be cleaned up and a panic if it can.
// This is only intended to catch things in tests.
// CanOldCodeBeCleanedUp is called at server startup to panic when
// [MinSupportedCapabilityVersion] has crossed a threshold at which a
// backwards-compat emit path can be deleted. Each entry pairs a
// [tailcfg.CapabilityVersion] threshold with the message identifying
// the code to remove; today there are none.
//
// All uses of Capability version checks should be listed here.
// All capability-version-gated cleanups should be registered here.
func CanOldCodeBeCleanedUp() {
if MinSupportedCapabilityVersion >= legacyDERPCapVer {
panic("LegacyDERP can be cleaned up in tail.go")
}
}
func tailscaleVersSorted() []string {
@@ -46,12 +39,12 @@ func capVersSorted() []tailcfg.CapabilityVersion {
return capVers
}
// TailscaleVersion returns the Tailscale version for the given CapabilityVersion.
// TailscaleVersion returns the Tailscale version for the given [tailcfg.CapabilityVersion].
func TailscaleVersion(ver tailcfg.CapabilityVersion) string {
return capVerToTailscaleVer[ver]
}
// CapabilityVersion returns the CapabilityVersion for the given Tailscale version.
// CapabilityVersion returns the [tailcfg.CapabilityVersion] for the given Tailscale version.
// It accepts both full versions (v1.90.1) and minor versions (v1.90).
func CapabilityVersion(ver string) tailcfg.CapabilityVersion {
if !strings.HasPrefix(ver, "v") {
@@ -115,7 +108,7 @@ func TailscaleLatestMajorMinor(n int, stripV bool) []string {
return majorSl[len(majorSl)-n:]
}
// CapVerLatest returns the n latest CapabilityVersions.
// CapVerLatest returns the n latest [tailcfg.CapabilityVersion] values.
func CapVerLatest(n int) []tailcfg.CapabilityVersion {
if n <= 0 {
return nil
+3 -1
View File
@@ -42,6 +42,7 @@ var tailscaleToCapVer = map[string]tailcfg.CapabilityVersion{
"v1.92": 131,
"v1.94": 131,
"v1.96": 133,
"v1.98": 138,
}
var capVerToTailscaleVer = map[tailcfg.CapabilityVersion]string{
@@ -77,6 +78,7 @@ var capVerToTailscaleVer = map[tailcfg.CapabilityVersion]string{
130: "v1.90",
131: "v1.92",
133: "v1.96",
138: "v1.98",
}
// SupportedMajorMinorVersions is the number of major.minor Tailscale versions supported.
@@ -84,4 +86,4 @@ const SupportedMajorMinorVersions = 10
// MinSupportedCapabilityVersion represents the minimum capability version
// supported by this Headscale instance (latest 10 minor versions)
const MinSupportedCapabilityVersion tailcfg.CapabilityVersion = 109
const MinSupportedCapabilityVersion tailcfg.CapabilityVersion = 113
+4 -4
View File
@@ -9,10 +9,9 @@ var tailscaleLatestMajorMinorTests = []struct {
stripV bool
expected []string
}{
{3, false, []string{"v1.92", "v1.94", "v1.96"}},
{2, true, []string{"1.94", "1.96"}},
{3, false, []string{"v1.94", "v1.96", "v1.98"}},
{2, true, []string{"1.96", "1.98"}},
{10, true, []string{
"1.78",
"1.80",
"1.82",
"1.84",
@@ -22,6 +21,7 @@ var tailscaleLatestMajorMinorTests = []struct {
"1.92",
"1.94",
"1.96",
"1.98",
}},
{0, false, nil},
}
@@ -30,7 +30,7 @@ var capVerMinimumTailscaleVersionTests = []struct {
input tailcfg.CapabilityVersion
expected string
}{
{109, "v1.78"},
{113, "v1.80"},
{32, "v1.24"},
{41, "v1.30"},
{46, "v1.32"},
+6 -6
View File
@@ -28,7 +28,7 @@ var (
ErrAPIKeyInvalidGeneration = errors.New("generated API key failed validation")
)
// CreateAPIKey creates a new ApiKey in a user, and returns it.
// CreateAPIKey creates a new [types.APIKey] in a user, and returns it.
func (hsdb *HSDatabase) CreateAPIKey(
expiration *time.Time,
) (string, *types.APIKey, error) {
@@ -84,7 +84,7 @@ func (hsdb *HSDatabase) CreateAPIKey(
return keyStr, &key, nil
}
// ListAPIKeys returns the list of ApiKeys for a user.
// ListAPIKeys returns the list of [types.APIKey] values for a user.
func (hsdb *HSDatabase) ListAPIKeys() ([]types.APIKey, error) {
keys := []types.APIKey{}
@@ -96,7 +96,7 @@ func (hsdb *HSDatabase) ListAPIKeys() ([]types.APIKey, error) {
return keys, nil
}
// GetAPIKey returns a ApiKey for a given key.
// GetAPIKey returns a [types.APIKey] for a given key.
func (hsdb *HSDatabase) GetAPIKey(prefix string) (*types.APIKey, error) {
key := types.APIKey{}
if result := hsdb.DB.First(&key, "prefix = ?", prefix); result.Error != nil {
@@ -106,7 +106,7 @@ func (hsdb *HSDatabase) GetAPIKey(prefix string) (*types.APIKey, error) {
return &key, nil
}
// GetAPIKeyByID returns a ApiKey for a given id.
// GetAPIKeyByID returns a [types.APIKey] for a given id.
func (hsdb *HSDatabase) GetAPIKeyByID(id uint64) (*types.APIKey, error) {
key := types.APIKey{}
if result := hsdb.DB.Find(&types.APIKey{ID: id}).First(&key); result.Error != nil {
@@ -116,7 +116,7 @@ func (hsdb *HSDatabase) GetAPIKeyByID(id uint64) (*types.APIKey, error) {
return &key, nil
}
// DestroyAPIKey destroys a ApiKey. Returns error if the ApiKey
// DestroyAPIKey destroys a [types.APIKey]. Returns error if the [types.APIKey]
// does not exist.
func (hsdb *HSDatabase) DestroyAPIKey(key types.APIKey) error {
if result := hsdb.DB.Unscoped().Delete(key); result.Error != nil {
@@ -126,7 +126,7 @@ func (hsdb *HSDatabase) DestroyAPIKey(key types.APIKey) error {
return nil
}
// ExpireAPIKey marks a ApiKey as expired.
// ExpireAPIKey marks a [types.APIKey] as expired.
func (hsdb *HSDatabase) ExpireAPIKey(key *types.APIKey) error {
err := hsdb.DB.Model(&key).Update("Expiration", time.Now()).Error
if err != nil {
+1 -1
View File
@@ -122,7 +122,7 @@ func TestSQLiteMigrationAndDataValidation(t *testing.T) {
// Expected: tags = ["tag:server"] (no duplicates)
node4 := findNode("node4")
require.NotNil(t, node4, "node4 should exist")
assert.Equal(t, []string{"tag:server"}, node4.Tags, "node4 should have tag:server without duplicates")
assert.Equal(t, []string{"tag:server"}, node4.Tags.List(), "node4 should have tag:server without duplicates") //nolint:goconst // descriptive test assertions read better with the literal inline
// Node 5: user2 has no RequestTags
// Expected: tags = [] (unchanged)
@@ -17,8 +17,8 @@ const (
fifty = 50 * time.Millisecond
)
// TestEphemeralGarbageCollectorGoRoutineLeak is a test for a goroutine leak in EphemeralGarbageCollector().
// It creates a new EphemeralGarbageCollector, schedules several nodes for deletion with a short expiry,
// TestEphemeralGarbageCollectorGoRoutineLeak is a test for a goroutine leak in [EphemeralGarbageCollector].
// It creates a new [EphemeralGarbageCollector], schedules several nodes for deletion with a short expiry,
// and verifies that the nodes are deleted when the expiry time passes, and then
// for any leaked goroutines after the garbage collector is closed.
func TestEphemeralGarbageCollectorGoRoutineLeak(t *testing.T) {
@@ -89,8 +89,8 @@ func TestEphemeralGarbageCollectorGoRoutineLeak(t *testing.T) {
t.Logf("Final number of goroutines: %d", runtime.NumGoroutine())
}
// TestEphemeralGarbageCollectorReschedule is a test for the rescheduling of nodes in EphemeralGarbageCollector().
// It creates a new EphemeralGarbageCollector, schedules a node for deletion with a longer expiry,
// TestEphemeralGarbageCollectorReschedule is a test for the rescheduling of nodes in [EphemeralGarbageCollector].
// It creates a new [EphemeralGarbageCollector], schedules a node for deletion with a longer expiry,
// and then reschedules it with a shorter expiry, and verifies that the node is deleted only once.
func TestEphemeralGarbageCollectorReschedule(t *testing.T) {
// Deletion tracking mechanism
@@ -145,8 +145,8 @@ func TestEphemeralGarbageCollectorReschedule(t *testing.T) {
deleteMutex.Unlock()
}
// TestEphemeralGarbageCollectorCancelAndReschedule is a test for the cancellation and rescheduling of nodes in EphemeralGarbageCollector().
// It creates a new EphemeralGarbageCollector, schedules a node for deletion, cancels it, and then reschedules it,
// TestEphemeralGarbageCollectorCancelAndReschedule is a test for the cancellation and rescheduling of nodes in [EphemeralGarbageCollector].
// It creates a new [EphemeralGarbageCollector], schedules a node for deletion, cancels it, and then reschedules it,
// and verifies that the node is deleted only once.
func TestEphemeralGarbageCollectorCancelAndReschedule(t *testing.T) {
// Deletion tracking mechanism
@@ -214,8 +214,8 @@ func TestEphemeralGarbageCollectorCancelAndReschedule(t *testing.T) {
deleteMutex.Unlock()
}
// TestEphemeralGarbageCollectorCloseBeforeTimerFires is a test for the closing of the EphemeralGarbageCollector before the timer fires.
// It creates a new EphemeralGarbageCollector, schedules a node for deletion, closes the GC, and verifies that the node is not deleted.
// TestEphemeralGarbageCollectorCloseBeforeTimerFires is a test for the closing of the [EphemeralGarbageCollector] before the timer fires.
// It creates a new [EphemeralGarbageCollector], schedules a node for deletion, closes the GC, and verifies that the node is not deleted.
func TestEphemeralGarbageCollectorCloseBeforeTimerFires(t *testing.T) {
// Deletion tracking
var (
@@ -264,7 +264,7 @@ func TestEphemeralGarbageCollectorCloseBeforeTimerFires(t *testing.T) {
deleteMutex.Unlock()
}
// TestEphemeralGarbageCollectorScheduleAfterClose verifies that calling Schedule after Close
// TestEphemeralGarbageCollectorScheduleAfterClose verifies that calling [EphemeralGarbageCollector.Schedule] after [EphemeralGarbageCollector.Close]
// is a no-op and doesn't cause any panics, goroutine leaks, or other issues.
func TestEphemeralGarbageCollectorScheduleAfterClose(t *testing.T) {
// Count initial goroutines to check for leaks
@@ -339,7 +339,7 @@ func TestEphemeralGarbageCollectorScheduleAfterClose(t *testing.T) {
}
// TestEphemeralGarbageCollectorConcurrentScheduleAndClose tests the behavior of the garbage collector
// when Schedule and Close are called concurrently from multiple goroutines.
// when [EphemeralGarbageCollector.Schedule] and [EphemeralGarbageCollector.Close] are called concurrently from multiple goroutines.
func TestEphemeralGarbageCollectorConcurrentScheduleAndClose(t *testing.T) {
// Count initial goroutines
initialGoroutines := runtime.NumGoroutine()
+2 -3
View File
@@ -49,7 +49,7 @@ type IPAllocator struct {
usedIPs netipx.IPSetBuilder
}
// NewIPAllocator returns a new IPAllocator singleton which
// NewIPAllocator returns a new [IPAllocator] singleton which
// can be used to hand out unique IP addresses within the
// provided IPv4 and IPv6 prefix. It needs to be created
// when headscale starts and needs to finish its read
@@ -272,7 +272,7 @@ func isTailscaleReservedIP(ip netip.Addr) bool {
}
// BackfillNodeIPs will take a database transaction, and
// iterate through all of the current nodes in headscale
// iterate through all of the current nodes ([types.Node]) in headscale
// and ensure it has IP addresses according to the current
// configuration.
// This means that if both IPv4 and IPv6 is set in the
@@ -346,7 +346,6 @@ func (db *HSDatabase) BackfillNodeIPs(i *IPAllocator) ([]string, error) {
// Use Updates() with Select() to only update IP fields, avoiding overwriting
// other fields like Expiry. We need Select() because Updates() alone skips
// zero values, but we DO want to update IPv4/IPv6 to nil when removing them.
// See issue #2862.
err := tx.Model(node).Select("ipv4", "ipv6").Updates(node).Error
if err != nil {
return fmt.Errorf("saving node(%d) after adding IPs: %w", node.ID, err)
+10 -94
View File
@@ -1,11 +1,9 @@
package db
import (
"encoding/json"
"errors"
"fmt"
"net/netip"
"slices"
"sort"
"strconv"
"sync"
@@ -17,7 +15,6 @@ import (
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"tailscale.com/net/tsaddr"
"tailscale.com/types/key"
"tailscale.com/util/dnsname"
)
@@ -112,7 +109,7 @@ func (hsdb *HSDatabase) getNode(uid types.UserID, name string) (*types.Node, err
})
}
// getNode finds a Node by name and user and returns the Node struct.
// getNode finds a [types.Node] by name and user and returns the [types.Node] struct.
func getNode(tx *gorm.DB, uid types.UserID, name string) (*types.Node, error) {
nodes, err := ListNodesByUser(tx, uid)
if err != nil {
@@ -132,7 +129,7 @@ func (hsdb *HSDatabase) GetNodeByID(id types.NodeID) (*types.Node, error) {
return GetNodeByID(hsdb.DB, id)
}
// GetNodeByID finds a Node by ID and returns the Node struct.
// GetNodeByID finds a [types.Node] by ID and returns the [types.Node] struct.
func GetNodeByID(tx *gorm.DB, id types.NodeID) (*types.Node, error) {
mach := types.Node{}
if result := tx.
@@ -150,7 +147,7 @@ func (hsdb *HSDatabase) GetNodeByMachineKey(machineKey key.MachinePublic) (*type
return GetNodeByMachineKey(hsdb.DB, machineKey)
}
// GetNodeByMachineKey finds a Node by its MachineKey and returns the Node struct.
// GetNodeByMachineKey finds a [types.Node] by its [key.MachinePublic] and returns the [types.Node] struct.
func GetNodeByMachineKey(
tx *gorm.DB,
machineKey key.MachinePublic,
@@ -171,7 +168,7 @@ func (hsdb *HSDatabase) GetNodeByNodeKey(nodeKey key.NodePublic) (*types.Node, e
return GetNodeByNodeKey(hsdb.DB, nodeKey)
}
// GetNodeByNodeKey finds a Node by its NodeKey and returns the Node struct.
// GetNodeByNodeKey finds a [types.Node] by its [key.NodePublic] and returns the [types.Node] struct.
func GetNodeByNodeKey(
tx *gorm.DB,
nodeKey key.NodePublic,
@@ -188,87 +185,6 @@ func GetNodeByNodeKey(
return &mach, nil
}
func (hsdb *HSDatabase) SetTags(
nodeID types.NodeID,
tags []string,
) error {
return hsdb.Write(func(tx *gorm.DB) error {
return SetTags(tx, nodeID, tags)
})
}
// SetTags takes a NodeID and update the forced tags.
// It will overwrite any tags with the new list.
func SetTags(
tx *gorm.DB,
nodeID types.NodeID,
tags []string,
) error {
if len(tags) == 0 {
// if no tags are provided, we remove all tags
err := tx.Model(&types.Node{}).Where("id = ?", nodeID).Update("tags", "[]").Error
if err != nil {
return fmt.Errorf("removing tags: %w", err)
}
return nil
}
slices.Sort(tags)
tags = slices.Compact(tags)
b, err := json.Marshal(tags)
if err != nil {
return err
}
err = tx.Model(&types.Node{}).Where("id = ?", nodeID).Update("tags", string(b)).Error
if err != nil {
return fmt.Errorf("updating tags: %w", err)
}
return nil
}
// SetApprovedRoutes takes a Node struct pointer and updates the approved routes.
func SetApprovedRoutes(
tx *gorm.DB,
nodeID types.NodeID,
routes []netip.Prefix,
) error {
if len(routes) == 0 {
// if no routes are provided, we remove all
err := tx.Model(&types.Node{}).Where("id = ?", nodeID).Update("approved_routes", "[]").Error
if err != nil {
return fmt.Errorf("removing approved routes: %w", err)
}
return nil
}
// When approving exit routes, ensure both IPv4 and IPv6 are included
// If either 0.0.0.0/0 or ::/0 is being approved, both should be approved
hasIPv4Exit := slices.Contains(routes, tsaddr.AllIPv4())
hasIPv6Exit := slices.Contains(routes, tsaddr.AllIPv6())
if hasIPv4Exit && !hasIPv6Exit {
routes = append(routes, tsaddr.AllIPv6())
} else if hasIPv6Exit && !hasIPv4Exit {
routes = append(routes, tsaddr.AllIPv4())
}
b, err := json.Marshal(routes)
if err != nil {
return err
}
if err := tx.Model(&types.Node{}).Where("id = ?", nodeID).Update("approved_routes", string(b)).Error; err != nil { //nolint:noinlineerr
return fmt.Errorf("updating approved routes: %w", err)
}
return nil
}
// SetLastSeen sets a node's last seen field indicating that we
// have recently communicating with this node.
func (hsdb *HSDatabase) SetLastSeen(nodeID types.NodeID, lastSeen time.Time) error {
@@ -283,7 +199,7 @@ func SetLastSeen(tx *gorm.DB, nodeID types.NodeID, lastSeen time.Time) error {
return tx.Model(&types.Node{}).Where("id = ?", nodeID).Update("last_seen", lastSeen).Error
}
// RenameNode takes a Node struct and a new GivenName for the nodes
// RenameNode takes a [types.Node] struct and a new [types.Node.GivenName] for the nodes
// and renames it. Validation should be done in the state layer before calling this function.
func RenameNode(tx *gorm.DB,
nodeID types.NodeID, newName string,
@@ -329,7 +245,7 @@ func (hsdb *HSDatabase) DeleteNode(node *types.Node) error {
})
}
// DeleteNode deletes a Node from the database.
// DeleteNode deletes a [types.Node] from the database.
// Caller is responsible for notifying all of change.
func DeleteNode(tx *gorm.DB,
node *types.Node,
@@ -343,7 +259,7 @@ func DeleteNode(tx *gorm.DB,
return nil
}
// DeleteEphemeralNode deletes a Node from the database, note that this method
// DeleteEphemeralNode deletes a [types.Node] from the database, note that this method
// will remove it straight, and not notify any changes or consider any routes.
// It is intended for Ephemeral nodes.
func (hsdb *HSDatabase) DeleteEphemeralNode(
@@ -360,7 +276,7 @@ func (hsdb *HSDatabase) DeleteEphemeralNode(
}
// RegisterNodeForTest is used only for testing purposes to register a node directly in the database.
// Production code should use state.HandleNodeFromAuthPath or state.HandleNodeFromPreAuthKey.
// Production code should use [state.State.HandleNodeFromAuthPath] or [state.State.HandleNodeFromPreAuthKey].
func RegisterNodeForTest(tx *gorm.DB, node types.Node, ipv4 *netip.Addr, ipv6 *netip.Addr) (*types.Node, error) {
if !testing.Testing() {
panic("RegisterNodeForTest can only be called during tests")
@@ -471,7 +387,7 @@ func NodeSetMachineKey(
// 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
// It is used to delete ephemeral nodes ([types.Node.IsEphemeral]) that have disconnected and should be
// cleaned up.
type EphemeralGarbageCollector struct {
mu sync.Mutex
@@ -483,7 +399,7 @@ type EphemeralGarbageCollector struct {
cancelCh chan struct{}
}
// NewEphemeralGarbageCollector creates a new EphemeralGarbageCollector, it takes
// NewEphemeralGarbageCollector creates a new [EphemeralGarbageCollector], it takes
// a deleteFunc that will be called when a node is scheduled for deletion.
func NewEphemeralGarbageCollector(deleteFunc func(types.NodeID)) *EphemeralGarbageCollector {
return &EphemeralGarbageCollector{
+4 -52
View File
@@ -178,55 +178,6 @@ func TestDisableNodeExpiry(t *testing.T) {
assert.Nil(t, nodeFromDB.Expiry, "expiry should be nil after disabling")
}
func TestSetTags(t *testing.T) {
db, err := newSQLiteTestDB()
require.NoError(t, err)
user, err := db.CreateUser(types.User{Name: "test"})
require.NoError(t, err)
pak, err := db.CreatePreAuthKey(user.TypedID(), false, false, nil, nil)
require.NoError(t, err)
pakID := pak.ID
_, err = db.getNode(types.UserID(user.ID), "testnode")
require.Error(t, err)
nodeKey := key.NewNode()
machineKey := key.NewMachine()
node := &types.Node{
ID: 0,
MachineKey: machineKey.Public(),
NodeKey: nodeKey.Public(),
Hostname: "testnode",
UserID: &user.ID,
RegisterMethod: util.RegisterMethodAuthKey,
AuthKeyID: &pakID,
}
trx := db.DB.Save(node)
require.NoError(t, trx.Error)
// assign simple tags
sTags := []string{"tag:test", "tag:foo"}
err = db.SetTags(node.ID, sTags)
require.NoError(t, err)
node, err = db.getNode(types.UserID(user.ID), "testnode")
require.NoError(t, err)
assert.Equal(t, sTags, node.Tags)
// assign duplicate tags, expect no errors but no doubles in DB
eTags := []string{"tag:bar", "tag:test", "tag:unknown", "tag:test"}
err = db.SetTags(node.ID, eTags)
require.NoError(t, err)
node, err = db.getNode(types.UserID(user.ID), "testnode")
require.NoError(t, err)
assert.Equal(t, []string{"tag:bar", "tag:test", "tag:unknown"}, node.Tags)
}
func TestAutoApproveRoutes(t *testing.T) {
tests := []struct {
name string
@@ -409,13 +360,15 @@ func TestAutoApproveRoutes(t *testing.T) {
assert.Equal(t, tt.expectChange, changed1)
if changed1 {
err = SetApprovedRoutes(adb.DB, node.ID, newRoutes1)
node.ApprovedRoutes = types.Prefixes(newRoutes1)
err = adb.DB.Save(&node).Error
require.NoError(t, err)
}
newRoutes2, changed2 := policy.ApproveRoutesWithPolicy(pm, nodeTagged.View(), nodeTagged.ApprovedRoutes, tt.routes)
if changed2 {
err = SetApprovedRoutes(adb.DB, nodeTagged.ID, newRoutes2)
nodeTagged.ApprovedRoutes = types.Prefixes(newRoutes2)
err = adb.DB.Save(&nodeTagged).Error
require.NoError(t, err)
}
@@ -625,7 +578,6 @@ func TestListEphemeralNodes(t *testing.T) {
assert.Equal(t, nodeEph.Hostname, ephemeralNodes[0].Hostname)
}
func TestListPeers(t *testing.T) {
// Setup test database
db, err := newSQLiteTestDB()
+2 -2
View File
@@ -31,7 +31,7 @@ func (hsdb *HSDatabase) GetPolicy() (*types.Policy, error) {
}
// GetPolicy returns the latest policy from the database.
// This standalone function can be used in contexts where HSDatabase is not available,
// This standalone function can be used in contexts where [HSDatabase] is not available,
// such as during migrations.
func GetPolicy(tx *gorm.DB) (*types.Policy, error) {
var p types.Policy
@@ -55,7 +55,7 @@ func GetPolicy(tx *gorm.DB) (*types.Policy, error) {
// PolicyBytes loads policy configuration from file or database based on the configured mode.
// Returns nil if no policy is configured, which is valid.
// This standalone function can be used in contexts where HSDatabase is not available,
// This standalone function can be used in contexts where [HSDatabase] is not available,
// such as during migrations.
func PolicyBytes(tx *gorm.DB, cfg *types.Config) ([]byte, error) {
switch cfg.Policy.Mode {
+8 -8
View File
@@ -40,7 +40,7 @@ const (
authKeyLength = 64
)
// CreatePreAuthKey creates a new PreAuthKey in a user, and returns it.
// CreatePreAuthKey creates a new [types.PreAuthKey] in a user, and returns it.
// The uid parameter can be nil for system-created tagged keys.
// For tagged keys, uid tracks "created by" (who created the key).
// For user-owned keys, uid tracks the node owner.
@@ -158,7 +158,7 @@ func (hsdb *HSDatabase) ListPreAuthKeys() ([]types.PreAuthKey, error) {
return Read(hsdb.DB, ListPreAuthKeys)
}
// ListPreAuthKeys returns all PreAuthKeys in the database.
// ListPreAuthKeys returns all [types.PreAuthKey] values in the database.
func ListPreAuthKeys(tx *gorm.DB) ([]types.PreAuthKey, error) {
var keys []types.PreAuthKey
@@ -170,7 +170,7 @@ func ListPreAuthKeys(tx *gorm.DB) ([]types.PreAuthKey, error) {
return keys, nil
}
// ListPreAuthKeysByUser returns all PreAuthKeys belonging to a specific user.
// ListPreAuthKeysByUser returns all [types.PreAuthKey] values belonging to a specific user.
func ListPreAuthKeysByUser(tx *gorm.DB, uid types.UserID) ([]types.PreAuthKey, error) {
var keys []types.PreAuthKey
@@ -290,13 +290,13 @@ func (hsdb *HSDatabase) GetPreAuthKey(key string) (*types.PreAuthKey, error) {
return GetPreAuthKey(hsdb.DB, key)
}
// GetPreAuthKey returns a PreAuthKey for a given key. The caller is responsible
// GetPreAuthKey returns a [types.PreAuthKey] for a given key. The caller is responsible
// for checking if the key is usable (expired or used).
func GetPreAuthKey(tx *gorm.DB, key string) (*types.PreAuthKey, error) {
return findAuthKey(tx, key)
}
// DestroyPreAuthKey destroys a preauthkey. Returns error if the PreAuthKey
// DestroyPreAuthKey destroys a preauthkey. Returns error if the [types.PreAuthKey]
// does not exist. This also clears the auth_key_id on any nodes that reference
// this key.
func DestroyPreAuthKey(tx *gorm.DB, id uint64) error {
@@ -331,10 +331,10 @@ func (hsdb *HSDatabase) DeletePreAuthKey(id uint64) error {
})
}
// UsePreAuthKey atomically marks a PreAuthKey as used. The UPDATE is
// UsePreAuthKey atomically marks a [types.PreAuthKey] as used. The UPDATE is
// guarded by `used = false` so two concurrent registrations racing for
// the same single-use key cannot both succeed: the first commits and
// the second returns PAKError("authkey already used"). Without the
// the second returns [types.PAKError]("authkey already used"). Without the
// guard the previous code (Update("used", true) with no WHERE) would
// silently let both transactions claim the key.
func UsePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error {
@@ -354,7 +354,7 @@ func UsePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error {
return nil
}
// ExpirePreAuthKey marks a PreAuthKey as expired.
// ExpirePreAuthKey marks a [types.PreAuthKey] as expired.
func ExpirePreAuthKey(tx *gorm.DB, id uint64) error {
now := time.Now()
return tx.Model(&types.PreAuthKey{}).Where("id = ?", id).Update("expiration", now).Error
+6 -6
View File
@@ -24,7 +24,7 @@ func isTextUnmarshaler(rv reflect.Value) bool {
}
func maybeInstantiatePtr(rv reflect.Value) {
if rv.Kind() == reflect.Ptr && rv.IsNil() {
if rv.Kind() == reflect.Pointer && rv.IsNil() {
np := reflect.New(rv.Type().Elem())
rv.Set(np)
}
@@ -34,8 +34,8 @@ func decodingError(name string, err error) error {
return fmt.Errorf("decoding to %s: %w", name, err)
}
// TextSerialiser implements the Serialiser interface for fields that
// have a type that implements encoding.TextUnmarshaler.
// TextSerialiser implements the [schema.SerializerInterface] for fields that
// have a type that implements [encoding.TextUnmarshaler].
type TextSerialiser struct{}
func (TextSerialiser) Scan(ctx context.Context, field *schema.Field, dst reflect.Value, dbValue any) error {
@@ -43,7 +43,7 @@ func (TextSerialiser) Scan(ctx context.Context, field *schema.Field, dst reflect
// If the field is a pointer, we need to dereference it to get the actual type
// so we do not end with a second pointer.
if fieldValue.Elem().Kind() == reflect.Ptr {
if fieldValue.Elem().Kind() == reflect.Pointer {
fieldValue = fieldValue.Elem()
}
@@ -76,7 +76,7 @@ func (TextSerialiser) Scan(ctx context.Context, field *schema.Field, dst reflect
// If it is not a pointer, we need to assign the value to the
// field.
dstField := field.ReflectValueOf(ctx, dst)
if dstField.Kind() == reflect.Ptr {
if dstField.Kind() == reflect.Pointer {
dstField.Set(fieldValue)
} else {
dstField.Set(fieldValue.Elem())
@@ -97,7 +97,7 @@ func (TextSerialiser) Value(ctx context.Context, field *schema.Field, dst reflec
// If the value is nil, we return nil, however, go nil values are not
// always comparable, particularly when reflection is involved:
// https://dev.to/arxeiss/in-go-nil-is-not-equal-to-nil-sometimes-jn8
if v == nil || (reflect.ValueOf(v).Kind() == reflect.Ptr && reflect.ValueOf(v).IsNil()) {
if v == nil || (reflect.ValueOf(v).Kind() == reflect.Pointer && reflect.ValueOf(v).IsNil()) {
return nil, nil //nolint:nilnil // intentional: nil value for GORM serializer
}
+4 -4
View File
@@ -25,7 +25,7 @@ func (hsdb *HSDatabase) CreateUser(user types.User) (*types.User, error) {
})
}
// CreateUser creates a new User. Returns error if could not be created
// CreateUser creates a new [types.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)
@@ -47,7 +47,7 @@ func (hsdb *HSDatabase) DestroyUser(uid types.UserID) error {
})
}
// DestroyUser destroys a User. Returns error if the User does
// DestroyUser destroys a [types.User]. Returns error if the [types.User] does
// not exist or if there are user-owned nodes associated with it.
// Tagged nodes have user_id = NULL so they do not block deletion.
func DestroyUser(tx *gorm.DB, uid types.UserID) error {
@@ -92,8 +92,8 @@ func (hsdb *HSDatabase) RenameUser(uid types.UserID, newName string) error {
var ErrCannotChangeOIDCUser = errors.New("cannot edit OIDC user")
// RenameUser renames a User. Returns error if the User does
// not exist or if another User exists with the new name.
// RenameUser renames a [types.User]. Returns error if the [types.User] does
// not exist or if another [types.User] exists with the new name.
func RenameUser(tx *gorm.DB, uid types.UserID, newName string) error {
var err error
+1 -1
View File
@@ -123,7 +123,7 @@ func TestDestroyUserErrors(t *testing.T) {
result := db.DB.First(&survivingNode, "id = ?", node.ID)
require.NoError(t, result.Error)
assert.Nil(t, survivingNode.UserID)
assert.Equal(t, []string{"tag:server"}, survivingNode.Tags)
assert.Equal(t, []string{"tag:server"}, survivingNode.Tags.List())
},
},
{
+1 -1
View File
@@ -84,7 +84,7 @@ func parseVersion(s string) (semver, error) {
}
// ensureDatabaseVersionTable creates the database_versions table if it
// does not already exist. Uses GORM AutoMigrate to handle dialect
// does not already exist. Uses [gorm.DB.AutoMigrate] to handle dialect
// differences between SQLite (datetime) and PostgreSQL (timestamp).
// This runs before gormigrate migrations.
func ensureDatabaseVersionTable(db *gorm.DB) error {
+12 -12
View File
@@ -19,9 +19,9 @@ import (
"tailscale.com/tsweb"
)
// protectedDebugHandler wraps an http.Handler with an access check that
// protectedDebugHandler wraps an [http.Handler] with an access check that
// allows requests from loopback, Tailscale CGNAT IPs, and private
// (RFC 1918 / RFC 4193) addresses. This extends tsweb.Protected which
// (RFC 1918 / RFC 4193) addresses. This extends [tsweb.Protected] which
// only allows loopback and Tailscale IPs.
func protectedDebugHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -31,7 +31,7 @@ func protectedDebugHandler(h http.Handler) http.Handler {
return
}
// tsweb.AllowDebugAccess rejects X-Forwarded-For and non-TS IPs.
// [tsweb.AllowDebugAccess] rejects X-Forwarded-For and non-TS IPs.
// Additionally allow private/LAN addresses so operators can reach
// debug endpoints from their local network without tailscaled.
ipStr, _, err := net.SplitHostPort(r.RemoteAddr)
@@ -177,7 +177,7 @@ func (h *Headscale) debugHTTPServer() *http.Server {
}
}))
// NodeStore endpoint
// [state.NodeStore] endpoint
debug.Handle("nodestore", "NodeStore information", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check Accept header to determine response format
acceptHeader := r.Header.Get("Accept")
@@ -301,7 +301,7 @@ func (h *Headscale) debugHTTPServer() *http.Server {
_, _ = w.Write(resJSON)
}))
// Batcher endpoint
// [mapper.Batcher] endpoint
debug.Handle("batcher", "Batcher connected nodes", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check Accept header to determine response format
acceptHeader := r.Header.Get("Accept")
@@ -329,7 +329,7 @@ func (h *Headscale) debugHTTPServer() *http.Server {
}
}))
// Ping endpoint: sends a PingRequest to a node and waits for it to respond.
// Ping endpoint: sends a [tailcfg.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 (
@@ -361,12 +361,12 @@ func (h *Headscale) debugHTTPServer() *http.Server {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(templates.PingPage(query, result, nodes).Render()))
_, _ = w.Write([]byte(templates.PingPage(query, result, nodes).Render())) //nolint:gosec // G705: templ component auto-escapes
}))
// statsviz.Register would mount handlers directly on the raw mux,
// [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.
// each handler with [protectedDebugHandler].
statsvizSrv, err := statsviz.NewServer()
if err == nil {
debugMux.Handle("/debug/statsviz/", protectedDebugHandler(statsvizSrv.Index()))
@@ -402,9 +402,9 @@ func (h *Headscale) debugBatcher() string {
activeConnections int
}
var nodes []nodeStatus
debugInfo := h.mapBatcher.Debug()
nodes := make([]nodeStatus, 0, len(debugInfo))
for nodeID, info := range debugInfo {
nodes = append(nodes, nodeStatus{
id: nodeID,
@@ -510,7 +510,7 @@ func (h *Headscale) connectedNodesList() []templates.ConnectedNode {
const pingTimeout = 30 * time.Second
// doPing sends a PingRequest to the node identified by query and waits for a response.
// doPing sends a [tailcfg.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{
+5 -5
View File
@@ -73,11 +73,11 @@ func loadDERPMapFromURL(addr url.URL) (*tailcfg.DERPMap, error) {
return &derpMap, err
}
// mergeDERPMaps naively merges a list of DERPMaps into a single
// DERPMap, it will _only_ look at the Regions, an integer.
// If a region exists in two of the given DERPMaps, the region
// form the _last_ DERPMap will be preserved.
// An empty DERPMap list will result in a DERPMap with no regions.
// mergeDERPMaps naively merges a list of [tailcfg.DERPMap] values into a single
// [tailcfg.DERPMap], it will _only_ look at the Regions, an integer.
// If a region exists in two of the given [tailcfg.DERPMap] values, the region
// form the _last_ [tailcfg.DERPMap] will be preserved.
// An empty [tailcfg.DERPMap] list will result in a [tailcfg.DERPMap] with no regions.
func mergeDERPMaps(derpMaps []*tailcfg.DERPMap) *tailcfg.DERPMap {
result := tailcfg.DERPMap{
OmitDefaultRegions: false,
+2 -2
View File
@@ -30,7 +30,7 @@ type ExtraRecordsMan struct {
hashes map[string][32]byte
}
// NewExtraRecordsManager creates a new ExtraRecordsMan and starts watching the file at the given path.
// NewExtraRecordsManager creates a new [ExtraRecordsMan] and starts watching the file at the given path.
func NewExtraRecordsManager(path string) (*ExtraRecordsMan, error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
@@ -177,7 +177,7 @@ func (e *ExtraRecordsMan) updateRecords() {
e.updateCh <- e.records.Slice()
}
// readExtraRecordsFromPath reads a JSON file of tailcfg.DNSRecord
// readExtraRecordsFromPath reads a JSON file of [tailcfg.DNSRecord]
// and returns the records and the hash of the file.
func readExtraRecordsFromPath(path string) ([]tailcfg.DNSRecord, [32]byte, error) {
b, err := os.ReadFile(path)
+39 -9
View File
@@ -26,6 +26,7 @@ import (
"tailscale.com/types/views"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
"github.com/juanfont/headscale/hscontrol/state"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
@@ -58,7 +59,7 @@ func (api headscaleV1APIServer) CreateUser(
return nil, status.Errorf(codes.Internal, "creating user: %s", err)
}
// CreateUser returns a policy change response if the user creation affected policy.
// [state.State.CreateUser] returns a policy change response if the user creation affected policy.
// This triggers a full policy re-evaluation for all connected nodes.
api.h.Change(policyChanged)
@@ -104,7 +105,7 @@ func (api headscaleV1APIServer) DeleteUser(
return nil, err
}
// Use the change returned from DeleteUser which includes proper policy updates
// Use the change returned from [state.State.DeleteUser] which includes proper policy updates
api.h.Change(policyChanged)
return &v1.DeleteUserResponse{}, nil
@@ -292,7 +293,7 @@ func (api headscaleV1APIServer) RegisterNode(
return nil, fmt.Errorf("auto approving routes: %w", err)
}
// Send both changes. Empty changes are ignored by Change().
// Send both changes. Empty changes are ignored by [Headscale.Change].
api.h.Change(nodeChange, routeChange)
return &v1.RegisterNodeResponse{Node: node.Proto()}, nil
@@ -395,11 +396,11 @@ func (api headscaleV1APIServer) SetApprovedRoutes(
return nil, status.Error(codes.InvalidArgument, err.Error())
}
// Always propagate node changes from SetApprovedRoutes
// Always propagate node changes from [state.State.SetApprovedRoutes]
api.h.Change(nodeChange)
proto := node.Proto()
// Populate SubnetRoutes with PrimaryRoutes to ensure it includes only the
// Populate [types.Node.SubnetRoutes] with [tailcfg.Node.PrimaryRoutes] to ensure it includes only the
// routes that are actively served from the node (per architectural requirement in types/node.go)
primaryRoutes := api.h.state.GetNodePrimaryRoutes(node.ID())
proto.SubnetRoutes = util.PrefixesToString(primaryRoutes)
@@ -553,7 +554,7 @@ func nodesToProto(state *state.State, nodes views.Slice[types.NodeView]) []*v1.N
for index, node := range nodes.All() {
resp := node.Proto()
// Tags-as-identity: tagged nodes show as TaggedDevices user in API responses
// Tags-as-identity: tagged nodes show as [types.TaggedDevices] user in API responses
// (UserID may be set internally for "created by" tracking)
if node.IsTagged() {
resp.User = types.TaggedDevices.Proto()
@@ -781,6 +782,35 @@ func (api headscaleV1APIServer) SetPolicy(
return response, nil
}
// CheckPolicy validates the given policy against the server's live users
// and nodes, running its `tests` block as a sandbox. Nothing is persisted
// and the live PolicyManager is not touched. Works regardless of
// policy.mode so operators can validate a policy file before storing it.
func (api headscaleV1APIServer) CheckPolicy(
_ context.Context,
request *v1.CheckPolicyRequest,
) (*v1.CheckPolicyResponse, error) {
polB := []byte(request.GetPolicy())
users, err := api.h.state.ListAllUsers()
if err != nil {
return nil, status.Errorf(codes.Internal, "loading users: %s", err)
}
nodes := api.h.state.ListNodes()
pm, err := policyv2.NewPolicyManager(polB, users, nodes)
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
if _, err := pm.SetPolicy(polB); err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
return &v1.CheckPolicyResponse{}, nil
}
// The following service calls are for testing and debugging
func (api headscaleV1APIServer) DebugCreateNode(
ctx context.Context,
@@ -822,9 +852,9 @@ func (api headscaleV1APIServer) DebugCreateNode(
authRegReq := types.NewRegisterAuthRequest(regData)
api.h.state.SetAuthCacheEntry(registrationId, authRegReq)
// Echo back a synthetic Node so the debug response surface stays
// stable. The actual node is created later by AuthApprove via
// HandleNodeFromAuthPath using the cached RegistrationData.
// Echo back a synthetic [types.Node] so the debug response surface stays
// stable. The actual node is created later by [headscaleV1APIServer.AuthApprove] via
// [state.State.HandleNodeFromAuthPath] using the cached [types.RegistrationData].
echoNode := types.Node{
NodeKey: regData.NodeKey,
MachineKey: regData.MachineKey,
+13 -17
View File
@@ -155,7 +155,7 @@ func TestSetTags_Conversion(t *testing.T) {
}
}
// TestSetTags_TaggedNode tests that SetTags correctly identifies tagged nodes
// TestSetTags_TaggedNode tests that [headscaleV1APIServer.SetTags] correctly identifies tagged nodes
// and doesn't reject them with the "user-owned nodes" error.
// Note: This test doesn't validate ACL tag authorization - that's tested elsewhere.
func TestSetTags_TaggedNode(t *testing.T) {
@@ -193,7 +193,7 @@ func TestSetTags_TaggedNode(t *testing.T) {
// Create API server instance
apiServer := newHeadscaleV1APIServer(app)
// Test: SetTags should work on tagged nodes.
// Test: [headscaleV1APIServer.SetTags] should work on tagged nodes.
resp, err := apiServer.SetTags(context.Background(), &v1.SetTagsRequest{
NodeId: uint64(taggedNode.ID()),
Tags: []string{"tag:initial"}, // Keep existing tag to avoid ACL validation issues
@@ -212,7 +212,7 @@ func TestSetTags_TaggedNode(t *testing.T) {
}
}
// TestSetTags_CannotRemoveAllTags tests that SetTags rejects attempts to remove
// TestSetTags_CannotRemoveAllTags tests that [headscaleV1APIServer.SetTags] rejects attempts to remove
// all tags from a tagged node, enforcing Tailscale's requirement that tagged
// nodes must have at least one tag.
func TestSetTags_CannotRemoveAllTags(t *testing.T) {
@@ -265,9 +265,8 @@ func TestSetTags_CannotRemoveAllTags(t *testing.T) {
}
// TestSetTags_ClearsUserIDInDatabase tests that converting a user-owned node
// to a tagged node via SetTags correctly persists user_id = NULL in the
// to a tagged node via [headscaleV1APIServer.SetTags] correctly persists user_id = NULL in the
// database, not just in-memory.
// https://github.com/juanfont/headscale/issues/3161
func TestSetTags_ClearsUserIDInDatabase(t *testing.T) {
t.Parallel()
@@ -309,7 +308,7 @@ func TestSetTags_ClearsUserIDInDatabase(t *testing.T) {
nodeID := node.ID()
// Convert to tagged via SetTags API.
// Convert to tagged via [headscaleV1APIServer.SetTags] API.
apiServer := newHeadscaleV1APIServer(app)
_, err = apiServer.SetTags(context.Background(), &v1.SetTagsRequest{
NodeId: uint64(nodeID),
@@ -404,9 +403,8 @@ func TestSetTags_NodeDisappearsFromUserListing(t *testing.T) {
assert.Contains(t, allResp.GetNodes()[0].GetTags(), "tag:web")
}
// TestSetTags_NodeStoreAndDBConsistency verifies that after SetTags, the
// in-memory NodeStore and the database agree on the node's ownership state.
// https://github.com/juanfont/headscale/issues/3161
// TestSetTags_NodeStoreAndDBConsistency verifies that after [headscaleV1APIServer.SetTags], the
// in-memory [state.NodeStore] and the database agree on the node's ownership state.
func TestSetTags_NodeStoreAndDBConsistency(t *testing.T) {
t.Parallel()
@@ -478,9 +476,8 @@ func TestSetTags_NodeStoreAndDBConsistency(t *testing.T) {
// TestSetTags_UserDeletionDoesNotCascadeToTaggedNode tests that deleting the
// original user does not cascade-delete a node that was converted to tagged
// via SetTags. This catches the real-world consequence of stale user_id:
// via [headscaleV1APIServer.SetTags]. This catches the real-world consequence of stale user_id:
// ON DELETE CASCADE would destroy the tagged node.
// https://github.com/juanfont/headscale/issues/3161
func TestSetTags_UserDeletionDoesNotCascadeToTaggedNode(t *testing.T) {
t.Parallel()
@@ -531,7 +528,7 @@ func TestSetTags_UserDeletionDoesNotCascadeToTaggedNode(t *testing.T) {
_, err = app.state.DeleteUser(*user.TypedID())
require.NoError(t, err)
// The tagged node must survive in both NodeStore and database.
// The tagged node must survive in both [state.NodeStore] and database.
nsNode, found := app.state.GetNodeByID(nodeID)
require.True(t, found, "tagged node must survive user deletion in NodeStore")
assert.True(t, nsNode.IsTagged())
@@ -555,7 +552,7 @@ func TestDeleteUser_ReturnsProperChangeSignal(t *testing.T) {
require.NotNil(t, user)
// Delete the user and verify a non-empty change is returned
// Issue #2967: Without the fix, DeleteUser returned an empty change,
// Without the fix, [state.State.DeleteUser] returned an empty change,
// causing stale policy state until another user operation triggered an update.
changeSignal, err := app.state.DeleteUser(*user.TypedID())
require.NoError(t, err, "DeleteUser should succeed")
@@ -564,8 +561,7 @@ func TestDeleteUser_ReturnsProperChangeSignal(t *testing.T) {
// TestDeleteUser_TaggedNodeSurvives tests that deleting a user succeeds when
// the user's only nodes are tagged, and that those nodes remain in the
// NodeStore with nil UserID.
// https://github.com/juanfont/headscale/issues/3077
// [state.NodeStore] with nil UserID.
func TestDeleteUser_TaggedNodeSurvives(t *testing.T) {
t.Parallel()
@@ -605,7 +601,7 @@ func TestDeleteUser_TaggedNodeSurvives(t *testing.T) {
nodeID := node.ID()
// NodeStore should not list the tagged node under any user.
// [state.NodeStore] should not list the tagged node under any user.
nodesForUser := app.state.ListNodesByUser(types.UserID(user.ID))
assert.Equal(t, 0, nodesForUser.Len(),
"tagged nodes should not appear in nodesByUser index")
@@ -615,7 +611,7 @@ func TestDeleteUser_TaggedNodeSurvives(t *testing.T) {
require.NoError(t, err)
assert.False(t, changeSignal.IsEmpty())
// Tagged node survives in the NodeStore.
// Tagged node survives in the [state.NodeStore].
nodeAfter, found := app.state.GetNodeByID(nodeID)
require.True(t, found, "tagged node should survive user deletion")
assert.True(t, nodeAfter.IsTagged())
+2 -2
View File
@@ -129,7 +129,7 @@ func parseCapabilityVersion(req *http.Request) (tailcfg.CapabilityVersion, error
}
// verifyBodyLimit caps the request body for /verify. The DERP verify
// protocol payload (tailcfg.DERPAdmitClientRequest) is a few hundred
// protocol payload ([tailcfg.DERPAdmitClientRequest]) is a few hundred
// bytes; 4 KiB is generous and prevents an unauthenticated client from
// OOMing the public router with arbitrarily large POSTs.
const verifyBodyLimit int64 = 4 * 1024
@@ -358,7 +358,7 @@ func authIDFromRequest(req *http.Request) (types.AuthID, error) {
// Listens in /register/:registration_id.
//
// This is not part of the Tailscale control API, as we could send whatever URL
// in the RegisterResponse.AuthURL field.
// in the [tailcfg.RegisterResponse.AuthURL] field.
func (a *AuthProviderWeb) RegisterHandler(
writer http.ResponseWriter,
req *http.Request,
+3 -3
View File
@@ -15,8 +15,8 @@ import (
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
// /verify handler refuses POST bodies larger than [verifyBodyLimit].
// The [http.MaxBytesReader] is applied in [Headscale.VerifyHandler], so we simulate
// the same wrapping here.
func TestHandleVerifyRequest_OversizedBodyRejected(t *testing.T) {
t.Parallel()
@@ -47,7 +47,7 @@ func TestHandleVerifyRequest_OversizedBodyRejected(t *testing.T) {
"oversized body must surface 413")
}
// errorAsHTTPError is a small local helper that unwraps an HTTPError
// errorAsHTTPError is a small local helper that unwraps an [HTTPError]
// from an error chain.
func errorAsHTTPError(err error) (HTTPError, bool) {
var h HTTPError
+20 -18
View File
@@ -27,7 +27,7 @@ var (
)
// offlineNodeCleanupThreshold is how long a node must be disconnected
// before cleanupOfflineNodes removes its in-memory state.
// before [Batcher.cleanupOfflineNodes] removes its in-memory state.
const offlineNodeCleanupThreshold = 15 * time.Minute
var mapResponseGenerated = promauto.NewCounterVec(prometheus.CounterOpts{
@@ -49,7 +49,7 @@ func NewBatcher(batchTime time.Duration, workers int, mapper *mapper) *Batcher {
}
}
// NewBatcherAndMapper creates a new Batcher with its mapper.
// NewBatcherAndMapper creates a new [Batcher] with its [mapper].
func NewBatcherAndMapper(cfg *types.Config, state *state.State) *Batcher {
m := newMapper(cfg, state)
b := NewBatcher(cfg.Tuning.BatchChangeDelay, cfg.Tuning.BatcherWorkers, m)
@@ -69,7 +69,7 @@ type nodeConnection interface {
updateSentPeers(resp *tailcfg.MapResponse)
}
// generateMapResponse generates a [tailcfg.MapResponse] for the given NodeID based on the provided [change.Change].
// generateMapResponse generates a [tailcfg.MapResponse] for the given [types.NodeID] based on the provided [change.Change].
func generateMapResponse(nc nodeConnection, mapper *mapper, r change.Change) (*tailcfg.MapResponse, error) {
nodeID := nc.nodeID()
version := nc.version()
@@ -130,17 +130,19 @@ func generateMapResponse(nc nodeConnection, mapper *mapper, r change.Change) (*t
// When a full update (SendAllPeers=true) produces zero visible peers
// (e.g., a restrictive policy isolates this node), the resulting
// MapResponse has Peers: []*tailcfg.Node{} (empty non-nil slice).
// [tailcfg.MapResponse] has Peers: []*tailcfg.Node{} (empty non-nil slice).
//
// The Tailscale client only treats Peers as a full authoritative
// replacement when len(Peers) > 0 (controlclient/map.go:462).
// An empty Peers slice is indistinguishable from a delta response,
// so the client silently preserves its existing peer state.
//
// This matters when a FullUpdate() replaces a pending PolicyChange()
// in the batcher (addToBatch short-circuits on HasFull). The
// PolicyChange would have computed PeersRemoved via computePeerDiff,
// but the FullUpdate path uses WithPeers which sets Peers: [].
// This matters when a [change.FullUpdate] replaces a pending
// [change.PolicyChange] in the batcher ([Batcher.addToBatch]
// short-circuits on [change.HasFull]). The [change.PolicyChange]
// would have computed PeersRemoved via
// [multiChannelNodeConn.computePeerDiff], but the [change.FullUpdate]
// path uses [MapResponseBuilder.WithPeers] which sets Peers: [].
//
// Fix: when a full update results in zero peers, compute the diff
// against lastSentPeers and add explicit PeersRemoved entries so
@@ -206,7 +208,7 @@ type workResult struct {
// work represents a unit of work to be processed by workers.
// All pending changes for a node are bundled into a single work item
// so that one worker processes them sequentially. This prevents
// out-of-order MapResponse delivery and races on lastSentPeers
// out-of-order [tailcfg.MapResponse] delivery and races on lastSentPeers
// that occur when multiple workers process changes for the same node.
type work struct {
changes []change.Change
@@ -225,9 +227,9 @@ var (
// Batcher batches and distributes map responses to connected nodes.
// It uses concurrent maps, per-node mutexes, and a worker pool.
//
// Lifecycle: Call Start() to spawn workers, then Close() to shut down.
// Close() blocks until all workers have exited. A Batcher must not
// be reused after Close().
// Lifecycle: Call [Batcher.Start] to spawn workers, then [Batcher.Close]
// to shut down. [Batcher.Close] blocks until all workers have exited.
// A [Batcher] must not be reused after [Batcher.Close].
type Batcher struct {
tick *time.Ticker
mapper *mapper
@@ -551,11 +553,11 @@ func (b *Batcher) addToBatch(changes ...change.Change) {
// still has it registered. By cleaning up here, we prevent "node not found"
// errors when workers try to generate map responses for deleted nodes.
//
// Safety: change.Change.PeersRemoved is ONLY populated when nodes are actually
// deleted from the system (via change.NodeRemoved in state.DeleteNode). Policy
// changes that affect peer visibility do NOT use this field - they set
// Safety: [change.Change.PeersRemoved] is ONLY populated when nodes are actually
// deleted from the system (via [change.NodeRemoved] in [state.State.DeleteNode]).
// Policy changes that affect peer visibility do NOT use this field - they set
// RequiresRuntimePeerComputation=true and compute removed peers at runtime,
// putting them in tailcfg.MapResponse.PeersRemoved (a different struct).
// putting them in [tailcfg.MapResponse.PeersRemoved] (a different struct).
// Therefore, this cleanup only removes nodes that are truly being deleted,
// not nodes that are still connected but have lost visibility of certain peers.
//
@@ -638,8 +640,8 @@ func (b *Batcher) processBatchedChanges() {
}
// cleanupOfflineNodes removes nodes that have been offline for too long to prevent memory leaks.
// Uses Compute() for atomic check-and-delete to prevent TOCTOU races where a node
// reconnects between the hasActiveConnections() check and the Delete() call.
// Uses xsync.Map.Compute for atomic check-and-delete to prevent TOCTOU races where a node
// reconnects between the hasActiveConnections check and the Delete call.
func (b *Batcher) cleanupOfflineNodes() {
var nodesToCleanup []types.NodeID
+8 -8
View File
@@ -158,14 +158,14 @@ type node struct {
//
// Returns TestData struct containing all created entities and a cleanup function.
func setupBatcherWithTestData(
t testing.TB,
tb testing.TB,
bf batcherFunc,
userCount, nodesPerUser, bufferSize int,
) (*TestData, func()) {
t.Helper()
tb.Helper()
// Create database and populate with test data first
tmpDir := t.TempDir()
tmpDir := tb.TempDir()
dbPath := tmpDir + "/headscale_test.db"
prefixV4 := netip.MustParsePrefix("100.64.0.0/10")
@@ -206,7 +206,7 @@ func setupBatcherWithTestData(
// Create database and populate it with test data
database, err := db.NewHeadscaleDatabase(cfg)
if err != nil {
t.Fatalf("setting up database: %s", err)
tb.Fatalf("setting up database: %s", err)
}
// Create test users and nodes in the database
@@ -226,12 +226,12 @@ func setupBatcherWithTestData(
// Now create state using the same database
state, err := state.NewState(cfg)
if err != nil {
t.Fatalf("Failed to create state: %v", err)
tb.Fatalf("Failed to create state: %v", err)
}
derpMap, err := derp.GetDERPMap(cfg.DERP)
require.NoError(t, err)
require.NotNil(t, derpMap)
require.NoError(tb, err)
require.NotNil(tb, derpMap)
state.SetDERPMap(derpMap)
@@ -248,7 +248,7 @@ func setupBatcherWithTestData(
_, err = state.SetPolicy([]byte(allowAllPolicy))
if err != nil {
t.Fatalf("Failed to set allow-all policy: %v", err)
tb.Fatalf("Failed to set allow-all policy: %v", err)
}
// Create batcher with the state and wrap it for testing
+37 -11
View File
@@ -7,13 +7,14 @@ import (
"time"
"github.com/juanfont/headscale/hscontrol/policy"
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
"github.com/juanfont/headscale/hscontrol/types"
"tailscale.com/tailcfg"
"tailscale.com/types/views"
"tailscale.com/util/multierr"
)
// MapResponseBuilder provides a fluent interface for building tailcfg.MapResponse.
// MapResponseBuilder provides a fluent interface for building [tailcfg.MapResponse].
type MapResponseBuilder struct {
resp *tailcfg.MapResponse
mapper *mapper
@@ -84,7 +85,9 @@ func (b *MapResponseBuilder) WithSelfNode() *MapResponseBuilder {
return slices.Concat(primaries, nv.ExitRoutes())
},
b.mapper.cfg)
b.mapper.cfg,
b.mapper.state.NodeCapMap(nv.ID()),
)
if err != nil {
b.addError(err)
return b
@@ -158,7 +161,7 @@ func (b *MapResponseBuilder) WithDNSConfig() *MapResponseBuilder {
return b
}
b.resp.DNSConfig = generateDNSConfig(b.mapper.cfg, node)
b.resp.DNSConfig = generateDNSConfig(b.mapper.cfg, node, b.mapper.state.NodeCapMap(node.ID()))
return b
}
@@ -177,6 +180,10 @@ func (b *MapResponseBuilder) WithUserProfiles(peers views.Slice[types.NodeView])
}
// WithPacketFilters adds packet filter rules based on policy.
//
// [State.FilterForNode] returns rules already reduced to only those relevant for this node.
// For autogroup:self policies, it returns per-node compiled rules.
// For global policies, it returns the global filter reduced for this node.
func (b *MapResponseBuilder) WithPacketFilters() *MapResponseBuilder {
node, ok := b.mapper.state.GetNodeByID(b.nodeID)
if !ok {
@@ -184,9 +191,6 @@ func (b *MapResponseBuilder) WithPacketFilters() *MapResponseBuilder {
return b
}
// FilterForNode returns rules already reduced to only those relevant for this node.
// For autogroup:self policies, it returns per-node compiled rules.
// For global policies, it returns the global filter reduced for this node.
filter, err := b.mapper.state.FilterForNode(node)
if err != nil {
b.addError(err)
@@ -230,7 +234,8 @@ func (b *MapResponseBuilder) WithPeerChanges(peers views.Slice[types.NodeView])
return b
}
// buildTailPeers converts views.Slice[types.NodeView] to []tailcfg.Node with policy filtering and sorting.
// buildTailPeers converts [views.Slice] of [types.NodeView] to a slice of [tailcfg.Node]
// with policy filtering and sorting.
func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) ([]*tailcfg.Node, error) {
node, ok := b.mapper.state.GetNodeByID(b.nodeID)
if !ok {
@@ -238,9 +243,10 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) (
}
// Get unreduced matchers for peer relationship determination.
// MatchersForNode returns unreduced matchers that include all rules where the node
// could be either source or destination. This is different from FilterForNode which
// returns reduced rules for packet filtering (only rules where node is destination).
// [State.MatchersForNode] returns unreduced matchers that include all rules where the
// node could be either source or destination. This is different from
// [State.FilterForNode] which returns reduced rules for packet filtering (only rules
// where node is destination).
matchers, err := b.mapper.state.MatchersForNode(node)
if err != nil {
return nil, err
@@ -255,17 +261,37 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) (
changedViews = peers
}
// Snapshot the per-node policy CapMap once per peer-list build
// instead of locking the policy manager per peer. The per-call
// path used to take pm.mu N times for an N-peer response.
allCapMaps := b.mapper.state.NodeCapMaps()
// Build tail nodes with per-peer via-aware route function.
tailPeers := make([]*tailcfg.Node, 0, changedViews.Len())
for _, peer := range changedViews.All() {
// Pass the peer's policy CapMap as selfPolicyCaps so per-peer
// address-shape rules (today: disable-ipv4) apply consistently
// in the viewer's netmap. The CapMap merge into tn.CapMap is
// overwritten by the PeerCapMap call below; only the address
// filtering side-effect inside TailNode survives.
tn, err := peer.TailNode(b.capVer, func(_ types.NodeID) []netip.Prefix {
return b.mapper.state.RoutesForPeer(node, peer, matchers)
}, b.mapper.cfg)
}, b.mapper.cfg, allCapMaps[peer.ID()])
if err != nil {
return nil, err
}
// [tailcfg.Node.CapMap] on a peer carries the small set of
// caps the Tailscale client reads from the peer view rather
// than the self view (suggest-exit-node, dns-subdomain-resolve
// — see ipn/ipnlocal/local.go:7534 and node_backend.go:745).
// The Tailscale-hosted control plane stamps these only when
// the peer satisfies the cap's emission condition; every other
// cap stays off the peer view, leaving CapMap empty for most
// peers. [policyv2.PeerCapMap] encodes those conditions.
tn.CapMap = policyv2.PeerCapMap(peer, allCapMaps[peer.ID()])
tailPeers = append(tailPeers, tn)
}
+153 -28
View File
@@ -7,6 +7,7 @@ import (
"net/url"
"os"
"path"
"regexp"
"slices"
"strconv"
"strings"
@@ -68,7 +69,7 @@ func newMapper(
}
}
// generateUserProfiles creates user profiles for MapResponse.
// generateUserProfiles creates user profiles for [tailcfg.MapResponse].
func generateUserProfiles(
node types.NodeView,
peers views.Slice[types.NodeView],
@@ -114,9 +115,25 @@ func generateUserProfiles(
return profiles
}
// nextDNSAttrPrefix is the form Tailscale uses for per-node NextDNS profile
// selection: an "attr" entry of "nextdns:<profile-id>" overrides the resolver
// path, and "nextdns:no-device-info" suppresses the metadata-appending step.
// See https://tailscale.com/docs/integrations/nextdns.
const (
nextDNSAttrPrefix = "nextdns:"
nextDNSAttrNoInfo tailcfg.NodeCapability = "nextdns:no-device-info"
)
// nextDNSProfileRE bounds the characters accepted in a `nextdns:<profile>`
// suffix. NextDNS profile IDs are short alphanumeric strings; restricting
// to that charset prevents a policy author from injecting `?`, `/`, `@`,
// or `..` into the resolver URL via a crafted cap name.
var nextDNSProfileRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
func generateDNSConfig(
cfg *types.Config,
node types.NodeView,
capMap tailcfg.NodeCapMap,
) *tailcfg.DNSConfig {
if cfg.TailcfgDNSConfig == nil {
return nil
@@ -124,36 +141,133 @@ func generateDNSConfig(
dnsConfig := cfg.TailcfgDNSConfig.Clone()
addNextDNSMetadata(dnsConfig.Resolvers, node)
profile := nextDNSProfileFromCapMap(capMap)
if profile != "" {
applyNextDNSProfile(dnsConfig.Resolvers, profile)
applyNextDNSProfile(dnsConfig.FallbackResolvers, profile)
for suffix, rs := range dnsConfig.Routes {
applyNextDNSProfile(rs, profile)
dnsConfig.Routes[suffix] = rs
}
}
if _, suppressMetadata := capMap[nextDNSAttrNoInfo]; !suppressMetadata {
addNextDNSMetadata(dnsConfig.Resolvers, node)
addNextDNSMetadata(dnsConfig.FallbackResolvers, node)
for suffix, rs := range dnsConfig.Routes {
addNextDNSMetadata(rs, node)
dnsConfig.Routes[suffix] = rs
}
}
return dnsConfig
}
// If any nextdns DoH resolvers are present in the list of resolvers it will
// take metadata from the node metadata and instruct tailscale to add it
// to the requests. This makes it possible to identify from which device the
// requests come in the NextDNS dashboard.
// nextDNSProfileFromCapMap returns the policy-selected
// `nextdns:<profile>` value on the node, or the empty string when none
// is set or the cap is malformed. The reserved
// `nextdns:no-device-info` string is not a profile — it controls
// metadata appending and is handled separately.
//
// This will produce a resolver like:
// `https://dns.nextdns.io/<nextdns-id>?device_name=node-name&device_model=linux&device_ip=100.64.0.1`
func addNextDNSMetadata(resolvers []*dnstype.Resolver, node types.NodeView) {
for _, resolver := range resolvers {
if strings.HasPrefix(resolver.Addr, nextDNSDoHPrefix) {
attrs := url.Values{
"device_name": []string{node.Hostname()},
"device_model": []string{node.Hostinfo().OS()},
}
// The profile pick is deterministic across reloads: cap keys are
// gathered, sorted, and the first valid profile wins. Map iteration
// order in Go is randomised, so taking the literal first match would
// cause the chosen profile to flip between reloads when a node has
// multiple `nextdns:` caps. The profile string is also validated
// against [nextDNSProfileRE] so a crafted cap cannot inject path or
// query characters into the resolver URL.
func nextDNSProfileFromCapMap(capMap tailcfg.NodeCapMap) string {
if len(capMap) == 0 {
return ""
}
if len(node.IPs()) > 0 {
attrs.Add("device_ip", node.IPs()[0].String())
}
candidates := make([]string, 0, len(capMap))
resolver.Addr = fmt.Sprintf("%s?%s", resolver.Addr, attrs.Encode())
for cap := range capMap {
if cap == nextDNSAttrNoInfo {
continue
}
profile, ok := strings.CutPrefix(string(cap), nextDNSAttrPrefix)
if !ok || profile == "" {
continue
}
if !nextDNSProfileRE.MatchString(profile) {
log.Warn().
Str("cap", string(cap)).
Msg("nextdns profile rejected: must match [A-Za-z0-9._-]{1,64}")
continue
}
candidates = append(candidates, profile)
}
if len(candidates) == 0 {
return ""
}
slices.Sort(candidates)
return candidates[0]
}
// nextDNSDoHHost matches a NextDNS DoH resolver address. The check is
// anchored on the host segment so a typo-squatted operator-configured
// resolver such as `https://dns.nextdns.io.attacker.example/x` does
// not slip through.
func nextDNSDoHHost(addr string) bool {
return addr == nextDNSDoHPrefix ||
strings.HasPrefix(addr, nextDNSDoHPrefix+"/") ||
strings.HasPrefix(addr, nextDNSDoHPrefix+"?")
}
// applyNextDNSProfile rewrites every NextDNS DoH resolver to point at
// the given profile, dropping any existing profile path or query. Per
// the Tailscale spec the per-node profile overrides the global value,
// so the rewrite is unconditional rather than additive.
func applyNextDNSProfile(resolvers []*dnstype.Resolver, profile string) {
for _, resolver := range resolvers {
if !nextDNSDoHHost(resolver.Addr) {
continue
}
resolver.Addr = nextDNSDoHPrefix + "/" + profile
}
}
// fullMapResponse returns a MapResponse for the given node.
// addNextDNSMetadata appends device metadata as a query string to
// every NextDNS DoH resolver. Existing query parameters on the
// resolver address are preserved by parsing the URL and merging into
// its [url.URL.RawQuery] rather than concatenating with `?`.
func addNextDNSMetadata(resolvers []*dnstype.Resolver, node types.NodeView) {
for _, resolver := range resolvers {
if !nextDNSDoHHost(resolver.Addr) {
continue
}
u, err := url.Parse(resolver.Addr)
if err != nil {
continue
}
q := u.Query()
q.Set("device_name", node.Hostname())
q.Set("device_model", node.Hostinfo().OS())
if ips := node.IPs(); len(ips) > 0 {
q.Set("device_ip", ips[0].String())
}
u.RawQuery = q.Encode()
resolver.Addr = u.String()
}
}
// fullMapResponse returns a [tailcfg.MapResponse] for the given node.
//
//nolint:unused
func (m *mapper) fullMapResponse(
@@ -198,13 +312,20 @@ func (m *mapper) selfMapResponse(
return ma, err
}
// policyChangeResponse creates a MapResponse for policy changes.
// policyChangeResponse creates a [tailcfg.MapResponse] for policy changes.
// It sends:
// - PeersRemoved for peers that are no longer visible after the policy change
// - PeersChanged for remaining peers (their AllowedIPs may have changed due to policy)
// - Updated PacketFilters
// - Updated SSHPolicy (SSH rules may reference users/groups that changed)
// - Optionally, the node's own self info (when includeSelf is true)
// - PeersRemoved for peers that are no longer visible after the policy change
// - PeersChanged for remaining peers (their AllowedIPs may have changed due to policy)
// - Updated PacketFilters
// - Updated SSHPolicy (SSH rules may reference users/groups that changed)
// - DNSConfig so the client's resolver state stays anchored even when a
// policy-triggered wgengine reconfigure races a netmon LinkChange (the
// LinkChange handler reapplies dns.Manager.Set with the engine's
// lastDNSConfig; if that snapshot is stale, the OS resolver loses the
// MagicDNS reverse-DNS routes and Nameservers and curl-by-FQDN stops
// resolving for the rest of the policy window).
// - Optionally, the node's own self info (when includeSelf is true)
//
// This avoids the issue where an empty Peers slice is interpreted by Tailscale
// clients as "no change" rather than "no peers".
// When includeSelf is true, the node's self info is included so that a node
@@ -220,6 +341,7 @@ func (m *mapper) policyChangeResponse(
builder := m.NewMapResponseBuilder(nodeID).
WithDebugType(policyResponseDebug).
WithCapabilityVersion(capVer).
WithDNSConfig().
WithPacketFilters().
WithSSHPolicy()
@@ -228,7 +350,7 @@ func (m *mapper) policyChangeResponse(
}
if len(removedPeers) > 0 {
// Convert tailcfg.NodeID to types.NodeID for WithPeersRemoved
// Convert [tailcfg.NodeID] to [types.NodeID] for [MapResponseBuilder.WithPeersRemoved]
removedIDs := make([]types.NodeID, len(removedPeers))
for i, id := range removedPeers {
removedIDs[i] = types.NodeID(id) //nolint:gosec // NodeID types are equivalent
@@ -239,14 +361,17 @@ func (m *mapper) policyChangeResponse(
// Send remaining peers in PeersChanged - their AllowedIPs may have
// changed due to the policy update (e.g., different routes allowed).
// Cross-user peers must also carry their user profile, otherwise the
// client's netmap shows the peer without a UserProfiles[user] entry.
if currentPeers.Len() > 0 {
builder.WithUserProfiles(currentPeers)
builder.WithPeerChanges(currentPeers)
}
return builder.Build()
}
// buildFromChange builds a MapResponse from a change.Change specification.
// buildFromChange builds a [tailcfg.MapResponse] from a [change.Change] specification.
// This provides fine-grained control over what gets included in the response.
func (m *mapper) buildFromChange(
nodeID types.NodeID,
+117
View File
@@ -68,6 +68,7 @@ func TestDNSConfigMapResponse(t *testing.T) {
TailcfgDNSConfig: &dnsConfigOrig,
},
nodeInShared1.View(),
nil,
)
if diff := cmp.Diff(tt.want, got, cmpopts.EquateEmpty()); diff != "" {
@@ -76,3 +77,119 @@ func TestDNSConfigMapResponse(t *testing.T) {
})
}
}
func TestNextDNSCapMapRendering(t *testing.T) {
t.Parallel()
mkConfig := func(addrs ...string) *types.Config {
resolvers := make([]*dnstype.Resolver, len(addrs))
for i, a := range addrs {
resolvers[i] = &dnstype.Resolver{Addr: a}
}
return &types.Config{
TailcfgDNSConfig: &tailcfg.DNSConfig{
Resolvers: resolvers,
},
}
}
mkNode := func() types.NodeView {
return (&types.Node{
ID: 1,
Hostname: "node1",
IPv4: iap("100.64.0.1"),
Hostinfo: &tailcfg.Hostinfo{OS: "linux"},
}).View()
}
// resolverAddr extracts the first resolver's address with a
// bounds check. Without it, a regression that drops the
// resolver list would nil-panic instead of failing cleanly.
resolverAddr := func(t *testing.T, got *tailcfg.DNSConfig) string {
t.Helper()
if got == nil {
t.Fatalf("generateDNSConfig returned nil")
}
if len(got.Resolvers) == 0 {
t.Fatalf("generateDNSConfig returned no Resolvers")
}
return got.Resolvers[0].Addr
}
t.Run("no_capmap_metadata_appended", func(t *testing.T) {
t.Parallel()
got := generateDNSConfig(
mkConfig("https://dns.nextdns.io/abc"),
mkNode(),
nil,
)
want := "https://dns.nextdns.io/abc?device_ip=100.64.0.1&device_model=linux&device_name=node1"
if addr := resolverAddr(t, got); addr != want {
t.Errorf("addr = %q, want %q", addr, want)
}
})
t.Run("profile_overrides_global", func(t *testing.T) {
t.Parallel()
capMap := tailcfg.NodeCapMap{
"nextdns:override": []tailcfg.RawMessage{},
}
got := generateDNSConfig(
mkConfig("https://dns.nextdns.io/global"),
mkNode(),
capMap,
)
want := "https://dns.nextdns.io/override?device_ip=100.64.0.1&device_model=linux&device_name=node1"
if addr := resolverAddr(t, got); addr != want {
t.Errorf("addr = %q, want %q", addr, want)
}
})
t.Run("no_device_info_skips_metadata", func(t *testing.T) {
t.Parallel()
capMap := tailcfg.NodeCapMap{
"nextdns:abc": []tailcfg.RawMessage{},
"nextdns:no-device-info": []tailcfg.RawMessage{},
}
got := generateDNSConfig(
mkConfig("https://dns.nextdns.io/global"),
mkNode(),
capMap,
)
want := "https://dns.nextdns.io/abc"
if addr := resolverAddr(t, got); addr != want {
t.Errorf("addr = %q, want %q", addr, want)
}
})
t.Run("non_nextdns_resolver_untouched", func(t *testing.T) {
t.Parallel()
capMap := tailcfg.NodeCapMap{
"nextdns:abc": []tailcfg.RawMessage{},
}
got := generateDNSConfig(
mkConfig("https://dns.example.org/dns-query"),
mkNode(),
capMap,
)
want := "https://dns.example.org/dns-query"
if addr := resolverAddr(t, got); addr != want {
t.Errorf("non-nextdns resolver was rewritten: %q", addr)
}
})
}
+13 -13
View File
@@ -17,10 +17,10 @@ import (
"tailscale.com/tailcfg"
)
// errNoActiveConnections is returned by send when a node has no active
// connections (disconnected but kept in the batcher for rapid reconnection).
// Callers must not update peer tracking state (lastSentPeers) after this
// error because the data was never delivered to any client.
// errNoActiveConnections is returned by [multiChannelNodeConn.send] when a node
// has no active connections (disconnected but kept in the batcher for rapid
// reconnection). Callers must not update peer tracking state (lastSentPeers)
// after this error because the data was never delivered to any client.
var errNoActiveConnections = errors.New("no active connections")
// connectionEntry represents a single connection to a node.
@@ -51,9 +51,9 @@ type multiChannelNodeConn struct {
// workMu serializes change processing for this node across batch ticks.
// Without this, two workers could process consecutive ticks' bundles
// concurrently, causing out-of-order MapResponse delivery and races
// on lastSentPeers (Clear+Store in updateSentPeers vs Range in
// computePeerDiff).
// concurrently, causing out-of-order [tailcfg.MapResponse] delivery and races
// on lastSentPeers (Clear+Store in [multiChannelNodeConn.updateSentPeers] vs
// Range in [multiChannelNodeConn.computePeerDiff]).
workMu sync.Mutex
closeOnce sync.Once
@@ -62,7 +62,7 @@ type multiChannelNodeConn struct {
// disconnectedAt records when the last connection was removed.
// nil means the node is considered connected (or newly created);
// non-nil means the node disconnected at the stored timestamp.
// Used by cleanupOfflineNodes to evict stale entries.
// Used by [Batcher.cleanupOfflineNodes] to evict stale entries.
disconnectedAt atomic.Pointer[time.Time]
// lastSentPeers tracks which peers were last sent to this node.
@@ -182,8 +182,8 @@ func (mc *multiChannelNodeConn) markConnected() {
}
// markDisconnected records the current time as the moment the node
// lost its last connection. Used by cleanupOfflineNodes to determine
// how long the node has been offline.
// lost its last connection. Used by [Batcher.cleanupOfflineNodes] to
// determine how long the node has been offline.
func (mc *multiChannelNodeConn) markDisconnected() {
now := time.Now()
mc.disconnectedAt.Store(&now)
@@ -235,8 +235,8 @@ func (mc *multiChannelNodeConn) drainPending() []change.Change {
// connection can block for up to 50ms), the method snapshots connections under
// a read lock, sends without any lock held, then write-locks only to remove
// failures. New connections added between the snapshot and cleanup are safe:
// they receive a full initial map via AddNode, so missing this update causes
// no data loss.
// they receive a full initial map via [Batcher.AddNode], so missing this update
// causes no data loss.
func (mc *multiChannelNodeConn) send(data *tailcfg.MapResponse) error {
if data == nil {
return nil
@@ -389,7 +389,7 @@ func (mc *multiChannelNodeConn) version() tailcfg.CapabilityVersion {
return mc.connections[0].version
}
// updateSentPeers updates the tracked peer state based on a sent MapResponse.
// updateSentPeers updates the tracked peer state based on a sent [tailcfg.MapResponse].
// This must be called after successfully sending a response to keep track of
// what the client knows about, enabling accurate diffs for future updates.
func (mc *multiChannelNodeConn) updateSentPeers(resp *tailcfg.MapResponse) {
+224 -24
View File
@@ -16,6 +16,8 @@ import (
)
func TestTailNode(t *testing.T) {
t.Parallel()
mustNK := func(str string) key.NodePublic {
var k key.NodePublic
@@ -51,7 +53,6 @@ func TestTailNode(t *testing.T) {
tests := []struct {
name string
node *types.Node
pol []byte
dnsConfig *tailcfg.DNSConfig
baseDomain string
want *tailcfg.Node
@@ -69,16 +70,14 @@ func TestTailNode(t *testing.T) {
Name: "empty",
StableID: "0",
HomeDERP: 0,
LegacyDERPString: "127.3.3.40:0",
Hostinfo: hiview(tailcfg.Hostinfo{}),
MachineAuthorized: true,
CapMap: tailcfg.NodeCapMap{
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.NodeAttrsTaildriveShare: []tailcfg.RawMessage{},
tailcfg.NodeAttrsTaildriveAccess: []tailcfg.RawMessage{},
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.NodeAttrDefaultAutoUpdate: []tailcfg.RawMessage{tailcfg.RawMessage("false")},
},
},
wantErr: false,
@@ -148,8 +147,7 @@ func TestTailNode(t *testing.T) {
PrimaryRoutes: []netip.Prefix{
netip.MustParsePrefix("192.168.0.0/24"),
},
HomeDERP: 0,
LegacyDERPString: "127.3.3.40:0",
HomeDERP: 0,
Hostinfo: hiview(tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
tsaddr.AllIPv4(),
@@ -165,11 +163,10 @@ func TestTailNode(t *testing.T) {
MachineAuthorized: true,
CapMap: tailcfg.NodeCapMap{
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.NodeAttrsTaildriveShare: []tailcfg.RawMessage{},
tailcfg.NodeAttrsTaildriveAccess: []tailcfg.RawMessage{},
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.NodeAttrDefaultAutoUpdate: []tailcfg.RawMessage{tailcfg.RawMessage("false")},
},
},
wantErr: false,
@@ -187,16 +184,14 @@ func TestTailNode(t *testing.T) {
Name: "minimal.example.com.",
StableID: "0",
HomeDERP: 0,
LegacyDERPString: "127.3.3.40:0",
Hostinfo: hiview(tailcfg.Hostinfo{}),
MachineAuthorized: true,
CapMap: tailcfg.NodeCapMap{
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.NodeAttrsTaildriveShare: []tailcfg.RawMessage{},
tailcfg.NodeAttrsTaildriveAccess: []tailcfg.RawMessage{},
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.NodeAttrDefaultAutoUpdate: []tailcfg.RawMessage{tailcfg.RawMessage("false")},
},
},
wantErr: false,
@@ -208,11 +203,12 @@ func TestTailNode(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := &types.Config{
BaseDomain: tt.baseDomain,
TailcfgDNSConfig: tt.dnsConfig,
RandomizeClientPort: false,
Taildrop: types.TaildropConfig{Enabled: true},
BaseDomain: tt.baseDomain,
TailcfgDNSConfig: tt.dnsConfig,
Taildrop: types.TaildropConfig{Enabled: true},
}
// Stub primary-route lookup: tt.node owns its SubnetRoutes,
@@ -234,6 +230,7 @@ func TestTailNode(t *testing.T) {
return slices.Concat(primaries[id], nv.ExitRoutes())
},
cfg,
nil,
)
if (err != nil) != tt.wantErr {
@@ -249,6 +246,208 @@ func TestTailNode(t *testing.T) {
}
}
// TestTailNodeBaselineGates focuses on the cfg-driven baseline cap
// emission: cfg.Taildrop.Enabled gates [tailcfg.CapabilityFileSharing]
// and cfg.AutoUpdate.Enabled controls the value of
// [tailcfg.NodeAttrDefaultAutoUpdate]. Admin and SSH are unconditional
// baseline.
func TestTailNodeBaselineGates(t *testing.T) {
t.Parallel()
autoUpdate := func(b bool) []tailcfg.RawMessage {
if b {
return []tailcfg.RawMessage{tailcfg.RawMessage("true")}
}
return []tailcfg.RawMessage{tailcfg.RawMessage("false")}
}
tests := []struct {
name string
cfg *types.Config
want tailcfg.NodeCapMap
}{
{
name: "taildrop_on_autoupdate_off",
cfg: &types.Config{
Taildrop: types.TaildropConfig{Enabled: true},
AutoUpdate: types.AutoUpdateConfig{Enabled: false},
},
want: tailcfg.NodeCapMap{
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.NodeAttrDefaultAutoUpdate: autoUpdate(false),
},
},
{
name: "taildrop_off_autoupdate_off",
cfg: &types.Config{
Taildrop: types.TaildropConfig{Enabled: false},
AutoUpdate: types.AutoUpdateConfig{Enabled: false},
},
want: tailcfg.NodeCapMap{
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.NodeAttrDefaultAutoUpdate: autoUpdate(false),
},
},
{
name: "taildrop_on_autoupdate_on",
cfg: &types.Config{
Taildrop: types.TaildropConfig{Enabled: true},
AutoUpdate: types.AutoUpdateConfig{Enabled: true},
},
want: tailcfg.NodeCapMap{
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.NodeAttrDefaultAutoUpdate: autoUpdate(true),
},
},
{
name: "taildrop_off_autoupdate_on",
cfg: &types.Config{
Taildrop: types.TaildropConfig{Enabled: false},
AutoUpdate: types.AutoUpdateConfig{Enabled: true},
},
want: tailcfg.NodeCapMap{
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.NodeAttrDefaultAutoUpdate: autoUpdate(true),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
node := &types.Node{GivenName: "baseline-node", Hostinfo: &tailcfg.Hostinfo{}}
got, err := node.View().TailNode(
0,
func(types.NodeID) []netip.Prefix { return nil },
tt.cfg,
nil,
)
if err != nil {
t.Fatalf("TailNode: %v", err)
}
if diff := cmp.Diff(tt.want, got.CapMap, cmpopts.EquateEmpty()); diff != "" {
t.Errorf("CapMap mismatch (-want +got):\n%s", diff)
}
})
}
}
// TestTailNodeDisableIPv4 asserts that a node with the disable-ipv4
// nodeAttr has its own IPv4 (the CGNAT /32) stripped from Addresses
// and AllowedIPs, while subnet routes the node advertises -- even
// IPv4 ones -- remain in AllowedIPs and PrimaryRoutes. Matches the
// SaaS behaviour captured in
// hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c1{5,6}-disable-ipv4*.hujson.
func TestTailNodeDisableIPv4(t *testing.T) {
t.Parallel()
const NodeAttrDisableIPv4 tailcfg.NodeCapability = "disable-ipv4"
v4 := iap("100.64.0.1")
v6Addr := netip.MustParseAddr("fd7a:115c:a1e0::1")
v6 := &v6Addr
subnet := netip.MustParsePrefix("10.33.0.0/16")
tests := []struct {
name string
hasCap bool
approved []netip.Prefix
wantAllowed []netip.Prefix
wantPrimary []netip.Prefix
wantAddrs []netip.Prefix
}{
{
name: "no-cap_emits_both_families",
hasCap: false,
wantAllowed: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32"), netip.MustParsePrefix("fd7a:115c:a1e0::1/128")},
wantAddrs: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32"), netip.MustParsePrefix("fd7a:115c:a1e0::1/128")},
},
{
name: "cap_strips_own_ipv4",
hasCap: true,
wantAllowed: []netip.Prefix{netip.MustParsePrefix("fd7a:115c:a1e0::1/128")},
wantAddrs: []netip.Prefix{netip.MustParsePrefix("fd7a:115c:a1e0::1/128")},
},
{
name: "cap_keeps_advertised_subnet_route",
hasCap: true,
approved: []netip.Prefix{subnet},
// AllowedIPs is sorted by netip.Prefix.Compare so IPv4
// sorts before IPv6.
wantAllowed: []netip.Prefix{
subnet,
netip.MustParsePrefix("fd7a:115c:a1e0::1/128"),
},
wantPrimary: []netip.Prefix{subnet},
wantAddrs: []netip.Prefix{netip.MustParsePrefix("fd7a:115c:a1e0::1/128")},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
node := &types.Node{
GivenName: "ipv4-disabled-node",
IPv4: v4,
IPv6: v6,
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: tt.approved,
},
ApprovedRoutes: tt.approved,
}
var selfCaps tailcfg.NodeCapMap
if tt.hasCap {
selfCaps = tailcfg.NodeCapMap{NodeAttrDisableIPv4: nil}
}
got, err := node.View().TailNode(
0,
func(types.NodeID) []netip.Prefix {
return tt.approved
},
&types.Config{Taildrop: types.TaildropConfig{Enabled: true}},
selfCaps,
)
if err != nil {
t.Fatalf("TailNode: %v", err)
}
prefStrings := func(ps []netip.Prefix) []string {
out := make([]string, len(ps))
for i, p := range ps {
out[i] = p.String()
}
return out
}
if diff := cmp.Diff(prefStrings(tt.wantAddrs), prefStrings(got.Addresses), cmpopts.EquateEmpty()); diff != "" {
t.Errorf("Addresses (-want +got):\n%s", diff)
}
if diff := cmp.Diff(prefStrings(tt.wantAllowed), prefStrings(got.AllowedIPs), cmpopts.EquateEmpty()); diff != "" {
t.Errorf("AllowedIPs (-want +got):\n%s", diff)
}
if diff := cmp.Diff(prefStrings(tt.wantPrimary), prefStrings(got.PrimaryRoutes), cmpopts.EquateEmpty()); diff != "" {
t.Errorf("PrimaryRoutes (-want +got):\n%s", diff)
}
})
}
}
func TestNodeExpiry(t *testing.T) {
tp := func(t time.Time) *time.Time {
return &t
@@ -290,6 +489,7 @@ func TestNodeExpiry(t *testing.T) {
return []netip.Prefix{}
},
&types.Config{Taildrop: types.TaildropConfig{Enabled: true}},
nil,
)
if err != nil {
t.Fatalf("nodeExpiry() error = %v", err)
-63
View File
@@ -1,10 +1,6 @@
package hscontrol
import (
"net/http"
"strconv"
"github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"tailscale.com/envknob"
@@ -42,63 +38,4 @@ var (
Name: "mapresponse_ended_total",
Help: "total count of new mapsessions ended",
}, []string{"reason"})
httpDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Namespace: prometheusNamespace,
Name: "http_duration_seconds",
Help: "Duration of HTTP requests.",
}, []string{"path"})
httpCounter = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: prometheusNamespace,
Name: "http_requests_total",
Help: "Total number of http requests processed",
}, []string{"code", "method", "path"},
)
)
// prometheusMiddleware implements mux.MiddlewareFunc.
func prometheusMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
route := mux.CurrentRoute(r)
path, _ := route.GetPathTemplate()
// Ignore streaming and noise sessions
// it has its own router further down.
if path == "/ts2021" || path == "/machine/map" || path == "/derp" || path == "/derp/probe" || path == "/derp/latency-check" || path == "/bootstrap-dns" {
next.ServeHTTP(w, r)
return
}
rw := &respWriterProm{ResponseWriter: w}
timer := prometheus.NewTimer(httpDuration.WithLabelValues(path))
next.ServeHTTP(rw, r)
timer.ObserveDuration()
httpCounter.WithLabelValues(strconv.Itoa(rw.status), r.Method, path).Inc()
})
}
type respWriterProm struct {
http.ResponseWriter
status int
written int64
wroteHeader bool
}
func (r *respWriterProm) WriteHeader(code int) {
r.status = code
r.wroteHeader = true
r.ResponseWriter.WriteHeader(code)
}
func (r *respWriterProm) Write(b []byte) (int, error) {
if !r.wroteHeader {
r.WriteHeader(http.StatusOK)
}
n, err := r.ResponseWriter.Write(b)
r.written += int64(n)
return n, err
}
+35 -16
View File
@@ -65,14 +65,14 @@ const (
// The first 9 bytes from the server to client over Noise are either an HTTP/2
// settings frame (a normal HTTP/2 setup) or, as Tailscale added later, an "early payload"
// header that's also 9 bytes long: 5 bytes (earlyPayloadMagic) followed by 4 bytes
// of length. Then that many bytes of JSON-encoded tailcfg.EarlyNoise.
// header that's also 9 bytes long: 5 bytes ([earlyPayloadMagic]) followed by 4 bytes
// of length. Then that many bytes of JSON-encoded [tailcfg.EarlyNoise].
// The early payload is optional. Some servers may not send it... But we do!
earlyPayloadMagic = "\xff\xff\xffTS"
// noiseBodyLimit is the maximum allowed request body size for Noise protocol
// handlers. This prevents unauthenticated OOM attacks via unbounded io.ReadAll.
// No legitimate Noise request (MapRequest, RegisterRequest, etc.) comes close
// handlers. This prevents unauthenticated OOM attacks via unbounded [io.ReadAll].
// No legitimate Noise request ([tailcfg.MapRequest], [tailcfg.RegisterRequest], etc.) comes close
// to this limit; typical payloads are a few KB.
noiseBodyLimit int64 = 1048576 // 1 MiB
)
@@ -86,12 +86,12 @@ type noiseServer struct {
machineKey key.MachinePublic
nodeKey key.NodePublic
// EarlyNoise-related stuff
// [tailcfg.EarlyNoise]-related stuff
challenge key.ChallengePrivate
protocolVersion int
}
// NoiseUpgradeHandler is to upgrade the connection and hijack the net.Conn
// NoiseUpgradeHandler is to upgrade the connection and hijack the [net.Conn]
// in order to use the Noise-based TS2021 protocol. Listens in /ts2021.
func (h *Headscale) NoiseUpgradeHandler(
writer http.ResponseWriter,
@@ -136,7 +136,7 @@ func (h *Headscale) NoiseUpgradeHandler(
// This router is served only over the Noise connection, and exposes only the new API.
//
// The HTTP2 server that exposes this router is created for
// a single hijacked connection from /ts2021, using netutil.NewOneConnListener
// a single hijacked connection from /ts2021, using [netutil.NewOneConnListener]
r := chi.NewRouter()
@@ -158,7 +158,12 @@ func (h *Headscale) NoiseUpgradeHandler(
},
}))
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
// The outer router resolved trusted_proxies on req before the
// upgrade; pin that value across the hijack so /machine/* logs the
// client IP instead of the reverse proxy's loopback peer.
r.Use(overrideRemoteAddr(req.RemoteAddr))
r.Use(middleware.RequestLogger(&zerologRequestLogger{}))
r.Use(middleware.Recoverer)
@@ -290,13 +295,27 @@ func rejectUnsupported(
return false
}
// overrideRemoteAddr returns middleware that pins r.RemoteAddr to addr.
// Used inside the Noise tunnel: the HTTP/2 server derives r.RemoteAddr
// from the hijacked TCP socket (the reverse proxy's loopback peer), so
// the outer request's resolved client IP must be carried across the
// hijack boundary by hand.
func overrideRemoteAddr(addr string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.RemoteAddr = addr
next.ServeHTTP(w, r)
})
}
}
func (ns *noiseServer) NotImplementedHandler(writer http.ResponseWriter, req *http.Request) {
log.Trace().Caller().Str("path", req.URL.String()).Msg("not implemented handler hit")
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.
// [tailcfg.PingRequest]. The client calls this endpoint to prove connectivity.
// The unguessable ping ID serves as authentication.
func (h *Headscale) PingResponseHandler(
writer http.ResponseWriter,
@@ -453,12 +472,12 @@ func (ns *noiseServer) SSHActionHandler(
}
// sshAction resolves the SSH action for the given request parameters.
// It returns the action to send to the client, or an HTTPError on failure.
// It returns the action to send to the client, or an [HTTPError] on failure.
//
// Three cases:
// 1. Initial request, auto-approved — source recently authenticated
// within the check period, accept immediately.
// 2. Initial request, needs auth — build a HoldAndDelegate URL and
// 2. Initial request, needs auth — build a [tailcfg.SSHAction.HoldAndDelegate] URL and
// wait for the user to authenticate.
// 3. Follow-up request — an auth_id is present, wait for the auth
// verdict and accept or reject.
@@ -510,7 +529,7 @@ func (ns *noiseServer) sshAction(
}
// sshActionHoldAndDelegate creates a new auth session bound to the
// (src, dst) pair and returns a HoldAndDelegate action that directs the
// (src, dst) pair and returns a [tailcfg.SSHAction.HoldAndDelegate] action that directs the
// client to authenticate.
func (ns *noiseServer) sshActionHoldAndDelegate(
reqLog zerolog.Logger,
@@ -632,8 +651,8 @@ func (ns *noiseServer) sshActionFollowUp(
case <-ctx.Done():
// The client disconnected (or its request timed out) before the
// auth session resolved. Return an error so the parked goroutine
// is freed; without this select sshActionFollowUp would block
// until the cache eviction callback signalled FinishAuth, which
// is freed; without this select [noiseServer.sshActionFollowUp] would block
// until the cache eviction callback signalled [types.AuthRequest.FinishAuth], which
// could be up to register_cache_expiration (15 minutes).
return nil, NewHTTPError(
http.StatusUnauthorized,
@@ -670,8 +689,8 @@ func (ns *noiseServer) sshActionFollowUp(
// This is the busiest endpoint, as it keeps the HTTP long poll that updates
// the clients when something in the network changes.
//
// The clients POST stuff like HostInfo and their Endpoints here, but
// only after their first request (marked with the ReadOnly field).
// The clients POST stuff like [tailcfg.Hostinfo] and their Endpoints here, but
// only after their first request (marked with the [tailcfg.MapRequest.ReadOnly] field).
//
// At this moment the updates are sent in a quite horrendous way, but they kinda work.
func (ns *noiseServer) PollNetMapHandler(
+39 -11
View File
@@ -21,8 +21,8 @@ import (
// newNoiseRouterWithBodyLimit builds a chi router with the same body-limit
// middleware used in the real Noise router but wired to a test handler that
// captures the io.ReadAll result. This lets us verify the limit without
// needing a full Headscale instance.
// captures the [io.ReadAll] result. This lets us verify the limit without
// needing a full [Headscale] instance.
func newNoiseRouterWithBodyLimit(readBody *[]byte, readErr *error) http.Handler {
r := chi.NewRouter()
r.Use(func(next http.Handler) http.Handler {
@@ -159,7 +159,7 @@ func TestNoiseBodyLimit_AtExactLimit(t *testing.T) {
}
// TestPollNetMapHandler_OversizedBody calls the real handler with a
// MaxBytesReader-wrapped body to verify it fails gracefully (json decode
// [http.MaxBytesReader]-wrapped body to verify it fails gracefully (json decode
// error on truncated data) rather than consuming unbounded memory.
func TestPollNetMapHandler_OversizedBody(t *testing.T) {
t.Parallel()
@@ -173,12 +173,12 @@ func TestPollNetMapHandler_OversizedBody(t *testing.T) {
ns.PollNetMapHandler(rec, req)
// Body is truncated → json.Decode fails → httpError returns 500.
// Body is truncated → [json.Decoder.Decode] fails → [httpError] returns 500.
assert.Equal(t, http.StatusInternalServerError, rec.Code)
}
// TestRegistrationHandler_OversizedBody calls the real handler with a
// MaxBytesReader-wrapped body to verify it returns an error response
// [http.MaxBytesReader]-wrapped body to verify it returns an error response
// rather than consuming unbounded memory.
func TestRegistrationHandler_OversizedBody(t *testing.T) {
t.Parallel()
@@ -192,8 +192,8 @@ func TestRegistrationHandler_OversizedBody(t *testing.T) {
ns.RegistrationHandler(rec, req)
// json.Decode returns MaxBytesError → regErr wraps it → handler writes
// a RegisterResponse with the error and then rejectUnsupported kicks in
// [json.Decoder.Decode] returns [http.MaxBytesError][regErr] wraps it → handler writes
// a [tailcfg.RegisterResponse] with the error and then [rejectUnsupported] kicks in
// for version 0 → returns 400.
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
@@ -236,7 +236,7 @@ func TestSSHActionRoute_OldPathReturns404(t *testing.T) {
}
// newSSHActionRequest builds an httptest request with the chi URL params
// SSHActionHandler reads (src_node_id and dst_node_id), so the handler
// [noiseServer.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()
@@ -253,8 +253,8 @@ func newSSHActionRequest(t *testing.T, src, dst types.NodeID) *http.Request {
}
// putTestNodeInStore creates a node via the database test helper and
// also stages it into the in-memory NodeStore so handlers that read
// NodeStore-backed APIs (e.g. State.GetNodeByID) can see it.
// also stages it into the in-memory [state.NodeStore] so handlers that read
// [state.NodeStore]-backed APIs (e.g. [state.State.GetNodeByID]) can see it.
func putTestNodeInStore(t *testing.T, app *Headscale, user *types.User, hostname string) *types.Node {
t.Helper()
@@ -276,7 +276,7 @@ func TestSSHActionHandler_RejectsRogueMachineKey(t *testing.T) {
src := putTestNodeInStore(t, app, user, "src-node")
dst := putTestNodeInStore(t, app, user, "dst-node")
// noiseServer carries the wrong machine key — a fresh throwaway key,
// [noiseServer] carries the wrong machine key — a fresh throwaway key,
// not dst.MachineKey.
rogue := key.NewMachine().Public()
require.NotEqual(t, dst.MachineKey, rogue, "test sanity: rogue key must differ from dst")
@@ -368,3 +368,31 @@ func TestSSHActionFollowUp_RejectsBindingMismatch(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, rec.Code,
"binding mismatch must be rejected with 401")
}
// TestOverrideRemoteAddr asserts the middleware used inside the Noise
// tunnel pins r.RemoteAddr to the value captured from the outer
// (pre-hijack) request, so /machine/* requests log the trusted-proxy
// resolved client IP instead of the hijacked TCP socket's loopback peer.
func TestOverrideRemoteAddr(t *testing.T) {
t.Parallel()
const clientAddr = "192.168.91.240"
r := chi.NewRouter()
r.Use(overrideRemoteAddr(clientAddr))
var observed string
r.Get("/x", func(w http.ResponseWriter, r *http.Request) {
observed = r.RemoteAddr
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/x", nil)
req.RemoteAddr = "127.0.0.1:44388"
r.ServeHTTP(httptest.NewRecorder(), req)
assert.Equal(t, clientAddr, observed)
}
+21 -18
View File
@@ -27,15 +27,15 @@ const (
defaultOAuthOptionsCount = 3
authCacheExpiration = time.Minute * 15
// authCacheMaxEntries bounds the OIDC state→AuthInfo cache to prevent
// authCacheMaxEntries bounds the OIDC state→[AuthInfo] cache to prevent
// unauthenticated cache-fill DoS via repeated /register/{auth_id} or
// /auth/{auth_id} GETs that mint OIDC state cookies.
authCacheMaxEntries = 1024
// cookieNamePrefixLen is the number of leading characters from a
// state/nonce value that getCookieName splices into the cookie name.
// state/nonce value that [getCookieName] splices into the cookie name.
// State and nonce values that are shorter than this are rejected at
// the callback boundary so getCookieName cannot panic on a slice
// the callback boundary so [getCookieName] cannot panic on a slice
// out-of-range.
cookieNamePrefixLen = 6
)
@@ -69,7 +69,7 @@ type AuthProviderOIDC struct {
cfg *types.OIDCConfig
// authCache holds auth information between the auth and the callback
// steps. It is a bounded LRU keyed by OIDC state, evicting oldest
// steps. It is a bounded [expirable.LRU] keyed by OIDC state, evicting oldest
// entries to keep the cache footprint constant under attack.
authCache *expirable.LRU[string, AuthInfo]
@@ -286,9 +286,9 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
util.LogErr(err, "could not get userinfo; only using claims from id token")
}
// The oidc.UserInfo type only decodes some fields (Subject, Profile, Email, EmailVerified).
// The [oidc.UserInfo] type only decodes some fields (Subject, Profile, Email, EmailVerified).
// We are interested in other fields too (e.g. groups are required for allowedGroups) so we
// decode into our own OIDCUserInfo type using the underlying claims struct.
// decode into our own [types.OIDCUserInfo] type using the underlying claims struct.
var userinfo2 types.OIDCUserInfo
if userinfo != nil && userinfo.Claims(&userinfo2) == nil && userinfo2.Sub == claims.Sub {
// Update the user with the userinfo claims (with id token claims as fallback).
@@ -444,10 +444,10 @@ func extractCodeAndStateParamFromRequest(
return "", "", NewHTTPError(http.StatusBadRequest, "missing code or state parameter", errEmptyOIDCCallbackParams)
}
// Reject states that are too short for getCookieName to splice
// Reject states that are too short for [getCookieName] to splice
// into a cookie name. Without this guard a request with
// ?state=abc panics on the slice out-of-range and is recovered by
// chi's middleware.Recoverer, amplifying small-DoS log noise.
// chi's [middleware.Recoverer], amplifying small-DoS log noise.
if len(state) < cookieNamePrefixLen {
return "", "", NewHTTPError(http.StatusBadRequest, "invalid state parameter", errOIDCStateTooShort)
}
@@ -552,15 +552,15 @@ func validateOIDCAllowedUsers(
//
// The following tests are always applied:
//
// - validateOIDCAllowedGroups
// - [validateOIDCAllowedGroups]
//
// The following tests are applied if cfg.EmailVerifiedRequired=false
// or claims.email_verified=true:
//
// - validateOIDCAllowedDomains
// - validateOIDCAllowedUsers
// - [validateOIDCAllowedDomains]
// - [validateOIDCAllowedUsers]
//
// NOTE that, contrary to the function name, validateOIDCAllowedUsers
// NOTE that, contrary to the function name, [validateOIDCAllowedUsers]
// only checks the email address -- not the username.
func doOIDCAuthorization(
cfg *types.OIDCConfig,
@@ -658,7 +658,7 @@ func (a *AuthProviderOIDC) createOrUpdateUserFromClaim(
const registerConfirmCSRFCookie = "headscale_register_confirm"
// renderRegistrationConfirmInterstitial captures the resolved OIDC
// identity and node expiry into the cached AuthRequest, sets the CSRF
// identity and node expiry into the cached [types.AuthRequest], sets the CSRF
// cookie, and renders the confirmation page that the user must
// explicitly submit before the registration is finalised.
func (a *AuthProviderOIDC) renderRegistrationConfirmInterstitial(
@@ -698,6 +698,7 @@ func (a *AuthProviderOIDC) renderRegistrationConfirmInterstitial(
CSRF: csrf,
})
//nolint:gosec // G124: Secure set conditionally via req.TLS; HttpOnly + SameSite already set
http.SetCookie(writer, &http.Cookie{
Name: registerConfirmCSRFCookie,
Value: csrf,
@@ -733,7 +734,7 @@ func (a *AuthProviderOIDC) renderRegistrationConfirmInterstitial(
// RegisterConfirmHandler is the POST endpoint behind the OIDC
// registration confirmation interstitial. It validates the CSRF cookie
// against the form-submitted token, finalises the registration via
// handleRegistration, and renders the success page.
// [AuthProviderOIDC.handleRegistration], and renders the success page.
func (a *AuthProviderOIDC) RegisterConfirmHandler(
writer http.ResponseWriter,
req *http.Request,
@@ -823,6 +824,7 @@ func (a *AuthProviderOIDC) RegisterConfirmHandler(
}
// Clear the CSRF cookie now that the registration is final.
//nolint:gosec // G124: Secure set conditionally via req.TLS; HttpOnly + SameSite already set
http.SetCookie(writer, &http.Cookie{
Name: registerConfirmCSRFCookie,
Value: "",
@@ -838,7 +840,7 @@ func (a *AuthProviderOIDC) RegisterConfirmHandler(
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
writer.WriteHeader(http.StatusOK)
// renderRegistrationSuccessTemplate's output only embeds
// [renderRegistrationSuccessTemplate]'s output only embeds
// HTML-escaped values from a server-side template, so the gosec
// XSS warning is a false positive here.
if _, err := writer.Write(content.Bytes()); err != nil { //nolint:noinlineerr,gosec
@@ -918,9 +920,9 @@ func renderAuthSuccessTemplate(
}
// getCookieName generates a unique cookie name based on a cookie value.
// Callers must ensure value has at least cookieNamePrefixLen bytes;
// extractCodeAndStateParamFromRequest enforces this for the state
// parameter, and setCSRFCookie always supplies a 64-byte random value.
// Callers must ensure value has at least [cookieNamePrefixLen] bytes;
// [extractCodeAndStateParamFromRequest] enforces this for the state
// parameter, and [setCSRFCookie] always supplies a 64-byte random value.
func getCookieName(baseName, value string) string {
return fmt.Sprintf("%s_%s", baseName, value[:cookieNamePrefixLen])
}
@@ -931,6 +933,7 @@ func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string) (string,
return val, err
}
//nolint:gosec // G124: Secure set conditionally via r.TLS; HttpOnly + SameSite already set
c := &http.Cookie{
Path: "/oidc/callback",
Name: getCookieName(name, val),
+2 -1
View File
@@ -24,6 +24,7 @@ func newConfirmRequest(t *testing.T, authID types.AuthID, formCSRF, cookieCSRF s
form,
)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
//nolint:gosec // G124: test fixture
req.AddCookie(&http.Cookie{
Name: registerConfirmCSRFCookie,
Value: cookieCSRF,
@@ -67,7 +68,7 @@ func TestRegisterConfirmHandler_RejectsCSRFMismatch(t *testing.T) {
"CSRF cookie/form mismatch must be rejected with 403")
// And the registration must still be pending — the rejected POST
// must not have called handleRegistration.
// must not have called [AuthProviderOIDC.handleRegistration].
cached, ok := app.state.GetAuthCacheEntry(authID)
require.True(t, ok, "rejected POST must not evict the cached registration")
require.NotNil(t, cached.PendingConfirmation(),
+36 -9
View File
@@ -44,20 +44,47 @@ func MatchesFromFilterRules(rules []tailcfg.FilterRule) []Match {
return matches
}
// MatchFromFilterRule derives a [Match] from a [tailcfg.FilterRule]. The
// destination IP set is the union of [tailcfg.FilterRule.DstPorts][].IP
// and [tailcfg.FilterRule.CapGrant][].Dsts: cap-grant-only rules (e.g.
// tailscale.com/cap/relay) carry their destinations in CapGrant.Dsts and
// would otherwise contribute nothing to peer-visibility derivation in
// [policy.BuildPeerMap] / [policy.ReduceNodes], hiding the cap target
// from the source unless a companion IP-level rule also exists.
func MatchFromFilterRule(rule tailcfg.FilterRule) Match {
dests := make([]string, 0, len(rule.DstPorts))
for _, dest := range rule.DstPorts {
dests = append(dests, dest.IP)
srcs := new(netipx.IPSetBuilder)
dests := new(netipx.IPSetBuilder)
for _, srcIP := range rule.SrcIPs {
set, _ := util.ParseIPSet(srcIP, nil)
srcs.AddSet(set)
}
return MatchFromStrings(rule.SrcIPs, dests)
for _, dp := range rule.DstPorts {
set, _ := util.ParseIPSet(dp.IP, nil)
dests.AddSet(set)
}
for _, cg := range rule.CapGrant {
for _, pref := range cg.Dsts {
dests.AddPrefix(pref)
}
}
srcsSet, _ := srcs.IPSet()
destsSet, _ := dests.IPSet()
return Match{
srcs: srcsSet,
dests: destsSet,
}
}
// MatchFromStrings builds a Match from raw source and destination
// 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
// resulting [Match] is narrower than the input described, but never
// wider. Callers that need strict validation should pre-validate
// their inputs via util.ParseIPSet.
// their inputs via [util.ParseIPSet].
func MatchFromStrings(sources, destinations []string) Match {
srcs := new(netipx.IPSetBuilder)
dests := new(netipx.IPSetBuilder)
@@ -104,7 +131,7 @@ func (m *Match) DestsOverlapsPrefixes(prefixes ...netip.Prefix) bool {
// 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
// (0.0.0.0/0 or ::/0), or if dests is a superset of [util.TheInternet]. A
// single-family /0 counts because operators may write it directly and
// it still denotes the whole internet for that family.
func (m *Match) DestsIsTheInternet() bool {
@@ -113,7 +140,7 @@ func (m *Match) DestsIsTheInternet() bool {
return true
}
// Superset-of-TheInternet check handles merged filter rules
// Superset-of-[util.TheInternet] check handles merged filter rules
// where the internet prefixes are combined with other dests.
theInternet := util.TheInternet()
for _, prefix := range theInternet.Prefixes() {
+72
View File
@@ -180,6 +180,78 @@ func TestMatchFromFilterRule(t *testing.T) {
srcMatch: true,
dstMatch: false,
},
{
// Regression: cap-grant-only rules (e.g. cap/relay)
// carry their destinations in CapGrant.Dsts. The
// matcher must surface those for peer-visibility
// derivation. https://github.com/juanfont/headscale/issues/3256
name: "CapGrant Dsts populate destination set",
rule: tailcfg.FilterRule{
SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32"},
CapGrant: []tailcfg.CapGrant{
{
Dsts: []netip.Prefix{
netip.MustParsePrefix("100.64.0.3/32"),
},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityRelay: nil,
},
},
},
},
checkSrc: netip.MustParseAddr("100.64.0.1"),
checkDst: netip.MustParseAddr("100.64.0.3"),
srcMatch: true,
dstMatch: true,
},
{
// Companion cap-grant shape produced by
// companionCapGrantRules: SrcIPs are the original
// destinations, CapGrant.Dsts are the original sources.
name: "companion CapGrant Dsts populate destination set",
rule: tailcfg.FilterRule{
SrcIPs: []string{"100.64.0.3/32"},
CapGrant: []tailcfg.CapGrant{
{
Dsts: []netip.Prefix{
netip.MustParsePrefix("100.64.0.1/32"),
netip.MustParsePrefix("100.64.0.2/32"),
},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityRelayTarget: nil,
},
},
},
},
checkSrc: netip.MustParseAddr("100.64.0.3"),
checkDst: netip.MustParseAddr("100.64.0.2"),
srcMatch: true,
dstMatch: true,
},
{
// Mixed rule: DstPorts and CapGrant both contribute to dests.
name: "DstPorts and CapGrant Dsts both contribute",
rule: tailcfg.FilterRule{
SrcIPs: []string{"100.64.0.1/32"},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.0.0.0/8"},
},
CapGrant: []tailcfg.CapGrant{
{
Dsts: []netip.Prefix{
netip.MustParsePrefix("100.64.0.3/32"),
},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityRelay: nil,
},
},
},
},
checkSrc: netip.MustParseAddr("100.64.0.1"),
checkDst: netip.MustParseAddr("100.64.0.3"),
srcMatch: true,
dstMatch: true,
},
}
for _, tt := range tests {
+25 -3
View File
@@ -42,11 +42,33 @@ type PolicyManager interface {
// both fields are empty and the caller falls back to existing behavior.
ViaRoutesForPeer(viewer, peer types.NodeView) types.ViaRouteResult
// NodeCapMap returns the policy-derived CapMap for the given node,
// or nil when no nodeAttrs entry targets it. The returned map is
// owned by the manager; treat it as read-only and copy before
// merging into a [tailcfg.Node]. It describes the node's own
// capabilities, not a per-viewer view.
NodeCapMap(id types.NodeID) tailcfg.NodeCapMap
// NodeCapMaps returns a snapshot of the per-node policy CapMap so
// callers can amortise lock acquisitions over a peer loop. The
// outer map is a fresh container; the inner [tailcfg.NodeCapMap]
// values are shared with the manager and read-only.
NodeCapMaps() map[types.NodeID]tailcfg.NodeCapMap
// NodesWithChangedCapMap returns the IDs of nodes whose nodeAttrs
// CapMap shifted during recent updateLocked calls. The buffer
// drains on read; callers consume it once per update cycle to
// decide which nodes need a self-targeted MapResponse.
// refreshNodeAttrsLocked appends to the buffer rather than
// overwriting, so a SetUsers/SetNodes between SetPolicy and the
// drain cannot lose the policy-reload diff.
NodesWithChangedCapMap() []types.NodeID
Version() int
DebugString() string
}
// NewPolicyManager returns a new policy manager.
// NewPolicyManager returns a new [PolicyManager].
func NewPolicyManager(pol []byte, users []types.User, nodes views.Slice[types.NodeView]) (PolicyManager, error) {
var (
polMan PolicyManager
@@ -61,8 +83,8 @@ func NewPolicyManager(pol []byte, users []types.User, nodes views.Slice[types.No
return polMan, err
}
// PolicyManagersForTest returns all available PostureManagers to be used
// in tests to validate them in tests that try to determine that they
// PolicyManagersForTest returns all available [PolicyManager] implementations to
// be used in tests to validate them in tests that try to determine that they
// behave the same.
func PolicyManagersForTest(pol []byte, users []types.User, nodes views.Slice[types.NodeView]) ([]PolicyManager, error) {
var polMans []PolicyManager
+7 -4
View File
@@ -51,6 +51,11 @@ func ReduceRoutes(
}
// BuildPeerMap builds a map of all peers that can be accessed by each node.
//
// Compared to [ReduceNodes], which builds the list per node, we end up with
// doing the full work for every node (O(n^2)), while this will reduce the
// list as we see relationships while building the map, making it O(n^2/2)
// in the end, but with less work per node.
func BuildPeerMap(
nodes views.Slice[types.NodeView],
matchers []matcher.Match,
@@ -58,9 +63,6 @@ func BuildPeerMap(
ret := make(map[types.NodeID][]types.NodeView, nodes.Len())
// Build the map of all peers according to the matchers.
// Compared to ReduceNodes, which builds the list per node, we end up with doing
// the full work for every node (On^2), while this will reduce the list as we see
// relationships while building the map, making it O(n^2/2) in the end, but with less work per node.
for i := range nodes.Len() {
for j := i + 1; j < nodes.Len(); j++ {
if nodes.At(i).ID() == nodes.At(j).ID() {
@@ -78,7 +80,8 @@ func BuildPeerMap(
}
// ApproveRoutesWithPolicy checks if the node can approve the announced routes
// and returns the new list of approved routes.
// and returns the new list of approved routes. The [PolicyManager] is consulted
// via [PolicyManager.NodeCanApproveRoute].
// The approved routes will include:
// 1. ALL previously approved routes (regardless of whether they're still advertised)
// 2. New routes from announcedRoutes that can be auto-approved by policy
+25 -5
View File
@@ -1317,7 +1317,7 @@ func TestSSHPolicyRules(t *testing.T) {
]
}`,
expectErr: true,
errorMessage: `invalid SSH action: "invalid", must be one of: accept, check`,
errorMessage: `"invalid" is not a valid action`,
},
{
name: "invalid-check-period",
@@ -1341,10 +1341,15 @@ func TestSSHPolicyRules(t *testing.T) {
]
}`,
expectErr: true,
errorMessage: "not a valid duration string",
errorMessage: `time: invalid duration "invalid"`,
},
// `autogroup:invalid` as an SSH user is no longer rejected:
// SaaS treats every `autogroup:*` user-string as a literal
// label and compiles it into the SSHUsers map. The compat
// suite covers this via ssh-malformed-user-autogroup-* — no
// dedicated case is needed here.
{
name: "unsupported-autogroup",
name: "ssh-user-unknown-autogroup-as-literal",
targetNode: taggedClient,
peers: types.Nodes{&nodeUser2},
policy: `{
@@ -1363,8 +1368,23 @@ func TestSSHPolicyRules(t *testing.T) {
}
]
}`,
expectErr: true,
errorMessage: "autogroup not supported for SSH user",
wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
{
Principals: []*tailcfg.SSHPrincipal{
{NodeIP: "100.64.0.2"},
},
SSHUsers: map[string]string{
"autogroup:invalid": "autogroup:invalid",
"root": "",
},
Action: &tailcfg.SSHAction{
Accept: true,
AllowAgentForwarding: true,
AllowLocalPortForwarding: true,
AllowRemotePortForwarding: true,
},
},
}},
},
{
name: "autogroup-nonroot-should-use-wildcard-with-root-excluded",

Some files were not shown because too many files have changed in this diff Show More