* [client] Skip peers without a PTR record in kubernetes commands
getKubernetesClusters returned on the first failed reverse lookup, so a
single peer without a PTR record made `netbird kubernetes list` and
`write-kubeconfig` fail even when a netbird-kubeapi-proxy peer was
reachable later in the list. Log the failure at debug and keep scanning,
stopping only when the context is cancelled.
* [client] Return cancellation when the last peer's lookup is skipped
A failed lookup or fingerprint on the final peer was skipped and the
loop ended with a nil error, so a cancelled command reported no clusters
instead of the cancellation.
* [client] Report the lookup error when no peer resolves
Skipping every failed reverse lookup hid a broken resolver behind "No
Kubernetes clusters available" or "cluster not found", with the cause
only in the debug log. When no lookup succeeds, return the last lookup
error. One resolvable peer still means the resolver works, so per-peer
failures stay skipped.
* [client] Stop the kubernetes peer scan when the resolver is down
With a dead resolver every peer waited out the resolver timeout in turn
before the error was reported. A missing PTR record answers NXDOMAIN, so
a timeout or SERVFAIL before any peer resolved means the resolver itself
is failing: return it at once. After one peer resolved, later failures
are still skipped.
* [client] Extract kubernetes cluster probing from the peer scan
The lookup error handling pushed findKubernetesClusters past the
cognitive complexity limit. Move the FQDN match and fingerprint into
probeCluster so the scan loop only handles lookups and deduplication.
* Bound the SSH session request queue by bytes and stop resizes reaching a loop with no consumer
* Give the window change drain loop a body
* Avoid an empty case clause in the SSH request filter
* Charge only the newest resize to the request queue and classify requests before the byte limit
* [client,proxy,management,signal,relay] Distrust compromised eMudhra CA keys
Eight eMudhra intermediate CA private keys were compromised and browsers
blocked them on 2026-10-17. Our binaries were built with go1.26.7, which
accepts chains through those intermediates whenever the trust store still
holds emSign ECC Root CA - G3. go1.26.10 rejects them in crypto/x509 on
every platform, regardless of the trust store.
The UBI base still ships the root and Red Hat has no fixed package yet, so
the UBI images blocklist it with p11-kit. The ingest image, the UI server
image and the pinned Chainguard digests also carried the root and move to
bases that no longer do.
* [client,proxy,management,signal,relay] Use the fixed UBI base instead of a blocklist
Red Hat shipped ca-certificates 2026.2.92_v9.0.319-90.3 on 2026-10-22,
which drops emSign ECC Root CA - G3 and AAA Certificate Services. The
current ubi9-minimal image carries it, so pin that digest and remove the
p11-kit blocklist step.
* [misc] Pin upload-server images by digest only and restore go.mod comment
An integrated validator can approve or revoke peers outside a login or
sync, for example during a periodic EDR sync. Only revocations of SSO
peers reached anyone; a newly approved peer kept its empty map, and its
peers did not see it, until an unrelated update or a reconnect.
Add SetPeerValidationChangeListener to the IntegratedValidator interface.
The account manager registers a handler that resolves the affected peers
and calls OnPeersUpdated, the same path a login-time approval takes. The
OSS validator and the mock implement it as a no-op.
* we somehow can get a nil peer when returning empty components; this fix compensates for the issue, but doesn't fix the root cause (which is unknown atm)
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
* fix test
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
---------
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
* [management] Skip inactivity expiration rescan when a job is scheduled
Every SSO peer connect and disconnect cancelled the account's inactivity
expiration job and re-armed it, loading every inactivity-enabled peer of
the account each time. During a management deploy, when all peers
reconnect, that is a full account scan per peer.
A connecting peer cannot expire, and a disconnecting peer expires after
every peer that was already disconnected, so neither can move the next
run earlier. Connect and disconnect now leave an already scheduled job in
place, as login expiration already does. Settings and per-peer toggle
changes still cancel and reschedule, since they can move the run earlier.
* [management] Keep inactivity rescan on disconnect
A disconnect that lands while the inactivity job is finishing its last
run saw the job as scheduled and skipped arming a new one; the scheduler
then removed the job, leaving the disconnected peer without an inactivity
timer. Only connect, where the peer cannot expire, now skips a scheduled
job; disconnect cancels and re-arms as before.
The test asserts on the synchronous Cancel call instead of sleeping.
* [proxy] Fix npm audit findings in the web UI
npm audit reported 10 vulnerabilities (7 high), all in build tooling:
Vite/launch-editor, PostCSS, Babel, browserslist, js-yaml, nanoid,
brace-expansion and source-map-js. All are resolved by the lockfile
update; Vite is pinned exactly, so it is bumped from 7.3.2 to 7.3.7.
* [client] Migrate the desktop UI to Tailwind CSS 4 and fix npm audit findings
pnpm audit reported 36 vulnerabilities in the desktop UI. All but two
(braces and postcss-selector-parser, both pulled in by Tailwind 3 with
no patched version in range) are fixed by updating within the existing
ranges; react-router moves to 7.18.4, which also fixes an open redirect
and an XSS in its error handler.
The remaining two go away by moving to Tailwind CSS 4, which the proxy
web UI already uses:
- The config moves into an @theme block in globals.css; the official
upgrade tool applied the v4 utility renames across the components.
- @tailwindcss/vite replaces the PostCSS plugin and autoprefixer.
- tw-animate-css replaces tailwindcss-animate with the same classes.
- The Prettier plugin reads the theme from the stylesheet.
A screenshot comparison of every screen against the Tailwind 3 build
found three regressions caused by v4's changed utility and variant
ordering, fixed here so the UI renders as before:
- Label combined inline-block and flex; v4 let inline-block win, which
made every settings row taller. The dead inline-block is removed.
- The active settings tab lost its highlight in dark mode because
dark: now outranks group-data-[state=active]:. The active color is
set for dark mode explicitly.
- v4 rebuilt its default palette in OKLCH, shifting colors the theme
does not override such as sky-400. The Tailwind 3 values for the
neutral, zinc, sky, orange and amber families are pinned in @theme.
The only remaining visible difference is 2px less height per row in
the profiles list, because v4's preflight resets the browser's default
table-cell padding.
The harness hands peers the management address netbird.local:8080, which
only resolves on the deployment's own Docker network, and keeps that
network, the client image and the client container private. A suite that
runs agents somewhere else, such as the Kubernetes operator's Kind
cluster, has no way to attach to the deployment, run the same client
build, or check routed access from a harness peer.
Expose the network name, resolve the client image on its own, and let a
caller exec into a client container.
* [client] Drop the non-functional admin URL
#4218 deprecated --admin-url, but #3980 merged right after it and restored the flag and its wiring. The value has been stored and echoed back ever since, without anything reading it.
Stop sending it from the CLI, ignore it in the daemon and drop it from the profile config, the UI settings service and the debug bundle. The proto fields stay for older clients, and the mobile getters and setters keep their signatures as deprecated stubs.
* [client] Drop adminUrl from the Wails API reference
* [client] Split resolv.conf parsing from file reading
Pure refactor, no behavior change. parseResolvConf parses content that
was already read, so a caller can compare and parse the same snapshot
of the file instead of reading it twice.
* [client] Return the written content from the resolv.conf update function
Pure refactor, no behavior change. The repair watcher needs to know what
NetBird wrote so it can tell its own write apart from a third party's.
Callers ignore the content for now.
* [client] Keep the resolv.conf watch armed while repairing
In file mode the repair watcher removed its inotify watch, rewrote
resolv.conf and re-added the watch, without looking at the file again.
A second third-party write landing in that window raised no event, so
the file stayed on the upstream resolver until something else touched
it. systemd-resolved with the stub listener off, the Amazon Linux 2023
default, writes the file twice in a row on restart and does not rewrite
it on an unchanged DHCP renewal, so the host stayed off NetBird DNS
while the client reported connected.
The watch now stays armed during the repair. NetBird's own write is
recognized by comparing the file with the content it wrote, which also
covers a write whose search list was truncated to the resolv.conf
limits and would otherwise fail the parameter check forever. The
watcher checks the file once when it is armed, since a change made
before that produced no event.
* [client] Consume inotify errors in the resolv.conf watcher
fsnotify blocks its reader until an error is received. The watcher
never read the Errors channel, so a single error, such as an event
queue overflow, stopped all further events and silently disabled the
repair. The watcher now consumes errors and checks the file, since an
overflow means events were dropped.
* [client] Parse resolv.conf one line at a time
Pure refactor, no behavior change. The per-line handling moves into
parseResolvConfLine, which keeps parseResolvConf under the cognitive
complexity limit.
* [client] Drop the comment restating parseResolvConfLine
* [misc] Announce tag-pushed releases on the forum
Since 0.77 a release is cut by pushing a tag, and goreleaser creates the
GitHub release with GITHUB_TOKEN. Events raised by that token start no
workflow, so the release:published trigger never fires for them and
0.78.0, 0.79.0, 0.80.0, 0.81.0 and 0.81.3 got no forum topic, while
releases published from the UI still did.
Post from the completion of the Release workflow instead, for tag runs
that succeeded and whose release was created by the bot. Release
candidates stay off the forum, and an existing topic with the same title
is left alone so re-running a green Release run does not post twice.
* [misc] Point an empty forum post at the release notes
goreleaser creates the release with an empty body and the notes are
written on the release page afterwards, so the topic would otherwise
hold only its title link. Say where the notes and downloads are.
* [misc] Fail closed in the forum post lookups
Announce stable tags only: every pre-release tag carries a hyphen, so
canaries and release candidates pushed as tags stay off the forum.
A failed release lookup now fails the step instead of passing as "no
release"; only a 404 skips. The duplicate check searches by title rather
than reading the first page of the category, and a failed search stops
the step instead of falling through to a post. Discourse requests
refuse any non-HTTPS redirect and retry transient errors.
* [client] Reproduce Signal reconnect listener shutdown deadlock
* [client] Invoke Signal reconnect listeners outside the client mutex
Capture the listener with the registration state, then release the mutex before calling it. Watcher shutdown can now unregister a listener while an in-flight callback waits for the watcher lock.
Keep callback invocation synchronous and document that unregistration does not drain an already captured callback. Record callback completion inside the regression test's external lock.
* [management] Return the reason when custom domain validation fails
The validate endpoint returned an empty 202 and ran the check in a
goroutine, so a failed verification only reached the management log.
Run the check in the request and return 204 or 412 with a classified
reason. Resolver errors are not forwarded.
* [management] Address review feedback on custom domain validation
Pass the request context to the lookup and store calls now that the
check runs in the request, compare the self-resolved name
case-insensitively, document the 412 like the other entries, and
check the returned error in the existing tests.
* [management] Match custom domain CNAME targets case-insensitively
DNS names are case-insensitive, so a CNAME pointing at the cluster in
different casing should validate instead of failing as a mismatch.
* [management] Add validation timeout
---------
Co-authored-by: DevBot NetBird <91386968+netbirddev@users.noreply.github.com>
StdNetBind.Send reused pooled *net.UDPAddr objects and copied the
destination into ua.IP before resizing the slice. After an IPv4 send the
slice was 4 bytes long, so the next IPv6 send copied only 4 bytes and
extended the slice with stale data, misdirecting datagrams to a truncated
or foreign IPv6 address. Userspace clients with both IPv4 and IPv6 P2P
peers saw intermittent loss and restarting handshakes.
The fork now carries the upstream fix (WireGuard/wireguard-go#135,
netbirdio/wireguard-go#24). Pick it up.
* [client] Keep interface discovery out of the bind open path
Since 0.81.0 a userspace-mode agent whose interface name is not covered
by the interface blacklist deadlocks while bringing the device up, and
the daemon, the GUI and the service stop all hang. wireguard-go holds
the device net lock across bind.Open, our bind builds the UDP mux there,
the mux enumerated interfaces in its constructor, and the interface
filter probed every unlisted name through wgctrl. For the agent's own
interface that probe connected to the daemon's own UAPI endpoint and
waited, with no deadline, for a reply the handler could only give after
taking the same lock. #7346 moved the first enumeration from the stdnet
constructor, which ran before the device existed, to this call site.
The mux no longer discovers interfaces when it is constructed. It
records whether its socket is bound to an unspecified address and
GetConn uses that instead of the length of the discovered list, while
GetListenAddresses keeps refreshing on demand, which pion calls during
candidate gathering outside the device locks. The WireGuard detector
also answers for the agent's own interface without probing, so no
filter asks the daemon about itself.
* [client] Take the agent's own interface name in the detector constructor
Every device implementation reports the configured name, so there is no
later name to follow and the setter was state without a reader that
needed it. The detector takes the name once and compares without a lock.
* [client] Create the default mux Net in the constructor
With discovery moved to GetListenAddresses, the nil Net fallback ran
there as well, and the first concurrent callers raced on writing it.
stdnet.NewNet does no discovery, so the constructor can create it before
the mux is shared and the address refresh only reads it.
* [client] Follow the created interface name in the detector
Android ignores the configured interface name and keeps the TUN name
returned by CreateUnmonitoredTUNFromFD, so the detector built from the
configuration would still probe the agent's real interface. The engine
updates the detector with wgInterface.Name() once the interface exists.
* [client] Keep the detector's own interface name immutable
On Android the kernel names the TUN and the name can change again on
renew, while the UAPI socket cannot be opened there at all, so neither
the probe nor a name set after creation can identify the agent's own
interface reliably. Excluding that interface on Android belongs to the
interface discovery the app provides. Everywhere else the configured
name is the created name, so the constructor argument is enough and the
detector needs no setter and no lock on the hot path.
* [proxy] Add --external-ip-map flag for the embedded client
The proxy behind a NAT with a forwarded fixed --wg-port could not advertise
its public address as an ICE candidate, so peers fell back to relay. Expose
the agent's external IP map through embed.Options and a proxy flag
(NB_PROXY_EXTERNAL_IP_MAP).
* [proxy] Validate --external-ip-map at startup
The engine drops every external IP mapping when one fails to parse, so a
typo or a space after a comma silently disabled the feature. Move the
agent's validator to shared/externalipmap, run it from the proxy at
startup, and trim entries first.
* [client,proxy] Require an external IP first in --external-ip-map
The engine reads the first component as the external IP, so a mapping like
iface/10.0.0.5 passed validation and then dropped every mapping.
The Linux network monitor skipped every route update whose Dst was not
nil, treating a nil Dst as the default route. Since the bump to
vishvananda/netlink v1.3 (v0.43.0) the default route arrives as an
explicit 0.0.0.0/0 or ::/0, so every default route change was filtered
out and the monitor never fired on Linux, no matter how the underlay
moved.
Treat both a nil Dst and a zero-length prefix as the default route.
* [client] Do not move an HA route on latency before both peers have a sample
A direct connection reports no latency until the first sample of its new ICE
agent, and a drop to relay clears the previous one. Route selection ranked
such a peer at unknownLatency, so a routing peer whose direct connection came
back within the relayed switch delay lost the route to an equivalent peer on
a "lower latency" switch right after recovering, resetting every masqueraded
flow the delay was meant to keep alive. The sample usually lands before the
evaluation, but nothing orders the two, and a pair with no round trip time
yet postpones it to the next sampling tick.
Latency-only switches now require a sample on both peers. Categorical
criteria (connected, metric, direct versus relayed) are unaffected.
* [client] Hold a never-direct HA routing peer before a direct peer takes over
The peers of an HA group come up over relay first and turn direct one after
the other, as when lazy connections wake the group or the client reconnects.
A routing peer that was relayed when it carried the route gave way at once
to the first equivalent peer that turned direct, so in a group of N peers
the route moved right after the session started in roughly (N-1)/N of the
cases, resetting every masqueraded flow, typically an SSH session opened on
the wake-up traffic.
Such a peer is now kept for the relayedSwitch delay, counted from the first
evaluation that finds a direct contender. If it turns direct within the
delay the route stays; otherwise it moves when the delay runs out, so the
route does not stay on a relay for longer than the delay. A peer that
dropped from direct keeps counting from the drop.
* [client] Keep allowed IPs when only the chosen routing peer's status changes
recalculateRoutes did not tell a status change of the chosen routing peer
(idle to connected when traffic wakes it, connected to relayed and back) from
a switch to another peer: it removed the allowed IPs and added them back on
the same peer. In between the prefix had no owner and packets for it were
dropped, and default routes published a spurious "disconnected due to high
availability change" event followed by two "connected" ones.
When the same route stays on the same peer with the same definition, only
the recorded status is updated now.
* [client] Limit the "never" relayed switch policy to peers that dropped from direct
NB_HA_RELAYED_SWITCH_DELAY=never keeps a routing peer whose direct
connection dropped to relay for as long as it stays connected. Once the
relayed delay also covered peers that were never direct, "never" pinned
those to the relay for good, although a direct equivalent was available
and the peer showed no sign of turning direct.
A peer that was never direct now uses the default delay under "never",
which keeps the policy's original scope.
* [client] Merge the unchanged-route checks of the HA route watcher
shouldSkipRecalculation and the status-only fast path in recalculateRoutes
both handled the same route staying on the same routing peer, one for an
unchanged status and one for a changed one. They are now a single
keepChosenRoute check.
Pure refactor, no behavior change.
* [client] Re-apply routed allowed IPs when a kept routing peer connects
A peer update can recreate a routing peer: modifyPeers removes it from the
interface, which drops its routed prefixes, and adds it back idle with its
overlay addresses only. The remove and re-add that recalculateRoutes used
to run on every status change of the chosen peer put the prefixes back as
a side effect once the peer connected. Without it, a watcher that sees the
same peer go idle and then connected leaves the routed subnet without an
owner until a route update or an HA switch.
When the chosen peer connects while keeping the route, the watcher now
re-applies its routed allowed IPs through the route manager's reconcile,
which is add-only and idempotent. Lazy connections already do the same when
they arm an idle peer.
* [client] Bound the HA route hold on a routing peer without a latency sample
A latency-only switch waited for a sample on both peers. If the current
peer never got one, because its candidate pair stats kept failing or
reporting no round trip time, it kept the route indefinitely against a
measured peer, however much faster that one was.
The hold on an unsampled current peer now lasts unmeasuredLatencyHold
(30s), long enough for a recovered direct connection to report its first
sample. After that the peer is ranked at unknownLatency again and the usual
switch margins apply. relayedHold is armed so the switch is reconsidered
when the hold runs out.
* [client] Restart the relayed delay of a never-direct peer when its contender goes
For a routing peer that was never direct, the relayed delay runs from the
first evaluation that finds a direct contender. It was only reset when the
current peer changed or turned direct, so if that contender went away and
another one showed up later, the delay counted from the first one and the
new contender took over at once.
The delay of such a peer now restarts once no direct contender is left.
The delay of a peer that dropped from direct keeps running from the drop.
* [client] Retrigger pull request synchronization
* [client] Retry a failed routed allowed IPs reconcile on the next evaluation
keepChosenRoute recorded the connected status before re-applying the routed
allowed IPs and only logged a failed reconcile. Later evaluations then saw
an unchanged status and never retried, so a transient AddAllowedIP failure
left the recreated routing peer without its routed prefixes until another
status transition or a route switch.
The status is now recorded only once the reconcile succeeds. A failure
leaves the previous status in place, so the next evaluation, at the latest
the periodic re-evaluation, sees the change again and retries.