Commit Graph
227 Commits
Author SHA1 Message Date
Kristoffer Dalby 7d8d875c80 db, types: add a credentials column for keys a token made
No released version made keys from tokens, so existing rows stay false.
2026-11-02 17:46:54 +00:00
Kristoffer Dalby f838b0ab2d api/v2: show userId on admin-made tagged keys
An admin key's user is recorded as the creator of the tagged keys it
makes. A tagged key re-registers a machine by its tags, whoever made it.
2026-11-02 17:46:54 +00:00
Kristoffer Dalby da693c4b43 db: keep at most 1000 access tokens per OAuth client
Minting needs only the client secret, whatever its scopes, and
added one row per mint. Each mint now ends tokens past the newest 1000.
2026-11-02 17:46:54 +00:00
Kristoffer Dalby c23a6efa73 db: succeed on a repeated revoke of an auth key
A repeated v2 DELETE of an auth key now answers 200, as Tailscale does.
2026-11-02 17:46:53 +00:00
Kristoffer Dalby bccfb46ab1 db, state: keep the ephemeral flag a node was created with
The flag moves from the key to the node, so a login with another key no
longer changes it. A user claiming a tagged node still clears it. v1
nodes carry the flag.

Fixes #3640
2026-11-02 17:46:53 +00:00
Kristoffer Dalby 5827e4b73c db, types: add a nodes column for the ephemeral flag
The migration fills it from the key each node references.
2026-11-02 17:46:53 +00:00
Kristoffer Dalby 95efb8cef1 state: assign and persist unique node names in the NodeStore
Reserve extra DNS names, fit collision suffixes within 63 bytes, and repair
duplicate startup names in memory. Persist registration names without
losing a created node on rename failure; remove unused DB renaming.
2026-11-02 17:46:53 +00:00
Kristoffer Dalby 77874782ca db, api/v1: revoke rather than delete an ephemeral pre-auth key in use
The v1 list now hides revoked keys, so keys revoked through v2 also drop
out of `headscale preauthkeys list`.
2026-11-02 17:46:53 +00:00
Kristoffer Dalby 8d3f800725 db: wrap the sequential IP allocator so freed addresses are reused 2026-11-02 17:46:53 +00:00
Kristoffer Dalby 644c88b1e6 db, types: add a column for auto-approved routes
It starts empty: the current policy cannot tell which earlier approvals
were automatic, so all of them stay as an admin's.
2026-11-02 17:46:53 +00:00
Kristoffer Dalby 789d70f672 db: bound legacy hash verification and reload verified credentials
Limit the wait for a verification slot and reload the credential after
verification so concurrent revocation or deletion remains effective.
2026-11-02 17:46:51 +00:00
Kristoffer Dalby 05ca808491 db: disable GORM's prepared statement cache on SQLite
database.gorm.prepare_stmt is now ignored, with a startup warning.
2026-11-02 17:46:51 +00:00
Kristoffer Dalby d326143556 state, db: validate and store the current policy before installing it
Reject empty input. Validate without changing live state, then store and
install under one lock; prune old rows only with a successful write.
2026-11-02 17:46:50 +00:00
Igor SerganovandClaude Opus 5.5 1ff8e368b6 hscontrol: arm ephemeral GC when a node registers with an auth key
The ephemeral garbage collector was only armed when a long-poll
session ended, or for every ephemeral node at startup. A client that
registered and went away before its first long-poll reached Connect,
for example a pod killed right after login, never ended a session,
so no deletion timer was armed. The node then stayed until headscale
restarted, well past node.ephemeral.inactivity_timeout.

Schedule the GC right after the auth-key registration, before the
error check: a failed policy refresh still returns the saved node. The
existing Cancel in serveLongPoll stops the timer once the node
connects. A timer that fires while a session is live is still skipped
by the ActiveSessions check.

TestEphemeralGCDoesNotDeleteReconnectedNode now checks that
registration arms the timer and cancels it before replaying its
sessions. Otherwise the 50ms timer could fire while the test's Connect
was still waiting for a NodeStore flush.

TestFailedReconnectDoesNotCancelEphemeralGC and
TestDeleteNodeCancelsEphemeralGC ran with a zero inactivity timeout,
so the registration timer deleted the node at once, and the delete
cancelled the timer the tests armed themselves. They now use 5s, which
outlasts the tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 17:05:36 +01:00
Igor Serganov 704f1cafd3 db: release the IPv4 address when IPv6 allocation fails in Next
IPAllocator.Next took the allocator mutex once per address family.
When the IPv6 allocation failed, the IPv4 address had already been
added to usedIPs and prev4 had moved past it, so the address stayed
reserved until headscale restarted even though no node held it.

