An empty answer tells the initiator the peer does not run the KEM, and it
stops offering for good. A decode or version error is not that: a newer
client talking to an older one would silently stay classical.
The split-key test only checked the manager's decision, so a broken
handshaker branch would still pass. The capability test asserted while
holding the manager lock, which hangs instead of failing.
The PSK usually lands after the connection came up, so the flag flips with
no other state change. Without a notification, UI and CLI subscribers kept
showing the peer as unprotected until something unrelated changed.
The recovery test could time out its own bootstraps under CI load, the
non-capable test passed with the cancellation removed, and the decoder
rejection test only exercised the encoder. The timing spike ran on every
go test; it is a benchmark now.
Only the controller's offer carries KEM material, so when the responder
starts a negotiation the controller is meant to reply with its own KEM
offer. It only did so before the very first exchange: once it held a
converged PSK it answered the responder's offer normally and kept that PSK,
on the assumption that a stale key would heal through the WireGuard watcher
reconnecting over Signal.
That assumption fails when the reconnect comes from the responder. If every
answer of a data-path rotation is lost, the responder commits the new PSK
and the controller does not, so WireGuard cannot handshake and no further
rotation runs to escalate. The watcher closes the connection after ~3.5
minutes and ICE reconnects in seconds, but the reconnect offer came from the
responder, the controller kept its stale key, and the tunnel stayed down.
In the lab it took 201 s and a lazy-connection idle/wake cycle to heal; with
lazy connections off, the default, nothing would heal it.
Treat a converged exchange as not in flight: a responder offer then always
gets a fresh bootstrap, so any responder-started renegotiation re-aligns
both PSKs. While the controller's own exchange is still in flight a
crossing offer is answered normally as before, so offers cannot multiply.
The cost is one extra Signal KEM round per responder-started reconnect.
The recovery that re-runs the ML-KEM bootstrap after a failed exchange was
called RequestReoffer (and "reoffer" along the callback chain). The name
only says that another offer goes out, so it reads like routine
renegotiation or key rotation, while it fires only when the exchange has
failed: the initial exchange, or rotations failing repeatedly. Normal
rotation never takes this path; it runs over the data path.
Rename it RecoverPQKeyOverSignal, and the callback field, engine wiring, log
line, comments and test with it. The controller re-offer in the handshaker
is a different mechanism and keeps its name. Pure rename, no behaviour
change; only the debug log wording follows the new name.
Two derivations for the same peer can overlap, for example a rotation and
a Signal re-bootstrap. The generation guard checked and recorded the
generation under a lock but called SetPresharedKey after releasing it, so an
older apply already inside SetPresharedKey could finish after a newer one
and leave WireGuard on the stale key, splitting the peers until the next
exchange (reported in the security review of #7098).
Hold the lock across the generation check and the SetPresharedKey call. The
lock only serialises PSK applies, which are quick local calls, so this does
not reach into the manager's own locking.
When every offer of a data-path rotation was lost, the initiator dropped
the exchange and logged that it would retry next cycle. It never did: a
rekey only chains a new rotation from an exchange awaiting it, and none was
left. Rotation stopped until the next Signal re-bootstrap (a lazy wake, or
never on a long-lived connection), and because no further attempt ran, the
consecutive-failure count could not reach the threshold that hands recovery
to the host either (reported in the security review of #7098).
On a rotation timeout, hand the chain back to the exchange the rotation
acknowledged. The next rekey then starts a fresh rotation from it: if the
offer was simply lost both peers still hold that PSK and the rotation
converges, and if the responder had already moved on, the attempts keep
failing until the threshold triggers a re-bootstrap. A timed-out bootstrap
is still dropped, as it has nothing to fall back to.
A failed KEM exchange makes the manager ask for a fresh signalling offer.
For a peer that is registered but offline that offer can never be answered,
so with lazy connections off (the default) each such peer drew a new offer
at every exchange timeout, about every 22 seconds, forever. That runs on top
of the reconnect guard, which already retries the peer with exponential
backoff, and every guard offer starts a fresh KEM exchange anyway. With many
offline peers it is a steady stream of useless Signal traffic.
Send the recovery offer only while the peer is connected. That is where it
is needed: the transport is up but the KEM did not converge (strict mode
stuck before its first PSK, or a broken rotation). An unreachable peer is
left to the guard's backoff.
ML-KEM strict mode promises that no traffic crosses on a non-PQ key. Under
lazy connections an idle peer is still installed in WireGuard, so that
traffic towards it can wake it, and it accepts a handshake the remote
initiates directly. Both lazy listeners installed it without a preshared
key, so a freshly created idle peer accepted a classical session before any
KEM exchange, for example from a remote whose WireGuard still targets the
victim's real endpoint (security review of #7098, finding F-002). The
strict sentinel only reached WireGuard later, on the normal connection
path.
Let the lazy peer config carry a key provider that the listeners install
with the idle peer. In strict mode it yields the PQ PSK once derived and
the sentinel until then; it is read at install time so it is never stale.
Outside strict mode it yields nothing and the idle peer's key is left
unchanged, as before.
Preparation for installing idle peers with ML-KEM strict-mode key material
(security review of #7098, finding F-002). The lazy connection manager
receives a peer description from two call sites that each spell out the
same struct literal; the new field should be added once, not kept in sync in
two places.
Pure refactor, no behaviour change: both sites call newLazyPeerConfig.
The data-path socket identified a sender only by its source IP and port and
trusted that anything reaching the overlay address came through the tunnel.
On a host-bound socket that does not hold: Linux delivers a datagram to a
local overlay IP from a physical interface too (rp_filter is loose, the
firewall's input rules match the tunnel interface), so a LAN-adjacent host
spoofing a peer's overlay source could inject a KEM offer. A forged
bootstrap offer was adopted outright, desynchronising the two PSKs until a
re-bootstrap, and repeating it sustains the outage (security review of
#7098, finding F-003). No traffic is disclosed, but the exchange must not
accept unauthenticated input.
Seal every data-path message with an HMAC keyed from the PSK the two peers
share; only a peer that holds it can rotate. A reply is sealed under the key
that verified the request, because the responder has already committed the
new PSK when it answers. The previous PSK stays valid for verification so an
offer retransmitted after that commit is still answered. Bootstraps are
refused on the data path entirely: they start without a shared PSK and only
signalling can carry them.
A rotation between peers whose configured secrets differ is now refused at
authentication, so the configured-secret test checks that instead.
Preparation for authenticating data-path messages (security review of
#7098, finding F-003). The authentication needs the existing PSK while a
new one is being derived, to produce a MAC attached to the offers and their
answers that proves they are authentic. So committing a new PSK must keep
the previous one, and a reply must be sealed with the PSK that verified the
offer rather than the one just committed. Today both operations are inlined
at their call sites.
Pure refactor, no behaviour change: the two PSK commit sites call
commitPSKLocked, and pushDataPath delegates the endpoint lookup and send to
sendDataPath.
--pre-shared-key promises that only peers holding the same key can
communicate. With ML-KEM enabled the derived PQ PSK replaces it in
WireGuard, but the derivation never saw it: two peers with different
configured keys, or only one with a key, still agreed on the same PQ PSK
and talked. A permitted peer identity, or a stolen identity key, was enough
to bypass the extra secret (security review of #7098, finding F-001).
Rosenpass does not have this gap because it seeds its exchange with the
configured key.
Mix the configured key into the HKDF keying material, so a peer without it
derives a different PSK and WireGuard fails closed. It goes through the
binding shared by every derivation, so bootstrap and rotation both enforce
it. The library takes it as a generic option, keeping it free of WireGuard
concepts.
The per-peer "Quantum resistance" flag was computed only when the relay or
ICE connection came up. When the KEM answer lands after the WireGuard
endpoint is configured (the controller answering a crossing responder offer,
the common case under lazy connections), the peer kept being reported as not
converged while the PSK was programmed and traffic flowed. Reproduced in 4 of
7 fresh lab runs.
Mark the peer in the status recorder once OnNewPSKReady has programmed the
PSK, and OR that mark into every relay/ICE status update under the recorder's
lock. The connection path reads the PQ state before configuring WireGuard and
writes the status afterwards, so without the mark its stale "false" would
clear the flag again. The mark is dropped with the peer, matching the PSK's
lifetime, and the update is best-effort: a peer missing from the recorder
never fails the PSK apply. Status-only change; PSK programming and strict
gating are unaffected.
In netstack mode (js/wasm and embedded clients) the overlay IP lives only
inside the userspace stack, so binding it on the host failed with "cannot
assign requested address". The exchange was then disabled, and in strict
mode the engine refused to start at all. Reproduced in the lab with
NB_USE_NETSTACK_MODE=true.
Open the socket through the interface's netstack when there is one. Its
datagrams then ride the tunnel like any other overlay traffic (over the
relay's WebSocket in the browser). Host-TUN clients keep binding on the host.
Preparation for running the ML-KEM exchange on the js/wasm client. There, and
in other netstack-mode clients, the overlay IP exists only inside the
userspace stack, so the KEM socket has to be opened on the netstack. The
netstack returns a *gonet.UDPConn rather than a *net.UDPConn, so the
transport can no longer hold the host socket's concrete type.
Hold the socket as a net.PacketConn, which both implement, and convert
endpoints at the edges. Sources are unmapped explicitly, since a udp4
socket's net.UDPAddr may carry the 16-byte form. No behaviour change: the
transport still binds on the host, and the next commit opens it on the
netstack. Adds a host round-trip test that also covers the fallback to an
ephemeral port.
* [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.
* [infrastructure] Restrict the FreeBSD port job to stable releases
The port scripts picked the target version by scraping the GitHub tags
page and only dropped tags containing "rc". A canary tag such as
v0.81.0-canary.pr-8098.1 sorts above the latest stable release, so the
job tried to package a prerelease, and the page only lists ten tags, so
a run of prereleases could leave no candidate at all. Both scripts now
share one helper that reads the tags API, keeps MAJOR.MINOR.PATCH
versions only, and rejects an explicitly requested prerelease. Go proxy
downloads fail instead of hashing an error page.
The job moves out of the release workflow into its own, so it runs on
pushes to main, on release tags, and on pull requests that touch the
scripts or the workflow itself. Prerelease tags skip the job, and a
stable tag is packaged directly rather than whatever happens to be the
latest release at the time.
* [infrastructure] Keep the FreeBSD port job in the release workflow
A separate workflow would rename the check and detach the job from the
release run, so the job stays in release.yml behind a small trigger job
that decides when it runs: pushes to main, stable release tags, and pull
requests that touch the port scripts or the workflow. Prerelease tags
and other branches skip it.
Tags are now listed with git ls-remote, which returns every tag in one
call and needs neither a token nor pagination, and the Go module proxy
downloads are limited to HTTPS like the other fetches.
* [infrastructure] Address Sonar findings in the FreeBSD port helper
Read the version into a local and return explicitly from is_stable_version.
* [infrastructure] Address review findings in the FreeBSD port job
Only a full vMAJOR.MINOR.PATCH tag starts the port job, so a tag like
v1.2 skips it instead of failing in the resolver. The diff detection
steps use a glob array rather than parsing ls, and quote GITHUB_OUTPUT.
A failed tag listing now stops the issue body script instead of writing
an incomplete changelog, and a bare "v" argument is rejected rather than
treated as a request for the latest release.
Homebrew deprecated the postflight and uninstall_preflight cask stanzas
in favour of the declarative *_steps DSL, so every brew command that
evaluates netbirdio/tap now prints deprecation warnings. Once the
deprecation becomes a disable the generated cask stops loading and
netbird-ui can no longer be installed or upgraded through Homebrew.
The *_steps blocks take JSON-serialisable steps run in a sandbox rather
than arbitrary Ruby, so system_command is re-expressed as run/remove.
The two postflight blocks merge into one because a cask carries only a
single instance, preserving the original order. set_permissions moves
from a hardcoded /Applications to base: :appdir, matching what the
installer invocation already did. The launchctl fallbacks keep their
tolerant semantics through must_succeed: false, and remove is a no-op
when the plist is absent.
Conn.Close previously called onDisconnected only when evalStatus
reported StatusConnected. When a connection drops or closes from
a connecting or disconnecting state, the callback was skipped.
This left registered listeners such as Rosenpass unaware of the
teardown, keeping peer workers active and leaking sockets.
Invoke onDisconnected unconditionally on Conn.Close if registered.
* [client] Move the Signal registration wait into its own helper
Pure move, no behavior change. The wait for the registration header and
the check on its value leave connect() for awaitRegistration(), so the
next commit can bound that wait in one place.
* [client] Split the Signal test server helper from its listener setup
Pure move, no behavior change. serveTestSignalExchange serves any
SignalExchangeServer on a local listener; startTestSignalServer keeps
serving the real server through it. Lets a test wrap the real server.
* [client] Bound the wait for the Signal registration header
After an outage the Signal client could wait forever for the registration
header of a new stream. Keepalive cannot end that wait when whatever
answers the transport (a proxy in front of Signal, or Signal itself)
accepts the stream and never answers it, and the receive watchdog only
starts once registration succeeds. Signal then stayed down until the
daemon was restarted, while Management and Relay recovered.
Cancel the stream when the header does not arrive within 10 seconds, so
connect() fails and the existing retry loop opens a new stream. A timer
is used instead of a context deadline because the stream context must
outlive registration.
* [client] Report Signal unhealthy while no stream is registered
IsHealthy judged Signal by the gRPC connection state alone and marked it
connected whenever the connection was Ready or Idle. With the connection
up but no stream registered, the client cannot receive offers or answers,
yet netbird status showed Signal: Connected, hiding a stuck registration
for as long as it lasted.
Require a registered stream before reporting healthy. When there is none,
return false without touching the recorder: the disconnect that dropped
the stream already reported its reason.
* [client] Name the server that reconnected in the SR watcher log
Signal and Relay shared one reconnect callback, which logged "reconnected
to Signal or Relay server" for either. A Relay reconnect while the Signal
stream was down read as Signal having recovered, which misled diagnosis
of a stuck Signal registration.
Register one callback per server so the log names the one that actually
reconnected. Which listeners get notified, and when, is unchanged.
* [client] Trim comments in the Signal registration and health code
Comment-only change, no behavior change. Drop comments that restate the
code and keep only the reason a timer is used instead of a context
deadline.
* [client] Check Signal stream registration before the connection state
IsHealthy returned true for a Connecting or Shutdown channel before the
registration check ran, so a client with no registered stream could
still be reported healthy while the channel was reconnecting. Check the
stream first; the connection-state cases now only apply to a client
that has a registered stream.
* [client] Log a plain connect in the SR watcher
The Signal and Relay callbacks also fire on the first connection, not
only after a disconnect, so "reconnected" was wrong at startup. Log
"connected to <server> server" for both cases.
* [client] Say what the SR watcher does when it logs
The line was read as a per-server connect log, which Signal and Relay
already emit themselves. What it marks is the peer guards being told to
reset their reconnection backoff, which happens only once both are
ready. Log that, naming the server whose event triggered it.
* [management] Add a release-wired UBI image variant
OpenShift and other Red Hat environments expect UBI-based images that run
as an arbitrary non-root UID. The proxy, combined server and rootless
client already ship -ubi variants; this adds the same for management,
published as <version>-ubi and ubi-latest for amd64 and arm64.
The default --port of 80 cannot bind without root, so the image defaults
to 8080. For Let's Encrypt, public 443 forwards to --port and
--letsencrypt-listen-address is empty (#7706), so the main listener
answers the TLS-ALPN challenges.
* [management] Certify the management UBI image
Adds management to the Red Hat certification components. Its Partner
Connect component ID goes in the REDHAT_CERT_ID_MANAGEMENT repository
variable.
* [relay] Add a release-wired UBI image variant
* [relay] Align the UBI image with the shared UBI pattern
Main now ships UBI variants of the client, combined server and proxy that
share one license collector and one image layout, and the Red Hat
certification workflow expects every certified image to carry both amd64
and arm64. The relay variant predated that, kept its own copy of the
license script, built amd64 only, and ran as UID 65532 on the privileged
default port :443, so it would not start under OpenShift or rootless Podman.
Use release_files/collect-licenses.sh and build amd64 and arm64 like the
other UBI images. Run as 1000:0 with a group-0 writable /var/lib/netbird
for Let's Encrypt data, and default the listener to :8443 so an arbitrary
non-root UID can bind it; the relay's Let's Encrypt flow uses TLS-ALPN on
that same listener, so no separate challenge port is needed.
* [infrastructure] Certify the relay UBI image on release
Releases certify the UBI images through redhat-certify.yml, which only
lists the client, proxy and combined server, so a released relay UBI
image would never reach the Red Hat catalog.
Add relay to the component list and the manual dispatch options. Its
Partner Connect ID comes from REDHAT_CERT_ID_RELAY, which must exist
before this merges: the resolve step fails the whole run when any
component's variable is missing.
* [signal] Add an explicit non-root UBI image variant
Keep the existing signal image unchanged while making a separate UBI image available for local certification-readiness checks. Collect the linked Go dependency license terms as portable build inputs, and use SIGINT for the existing graceful stop handler.
* [signal] Wire the UBI variant into GoReleaser
Build the additive UBI signal variant through the existing release pipeline instead of relying on local Docker commands. Stage its licenses in the per-image build context and restrict the variant to validated AMD64 without changing existing image tags.
* [signal] Align the UBI image with the other UBI variants
Main now ships UBI images for the client, combined server and proxy with
a shared license collector and a common shape, so the signal variant
should look the same to reviewers and to Red Hat certification. Signal
also listens on port 80 by default, which an arbitrary non-root UID
cannot bind on OpenShift or Podman.
Use release_files/collect-licenses.sh instead of a signal-only copy,
build for amd64 and arm64 like the other UBI entries, and run as
1000:0 with a group-writable /var/lib/netbird that also holds Let's
Encrypt data. Default NB_PORT to the legacy gRPC port 10000 so the
image starts unprivileged and serves a single listener.
* [infrastructure] Certify the signal UBI image on release
The signal UBI image is published with every release, but only images
listed in the certification workflow reach the Red Hat catalog, so it
would otherwise need a manual run each time.
Add signal to the dispatch options and the component list, using the
same -ubi tag suffix as .goreleaser.yaml. The run reads its Partner
Connect ID from REDHAT_CERT_ID_SIGNAL.
* [signal] Answer Let's Encrypt challenges on the UBI image's port
The challenge listener defaults to :443, which a non-root UID cannot
bind on OpenShift or Podman, so Let's Encrypt could not work in the
UBI image. Now that the listener address is configurable, default it
to empty so the TLS listener on port 10000 answers the challenges
itself when public port 443 is forwarded to it.
The Windows installer shows LICENSE on its license page, where the
2022 copyright line reads as outdated. Extend the range to 2022-2026
in LICENSE, its REUSE copy, and the installer's LegalCopyright, keeping
2022 as the year of first publication.
* [client] Fix Windows UI exiting on first launch
Bump the Wails fork to pick up netbirdio/wails#6, which stops
SetBackgroundColour from dereferencing a WebView2 controller that does
not exist yet. On first launch the theme service re-tints the Welcome
window while it is still being created, and losing that race made the
UI exit with no window.
* [client] Point the Wails fork at the merged netbirdio/wails#6
Replace the temporary pin on emrcbrn/wails with the merge commit of
netbirdio/wails#6 on the fork's integration branch.
Reaching an internal service meant installing the agent, which needs root
for a TUN device. nblink runs the embedded client in netstack mode and
forwards local HTTP listeners to upstreams inside the tunnel, so it works on
managed machines and in rootless containers. Upstream names resolve
in-process.
Forwards bind loopback unless --allow-public-bind is given. The listener
refuses rebound and cross-site browser requests and bounds stalled request
bodies. It ships as release archives and a distroless image that runs under
an arbitrary UID.
An embedded client given no StatePath now keeps its state in memory rather
than falling back to the installed agent's state file, which every embedded
client in a proxy process also shared. The state manager gets an explicit
in-memory store for it.
With the embedded IdP, management.json can leave out the HttpConfig
section, and ApplyEmbeddedIdPConfig creates it later. Command-line
overrides run before that, so --letsencrypt-domain, or --cert-file with
--cert-key, dereferenced a nil HttpConfig and management crashed at
startup.
Create the section only when one of those flags writes to it, so a
config without TLS flags keeps HttpConfig unset.
Verify access decisions through real management and proxy containers, including upstream delivery and denial. Cover inherited auth, bypass/block, precedence, credential stripping, updates, restrictions, deletion, and private-service rejection.
Validation: container E2E and scoped lint passed locally. The workflow builds both components from the checkout.
Signal can report a successful start and then stop with exit code 0, or
continue without a listener, when a listener fails. Supervisors and
operators do not see the failure.
Stop at startup when the Let's Encrypt challenge listener cannot bind.
Keep a failure that occurs before the run loop waits, and return a
non-zero exit code after a server failure. A listener that is closed
during shutdown is not a failure.