One verdict per session, so one waiter: the newest. A displaced one
returns at once (register error, SSH re-delegate), not at session end.
A waiter keeps only the follow-up URL and the cached registration.
Fixes ANT-2026-NM50ZP4N
The rest arrives with the first MapRequest. A relogin keeps the node's
current Hostinfo, endpoints and disco key; an oversized merge is a 400.
Fixes ANT-2026-RQS7JAPP
Keep the first 256 endpoints and 4096 routes; refuse longer hostnames,
too many tags, zoned endpoints and >64 KiB NetInfo or other Hostinfo.
The body limit counted wire bytes, not what NodeStore and peers keep.
Fixes ANT-2026-RQS7JAPP
Fixes ANT-2026-5B6KE87K
Fixes ANT-2026-KFCFFM34
handleRegister, the OIDC auth path and SetNodeExpiry refuse it. Expiries
are stored in UTC, which SQLite can read back, and loaded in UTC, as
PostgreSQL returns the server's zone. The v1 API answers 400.
Fixes ANT-2026-QMCAFJ1Q
json.Marshal refuses a year outside [0,9999], so one stored expiry
failed every map with that node. TailNode and key patches send UTC, the
builder drops what fails and the boot scan names the node.
Fixes ANT-2026-QMCAFJ1Q
Only a key that speaks for a tagged node re-authenticates it: a tagged
key, or one of the user it was tagged from. Another user's key or login
adds that user's own device, or only wakes the node under its node key.
Fixes ANT-2026-AWGRB7O1
Such a route names a node, not a subnet: it stays approved and listed
but no viewer gets it. Via inclusions skip the election, so the rule
sits on everything RoutesForPeer returns.
Fixes ANT-2026-MLP8ZR5T
Optional Steps, NodeIDs, error data and login state; v1 files unchanged.
Register log and raw map frame parsers; Write refuses auth keys and
login URLs.
* 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>
Adds DNS-01 for headscale's own certificate through dns.challenge.provider,
with the same propagation check as node certificates. Storage format
changes: first start gets a new account and certificate.
Updates #2527
Updates #504
tls.cert_path, tls.key_path and tls.acme.* replace the flat keys, which
still work. Warn on them only when they turn TLS on: NixOS always sets them.
Updates #2527
_acme-challenge is CNAMEd once to an acme-dns registration, so headscale
needs no DNS credentials. Node certificates need one registration per
name; CNAME chains are followed and writes never evict a live value.
Updates #2137
A node may publish only its own _acme-challenge TXT value; any other
request is rejected before the provider is called. Answers once all
authoritative nameservers serve the value; 503 while shutting down.
Fixes#2137Fixes#1921Fixes#2527
Both gate tailscale cert and serve HTTPS on the client; httpsEnabled
follows, as in Tailscale. No certificate when an operator extra record
sits at or below the name: a wildcard would cover it.
Updates #2137
Updates #1921
dns.challenge.provider builds a libdns provider at load, so configtest
catches typos. dns.certificates gates node certs; zone defaults to base_domain.
Updates #2137
The ping ID in the URL is the only authentication on the answer, so
rescuing an untargeted ping for every node would let any node answer
for another. Rescue a ping only for the node it targets.
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.
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.
Sub-builders called after validateServerConfig (prefixV4, prefixV6,
allocation strategy, dns, oidc client secret/path, isSafeServerURL)
each returned the first error. So an operator that fixed one
issue saw the next one only on the next startup.
Lift one *configValidator across the entire LoadServerConfig flow.
validateServerConfigInto(v) populates it; each sub-builder's error
is wrapped in a structured *ConfigError (with the original sentinel
kept on the Cause field, so errors.Is against errOidcMutuallyExclusive,
errServerURLSame/Suffix, ErrNoPrefixConfigured, and
ErrInvalidAllocationStrategy keeps working). v.Err() is checked once,
right before the &Config{} construction.
TestReadConfig/base-domain-in-server-url-err matched the old sentinel
wording; flipped to the new structured Reason. Added
TestLoadServerConfig_CollectsAcrossSubBuilders to lock the wiring:
four sub-builder failures from a single config produce four
*ConfigErrors in the report.
Updates #3227
deprecator.Log() called log.Fatal directly, so a single deprecated
key killed the process before any other validation rule could
report. Operators fixing one issue at a time.
Replace Log() with Apply(*configValidator). Warns still go to
log.Warn; fatals are pushed onto the validator as *ConfigError so
they merge with the rest of the config-load report. The free-form
strings collected via set.Set are gone; deprecator now stores
typed deprecation{OldKey, NewKey} records and Apply renders them
into the structured ConfigError shape.
Move the early-return PKCE check into a new validatePKCEConfig
modular validator for the same reason.
Updates #3227
A *ListenerBindError that wraps syscall.EADDRINUSE now ends with a
"sudo ss -tlnp 'sport = :PORT'" pointer, and one wrapping
syscall.EACCES with a CAP_NET_BIND_SERVICE / setcap pointer. The
underlying chain is preserved via fmt.Errorf("%w"), so errors.Is /
errors.As continue to walk to the typed bind error and the syscall
errno.
Drop the "headscale ran into an error and had to shut down" wrap,
which only restated the symptom.
Export types.PortFromAddr so the classifier can render the port
number in the hint.
Updates #3227
ListenerBindError wraps a TCP listener bind failure with the listener
name and the YAML key that drove its address. Preserves the underlying
*net.OpError via Unwrap so errors.Is(err, syscall.EADDRINUSE) keeps
working through any number of fmt.Errorf("%w") wraps. Lets the
top-level CLI classifier match listener failures with errors.As
instead of string matching.
Updates #3227
derpConfig, databaseConfig, and dnsToTailcfgDNS used log.Fatal to
reject invalid combinations the moment they were observed. Lift those
checks into modular validators (validateDERPConfig,
validateDatabaseConfig, validateMagicDNSConfig) called from
validateServerConfig so each violation lands in the same configValidator
collector and renders as a structured ConfigError next to all other
config feedback. The sub-builder functions trust validation has run
and no longer crash the process.
Updates #3227
Convert the errorText accumulator inside validateServerConfig to the
typed configValidator pattern. Each rule violation now renders as a
multi-line block naming the YAML key, the value the operator wrote,
and a hint pointing at the resolution. The dns.extra_records mutex
that previously crashed via log.Fatal is folded into the same
collector so an operator sees every problem in one go instead of
fixing them one startup attempt at a time.
Test wantErr assertions on validation output switch to substring
match because the rendered errors are now multi-line.
Updates #3227
Reject configurations where two configured TCP listeners would bind
the same kernel socket. Covers every pair of listen_addr,
grpc_listen_addr, metrics_listen_addr, and tls_letsencrypt_listen
(when HTTP-01 ACME is in use). Each violation renders as a structured
ConfigError naming both YAML keys, the values the operator wrote, and
a hint pointing at the canonical setup.
Closes the symptom in #3227: the misconfiguration is now rejected at
config-load time with a self-explaining error, instead of failing at
runtime with a misleading "address already in use" log because the
ACME challenge listener and the main HTTP listener competed for the
same port inside the same process.
Fixes#3227
portFromAddr resolves numeric and named ports (":http", ":https")
without touching /etc/services or the resolver. listenersOverlap
follows kernel rules: same port plus a wildcard host on either side,
or same port plus identical specific host, both count as collision;
different specific hosts on the same port do not. Set an explicit
viper default for tls_letsencrypt_listen so a minimal config still
resolves to ":http".
Updates #3227
Foundation for restructuring config validation feedback. Typed error
with errors.Is/As/Join hooks, structured fields rendered as a
multi-line operator-facing block (current, conflicts with, allowed,
minimum, maximum, why, hint, see), and a configValidator that joins
violations via errors.Join. ConfigErrors walks the tree to collect
every *ConfigError, and Cause keeps existing sentinel identities
reachable through errors.Is.
Updates #3227
Online now requires a live session and an unexpired key, derived in one
place by Node.ShouldBeOnline so every writer agrees what online means.
Fixes#3470