Allocate both families under a single critical section, and on an
IPv6 failure remove the IPv4 address from usedIPs and restore prev4.
2026-10-30 17:05:25 +01:00
Igor Serganov 2088f1d2a6 db: make the ephemeral GC safe to close twice
A second Close panicked on the closed cancel channel.
2026-10-30 17:05:14 +01:00
Kristoffer Dalby b5bebf9b32 db, state: let only the delete that removed a node free its addresses
Fixes ANT-2026-NWTIBB02
2026-10-29 14:00:02 +01:00
Michael LopezandClaude Fable 5.1 494e2a6808 db: quote postgres connection parameters (#3477)
* db: quote postgres connection parameters

The DSN was built by string concatenation, so a password containing a
space, quote or backslash truncated it or injected extra parameters.
Build it with libpq keyword/value quoting and keep the password out of
the startup log line.

* db: mark the DSN test fixture password as not a credential

gosec G101 flags the literal assigned to Pass in TestBuildPostgresDSN.
The value exists to exercise quoting of spaces, quotes and backslashes
and is not a secret.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* types, db: build the postgres DSN as a URL from PostgresConfig

Review feedback: move the DSN construction onto types.PostgresConfig as
DSN(), so the database layer calls cfg.Postgres.DSN() and logs
redacted.DSN(), and build it as a postgres:// URL with net/url instead
of hand-rolled libpq quoting. Unix socket directories travel as the
host query parameter; bare IPv6 literals are bracketed. Tests parse the
URL back and check each component rather than the encoded string.

* types: silence gosec on the whole DSN test fixtures

gosec reports G101 on the composite literal that holds a Pass value, not
on the Pass line itself, so the previous nolint did not cover it. Place
the directive before each fixture element.

* db: drop imports left unused by the merge with main

The merge of main reintroduced the slices and strconv imports in db.go,
but the postgres DSN is now built by PostgresConfig.DSN and nothing in
the file uses them, so the package failed to compile and every CI check
failed on typecheck.

Also link the CHANGELOG entry to the PR.

* db: restore tests and formatting lost in the rebase onto main

Resolving the rebase conflicts in db_test.go kept this branch's side and
dropped the credential tests main had added, including the
requireCredentialConstraints helper another test still calls, so the db
test package no longer compiled. Restore the file from main; this branch
does not change it.

The resolution also indented the package clause in db.go and removed the
blank line between the standard library and module imports.

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 22:36:04 +02:00
Igor Serganov 8d65201a93 db: check errors in openDB
On the SQLite path openDB called db.DB() and configured the pool
before looking at the error from gorm.Open, and both backends
discarded the error from db.DB(). A failed open returned a
half-initialised *gorm.DB alongside the error, and a nil *sql.DB
from db.DB() would have been dereferenced.

Return early on either error so callers get a nil handle and the
original error.
2026-10-22 22:33:03 +02:00
Kristoffer Dalby 393dd3e2d9 db: store all credentials in one SHA-256-hashed table
API keys, pre-auth keys and OAuth clients/tokens share one table and verify
path. Secrets carry 256 bits of crypto/rand entropy, so a SHA-256 digest
needs no stretching; bcrypt/argon2id rows rehash on use until 0.32.
2026-09-26 00:33:12 +02:00
Kristoffer Dalby e90500e3a9 db: drop migrations predating 0.29
Only 0.29.x -> 0.30 upgrades are supported; checkMinimumMigration refuses
older databases instead of silently skipping the removed steps.
2026-09-26 00:33:12 +02:00
Michael Lopez 04d1e3c83f db: name the nodes that block a user deletion
The error only said the user still has nodes, which the CLI prompt did
not mention at all. Wrap ErrUserStillHasNodes with the ID and hostname
of every blocking node so the operator knows what to remove. Run the
DestroyUser test table on Postgres as well as SQLite, since the two
schemas define different foreign-key actions; the Postgres variant
skips without a local server.
2026-09-25 23:56:02 +02:00
MsfPablo d60bac5c79 db: reject unknown OAuth client scopes at creation (#3424)
Unknown scopes grant nothing, so clients were silently under-privileged.
Report every invalid scope at once.

Fixes #3406
2026-09-25 21:24:37 +02:00
Kristoffer Dalby 69e84c356d db: accept tskey-client- prefix for OAuth client auth
The upstream tailscale client only runs its OAuth client-credentials exchange
for secrets prefixed tskey-client-, so accept it as an alias for hskey-client-.
The prefix is only a label sliced off before lookup, so the same stored client
authenticates under either; lets the official client and GitHub Action mint auth
keys against headscale.
2026-09-25 17:09:19 +02:00
Igor Serganov 0661c6f540 db: return non-NotFound errors from user lookups (#3480) 2026-09-23 15:24:57 +02:00
Michael Lopez fdcdebb392 db: delete pre-auth key on its own savepoint 2026-09-23 15:24:22 +02:00
Kristoffer Dalby 2c76d5c5b7 all: apply golangci-lint autofixes
Go 1.27 allows setting promoted fields of embedded structs directly in
composite literals, so gorm.Model wrappers go away. Plus strings.Cut,
errors.AsType, reflect.TypeAssert and one gofumpt nit.
2026-08-25 21:59:00 +02:00
Igor SerganovandCursor cfd845cb53 poll: do not cancel ephemeral GC until Connect succeeds
With node.ephemeral.inactivity_timeout set, ephemeral nodes are
usually deleted after they go offline, but under reconnect churn some
departed nodes stayed in the node list as disconnected indefinitely
until removed manually or until Headscale restarted.

Ephemeral cleanup is timer-based via EphemeralGarbageCollector, not a
periodic LastSeen scan. serveLongPoll cancelled any pending GC timer
at the very start of a long-poll attempt and only rescheduled on a
clean disconnect after Connect. If a reconnect cancelled the timer and
then failed before Connect (for example an UpdateNodeFromMapRequest
error), the deferred cleanup saw connectGen == 0 and returned without
Schedule. The node remained offline with no deletion timer and no
reconciler to recover it.

Cancel the ephemeral GC timer only after a successful Connect, so a
failed reconnect leaves an already-armed inactivity timer intact.
Successful reconnects still cancel GC once the node is online, and a
later disconnect reschedules as before.

Add TestFailedReconnectDoesNotCancelEphemeralGC to lock in the
ordering, plus IsScheduled and DeleteNodeFromStoreForTest helpers for
the test.

Fixes #3382

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:28:00 +02:00
Kristoffer Dalby 1ed5693fa4 state: do not expire tagged nodes on logout
Tagged nodes never expire, but handleLogout stamped a past expiry on them,
leaving them stuck expired and unable to re-authenticate.

Fixes #3371
2026-07-28 11:55:17 +02:00
Kristoffer Dalby ee95cf58d9 hscontrol: add the OAuth client and access-token model
Add the OAuth client type, its database storage, the scope grant package,
policy tag-ownership exposure, and the state operations backing the v2
OAuth client-credentials flow.
2026-06-26 09:18:06 +02:00
Kristoffer Dalby 6b413fe237 api/v2: soft-revoke auth keys with a configurable collector
Tailscale's keys API has no separate expire verb: DELETE is the revoke.
Map it to a soft revoke so the key stays retrievable as invalid afterwards
instead of vanishing, matching the SDK and Terraform's expectations.

Add a revoked timestamp to pre-auth keys (migration plus schema), mark a
key invalid once revoked, and have the keys DELETE handler stamp it rather
than destroy the row. A background collector reaps revoked keys after a
configurable retention window (preauth_keys.revoked_retention, default
168h), so the table does not grow without bound.
2026-06-21 04:17:03 +02:00
Kristoffer Dalby 3d811f29a6 db: add API-key owner and pre-auth-key description for v2
Give API keys an optional owning user and pre-auth keys a free-text
description, plus the state accessors the v2 API needs to act as a
key's owner and to persist descriptions.
2026-06-21 04:17:03 +02:00
Kristoffer Dalby 560b6d81ad hscontrol: remove the proto, gRPC and grpc-gateway stack
With the v1 API served by Huma and the CLI on the HTTP client, nothing
uses the gRPC service or its generated code. Delete proto/, the generated
gen/go and gen/openapiv2, grpcv1.go, the buf config, the proto .Proto()
helpers and gRPC config, and the proto build tooling from the flake and
CI.
2026-06-19 15:21:00 +02:00
Kristoffer Dalby 97eff90ebe db: report a missing pre-auth key on expire and destroy
Expiring or deleting a pre-auth key that does not exist updated zero rows
and returned no error, so the caller could not tell a missing key from a
successful no-op. Return ErrPreAuthKeyNotFound when RowsAffected is zero.
2026-06-19 15:21:00 +02:00
Kristoffer Dalby 9f1886f587 db: preserve user_id on untagged nodes with tags='null'
A nil tags slice marshals to JSON `null`; the clear-tagged migration
read that as tagged and cleared user_id. Exclude it, and recover
already-detached nodes from their pre-auth key.

Fixes #3323
2026-06-18 14:54:44 +02:00
Kristoffer Dalby ea5165e325 util, db: generate key material as hex via tailscale rands 2026-06-17 16:12:19 +02:00
Kristoffer Dalby ac725f27e4 all: adopt slices helpers 2026-06-17 16:12:19 +02:00
Kristoffer Dalby 77f289a627 db: consolidate IP allocation helpers 2026-06-17 16:12:19 +02:00
Kristoffer Dalby 55b9e3cfda db, sqliteconfig: consolidate query and config helpers 2026-06-17 16:12:19 +02:00
Kristoffer Dalby f49f27adf8 db, sqliteconfig: remove dead and unreachable code 2026-06-17 16:12:19 +02:00
Kristoffer Dalby 68a6d3cf17 db: drop ambiguous machine-key getter, match precisely in test helper
The First()-by-machine-key getter returned an undefined node when a machine
key mapped to several nodes. It was used only by RegisterNodeForTest; match on
(machine_key, user_id) there instead and remove the getter.

Updates #3312
2026-06-15 20:29:15 +02:00
Kristoffer Dalby 9b8949727d db: treat unknown pre-auth key as not found
An unknown or deleted key returned a bare error matching neither the
not-found nor pre-auth-key checks, so registration returned a server error
instead of 401. Wrap gorm.ErrRecordNotFound.

Updates #3312
2026-06-15 20:29:15 +02:00
Kristoffer Dalby 7d845ef65e db: advance allocator cursor under lock during IP backfill
Reading prev4/prev6 unlocked raced Next; not advancing made backfill O(n^2).
2026-06-08 10:04:49 +02:00
Kristoffer Dalby 99ad555d64 db: handle degenerate prefixes in random IP allocation
Single-address prefixes panicked; zero-high-byte addresses failed to encode.
2026-06-08 10:04:49 +02:00
Kristoffer Dalby 10696fa634 db: look up API keys by explicit primary key, not struct condition
GetAPIKeyByID(0) returned the first key instead of not-found.
2026-06-08 10:04:49 +02:00
Kristoffer Dalby ec94573258 db: drop superseded ephemeral deletions in the GC drain loop
A node reconnecting after its deletion queued is no longer removed.
2026-06-08 10:04:49 +02:00
Kristoffer Dalby 4c165ae5e7 db: reap ephemeral GC watcher goroutine on cancel and reschedule
A stopped timer never fires, so the per-node watcher leaked until close.
2026-06-08 10:04:49 +02:00
Kristoffer Dalby f61753e737 db: bound IP allocation scan so exhausted prefixes error out
Random strategy previously spun forever under the allocator mutex.
2026-06-08 10:04:49 +02:00
Shourya GautamandClaude Opus 4.7 ea8fc72570 db: backfill zero-time node expiry to NULL
Versions before 0.28 persisted a pointer to a zero time.Time as
'0001-01-01 00:00:00+00:00' in nodes.expiry rather than NULL. 0.29
reports those rows as expired. #3197 and #3199 stopped new writes;
this normalises the existing rows so the column once again means
"no expiry" when unset.

Fixes: #3284

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:49:05 +02:00
Kristoffer Dalby 77ba225cdb db: treat Go module pseudo-versions as dev builds
Untagged main-sha builds inherit a Go module pseudo-version from
runtime/debug.BuildInfo (vX.Y.Z-<timestamp>-<hash>). isDev only
filtered "", "dev", and "(devel)", so the pseudo-version was stored
in database_versions and the next real release tripped the
multi-minor upgrade guard:

    headscale version v0.29.0-beta.1 cannot be used with a database
    last used by v0.0.0-20260520093041-e4e742c776ee, upgrading more
    than one minor version at a time is not supported

Add pseudoVersionTime, a regex + time.Parse predicate covering all
three Go pseudo-version forms (v0.0.0 base, pre-release ancestor,
release ancestor), and delegate from isDev. The dev gate at
db.go:790 already prevents pseudo-versions from being written, so
already-poisoned databases self-heal on the next real-release start.

Fixes #3281
2026-05-26 17:29:13 +02:00