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.
* implement certificate posture check
* log signal address
* add keychain and cert store support
* read the console user's keychain through a user session helper
A root daemon cannot reach a login keychain: securityd is per session and a
key ACL needs a session to prompt in, so dropping uid is not enough. The
daemon now answers certificate challenges from the System keychain itself,
where MDM installs device identities, and launches "netbird posture
cert-proof" into the console user's desktop session with launchctl asuser
for the login keychain. Only the signature and the chain cross back, never
the private key.
The console user comes from SCDynamicStoreCopyConsoleUser, bound with purego
like the keychain calls. The login window reports no user, root, or
"loginwindow", and all three are treated as no keychain to read, so a Mac at
the lock screen sends device proofs alone.
Adds info logging across the path: the keychain search list, per class query
status and item counts, the chain built per candidate, and the verification
error for every rejected candidate. A run that sends nothing now says why.
README.md documents the trust model, the console user limitation and how to
read the logs.
* read the signed-in user's certificate store on Windows
A service reads LocalMachine\MY, where AD and Intune enrol device
certificates. CurrentUser\MY lives in the signed-in user's registry hive
with keys protected against their profile, and a service that opens it does
not fail: "current user" resolves to HKU\S-1-5-18, so it silently reads the
service account's own empty store. The service therefore reads the machine
store itself and launches "netbird posture cert-proof" with the session
token for the rest, mirroring the macOS console user helper.
Windows lets a privileged service assume a user identity, so the token goes
straight into the child process and no external tooling is involved.
CREATE_NO_WINDOW keeps a console window from flashing on the desktop every
sync. In-process impersonation would also work but is per OS thread while
goroutines migrate, so the child process avoids that class of bug.
Session selection prefers the physical console and falls back to any active
session, so remote desktop and VDI hosts are covered. WTSQueryUserToken
needs SE_TCB_NAME, so a user-run client skips the helper and reads the
machine store alone.
SystemStore takes a store location, gaining NewUserStore alongside
NewSystemStore and the per candidate logging macOS already had. The request
building and proof merging move to helper_spawn.go, shared by both
platforms, and helperStore picks what the helper reads per platform.
* start TPM support
* split goreleaser to support pkcs11 and exclude on docker
* update goreleaser
* go mod tidy
* add tpm pin to netbird config
* split cert and key location and allow key lookup on tpm
* add unsupported flag for mobile devices
* Isolate the cert proof helper from the service environment and cap its output
* Read the PKCS#11 token PIN from NB_TPM_PIN instead of the profile config
* Bound certificate proof collection so a stuck token or keychain cannot hold the sync loop
* Stop retrying a PKCS#11 PIN the token rejected
* Log certificate posture details at debug level
* Sign only nonces and peer keys of the size management issues
* Skip certificate files whose key belongs to another certificate
* Bound PKCS#11 driver sizes, pin template values, and log out only a login the session owns
* Never pass NULL to CFRelease and skip unreadable keychain identities
* Keep the macOS keychain code out of iOS and the PKCS#11 driver out of Android
* Find a chain to each challenge's CAs through every intermediate the store holds
* Require a token label whenever a PKCS#11 PIN is set
* Read user certificates only from the session of the active profile's owner
* Collect certificate proofs again when the owner's session changes and report lost proofs
* Test the PKCS#11 build against SoftHSM in CI and warn once where the build has no driver
* Document where an inline PKCS#11 PIN is stored and how it is protected
* Refuse PKCS#11 URIs that this client cannot honour instead of widening the match
* Trust certificate and key files only when no other user can write or redirect them
* Explain a Windows certificate whose key only a legacy CryptoAPI provider holds
* Use platform absolute module paths in tests and add a real owner session test for Windows
* Match the Windows profile owner by name instead of resolving it through the domain controller
* Keep the certificate stores and TPM library out of the WebAssembly build
* [client] Read TSS2 key files on go-tpm, checked against the library it replaces
The TSS2 parser was the only reason this repository depended on a crypto suite
whose own build tooling it inherits. The replacement sits on go-tpm, which was
already a direct dependency and is in fact what that suite calls underneath, so
this removes a wrapper rather than porting onto a different library: the load,
the derived storage root key and the signing commands are the same calls.
Swapping a parser on the one path a customer actually runs is not something to
assert, so the two are held side by side for this commit. One test feeds the
replacement bytes the old library wrote and requires the same key type, empty
auth flag, parent handle, blobs and decoded public key; the other feeds both the
fixtures the tests are built on, so those are the shape the format calls for and
not merely the shape the new parser reads. The scaffolding goes away with the
dependency in the commit that follows.
The encoder behind the fixtures is written out separately from the parser under
test, so an encoder bug and a decoder bug cannot cancel each other out.
* [client] Drop go.step.sm/crypto and the repo-wide upgrades it imposed
The TSS2 parser was the only thing in the repository that used this module, and
it brought 302 modules into the graph to do it — 35 of them linters, along with
Google Cloud KMS and IAM, the AWS SDK and a terminal styling library. Those are
the module's own development dependencies, which minimal version selection turns
into floors in ours, and they are the whole reason gRPC, protobuf, the AWS SDK,
OpenTelemetry, logrus and five x/ packages had moved. Management, signal, relay
and proxy inherited every one of them for a feature none of them runs.
Removing the import is not enough, because tidy never downgrades: the raised
floors stay written in go.mod. Each one is pinned back to the version main had,
then tidy is left to raise again whatever something still genuinely needs. It
raised nothing: all 43 are back where they were, and go-tpm was already in the
graph at the same version, so the certificate feature now costs no new module at
all.
The differential tests go with it. They existed to check the swap against the
library while both were present, and there is nothing left to compare against.
* [client] Clear the lint findings only the macOS and Windows runners see
golangci-lint analyses one build at a time, so running it on Linux says nothing
about the two platforms CI also lints. Against those builds the feature's
packages reported eight findings, and the structural one is Config.dir: it is
dead on macOS and Windows because neither reads a directory at all, their
collectors take the configuration and discard it. Moving the method beside its
only callers makes that visible in the layout instead of in a linter, and leaves
the gap itself — no file or token store on those platforms — where it belongs,
as something to decide rather than something to silence.
An absent key file beside a certificate was reported as a nil signer with a nil
error, which the caller then had to recognise by its nilness. It is a sentinel
now, so the meaning is in the error rather than in the absence of one.
The rest follow the standard library: the elliptic coordinates and the private
scalar come from the encoding helpers rather than the deprecated big.Int fields,
and an error string loses its trailing colon.
Lint is clean on linux, darwin and windows; the hardware TPM path was exercised
separately against a real device and passes.
* Accept the TSS2 emptyAuth boolean OpenSSL writes and persistent parents on 32-bit builds
* Count the certificates field in the peer meta store test
* Check the store directory before listing it, refuse group-writable files, and reject a URI with two PIN sources
* Share a PKCS#11 login between sessions and send each PIN at most once at a time
* Collect certificate proofs again when the meta sync carrying them failed
* Use no Windows user store when a domainless owner matches accounts of several domains
* Use no user certificate store when the active profile's owner cannot be read
* Document the PIN sources on CertPKCS11URI and keep the README PIN example off the command line
* Test that the PKCS#11 URI stays out of the debug bundle and run the wrong-PIN test only on a disposable token
* Refuse a TPM PSS signature request for the maximum salt length
* Add the certificate fields to the network map golden data
* Retry posture checks whose meta sync timed out instead of dropping them
* Start no system info gathering while a timed-out one is still running
* Guard the applied posture checks across goroutines and keep refreshing proofs while a pending update times out
* Log what a successful certificate proof helper wrote to stderr
* Send recollected certificate proofs to management only when the proven chains changed
* Explain a macOS keychain key whose access list does not allow netbird
* Kill the whole macOS certificate helper process group when it times out
* End sudo option parsing before the macOS certificate helper binary
* Hold off system info gathering only while a timed-out one is still running
* Collect certificate proofs on the posture watcher instead of under the sync lock
* Read the certificate store directory and PKCS#11 URI from the daemon environment, not the profile config
* Install the RPM sysconfig file readable by root only and show the certificate posture variables
* Move the certificate posture README into the package doc and the docs site
* Name NB_CERT_PKCS11_URI in the PIN-without-token error
* Keep the file check results of the latest-started system info refresh
* Give the full import command for a keychain key netbird may not use, and correct the package doc
* Restrict the service environment file to root on every package install
* Search only the System keychain in the macOS daemon and only the login keychain in the user helper
* Let the certificate proof helper read the PKCS#11 token from the environment on Linux
* Ask a macOS user's keychain again only after an hour when it proved nothing
* Clear the lint findings in certificate posture
* Hold off the keychain helper only after a completed or timed-out run, independent of CA order
* Keep free functions out of the method lists of PKCS11Store, URI and Challenger
* Name the post-install permission helper in snake case and shorten the sysconfig certificate block
* Drop the certificate store directory from certproof.Config, which only NB_CERT_STORE_DIR sets
* [management] Renew certificate challenge nonces on quiet accounts
A certificate challenge nonce is accepted for its own window and the one before
it, and it only reaches a peer attached to a network map. An account where
nothing changes sends no map, so after a day the peer re-sends the nonce it
still holds, verification rejects its whole proof set, and the certificates
stored for it are dropped. It fails the certificate check and loses every policy
gated on it until some unrelated change happens to push a map. The outage
repairs itself in seconds, which is what makes it expensive: it is intermittent,
it only hits stable networks, and it is not reproducible on demand.
Push the account's peers an update often enough that the nonce they hold is
never close to expiring. Only accounts whose posture checks actually ask for a
certificate are tracked, so a deployment without the feature does no extra work.
The refresh runs from one goroutine over a map of accounts rather than a timer
per account: the period is hours, so one pass every few minutes costs nothing
next to it, and there is no timer to re-arm when an account that falls due
sooner appears. Each account's first run is offset by a hash of its ID, because
the challenge window is global and an instance restart would otherwise arm every
account in the same moment.
The push carries no administrative change, so it is counted as a refresh rather
than an update and stays out of the figures that track what was edited.
(cherry picked from commit 7ad4a0df37)
* [management] Make the certificate challenge window one knob to turn
Renewal was timed against the window in two different ways: the period derived
from it, the sweep interval did not. Shortening the window to watch a renewal in
an end-to-end run would have left the refresher still looking for due accounts
every quarter of an hour, so nothing would have been renewed in time and the
test would have reported the feature broken.
Derive the sweep from the period, within bounds that keep a very short window
from spinning and a normal one from checking less often than is useful, and
allow the window itself to be set through the environment so a run can take
seconds instead of half a day. A value that cannot be parsed or falls outside
the bounds keeps the default, because a window nobody intended is a security
property nobody chose, and an override is logged at warning level since it sets
how long a device keeps passing the check after its key is gone.
Every instance has to be given the same value: the window is part of the nonce,
so instances that disagree reject each other's.
(cherry picked from commit 0e38fcf409)
* [management] Pin the property that makes per-peer nonce state unnecessary
A nonce carries the window it was minted in, not the instant, and is accepted
for that window and the one before it. So a peer re-stamped at least once per
window can never be left holding one outside the accepted pair, whenever it was
last served and however much life its own nonce had left. That is the whole
reason management tracks nothing per peer, and it was resting on an argument
rather than a test.
The phases are part of the property, not decoration: accounts are deliberately
given a refresh phase of their own, so the guarantee has to hold off the window
boundary too. The negative case shows why that matters — a cadence of exactly
two windows lands inside the grace window when it is aligned to the boundary and
leaves a gap when it is not.
(cherry picked from commit dee68facfd)
* [management] Renew challenges only for the peers that answer one
The refresh pushed an update to every connected peer of the account, while only
the peers a certificate check applies to carry a nonce. On an account where a
handful of peers sit behind the check and the rest do not, everyone was woken
several times a day to be handed a map that changed nothing for them.
Push to the sources of the enabled policies whose posture checks include a
certificate check, which is exactly the set that is sent a challenge.
Resolving the set the other way round than the gRPC layer does is the risk here:
a peer the refresh forgets stops being renewed and falls out of its policies
silently, which is the failure this whole mechanism exists to prevent. So the
selection is held against processPeerPostureChecks, the per-peer rule that
decides who receives a challenge in the first place, by a test that asks both
the same question and requires the same answer.
(cherry picked from commit dc4d0e0274)
* [management] Derive certificate challenge nonces from the stored encryption key
The nonce secret came from the server's WireGuard key, which is generated afresh
in every process and never persisted. A nonce carries no state, so the only
thing that lets one instance verify what another issued is deriving the same
secret — and that premise, written in the comment above the challenger, was not
met: every instance had its own key.
A peer reconnecting after a restart therefore presented a nonce minted under the
previous secret, verification failed with a mismatch, its whole proof set was
rejected and the certificates stored for it were dropped until it signed again.
Reproduced three times on the lab, each one logging "nonce was not issued to
this peer", which only a changed secret produces. On a single instance it costs
seconds of lost policy access per restart; across instances it is not transient
at all, because every reconnect that lands elsewhere is rejected the same way.
Derive from the data store encryption key instead: it is generated once, written
back to the configuration and read by every instance, so it survives restarts
and is shared. Where none is configured the secret falls back to the WireGuard
key with a warning — degraded but still unpredictable, which is the property
that matters most: a peer able to guess it could mint the nonces of future
windows, sign them while its key is present and keep passing after it is gone.
The challenger is now built once and passed to the two places that need it,
rather than re-derived per message.
(cherry picked from commit 278f2f3807)
* [management] Register an account for renewal where its nonce is issued
Renewal was armed when a peer connected or when a posture check was saved, both
of which ask the store whether the account has a certificate check. That misses
the case it most needs to catch: the check is created through one instance while
the peers are connected to another, so the instance serving them never learns it
has anything to renew and their nonce expires. It also charged a query to every
peer connect in every account, including the ones that will never use the
feature, which a fleet reconnecting after a restart pays all at once.
Register where the nonce is actually stamped instead. A nonce is verified from a
shared secret and so travels between instances, but the renewal that keeps it
fresh cannot: only the instance holding a peer's stream can push to it. Issuing
and renewing now line up by construction — an instance renews exactly the
accounts it has issued nonces for — and an instance that never issues one has
nothing to renew, so there is no case left to miss.
The registration is a map insert with no store access, which is what lets it sit
on a path taken by every login and every initial sync.
Reported by Viktor Liu, who also proposed registering at the point of issue.
(cherry picked from commit 2d16dd7d7cf54762f2e64c5630ea092f32ef63ab)
* [management] Register for renewal on pushed updates, not only on connect
Registering where the nonce is stamped only covered the login and the initial
sync, which both happen when a peer opens a stream. That left out the path the
mechanism exists for.
On the cloud the network map controller is wrapped so that an update publishes
to an event bus instead of pushing locally: an instance handling a REST change
broadcasts, and every instance holding a peer of that account pushes to its own.
Those pushes stamp a nonce through the update handler, and nothing there
registered, so an instance learned about an account only when one of its peers
happened to reconnect. For a quiet fleet that is the original bug: the check is
created, the peers are told about it, and nobody renews what they were told.
Registering on the pushed update closes it, and is the difference between
stamping and marking a peer connected — one happens on every push, the other
only when a stream opens. Reported by Viktor Liu; the broadcast that makes it
work was pointed out by Pascal Fischer.
(cherry picked from commit 59efe8d93e53bacdf57cb546f4ab2c19dc4eddab)
* [management] Let the challenge refresh loop stop with the manager that owns it
The loop was started on a context explicitly detached from the caller's, so
nothing could ever stop it. Production is unaffected either way, since
BuildManager is called with context.Background(), but a test that builds a
manager leaked a sweeping goroutine for the rest of the run, and a shutdown
path added later would have had no way to reach it.
Take the manager's context as the request buffer built on the line above
already does. The test pins the contract the loop offers, so a detached
context cannot come back inside Start either.
* [management] Bound one account's challenge refresh so it cannot starve the rest
Resolving which peers answer a challenge reads the store three times, and the
refresher sweeps accounts one after another on a single goroutine. A read that
never returns held the sweep for the life of the process, so every other
account on the instance stopped being renewed and its peers fell out of the
policies gated on the check: one account's bad luck became an outage for all
of them.
Give each refresh the sweep interval it is allowed to occupy, capped at 30s so
a 12-hour window does not grant minutes to a query that should take
milliseconds. A refresh that runs out of time keeps its account tracked, since
a deadline says nothing about whether that account still has a certificate
check.
* [management] Send challenge refreshes down the path the rest of management uses
The refresh dispatched through UpdateAffectedPeers, the one variant that takes
no reason, so it was missing from the update counters and coalesced with
nothing. An administrator editing a policy while the sweep ran made the
account's network map twice over, and UpdateOperationRefresh, added for
exactly this caller, was never referenced.
Buffer it with a posture_check/refresh reason instead. The periodic push is
now visible in the metrics as what it is, distinct from an edit, and the send
detaches from the sweep deadline on its own, so that deadline bounds the store
reads it was meant for.
* [management] Keep the certificate challenge comments to what the history does not say
Four of these ran to three and four times the comment budget, the longest at
992 characters. Most of the excess argued against designs that were never
written or explained a bug that no longer exists in the code, which is what
the commit that fixed it is for.
What is left is the part a reader cannot recover from the code: that the
nonce secret has to be persisted and unpredictable, that stamping and
renewing are decided together because only the serving instance can push, and
that the target rule is the inverse of processPeerPostureChecks.
* Keep the newest posture checks pending whatever made their meta sync fail
* Report no lost certificate when the engine stops during a proof collection
* Share the proof collection single-flight across engine restarts
* Close a PKCS#11 module that loads but cannot be used
* Fix the pending checks comments
* Renew certificate challenges only for the peers streamed to this instance
* Ignore a challenge stamp from an older sync stream of the same peer
* Kill the Windows certificate proof helper with its whole process tree
* Expect the challenge untrack in the session ownership test
* Drop an invalid certificate proof without discarding the valid ones
* Start a system info gathering beside one that has been stuck for ten timeouts
---------
Co-authored-by: pascal <pascal@netbird.io>
Co-authored-by: mlsmaycon <mlsmaycon@gmail.com>
Co-authored-by: riccardom <riccardomanfrin@gmail.com>
For a manifest list, preflight writes results.json only under
artifacts/<arch>/. The literal artifacts/results.json path is not a glob,
so nullglob kept it in the list and jq failed on the missing file even
though every check passed. A single globstar pattern matches both layouts.
* Add Galician (gl) localization for desktop client
* [client] Address review feedback and sync latest i18n keys for Galician
Fix terms per review feedback (usarase, creen, auditar, JWT cache, poscuántico, etc.) and translate newly added upstream i18n keys.
* [client] Fix translations, polish terminology, and sync latest keys for Galician
* [client] Fix i18n parity against branch source of truth and drop undeclared uk
* [ci] Skip Galician locales in codespell check
* [client] Add latest error keys for Galician and revert _index.json formatting
Add error.settings_locked and error.settings_managed_by_mdm to Galician locale for 100% key parity with en/common.json. Revert multi-line formatting in _index.json to keep a single-line entry for Galician.
* [self-hosted] Add a UBI image variant for the combined server
OpenShift and other Red Hat environments expect UBI-based images that run
as an arbitrary non-root UID. The proxy and rootless client already ship
-ubi variants; this adds the same for netbird-server, published as
<version>-ubi and ubi-latest for amd64 and arm64.
* [self-hosted] Check the license output path before creating temp files
The existing-output exit ran before the cleanup trap was registered, so it
left the two mktemp files behind.
* [self-hosted] Certify the netbird-server UBI image
Adds netbird-server to the Red Hat certification components. Its Partner
Connect component ID goes in the REDHAT_CERT_ID_NETBIRD_SERVER repository
variable.
* [misc] Share one license collection script across the UBI images
The client, proxy and combined images each carried a near-identical copy of
collect-licenses.sh, and the signal and relay variants would add two more.
The copies differed only in the Go package, build tags, component license
and the proxy's web licenses, which are now options of one script in
release_files/. The client gains the staged write the others already had.
* [self-hosted] Add a UBI image variant for the combined server
OpenShift and other Red Hat environments expect UBI-based images that run
as an arbitrary non-root UID. The proxy and rootless client already ship
-ubi variants; this adds the same for netbird-server, published as
<version>-ubi and ubi-latest for amd64 and arm64.
* [self-hosted] Check the license output path before creating temp files
The existing-output exit ran before the cleanup trap was registered, so it
left the two mktemp files behind.
* [self-hosted] Certify the netbird-server UBI image
Adds netbird-server to the Red Hat certification components. Its Partner
Connect component ID goes in the REDHAT_CERT_ID_NETBIRD_SERVER repository
variable.
* [infrastructure] Create the preflight artifacts directory before submitting
* [infrastructure] Add a workflow to certify UBI images on demand
Move the Red Hat certification job into redhat-certify.yml so it can be
run by hand for any released version and component, or for all of them.
release.yml calls it with component "all" on stable tags.
Component IDs now come from REDHAT_CERT_ID_<COMPONENT> repository
variables. With "all", components without a variable are skipped.
* [infrastructure] Fail Red Hat certification on missing IDs or timeout
Fail before certifying when a selected component's REDHAT_CERT_ID_*
variable is missing, listing every missing variable. Filter the Pyxis
poll by tag so older versions are found past the first page, and fail
the job when both architectures are not certified within 10 minutes.
* [misc] Move the FreeBSD port test to release 15.1
FreeBSD 15.0 reached end of life on 2026-09-30 and the ports tree marks
it unsupported since freebsd/freebsd-ports@ed90b23fe9 (2026-10-01), so
`make package` refuses to run on the 15.0 VM and the FreeBSD Port job
fails on every PR. The pinned vmactions/freebsd-vm v1.4.8 ships a 15.1
image, so only the release needs to move.
* [misc] Run the FreeBSD unit tests on release 15.1 too
The job installs binary packages instead of building from the ports
tree, so it kept passing on the EOL 15.0 image, but the client should
be tested on the same supported release the port is built on, and the
EOL image is only served from the archive mirror from now on.
* [client] Migrate macOS cask template to Homebrew install steps
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.
(cherry picked from commit df3756151f)
* [client] Test the macOS Homebrew cask on a disposable runner
The cask template only runs on real macOS with Homebrew, sudo and
launchd, so changes to it have never been exercised before merge. This
job installs the rendered cask on a GitHub macOS runner, walks the
uninstall through a running, stopped and missing daemon, and reinstalls
over the tap's published legacy cask, which is the path every existing
user takes on their next upgrade.
The fixture is the published cask itself rather than a pinned version
and checksums, so the test follows each release instead of breaking at
the next one. The installer scripts inside the signed archives are not
under test, which is why their paths are left out of the trigger.
* [client] Address SonarCloud findings in the Homebrew cask test
Positional parameters move into local variables and the scenario switch
gains an explicit default, so an unknown scenario fails instead of
silently running the plain install and uninstall path.
* [client] Make the Homebrew cask test deterministic with a stub bundle
The released installer script opens the UI as root, which never returns
on a headless runner, so a test that installs the published archive
hangs until the job timeout. The cask itself never looks past two script
paths and a version argument, so the test now builds a stub bundle on
the runner, serves it from a local HTTP server and renders the template
against it. The scripts ship without the executable bit, which turns the
0755 check into proof that set_permissions ran, and the stub records the
version and uid it received. The published archives are still downloaded
to assert the two script paths exist, and the published cask still
supplies the legacy stanzas for the reinstall scenario.
* [client] Drop the launchctl stderr check from the Homebrew cask test
The test asserts what the cask template promises: install, uninstall and
no deprecation warnings. Whether the uninstall steps print launchctl
errors is a review remark on the template, not part of that contract.
* [client] Retry the daemon start in the Homebrew cask test stub
A reinstall runs the previous cask's bootout and the new postflight
within a second of each other. launchd is still tearing the old daemon
down at that point, so loading the same label again fails with EIO. The
stub now retries the start for up to fifteen seconds, and the test still
verifies afterwards that the daemon reached the running state.
---------
Co-authored-by: Daniele Casciani <d.casciani@genogra.com>
Add a UBI-based reverse-proxy image for the internal Red Hat certification requirement, without changing the existing image or deployment defaults. Includes non-root execution, licensing and image metadata, plus an AMD64 GoReleaser entry with separate UBI tags.
Preflight and TLS/overlay checks passed. An intermittent shutdown exit error remains deferred; this stays draft pending maintainer testing.
Inline the job in release.yml, gated on a stable vX.Y.Z tag on the upstream
repo so it stays out of main, release branches and pull requests. Its env and
contents:read permission move to the job, keeping them off the rest of the
workflow.
upload-server/Dockerfile only packaged the goreleaser-built binary, so
the image could not be built from a checkout. It is now a multi-stage
build on Chainguard static, running as the nonroot user (uid 65532),
with a VARIANT=debug build arg that swaps in busybox for a shell.
The goreleaser packaging file moves unchanged to Dockerfile.release and
.goreleaser.yaml points at it, so the published netbirdio/upload image
stays as it was: distroless and root.
The bases are pinned by digest, since Chainguard publishes only :latest
for free. A Dependabot docker entry for /upload-server moves them
weekly, leaving the release base alone and holding golang to patch
updates.
Nothing in CI compiles the files behind //go:build android or //go:build ios.
The android bridge builds 7 of its 23 files on linux and skips client.go; the
iOS SDK is not built at all. The linter matrix picks a GOOS by picking a runner
OS, so it loads the same file set as the host build and never sees them either.
A type error in client/android/client.go therefore passes every check on its
PR, merges, and is discovered by netbirdio/android-client after sync-tag.yml
fires trigger_android_bump on the release tag.
The new Mobile workflow cross-compiles ./client/android/... for the GOARCH
values gomobile ships and ./client/ios/..., and vets the android bridge. The
new Android and iOS lint jobs run golangci-lint with GOOS/GOARCH in the job
env. No NDK, Xcode or gomobile is needed: these are library packages, so the
compiler type-checks them without a link step, and the dependency graph drags
in the android/ios-tagged files across client/iface, client/internal/dns and
client/internal/routemanager with them.
Linting those files for the first time surfaces one gosec G101 on the SSH
password-required marker. It is a sentinel string the Java side matches on,
not a credential, so it is suppressed at the declaration.
GitHub Actions runners no longer ship Node 20 for JavaScript actions, and the
ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION opt-out is gone, so any action whose own
action.yml declares `runs.using: node20` now fails to start. Eleven call sites
across three workflows were still on such actions: actions/setup-node (five),
pnpm/action-setup (four), actions/cache and actions/setup-go (one each). Every
target was verified by reading `runs.using` out of the pinned ref's action.yml
rather than inferred from its version number.
Pin style is preserved per call site: SHA-pinned refs stay SHA-pinned with a
corrected `# vX.Y.Z` comment, tag-pinned refs stay tag-pinned. cache and setup-go
go to v6 rather than the newest release so the stragglers join the versions the
rest of this repo already runs.
Breaking changes were checked and none apply. The setup-node v5/v6 automatic
dependency caching never triggers: it resolves package.json from the repo root,
which does not exist here, and the frontend caches the pnpm store itself. v7
drops the dummy NODE_AUTH_TOKEN export and adds cache outputs, neither of which
any workflow reads. setup-go v6 reworks toolchain selection, but the one
straggler passes the same go-version-file and `cache: false` as the 22 setup-go
v6.5.0 pins already in CI. The v5/v6 runner floor is met because every job runs
on GitHub-hosted runners.
pnpm/action-setup v4 added a hard error when the `version:` input disagrees with
package.json's `packageManager`. It stays dormant here only because the action
looks for package.json at the repo root and swallows the resulting ENOENT; all
four sites pass `version: 11` while client/ui/frontend/package.json says
pnpm@11.4.0. Left alone to keep this change to the runtime bump, but adding a
root package.json or setting `package_json_file` would make all four fail.
git-town/action is knowingly left on node20. Every release through the latest
v1.3.3, and main HEAD, still declares `runs.using: node20`, so there is nothing
to bump to. That job will break when the runtime is retired and needs its own
decision: drop it, fork the action onto node24, or file upstream.
node-version stays at 22. That is the Node toolchain used to build the frontend,
not an action runtime, so this retirement does not touch it, and the build cannot
be validated on this host because the binding generator needs Linux-only GTK4 and
WebKitGTK dev packages. It belongs in a separately verified change.
Goreleaser's RPM build is split into one nfpm entry per architecture, each pinned to a single-arch build, with the version substituted from the release job. The deb package, archives, and container images are unaffected.
Also fixes rpmlint: incoherent-version-in-changelog, found while investigating: nfpm writes the changelog title straight from semver and never appends the release, so entries read 0.79.0 against a 0.79.0-1 package.
Declare the runtime dependencies, generate the changelog from git tags with
chglog at release time, and ship LICENSE, README.md and an example
/etc/sysconfig/netbird as %license, %doc and %config(noreplace). The unit
generated by "netbird service install" already reads that path via
EnvironmentFile, so post_install.sh is unchanged.
Management / Unit (amd64, mysql) hit the 20 minute go test budget on #7516. The package was not hung: each of the 133 test store creations in management/server paid about 1.6s on MySQL for CREATE DATABASE, the pre-migrations, a 40-table AutoMigrate and the post-migrations, which puts the package at 10 minutes on a healthy runner and over the budget on a slow one.
The migration now runs once per test binary into a template database and each test database is cloned from it, with CREATE DATABASE ... TEMPLATE on Postgres and a replay of SHOW CREATE TABLE on MySQL. The MySQL test container also drops the binary log, doublewrite buffer and per-commit redo fsync. Two goroutine leaks in the test helpers are fixed.
tools/gotestsummary turns the go test -json stream into a readable log, and the Management unit and integration jobs now pipe through it, so a timeout names the tests still running. On MySQL, management/server went from 10m16s to 6m36s.
* [client] Fix the Windows tray deadlock on re-entrant window creation
The Wails systray runs the left-click handler synchronously inside the
tray window procedure, and creating a window on a running app pumps a
nested Win32 message loop while WebView2 initialises. ensureWindow held
the non-reentrant createMu across that creation, so the second button-up
of a double click re-entered ShowWindow from the pump and blocked the
main thread on its own lock. A goroutine holding createMu while the main
thread pumped, and the Open* dialogs holding mu across NewWithOptions,
Show, Hide and InvokeSync, exposed the same inversion.
WindowManager now serialises creation with a per-slot creating flag and
queues the callers' operations until the window exists, and no Wails call
runs while mu is held. The tray click and second-instance handlers call
ShowWindow off the message loop.
* [client] Serialize window operations while a slot is being created
Callers arriving after the window is published but before the creator
has drained the queue took the existing-window fast path and could run
ahead of older queued operations, so a newer SetURL could be overwritten
by an older one. withWindow now queues every caller while the creating
flag is set and clears the flag only once the queue is seen empty under
the lock.
A factory panic or a nil window left the creating flag set and the slot
dead; creation and drain now reset that state on early exit.
hideOtherWindows records the windows it hid only when no restore ran
in between, tracked by a generation counter, and re-shows them otherwise,
so a restore racing the hide cannot strand hidden windows.
* [misc] Run the client/ui subpackage tests in CI
The three test workflows filtered the package list with a `/client/ui`
prefix match, which dropped the subpackages along with the package that
cannot compile without a frontend build. `services`, `preferences`,
`i18n` and `authsession` all carry Go-side unit tests that never ran,
including the window manager re-entrancy regression test.
Anchor the pattern so only `client/ui` itself is excluded. The linux leg
keeps the prefix match on 386, where only the 64-bit gtk4/webkitgtk dev
packages are installed and the Wails application package would fail to
link, and the alpine container job keeps it for the same reason.
* [misc] Run the client/ui subpackage tests on a gtk4 4.10 runner
The previous commit let the subpackages into the linux client job, where
client/ui/services failed to build: the wails runtime's linux cgo layer
uses GtkFileDialog, which arrived in gtk4 4.10, and the job's ubuntu-22.04
runner ships 4.6.
Move them to their own job pinned to ubuntu-24.04 and restore the linux
client job's original exclusion, leaving the 386 and privileged legs on
the runner they have used since 2024. The new job needs no build cache,
sudo or privileged tag, so it stays a few seconds long.
Darwin and Windows keep the anchored pattern from the previous commit and
already run these tests green, including the window manager re-entrancy
regression test on the platform the deadlock was reported on.
* [client] Defer a window close that lands while the window is still being created
WindowManager publishes a dialog's slot only after the factory returns,
and on Windows the factory blocks in the WebView2 embed pump. A Close*
arriving in that gap found a nil slot and returned without doing
anything, so the dialog appeared afterwards for a flow that had already
been cancelled. The pre-fix Open* dialog functions held mu across the
whole creation, which blocked a concurrent Close* until the slot was
set; removing that lock hold reopened this gap.
Close* now goes through closeWindow: while the slot is being created it
records a closer in pendingClose, and finishCreation runs that closer
before any queued operation, so a window that is going away is never
shown and Wails never sees a Show on a destroyed window, which would
recreate it. Ops queued behind a close are dropped; windowOp carries no
factory, so they cannot be replayed into a new creation, and the
frontend callers reissue on the next state change.
The browser-login slot uses the same restoring closer from both
CloseBrowserLogin and CloseRenewFlow, since the popup's WindowClosing
hook only restores on a user close. Where two closers race one
creation the first registered wins, so a later caller cannot replace a
restoring closer with one that does not restore.
A push that creates a branch sends the all-zero SHA as `before`, and
bufbuild/buf-action unconditionally builds its default baseline from
it:(src/inputs.ts:86), so `buf breaking` failed cloning a ref that does
not exist. This broke the first run on every new release-* branch, most
recently release-0.78.
Gate `breaking` on github.event.created instead. Nothing is lost: every
commit on a freshly cut release branch already passed the check on main,
and pushes with a real `before` -- plus pull requests, which compare
against their own base -- keep the action's own default baseline, so
stacked PRs are unaffected.
Also drop `build: false`, which is not an input this action accepts and
only produced a warning.
* Bump go version to 1.26 and go-quic to v0.62.0
* Replace deprecated ecdsa public key assembly and add tests for jwt
* Update goversioninfo
* Pin go toolchain to 1.26.7
[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.
* [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.
English (en) is the source of truth for UI translation keys; the other
nine locales rely on runtime English fallback for any missing key, so a
gap never surfaces in CI. Add a dependency-free Node check that fails
when any locale declared in _index.json does not carry the exact same
key set as en (missing or orphaned keys), wired into a dedicated
UI Translations workflow that runs on locale changes.
Also close the one existing gap the check found: ja was missing
daemon.outdated.download ("Download Latest").
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Prepares the repository for the release-branch process agreed internally:
one long-lived release-0.N branch per minor, with fixes backported by
cherry-pick and patch releases tagged from the branch.
Pushes to release-* branches now run the Release workflow and publish
immutable sha-* container images, the way pushes to main already do, so
a release branch can be tested before it is tagged. Release branches
never publish the floating "main" image tag. The push-to-main CI
workflows (Go tests on all platforms, frontend UI, install script,
mobile/wasm validation, infrastructure files, license check) also run
on release-* pushes; pull request triggers were already unfiltered, so
backport PRs were covered — this closes the post-merge gap.
Releases are no longer marked latest before signing: make_latest is
now false in all four goreleaser configs, so a release stays published
but not latest until the signing pipeline uploads the signed Windows
and macOS artifacts and marks it latest itself. Previously the release
became GitHub's "Latest release" at publish time, and the download
endpoints that resolve through the latest-release API could serve a
release whose signed installers did not exist yet. prerelease: auto
additionally labels rc tags as prereleases, so a release candidate can
never take the latest slot.
The trigger_sync_tag job is removed: it dispatched a downstream
image build on every v* tag (release candidates included), which would
race the deliberate release-branch build on every release. The android
and ios submodule bumps are unchanged.
Also sets perennial-regex = "^release-" so git-town never syncs or
ships a release branch into main.
The community and enterprise bootstrap scripts now generate a dedicated
`server.auth.sessionCookieEncryptionKey` from 32 random bytes for fresh
installations.
The key is Base64-encoded, persisted independently from the datastore
encryption key, and reused from the generated configuration after
restarts.
The community script now also creates `config.yaml` with mode `0600`
before writing it, matching the existing enterprise behavior.
The server already supports the session cookie encryption key, but the
bootstrap scripts left it unset.
This adds defense-in-depth for newly generated deployments while
preserving the existing server-side nonce validation.
Because this only changes newly generated configuration, existing
installations and sessions are unchanged.
A focused regression test checks key presence, decoded length,
separation from the datastore key, YAML placement, and file mode for
both scripts.
It is included in the infrastructure workflow.
## Describe your changes
Extend CI tag list with Android and iOS
## Issue ticket number and link
<!--
Required for anything that changes behavior. Link the issue (or the
validated
discussion it came from) that the NetBird team already agreed on. See
https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second
-->
## Stack
<!-- branch-stack -->
### Checklist
- [ ] Is it a bug fix
- [ ] Is a typo/documentation fix
- [x] Is a feature enhancement
- [ ] It is a refactor
- [ ] Created tests that fail without the change (if possible)
- [ ] I ran and tested this change locally — I did not rely on CI to
find out whether it works
- [ ] This PR has a single purpose (not a fix + refactor + feature in
one)
- [ ] This change is a trivial fix, **OR** it links an issue the NetBird
team agreed on beforehand. Changes to the public API, gRPC protocols,
functionality behavior, CLI / service flags, or new features always need
that agreement first. See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second).
> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).
## Documentation
Select exactly one:
- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)
### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:
https://github.com/netbirdio/docs/pull/__
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Chores**
* Updated pull request title validation to accept `android` and `ios`
tags.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Describe your changes
We are updating the contributing guide to require an issue to be opened
before PRs; this will allow discussing changes before code is shipped.
Update the rest of the file because of outdated information and align
the pull request template
## Issue ticket number and link
- **Wails v3 application** (`client/ui`) with a React + TypeScript + Tailwind frontend replacing the Fyne UI: main connection view, exit-node switcher, networks/peers browser with detail panels, profile management, settings (general, network, SSH, security, troubleshooting, appearance), debug-bundle creation, and a first-run welcome flow.
- **Internationalization**: go-i18n bundle with 9 locales (en, de, es, fr, hu, it, pt, ru, zh-CN) shared between the tray and the frontend.
- **New system tray** implementation with per-platform theme-aware icons, including a native XEmbed host for Linux (`xembed_tray_linux.c`) and a Linux theme watcher.
- **Session handling**: auth session watcher (`client/internal/auth/sessionwatch`), pending login flow, session-expiration dialog and tray notifications, and `netbird login` improvements.
- **Daemon API extensions** (`daemon.proto`): status stream subscription, event stream, networks/exit-node selection endpoints, and richer full status — with probe throttling on the daemon side to protect against UI-driven request storms.
- **UI preferences store** persisted per profile, autostart management via the daemon (single source of truth in HKCU on Windows).
- **Build system**: Taskfile-based builds per platform (macOS, Linux, Windows), Docker cross-compilation images, MSIX/NSIS/nfpm/AppImage packaging, and a new `frontend-ui` CI workflow.
Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com>
Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
Co-authored-by: braginini <bangvalo@gmail.com>
Co-authored-by: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com>
Co-authored-by: riccardom <riccardomanfrin@gmail.com>
Restore the pre-split native, sudo-based run for the Linux Client / Unit
job: build with the privileged tag and run under sudo, matching the darwin
job. Excludes the dockertest harness (client/testutil/privileged) so it does
not recurse into a container spawn. The Docker privileged job is kept as-is.
The suite builds combined/proxy/client from source and drives live
provider traffic, so running it per push/PR is too costly. Switch to a
nightly schedule (03:00 UTC) plus workflow_dispatch, and drop the
now-unneeded fork guard that only mattered for pull_request runs.