The ownership check only ran when the account's own view of the cluster
came back empty, so an account holding any row for the host — its own
canonical proxy — skipped it entirely. A row another account left behind
under a non-canonical spelling was then never seen, and the pin the check
exists to refuse went through. That is the case worth catching, not the
one to skip: two accounts claiming one hostname is the ambiguity the
connect-time conflict check prevents going forward and cannot see for a
row written before addresses were canonicalized, and the endpoint pinned
here cannot be moved afterwards.
Ask ownership first, and narrow what counts as foreign while doing it.
The query treated a shared proxy as outside the account, which was
harmless while it ran only for a host the account had no view of — a
shared row would have given it one — but refuses the cluster most
accounts pin to once it runs first. Only a row owned by a different
account is foreign now, which is also what the name says.
Two follow-ups to canonicalizing the proxy address.
netip accepts a zoned literal where net.ParseIP did not, so "fe80::1%eth0"
started passing validation and would have been stored as a cluster key. An
address scoped to one host's interface cannot name a cluster others reach,
so zones are rejected, as before.
Making the ownership query exact also left the bootstrap check without a
fallback for rows written before canonicalization: a foreign cluster
stored as "BYOP.Account2.Example.com" no longer matches the normalized
address, reads as never declared, and the pin it should refuse goes
through. Split the two callers instead of choosing between them.
IsClusterAddressConflicting stays exact for the per-connect path that
needs the index; HasProxyOutsideAccountAtHost folds case for the
bootstrap, which runs once per account and is the only thing standing
between it and pinning its immutable endpoint to somebody else's cluster.
The settings delete guard already made that trade for the same reason.
Restores the foreign-casing case that went with the exact query, and adds
the zone cases to the ingress test.
The proxy-connect path already computes the canonical form of the address
a proxy declares — ValidateDomains returns lowercase punycode — and then
throws it away, storing the string as declared. cluster_address is the key
every capability, ownership and routing lookup matches on, so one host
could sit in that column under two spellings, and the previous commit
compensated with LOWER() in the ownership query, which gives up the
cluster_address index on a query that runs for every account-scoped proxy
connect.
Keep the canonical form instead. Connect is the only writer of the column
(heartbeats touch last_seen and status), and proxy rows are session state
rebuilt on every connect rather than durable config, so the column
converges without a migration and the lookups can stay exact and indexed.
Folding happens before punycode conversion, not after: idna lowercases the
ASCII it produces but does not case-fold the unicode it consumes, so
PRÖXY.example.com and pröxy.example.com would otherwise encode to two
different labels for one host.
The agent network check keeps comparing normalised forms in memory, which
costs nothing there — it is a pass over the account's cluster list, not a
query — and covers rows written before this landed.
Proxies store their cluster address as they declared it, while
proxy_address is normalised lowercase before validation. The capability
and ownership lookups match cluster_address exactly, so feeding them the
normalised form asked about a spelling the store may never have seen: a
private cluster declared with capitals came back unproven and was
refused, and — worse — another account's cluster declared with capitals
came back as "never declared" and let the pin through.
Compare identity on the normalised form but keep the stored spellings,
and read the capability under each of them, any-true, the same way it
aggregates over a cluster's proxies. Ownership gets the same treatment at
the source: hostnames are case-insensitive, so two spellings of one host
are one cluster and must conflict rather than being claimable side by
side, which also closes the same gap in the proxy-registration
availability check that shares the query.
The e2e's wait for a stopped cluster to leave the active list now allows
for the active window rather than 90s: a proxy that dies without closing
its stream is only dropped once its last heartbeat ages past
proxyActiveThreshold, so the old budget could fail the test on the slow
path alone.
A bad upstream or key saved cleanly and surfaced minutes later as a failed
request or an empty model picker, with nothing pointing back at the record.
CreateProvider now spends the credential once against the vendor's model
listing. UpdateProvider does the same when the upstream, the key, the catalog
provider or the skip-TLS flag changed — only then, so renames and price edits
neither wait on a vendor nor fail because one is down. Both run before the store
write, so a rejected rotation leaves the working key where it was.
What cannot be checked still saves: no listing endpoint, no derivable Bedrock
control-plane host, a private upstream, a record skipping TLS verification.
Everything else blocks, outages included — 5xx, 429 and timeouts leave the
record unverified just as a refusal does. Refusals return 422 and carry no
status code or echoed URL.
Discovery now reads as a partial edit, so a retyped URL can be listed against
without also rotating the credential. Entries with their own listing host
(Bedrock) get their configured upstream resolved separately, since a successful
listing said nothing about it.
The synthesised agent network gateway service is unconditionally private:
agents reach it over the WireGuard tunnel and are authorised by
ValidateTunnelPeer against the enabled policies' source groups, and its
only target is the cluster itself with DirectUpstream. Only a proxy
running embedded in a netbird client can serve that, which management
already reports per cluster as the `private` capability.
CreateSettings accepted any hostname as proxy_address, so a labeled
bootstrap could pin an account to a cluster that cannot serve its
gateway — another account's BYOP cluster, or one whose proxies are all
centralised. The endpoint assigned at bootstrap is immutable, so the
account is then stuck with a dead gateway until someone deletes and
re-bootstraps the settings row.
Validate the cluster before allocating an endpoint beneath it. Whether
management knows a cluster is decided on its proxy rows, never on how
fresh their heartbeats are: the rows outlive their proxies' liveness, so
a known cluster stays judged as one and has to prove with a live embedded
proxy that it can serve the gateway. Deciding on liveness instead would
let the same centralised cluster pass or fail depending on whether its
proxies had heartbeated in the last couple of minutes, turning "wait for
the proxy to go quiet" into a way to pin the endpoint to a cluster that
can never serve it. Ownership comes from the same time-independent
source, so a foreign cluster stays refused while it is offline.
Only a cluster no proxy has ever declared is still pinnable — that is the
address-first order the dedicated (self-addressed) path documents, and
the one self-hosted setups follow when they configure before deploying.
Store the per-account gateway endpoint as {domain, proxy_address} with a
global unique index on the full hostname; dedicated = (domain ==
proxy_address). Bootstrap becomes an explicit POST carrying exactly one
of proxy_address (server allocates an adjective-noun label beneath it)
or endpoint (claimed verbatim, address-first); provider create loses its
bootstrap side effect. PUT is a full replace with every field required —
the immutable identity fields must be echoed unchanged and a mismatch is
rejected with 422. A guarded DELETE releases the endpoint: refused with
412 while providers exist or a proxy is actively serving the endpoint
hostname (matched case-insensitively); re-creating bootstraps fresh. A
self-addressed pin excludes its address from the account's cluster allow
list, and the live mapping update path now addresses the serving proxy
from the synthesized service. Existing rows are migrated on all three
store engines.