Two gaps a review found in the live suite.
The rotation test finished by renaming the provider and expecting that to
succeed. A rename touches none of the fields the check looks at, so it is
stored without asking the vendor anything — it would have passed just as well
against a key the rotation had already replaced. It now moves the upstream by a
trailing slash, which reaches the same host but differs as a string, so the
check runs and the stored key is what has to satisfy it.
And the refused-url test only asserted the error. An error is not the same fact
as an absent record, so it now lists the providers and looks for the one that
must not be there — which the refused-key test beside it already did.
The discovery upstream override is logged. It sends the stored credential to a
host the caller named, which the same permission set can already do by pointing
the record there and letting the write check it — but that leaves an activity
event behind, and this would otherwise leave nothing.
The generated file carries the schema descriptions as doc comments, so editing
them leaves it behind and the release job's diff check fails. Comments only —
no field or type moved.
Two gaps a review surfaced.
Moving a record from one catalog provider to another changed neither field the
check looked at, so an unchanged credential started being offered to a
different vendor, under a different auth header, with nothing asking whether it
was accepted there.
And each host lookup built its own eight-second budget from a background
context, so a slow resolver could spend one before the request spent another,
and a caller that gave up was still waiting. Bedrock made that three: it now
checks its runtime host as well. One deadline is taken at the top of Fetch and
carried through both lookups and the request.
The create description also had the exemption backwards, reading as though an
upstream the check cannot reach is stored unverified. Unreachable blocks; only
what cannot be checked at all is exempt.
* Run lazy connection manager for rosenpass peers
* Treat forward-target peers as normal lazy connections
* Run Rosenpass in permissive mode on the embedded proxy
* [client] Add iOS NetBirdSDK profile manager binding
Mirror the Android profile manager in the iOS gomobile binding so the
core's ID-based profilemanager.ServiceManager owns profile state on iOS
too, instead of a parallel Swift reimplementation.
Adds client/ios/NetBirdSDK/profile_manager.go (//go:build ios): an
ID-based ProfileManager wrapping ServiceManager with iOS-specific path
handling (default profile at the container-root netbird.cfg, others as
profiles/<id>.json) and a gomobile-friendly API: List/Add/Switch/Rename/
Logout/Remove plus active config/state path accessors. The default
profile keeps the reserved "default" id and is never assigned a hex id.
* fix(ios): preserve profile name when saving config during auth
NewAuth built a fresh in-memory config from only the management URL, so
the SSO/setup-key save (DirectWriteOutConfig) overwrote the profile config
file the profile manager had just written, wiping the display name to ""
and forcing the UI to fall back to the profile ID. Load the existing config
when present and override only the management URL, keeping the name and keys.
* [client] Extract the mobile profile manager into client/mobile
The Android and iOS gomobile bindings carried two near-identical copies of
the profile manager. Move the shared implementation into a new client/mobile
package and reduce both bindings to thin adapters that only translate to
gomobile-friendly types (gomobile binds per package, so the Profile /
ProfileArray wrappers have to stay platform-side).
Also bring the account-email layer over to the shared package: an SSO login
records the account under <stem>.account.json so the next login can pass it
as an OIDC login_hint. Logout keeps it, profile removal drops it. The suffix
deliberately differs from .state.json, which the engine's state manager owns
in the same directory on mobile.
Adds profilemanager.Prefs (namespaced per-profile preference store) and its
cleanup in ServiceManager.RemoveProfile, exposed through the shared manager
as ProfilePrefs.
* Support per-peer lazy connection state and default proxy peers to lazy
* Classify forward targets from incoming config in lazy exclusion
* Set IsUserspaceBind mock so lazy manager starts in engine test
* Skip lazy exclude reconciliation when the set is unchanged
* Keep cached lazy flag when a sync carries no peer config
Bedrock lists from the control plane and infers on the runtime host, so a
successful listing says nothing about the URL on the record. An upstream
matching no catalog template left no region to derive, which skipped the check
entirely: a record pointed at a host that does not exist saved clean, and every
request it later served went nowhere.
Entries that declare a listing host of their own now have their configured
upstream resolved on its own account. A host that will not resolve blocks the
save; one that resolves privately does not, since Bedrock behind a proxy is a
supported configuration and stays the unverifiable case it already was.
Editing a provider's upstream URL meant retyping its API key. The key is the
one field the API never returns, so there was nothing to retype from, and the
dashboard had to demand it because a provider_id request resolved the upstream
from the stored row — listing the old endpoint while the form showed the new
one.
The request now reads as a partial edit of the record it names, the same way
PUT on a provider already does: an upstream in the body overrides the stored
one, an omitted upstream keeps it. The credential and the catalog entry still
come from the record only, so a caller cannot aim a stored key at a vendor of
their choosing.
Losing the last network only flipped the availability state: the dead management, signal and relay sockets stayed silently connected until their own timeouts, so the client kept reporting Connected with no network at all.
Introduce client/netevents with a Manager that ties the availability state, the connection sweeper and the status recorder together, and move the netstate and netsweep packages under it (netsweep renamed to sweep). SetNetworkAvailable(false) now also sweeps the registered connections so their owners redial and the listener reaches the NoNetwork state.
The Android and iOS bindings own a Manager instance and inject it through the constructors; consumers hold the concrete *Manager whose nil zero value reports always-online and never sweeps, with interfaces kept only as parameter contracts. The relay guard settle wait moved into the Manager as WaitSettled, removing the netevents import from the relay package.
* fix a nil-ptr error occuring in sendInitialSync when the peer being synced is deleted
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
* handle a nil ptr in GetPeerNetworkMapComponents
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
---------
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
The main and Settings windows were created at startup and kept alive hidden on close, so an idle tray held two webview processes for surfaces the user may never open. Both are now built on first show and destroyed on close, which takes the idle footprint on macOS from ~160 MB to ~74 MB.
The WindowManager owns creation: it rebuilds the main window on the next show and hands out live pointers, since a stored one goes stale. Every show is deferred until the frontend reports it has rendered, so a freshly created window is never on screen empty, with a timeout so a frontend that never reports cannot strand a window hidden.
Pressing "Load models from provider" against a key the vendor refuses answered
"internal server error". Every outcome on that path is the operator's own key
or upstream, so a 500 was wrong twice over: it told them the server broke, and
it named nothing they could act on.
The failures are already typed and already have sentences written for them —
the save-time check translates the same set. Discovery now runs them through
the same classifier, so the button reports a refused credential or an
unreachable url in the words the form uses elsewhere.
ErrNoDiscovery and ErrInvalidRequest pass through untouched. The handler maps
both already, and a provider with no listing endpoint is a fact about the
catalog entry rather than a failure — the caller falls back to the catalog's
own models instead of showing an error at all.
The dial-time guard reported every non-public address as ErrPrivateHost, which
the credential check reads as "this upstream cannot be reached from here, so
save it unchecked". checkPublicHost has already cleared the target by the time
anything is dialled, so an address refused at the socket is never the
operator's upstream — it is a rebinding attempt, or an HTTP proxy the
management server egresses through. A deployment behind such a proxy would
install this feature and have it silently do nothing on every provider.
It now reports an ordinary failure, which classifies as unreachable and blocks.
Only the resolve-stage check still means "cannot be checked", and that one
knows it is looking at the operator's own host.
This is also why the three fixtures below passed locally and failed in CI: a
sandbox that egresses through a loopback proxy skipped the check entirely,
while CI reached the real api.openai.com and had the dummy key refused. They
want a provider row rather than a working vendor, so they move to a private
address and no longer depend on where a hostname resolves or whether the runner
has egress.
The SSRF guard resolves the host before any request is built, so a name that
does not resolve fails there rather than at the transport, and that error
reached the caller unclassified — an operator with a typo in the hostname was
told the provider could not be checked rather than that the url could not be
reached. It is the commonest way for an upstream to be wrong.
The live suite is what caught it: the unit tests construct the transport
errors directly and so never went through the guard.
The management fixture moves to a private upstream in the same change. It
wants a provider row to hang a policy off, not a working vendor, and it was
pointing a dummy key at the real api.openai.com — which the credential check
now correctly refuses. A private address is left unchecked whether or not the
run has vendor keys, and covers that path while it is there.
The OpenAPI change is the 422 both provider routes can now answer, plus what
decides it: the create path checks the pair before storing, the update path
checks only when the upstream or the key moved, and an update omitting the key
is checked against the stored one.
The live tests cover what a unit test structurally cannot. Mocked refusals
prove the classifier maps a status to a message; they cannot show that these
vendors refuse a bad key on their listing endpoint at all, which is the
assumption the feature rests on. The good-key case earns its place beside the
bad one — a check that refused everything would satisfy a test asserting only
the refusal.
The rotation case pins the state worth the most: after a rejected key, an edit
that reuses the stored one still passes. The API never returns a key, so that
is the only way to show the working credential is still there.
The provider form accepted anything and found out later. A typo in the
upstream, a key pasted a character short, an AWS access key in a field that
wants a Bedrock API key — all saved cleanly, then surfaced minutes later as a
failed request or an empty model picker, with nothing pointing back at the
record that caused it.
CreateProvider now spends the credential once against the vendor's own model
listing, and UpdateProvider does the same when the upstream or the key
changed — only then, so renames, model rows and price edits neither wait on a
vendor nor fail because one is having a bad day. Both run before the store
write, so a rejected rotation leaves the working key exactly where it was.
The check reuses the discovery Fetch rather than a lighter status probe. It
exercises the path the model picker will take, so a URL answering 200 with a
login page fails here instead of passing a status check and producing an empty
picker later.
Failures that mean "we cannot ask" are not failures: a gateway with no listing
endpoint, a Bedrock record behind a proxy where no control-plane host can be
derived, and a self-hosted endpoint on a private network that the proxy
reaches through the tunnel but management cannot. None of those are evidence
the record is wrong, and refusing them would make this a lockout.
Discovery failures are typed for it. The message an operator sees carries no
status code and never echoes their URL — WriteError lowercases it, and paths
are case-sensitive, so an echoed URL would come back altered and describe
something they did not type. The vendor's status is logged instead.
[proxy,management] Serve Bedrock model discovery from the control plane
A Bedrock provider could never answer a model-discovery request. The router
sent GET /inference-profiles to the record's upstream, which has to be
bedrock-runtime.<region> for InvokeModel to work, and that host does not
implement the operation. ListInferenceProfiles is a control-plane operation on
bedrock.<region>.amazonaws.com, and one provider record carries one upstream,
so the two hosts genuinely differ.
The route now carries a discovery host, taken from the catalog's declaration
with the region read back out of the configured upstream, and the listing — and
only the listing — goes there. Inference is untouched. A proxied or self-hosted
Bedrock endpoint gets no discovery host at all rather than a guessed one, since
inventing a host would send the operator's credential somewhere they never
configured.
Two things had to follow for the listing to be usable once it arrives. The
response filter only understood OpenAI's {"data":[{"id":…}]}, so a Bedrock
listing fell through it untouched, offering every profile in the account
whatever the policy said. And discoverableModels intersected by exact string,
so a record registering the raw profile id while a guardrail names the catalog
key intersected to nothing — bounding a working provider's listing down to
empty.
Normalisation is the third. The geography in front of a cross-region profile
was matched against a hardcoded list of four, so every profile issued under jp,
au, ca, sa or us-gov carried its prefix into the pricing key, matched no
catalog entry and metered at zero. It is now recognised by either the geography
or the vendor that follows it, so an id has to be new on both axes at once to
slip through — a live eu-central-1 listing returned "global.xai.grok-4.6" days
after the vendor list was first written.
[management] Offer a provider's live model list in the config form
Adds POST /api/agent-network/catalog/providers/models, which asks a vendor
which models an operator's own credential can actually reach, so the provider
form can offer a live list instead of only the compiled-in catalog. The catalog
goes stale, and it cannot see an account: which OpenAI models an org is
entitled to, which Bedrock inference profiles an account and region hold, which
Vertex models a project has enabled.
The endpoints, auth headers and response shapes come from probing the live APIs
(#7244); each vendor invented its own envelope and none can be guessed from the
request. Bedrock shaped the design: its listing lives on the control plane
while inference must go to the runtime host, so Discovery carries its own host
rather than reusing the record's upstream, and profile ids are taken verbatim
because the region prefix is what AWS requires at invoke time.
A caller supplies either the key they are typing or the id of a saved record
whose stored credential is reused — never both, since accepting both would run
an arbitrary credential under the identity of a record the caller may only be
permitted to read. Gated on Create rather than Read, because this spends the
operator's credential against a third party.
Management has not made outbound calls on an operator's behalf before and it
holds a credential for every provider, so every resolved address must be public
— covering loopback, RFC1918, the cloud metadata address and NetBird's own
100.64/10 range — and redirects are not followed, since a redirect moves the
request to a host the check never saw.
The vendor is authoritative for the id; the catalog stays authoritative for
pricing. A discovered model the shipped table cannot price returns
pricing_known: false so the operator must set rates rather than being
registered at a silent zero.
[proxy,management] Bound model discovery to the caller's own policies
GET /v1/models was bounded by the provider record's enumerated models, which is
the right bound only while a single policy reaches a provider. Where two teams
share one provider under different allowlists, every caller was offered the
union — each model outside their own policy being a request the guardrail
refuses a moment later. A gateway record enumerating nothing was worse: it
offered the upstream's entire catalogue however narrow the policy was.
Each route now carries one rule per authorising policy — its source groups and
the models it permits — instead of a single flattened list. At request time the
router keeps the rules whose groups intersect the caller's, unions their
models, and intersects that with what the provider serves.
nil and [] stay distinct end to end: a policy setting no allowlist reaches the
router as nil and lifts the restriction for the groups it binds, while an
enabled allowlist with no models arrives as [] and permits nothing. Collapsing
them would let a listing that should offer nothing fall open to everything.
The guardrail's own per-provider allowlist is untouched. It is a fail-closed
backstop that cannot tell who is asking, so discovery is now narrower than the
backstop rather than wider.
[proxy,management] Conform the Agent Network endpoint to the LLM gateway protocol
Reviewed the proxy against Claude Code's published gateway contract. The
transport layer already held up; fourteen gaps sat one layer up, in the model
catalog and in the non-inference endpoints clients call.
Two of them cost money. The catalog carried no claude-opus-5 or
claude-sonnet-5, so an operator could not authorise the models coding agents
default to — those requests denied as not-routable, or priced at zero where a
catch-all carried them. And gateway records pin ParserID "openai" while the
same record serves /v1/messages, so Anthropic responses were read with the
OpenAI parser, which never looks at message_start where input tokens live:
input metered as roughly zero on every stream and cost was skipped entirely.
The rest fix requests refused for structural rather than policy reasons: model
discovery denied for every account with a model allowlist, token counting
denied on Bedrock and mis-parsed on Vertex, startup probes refused and written
into the access log at every session start, and denials rendered in a shape no
LLM client parses. Two changes are additive by design — the deny body keeps
every field it had and adds the vendor's error object alongside, and body-level
identity injection is now gated on the request's dialect so it stops sending
OpenAI-shape fields into Anthropic bodies that reject them.
The end-to-end work turned up one more: the discovery filter treated any slash
in a model id as a gateway prefix, which would have dropped every self-hosted
"Qwen/..." model from the picker.
* [misc] Let gomobile init fetch the toolchain gobind needs
The previous commit's CI run confirmed the failure on a comment-only diff
off main, so the cause is not any branch's changes:
Android / Build failure
iOS / Build failure
`gomobile init` re-installs gobind from x/mobile@latest whatever gomobile is
pinned to, and setup-go sets GOTOOLCHAIN=local, so the install dies the
moment @latest declares a newer Go than go.mod does:
gomobile: go install golang.org/x/mobile/cmd/gobind@latest failed: exit status 1
go: golang.org/x/mobile@v0.0.0-20260821190718-4776eadac327
requires go >= 1.26.0 (running go 1.25.12; GOTOOLCHAIN=local)
GOTOOLCHAIN=auto on that step alone lets the install fetch what it asks for.
Scoped to the step deliberately: the repo's Go version and every build below
it stay on go.mod's toolchain, so this buys the mobile jobs nothing except
the ability to run gobind.
Pinning gobind next to gomobile does not work — init re-installs @latest
regardless. A durable fix is to stop `init` reaching the network at all, or
to track x/mobile's Go requirement in go.mod; both are larger changes than a
red CI warrants right now.
The updater runs as LocalSystem and started netbird-ui via
CreateProcessAsUser with a nil environment, so the UI inherited the
SYSTEM environment (USERPROFILE, APPDATA pointing at systemprofile)
while running under the user's token. The WebView2-based UI exits
immediately in that state, so the UI never came back after an update.
Build the environment from the user's token with CreateEnvironmentBlock
and pass it to CreateProcessAsUser.
Android never reported its local network interfaces, so PeerNetworkRange posture checks could not be evaluated: NetworkAddresses always arrived empty.
net.Interfaces() is unusable on Android 11+ (SELinux blocks netlink), so the addresses are parsed from the interface description the host app already provides via stdnet.ExternalIFaceDiscover. The MAC filter is skipped, mirroring #5906
for iOS, since Android does not expose MACs either and nothing reads Mac server side.
Stop the UI before a silent Windows update and suppress the installer reboot
On silent MSI updates msiexec could reboot the machine on its own. The running UI holds a lock on its own exe, and since msiexec runs as LocalSystem it cannot close the interactive user's UI via Restart Manager, so the MSI scheduled the
file replacement for the next reboot and marked the install restart-required.
Terminate netbird-ui.exe before launching the installer and wait until its image file is released; the existing deferred restart brings it back after the install on every exit path
Run msiexec with /norestart REBOOT=ReallySuppress so it never reboots on its own
Treat exit codes 3010/1641 as success with a warning instead of a failure
---------
Co-authored-by: Viktor Liu <viktor@netbird.io>
* [client] Clear stale installer result before starting update
The installer result file could survive a previous update attempt (e.g.
when the updater wrote it after the restarted daemon already ran its
startup check). A new install attempt left the old file in place, so the
GUI progress window's first GetInstallerResult poll read the outdated
result: a stale success made the GUI quit mid-install, which cancelled
the TriggerUpdate context and aborted the artifact verification; a stale
error surfaced a bogus failure dialog for a succeeding update.
Remove any leftover result file at the start of RunInstallation, before
the download begins, so result watchers only see the current attempt's
outcome.
* [client] Align stale-result warning with log message style
On network changes the client restarted the whole engine. That is heavy-handed and slow: it tears down working state to recover from a transition the engine could handle itself. This replaces the restart with proper network event handling.
Suspend the retry loops while no network is available. Instead of burning through backoff intervals against an unreachable network, the reconnection loops park until the OS reports a usable network again.
Reconnect immediately on a network switch. When the OS hands us a new network, connections bound to the old one are swept and re-dialed right away, rather than waiting for a timeout to notice they are dead.
Adds an SSHClient gomobile binding so the Android app can run an SSH session over the tunnel with a PTY, exposed through a listener interface for the in-app terminal.
Server type is auto-detected from the SSH banner, which selects the auth path: JWT device-code flow, NetBird key, or a regular server (NetBird key first, then password). Host keys are verified against the peer registry for NetBird servers and trust-on-first-use for regular ones.