Compare commits

..

16 Commits

Author SHA1 Message Date
pascal
525a4fb29c disable stale proxy cleanup 2026-07-13 20:16:20 +02:00
Pascal Fischer
62703ca23e [management] add logs to ephemeral delete (#6747) 2026-07-13 19:06:22 +02:00
Viktor Liu
cc64a93953 [client] Include system events in ToProtoFullStatus conversion (#6746) 2026-07-13 18:49:17 +02:00
Maycon Santos
831325d6e2 [management] require dashboard_features.agent_network when enabling agent_network_only (#6750)
Adds a settings constraint: enabling `agent_network_only` requires `dashboard_features.agent_network` to be `true` in the same account update. Without the Agent Network menu flag, a focused account that later turns the focused view off would lose access to the Agent Network menu entirely, so the two must be set together.

The check runs in `updateAccountRequestSettings` against the parsed request state: if the resulting settings have `agent_network_only == true` but `dashboard_features.agent_network` is not `true`, the update is rejected with `status.InvalidArgument` (HTTP 422) before anything is persisted.

The OpenAPI field descriptions for `agent_network_only` and `dashboard_features.agent_network` document the requirement. Only the descriptions changed — `required` and the schema `$ref` are untouched — and `types.gen.go` was regenerated from the spec (diff is the two comment lines).
2026-07-13 17:28:19 +02:00
Maycon Santos
8f64173574 [client] Enable launch-on-login by default on fresh GUI installs (#6738)
* [client] Add autostart preference marker and MDM disableAutostart key

Adds the autostartInitialized marker to the Wails UI preferences store so
the one-time autostart default decision can persist per OS user, and a
UI-only disableAutostart MDM policy key that suppresses the default and
flows into GetConfigResponse.mDMManagedFields like disableAutoConnect.

* [client] Enable launch-on-login by default on fresh GUI installs

On the first interactive run the GUI persists the autostartInitialized
marker before any enable attempt, then enables autostart only when the
platform supports it, MDM policy does not disable it, the process was not
relaunched by an installer/updater (--post-update), and the installer's
fresh-install breadcrumb is present. Upgrading users have no breadcrumb,
so an update can never write login items, and a user's disable in
Settings is never overridden.

* [release] Write fresh-install breadcrumb from installers

Installers write a .fresh-install breadcrumb on fresh installs only and
delete stale breadcrumbs on upgrade; none of them writes login items or
registry Run keys. Windows NSIS detects upgrades via the uninstall
registry entry or an existing installed executable; the macOS pkg via the
previous pkgutil receipt; Linux deb/rpm via the standard postinstall
arguments. Post-update GUI relaunches (macOS open, Linux
ui-post-install.sh) pass --post-update so the first-run autostart default
cannot fire on updates.

* Revert installer breadcrumb changes

The real Windows installer does uninstall-then-install and deletes
$INSTDIR, so a breadcrumb written there cannot survive or discriminate
a fresh install from an upgrade. Restore the three installer files to
their main versions; no installer or updater writes an autostart entry.

* Detect fresh install from NetBird footprint instead of installer breadcrumb

Replace the installer-written breadcrumb discriminator with a GUI-side
check. netbirdFootprintExists inspects the daemon config/state files
(default.json, legacy config.json, state.json) under profilemanager's
default config dir; combined with whether the UI preferences file already
existed, this tells a genuinely fresh machine from an existing or
upgrading user. Only the signed GUI, via Wails, ever enables
launch-on-login, and a user's later manual disable is never overridden.
The preferences store now exposes ExistedAtLoad and the --post-update
flag is dropped.

* Update tests for footprint-based autostart default

Table tests for shouldEnableAutostartDefault now cover supported,
mdmDisabled, and priorInstall guards plus precedence; breadcrumb and
post-update cases are removed. Add a store test asserting ExistedAtLoad
is false with no file and true after persisting and reopening.
2026-07-13 13:39:57 +02:00
Maycon Santos
76877e83c4 [client] Bring the connection up in Go after SSO login (#6744)
* [client] Bring the connection up in Go after SSO login

The post-login Up ran as a frontend promise continuation after WaitSSOLogin
resolved. During SSO the tray window is hidden and the webview is suspended
(macOS App Nap / hidden-window timer throttling), so that continuation didn't
run until the user woke the window (e.g. hovering the tray icon), leaving the
client not connected for a long time. Combine WaitSSOLogin and Up in a single
Go method so the daemon connects the moment SSO completes, independent of
webview state. The frontend no longer issues a separate Up on the SSO path.

* [client] unexport waitSSOLogin and move below exported methods
2026-07-13 13:32:47 +02:00
Maycon Santos
ecd398d895 [management] Add dashboard_features account setting (#6742)
Introduce a nullable dashboard_features object on account settings, serialized
to a single JSON column so new dashboard sections can be added without schema
changes. Starts with agent_network (show the Agent Network menu for an account
without the deployment flag). Wires the API handler mapping, the pgx GetAccount
loader, and adds store round-trip and handler tests.
2026-07-12 21:16:37 +02:00
Maycon Santos
aa92ad3fb1 [management] Add agent_network_only account setting (#6736)
* [management] Add agent_network_only account setting

* [management] Load agent_network_only in pgx account loader and cover persistence
2026-07-12 14:46:08 +02:00
Sufiyan Khan
fd94fdb42b [management] fix duplicate operationId in OpenAPI spec (#6734) 2026-07-12 14:15:55 +02:00
Maycon Santos
30d15ecc3d [client,management] sync 0.74.4 changes (#6727)
* [management] fix: prevent reverse proxy domain from being pushed as DNS search domain by @blaugrau90 in https://github.com/netbirdio/netbird/pull/6498
* [client] Recover from rosenpass key desync by @lixmal in https://github.com/netbirdio/netbird/pull/6714
* [client] Bump golang.org/x/crypto to v0.54.0 by @lixmal in https://github.com/netbirdio/netbird/pull/6709
* [client] fix MDM managementURL conflict on default-port URL echo by @riccardomanfrin in https://github.com/netbirdio/netbird/pull/6672
* [client] Update gopsutil to v4 by @mlsmaycon in https://github.com/netbirdio/netbird/pull/6688
* [client] Fix hanging status command during relay dial by @theodorsm in https://github.com/netbirdio/netbird/pull/6694

---------

Co-authored-by: Theodor Midtlien <theodor@midtlien.com>
Co-authored-by: blaugrau90 <61945343+blaugrau90@users.noreply.github.com>
Co-authored-by: Viktor Liu <17948409+lixmal@users.noreply.github.com>
2026-07-11 11:03:55 +02:00
Maycon Santos
8e02154bf5 [client] Add SSO login flow timing instrumentation (#6717)
Users reported long delays between finishing browser authentication and
the client connecting. Logs could not attribute the time: the PKCE and
device flows were silent between issuing the auth URL and returning the
token, and nothing recorded when the GUI issued the Up request after
WaitSSOLogin completed.

Add log lines covering the full chain: PKCE callback arrival and token
exchange duration, device-flow polling and approval timing, GUI-side
brackets around WaitSSOLogin and Up, daemon-side Up arrival and
WaitSSOLogin return, and a frontend stall detector that reports when
webview timers were suspended (macOS App Nap / hidden-window
throttling), which delays the WaitSSOLogin-to-Up handoff.
2026-07-10 16:11:27 +02:00
dmitri-netbird
e0c25ba4ba [client] fix flaky test around event aggregation (#6710)
* fix flaky test around event aggregation: control time.Now() from the test

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* actually use passed in func to generate time

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

---------

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-09 18:17:28 +02:00
Pascal Fischer
2560c6bd6c [management] add traffic filters for source and dest id (#6697) 2026-07-09 14:37:31 +02:00
Viktor Liu
96ac15d292 [client] Fix js relay WebSocket close, raise RDP dial timeout, adjust WASM log levels (#6684) 2026-07-09 13:21:08 +02:00
Misha Bragin
488bbcb22b [doc] Update Agent Network Readme (#6699) 2026-07-08 17:53:55 +02:00
Theodor Midtlien
b7bbb44286 [client] Merge v0.74.x branch (#6700)
* [client] Update gopsutil to v4 (#6688)
* [client] Fix hanging status command during relay dial (#6694)

---------

Co-authored-by: Maycon Santos <mlsmaycon@gmail.com>
2026-07-08 17:52:50 +02:00
53 changed files with 1506 additions and 155 deletions

View File

@@ -1,16 +1,47 @@
# NetBird Agent Network
Agent Network is NetBird's access control layer for AI agents and the people who run
them. It gives every agent a real identity, tied to your identity provider (IdP), and
governs what it can reach — the LLM APIs and AI gateways it can call, and the internal
resources it can access. Traffic flows only over the encrypted NetBird tunnel, scoped by
policy, with no API keys to leak.
Agent Network is NetBird's access control layer for AI agents and the people who run them.
It gives every agent a real identity, tied to an identity provider (IdP), and governs what it can reach: LLM APIs and
AI gateways it can call, and the internal resources it can access. Traffic flows only over the encrypted NetBird tunnel,
scoped by policy, with no API keys or other credentials to leak. It also gives you control over cost and token usage.
> **Beta.** Agent Network is open source and can be self-hosted on your own
> infrastructure.
Because every LLM request passes through an
identity-aware proxy, you can:
- **Set spending and rate limits** per agent, per user, or per team — with hard caps
that stop requests once a budget is reached.
- **Restrict models and providers** so agents can only call approved (and cost-appropriate)
endpoints, keeping expensive models off-limits unless explicitly allowed.
- **Attribute usage** by tracking token consumption and cost per identity, group, or cost center so every
request is tied back to the agent and person responsible.
- **Reuse your existing AI gateway** — point the proxy at a gateway you already run,
keeping its routing and config in place while it adds identity on top, so you skip
API key distribution.
https://github.com/user-attachments/assets/44d18286-d8ab-49f8-a457-98ccd66f3268
> **Beta.** Agent Network is in beta, but it's stable and already running in
> production environments. It's fully open source and can be self-hosted on your own
> infrastructure, with no vendor lock-in and no data leaving your environment.
## How it works
Say you have a simple use case: your Engineering or IT team needs access to Claude Code or Codex, and you want visibility into usage plus the ability to enforce budgets.
How can you do that without creating a dedicated API key for every team?
With Agent Network you get a private endpoint inside your network, for example: https://mirror.netbird.ai
Teams configure their agents to point to that endpoint instead of using individual API keys directly.
This endpoint is only reachable when users are connected to your NetBird network and authenticated through your IdP. Otherwise, it is not accessible from the public internet.
You can then use this private endpoint to configure your AI agents, whether that is Claude Code, Codex, or another tool.
## Quickstart
Full step-by-step setup:
**https://docs.netbird.io/agent-network/quickstart**
## Architecture
Agent Network is built on two existing NetBird capabilities:
- **Overlay network** — the encrypted WireGuard mesh between peers.
@@ -22,6 +53,9 @@ LLM traffic is routed through the proxy's identity-aware pipeline, while interna
resources (databases, internal APIs, self-hosted models) are reached directly over
peer-to-peer WireGuard tunnels, governed by the same identities and access policies.
<img width="4720" height="2218" alt="image" src="https://github.com/user-attachments/assets/1afa5da1-4b82-4f8a-a7a8-f417efadf1eb" />
## Where the code lives
There is no separate "agent-network" service — it reuses the reverse-proxy and management

View File

@@ -259,12 +259,18 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
ticker := time.NewTicker(interval)
defer ticker.Stop()
log.Infof("device flow: waiting for user authorization, polling token endpoint every %s, code expires in %s", interval, timeout)
start := time.Now()
polls := 0
for {
select {
case <-waitCtx.Done():
return TokenInfo{}, waitCtx.Err()
case <-ticker.C:
polls++
tokenResponse, err := d.requestToken(info)
if err != nil {
return TokenInfo{}, fmt.Errorf("parsing token response failed with error: %v", err)
@@ -272,10 +278,12 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
if tokenResponse.Error != "" {
if tokenResponse.Error == "authorization_pending" {
log.Tracef("device flow: authorization still pending after poll %d", polls)
continue
} else if tokenResponse.Error == "slow_down" {
interval += (3 * time.Second)
ticker.Reset(interval)
log.Infof("device flow: IdP requested slow_down, polling interval increased to %s", interval)
continue
}
@@ -296,6 +304,7 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err)
}
log.Infof("device flow: user authorization confirmed after %d polls in %s", polls, time.Since(start).Round(time.Second))
return tokenInfo, err
}
}

View File

@@ -188,6 +188,8 @@ func (p *PKCEAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowInfo
waitCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
log.Infof("pkce flow: waiting for authorization callback on %s, timeout %s", p.oAuthConfig.RedirectURL, timeout)
tokenChan := make(chan *oauth2.Token, 1)
errChan := make(chan error, 1)
@@ -221,6 +223,7 @@ func (p *PKCEAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowInfo
func (p *PKCEAuthorizationFlow) startServer(server *http.Server, tokenChan chan<- *oauth2.Token, errChan chan<- error) {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
log.Infof("pkce flow: received authorization callback from IdP")
cert := p.providerConfig.ClientCertPair
if cert != nil {
tr := &http.Transport{
@@ -271,11 +274,18 @@ func (p *PKCEAuthorizationFlow) handleRequest(req *http.Request) (*oauth2.Token,
return nil, fmt.Errorf("authentication failed: missing code")
}
return p.oAuthConfig.Exchange(
exchangeStart := time.Now()
token, err := p.oAuthConfig.Exchange(
req.Context(),
code,
oauth2.SetAuthURLParam("code_verifier", p.codeVerifier),
)
if err != nil {
return nil, err
}
log.Infof("pkce flow: authorization code exchanged for token in %s", time.Since(exchangeStart).Round(time.Millisecond))
return token, nil
}
func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo, error) {

View File

@@ -109,7 +109,7 @@ func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) er
return nil
}
log.Warnf("lazy connection manager is enabled by management feature flag")
log.Infof("lazy connection manager is enabled by the management feature flag")
e.initLazyManager(ctx)
e.statusRecorder.UpdateLazyConnection(true)
return e.addPeersToLazyConnManager()

View File

@@ -480,7 +480,6 @@ func (g *BundleGenerator) addStatus() error {
fullStatus := g.statusRecorder.GetFullStatus()
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
protoFullStatus.Events = g.statusRecorder.GetEventHistory()
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{
Anonymize: g.anonymize,
ProfileName: profName,

View File

@@ -551,7 +551,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
} else {
log.Infof("running rosenpass in strict mode")
}
e.rpManager, err = rosenpass.NewManager(e.config.PreSharedKey, e.config.WgIfaceName)
e.rpManager, err = rosenpass.NewManager(e.config.PreSharedKey, e.config.WgIfaceName, publicKey)
if err != nil {
return fmt.Errorf("create rosenpass manager: %w", err)
}

View File

@@ -175,7 +175,9 @@ func TestFlowAggregationOfUnknownProtocols(t *testing.T) {
}
func TestResetAggregationWindow(t *testing.T) {
store := NewAggregatingMemoryStore()
now := time.Now()
nowFunc := func() time.Time { return now }
store := NewAggregatingMemoryStoreWithTimeFunc(nowFunc)
store.StoreEvent(&types.Event{
ID: uuid.New(),
Timestamp: time.Now(),
@@ -198,6 +200,7 @@ func TestResetAggregationWindow(t *testing.T) {
},
})
now = now.Add(1 * time.Second)
reset := store.ResetAggregationWindow()
previousEvents, ok := reset.(*AggregatingMemory)
assert.True(t, ok)

View File

@@ -29,6 +29,7 @@ type AggregatingMemory struct {
WindowStart time.Time
WindowEnd time.Time
rnd *v2.PCG
nowFunc func() time.Time
}
func (m *Memory) StoreEvent(event *types.Event) {
@@ -62,14 +63,19 @@ func (m *Memory) DeleteEvents(ids []uuid.UUID) {
}
func NewAggregatingMemoryStore() *AggregatingMemory {
return &AggregatingMemory{WindowStart: time.Now(), Memory: Memory{events: make(map[uuid.UUID]*types.Event)}, rnd: v2.NewPCG(rand.Uint64(), rand.Uint64())}
return NewAggregatingMemoryStoreWithTimeFunc(defaultNowFunc)
}
// used in tests when deterministic (less random) time intervals are required
func NewAggregatingMemoryStoreWithTimeFunc(nowFunc func() time.Time) *AggregatingMemory {
return &AggregatingMemory{WindowStart: nowFunc(), Memory: Memory{events: make(map[uuid.UUID]*types.Event)}, nowFunc: nowFunc, rnd: v2.NewPCG(rand.Uint64(), rand.Uint64())}
}
func (am *AggregatingMemory) ResetAggregationWindow() types.FlowEventAggregator {
am.mux.Lock()
defer am.mux.Unlock()
now := time.Now()
now := am.nowFunc()
toret := AggregatingMemory{WindowStart: am.WindowStart, WindowEnd: now, Memory: Memory{events: am.events}, rnd: v2.NewPCG(rand.Uint64(), rand.Uint64())}
am.events = make(map[uuid.UUID]*types.Event)
@@ -152,3 +158,7 @@ func (am *AggregatingMemory) GetAggregatedEvents() []*types.Event {
return slices.Collect(maps.Values(aggregated)) // could return an iterator instead here
}
func defaultNowFunc() time.Time {
return time.Now()
}

View File

@@ -30,6 +30,11 @@ import (
relayClient "github.com/netbirdio/netbird/shared/relay/client"
)
// wgTimeoutEscalationThreshold is the number of consecutive WireGuard
// handshake timeouts after which the rosenpass state for the peer is
// considered desynced and gets reset.
const wgTimeoutEscalationThreshold = 3
// MetricsRecorder is an interface for recording peer connection metrics
type MetricsRecorder interface {
RecordConnectionStages(
@@ -118,6 +123,9 @@ type Conn struct {
wgWatcher *WGWatcher
wgWatcherWg sync.WaitGroup
wgWatcherCancel context.CancelFunc
// wgTimeouts counts consecutive WireGuard handshake timeouts without a
// successful handshake in between. Guarded by mu.
wgTimeouts int
// used to store the remote Rosenpass key for Relayed connection in case of connection update from ice
rosenpassRemoteKey []byte
@@ -683,6 +691,29 @@ func (conn *Conn) onWGDisconnected() {
default:
conn.Log.Debugf("No active connection to close on WG timeout")
}
conn.escalateWGTimeoutLocked()
}
// escalateWGTimeoutLocked resets the peer's rosenpass state after repeated
// handshake timeouts. With rosenpass enabled, persistent timeouts mean the
// preshared keys have desynced; the renewal exchange runs over the dead
// tunnel and cannot resync them. Reporting the peer disconnected drops its
// rosenpass state, so the next connection configuration programs the
// rendezvous key and the tunnel can bootstrap again. Callers must hold mu.
func (conn *Conn) escalateWGTimeoutLocked() {
if conn.config.RosenpassConfig.PubKey == nil {
return
}
conn.wgTimeouts++
if conn.wgTimeouts < wgTimeoutEscalationThreshold || conn.onDisconnected == nil {
return
}
conn.wgTimeouts = 0
conn.Log.Warnf("%d consecutive WireGuard handshake timeouts, resetting rosenpass state for peer", wgTimeoutEscalationThreshold)
conn.onDisconnected(conn.config.WgConfig.RemoteKey)
}
func (conn *Conn) updateRelayStatus(relayServerAddr string, rosenpassPubKey []byte, updateTime time.Time) {
@@ -812,7 +843,7 @@ func (conn *Conn) enableWgWatcherIfNeeded(enabledTime time.Time) {
conn.wgWatcherWg.Add(1)
go func() {
defer conn.wgWatcherWg.Done()
conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess)
conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess, conn.onWGCheckSuccess)
}()
}
@@ -892,6 +923,15 @@ func (conn *Conn) onWGHandshakeSuccess(when time.Time) {
conn.recordConnectionMetrics()
}
// onWGCheckSuccess is called for every watcher check that observed a fresh
// handshake, including handshakes of connections that were already up when
// the watcher started.
func (conn *Conn) onWGCheckSuccess() {
conn.mu.Lock()
conn.wgTimeouts = 0
conn.mu.Unlock()
}
// recordConnectionMetrics records connection stage timestamps as metrics
func (conn *Conn) recordConnectionMetrics() {
if conn.metricsRecorder == nil {

View File

@@ -7,6 +7,7 @@ import (
"testing"
"time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/netbirdio/netbird/client/iface"
@@ -304,3 +305,84 @@ func TestConn_presharedKey_RosenpassManaged(t *testing.T) {
t.Fatalf("expected non-nil presharedKey before Rosenpass manages PSK")
}
}
func newWGTimeoutTestConn(rosenpassEnabled bool, disconnected *[]string) *Conn {
cfg := ConnConfig{
Key: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
LocalKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
WgConfig: WgConfig{RemoteKey: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU="},
}
if rosenpassEnabled {
cfg.RosenpassConfig = RosenpassConfig{PubKey: []byte("dummykey")}
}
conn := &Conn{
ctx: context.Background(),
config: cfg,
Log: log.WithField("peer", cfg.Key),
metricsStages: &MetricsStages{},
}
conn.SetOnDisconnected(func(remotePeer string) {
*disconnected = append(*disconnected, remotePeer)
})
return conn
}
// TestConn_onWGDisconnected_EscalatesToRosenpassReset: repeated handshake
// timeouts with rosenpass enabled mean the preshared keys have desynced. The
// renewal exchange runs over the dead tunnel and cannot resync them, so after
// wgTimeoutEscalationThreshold consecutive timeouts the conn must report the
// peer disconnected, dropping its rosenpass state so the next configuration
// programs the rendezvous key.
func TestConn_onWGDisconnected_EscalatesToRosenpassReset(t *testing.T) {
var disconnected []string
conn := newWGTimeoutTestConn(true, &disconnected)
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
conn.onWGDisconnected()
}
assert.Empty(t, disconnected, "escalation must not fire below the threshold")
conn.onWGDisconnected()
assert.Equal(t, []string{conn.config.WgConfig.RemoteKey}, disconnected,
"reaching the threshold must report the peer disconnected once")
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
conn.onWGDisconnected()
}
assert.Len(t, disconnected, 1, "escalation must restart counting after firing")
conn.onWGDisconnected()
assert.Len(t, disconnected, 2, "continued timeouts must escalate again")
}
// TestConn_onWGDisconnected_CheckSuccessResetsEscalation: a successful
// handshake between timeouts means the tunnel recovered; the counter must
// start over.
func TestConn_onWGDisconnected_CheckSuccessResetsEscalation(t *testing.T) {
var disconnected []string
conn := newWGTimeoutTestConn(true, &disconnected)
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
conn.onWGDisconnected()
}
conn.onWGCheckSuccess()
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
conn.onWGDisconnected()
}
assert.Empty(t, disconnected, "handshake success must reset the timeout count")
}
// TestConn_onWGDisconnected_NoEscalationWithoutRosenpass: without rosenpass
// there is no per-peer key state to reset; repeated timeouts must not report
// disconnects.
func TestConn_onWGDisconnected_NoEscalationWithoutRosenpass(t *testing.T) {
var disconnected []string
conn := newWGTimeoutTestConn(false, &disconnected)
for i := 0; i < wgTimeoutEscalationThreshold*3; i++ {
conn.onWGDisconnected()
}
assert.Empty(t, disconnected, "escalation must be limited to rosenpass connections")
}

View File

@@ -71,9 +71,11 @@ func (w *WGWatcher) PrepareInitialHandshake() (ok bool) {
// EnableWgWatcher runs the WireGuard watcher loop using the handshake baseline captured by
// PrepareInitialHandshake. The watcher runs until ctx is cancelled. Caller is responsible
// for context lifecycle management.
func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time)) {
w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, enabledTime, w.initialHandshake)
// for context lifecycle management. onHandshakeSuccessFn is called only for the first
// handshake observed by this run, onCheckSuccessFn for every check that observed a fresh
// handshake, including the first.
func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), onCheckSuccessFn func()) {
w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, onCheckSuccessFn, enabledTime, w.initialHandshake)
w.muEnabled.Lock()
w.enabled = false
@@ -90,7 +92,7 @@ func (w *WGWatcher) Reset() {
}
// wgStateCheck help to check the state of the WireGuard handshake and relay connection
func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), enabledTime time.Time, initialHandshake time.Time) {
func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), onCheckSuccessFn func(), enabledTime time.Time, initialHandshake time.Time) {
w.log.Infof("WireGuard watcher started")
timer := time.NewTimer(wgHandshakeOvertime)
@@ -117,6 +119,10 @@ func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn
}
}
if onCheckSuccessFn != nil && ctx.Err() == nil {
onCheckSuccessFn()
}
lastHandshake = *handshake
resetTime := time.Until(handshake.Add(checkPeriod))

View File

@@ -24,6 +24,72 @@ func (m *MocWgIface) disconnect() {
m.stop = true
}
type mockHandshakeStats struct {
mu sync.Mutex
handshake time.Time
}
func (m *mockHandshakeStats) GetStats() (map[string]configurer.WGStats, error) {
m.mu.Lock()
defer m.mu.Unlock()
return map[string]configurer.WGStats{"": {LastHandshake: m.handshake}}, nil
}
func (m *mockHandshakeStats) advance() {
m.mu.Lock()
defer m.mu.Unlock()
m.handshake = time.Now()
}
// TestWGWatcher_CheckSuccessCallback: onCheckSuccessFn must fire for a fresh
// handshake even when the watcher started with an existing handshake baseline,
// the case where onHandshakeSuccessFn stays silent.
func TestWGWatcher_CheckSuccessCallback(t *testing.T) {
// checkPeriod bounds how stale a handshake may be before the watcher treats it
// as a suspended-machine timeout. The first check fires after wgHandshakeOvertime,
// so keep checkPeriod well above any scheduling jitter to avoid a false timeout
// converting the expected success into a disconnect on a loaded runner.
checkPeriod = 1 * time.Minute
wgHandshakeOvertime = 1 * time.Second
mlog := log.WithField("peer", "tet")
// Use an old baseline so advance() yields a strictly newer handshake even on
// platforms with coarse clock resolution (Windows), where two time.Now() calls
// microseconds apart can return the same instant and read as a timed-out handshake.
stats := &mockHandshakeStats{handshake: time.Now().Add(-time.Hour)}
watcher := NewWGWatcher(mlog, stats, "", newStateDump("peer", mlog, &Status{}))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
require.True(t, watcher.PrepareInitialHandshake())
firstHandshake := make(chan struct{}, 1)
checkSuccess := make(chan struct{}, 1)
go watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {
firstHandshake <- struct{}{}
}, func() {
select {
case checkSuccess <- struct{}{}:
default:
}
})
stats.advance()
select {
case <-checkSuccess:
case <-time.After(10 * time.Second):
t.Errorf("timeout waiting for check success callback")
}
select {
case <-firstHandshake:
t.Errorf("first-handshake callback must not fire for a non-zero baseline")
default:
}
}
func TestWGWatcher_EnableWgWatcher(t *testing.T) {
checkPeriod = 5 * time.Second
wgHandshakeOvertime = 1 * time.Second
@@ -44,7 +110,7 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) {
onDisconnected <- struct{}{}
}, func(when time.Time) {
mlog.Infof("onHandshakeSuccess: %v", when)
})
}, nil)
// wait for initial reading
time.Sleep(2 * time.Second)
@@ -73,7 +139,7 @@ func TestWGWatcher_ReEnable(t *testing.T) {
wg.Add(1)
go func() {
defer wg.Done()
watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {})
watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {}, nil)
}()
cancel()
@@ -89,7 +155,7 @@ func TestWGWatcher_ReEnable(t *testing.T) {
onDisconnected := make(chan struct{}, 1)
go watcher.EnableWgWatcher(ctx, time.Now(), func() {
onDisconnected <- struct{}{}
}, func(when time.Time) {})
}, func(when time.Time) {}, nil)
time.Sleep(2 * time.Second)
mocWgIface.disconnect()

View File

@@ -11,6 +11,7 @@ import (
"runtime"
"sort"
"strings"
"syscall"
log "github.com/sirupsen/logrus"
@@ -439,7 +440,11 @@ func (s *ServiceManager) GetStatePath() string {
activeProf, err := s.GetActiveProfileState()
if err != nil {
log.Warnf("failed to get active profile state: %v", err)
if errors.Is(err, syscall.ENOSYS) {
log.Debugf("active profile state unavailable on this platform: %v", err)
} else {
log.Warnf("failed to get active profile state: %v", err)
}
return defaultStatePath
}

View File

@@ -39,6 +39,7 @@ type rpServer interface {
type Manager struct {
ifaceName string
localWgKey wgtypes.Key
spk []byte
ssk []byte
rpKeyHash string
@@ -51,8 +52,9 @@ type Manager struct {
wgIface PresharedKeySetter
}
// NewManager creates a new Rosenpass manager
func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error) {
// NewManager creates a new Rosenpass manager. localWgKey is the local
// WireGuard public key, used to derive the per-peer rendezvous key.
func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string, localWgKey wgtypes.Key) (*Manager, error) {
public, secret, err := rp.GenerateKeyPair()
if err != nil {
return nil, err
@@ -62,6 +64,7 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error)
log.Tracef("generated new rosenpass key pair with public key %s", rpKeyHash)
return &Manager{
ifaceName: wgIfaceName,
localWgKey: localWgKey,
rpKeyHash: rpKeyHash,
spk: public,
ssk: secret,
@@ -73,7 +76,7 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error)
// nil receiver in addPeer -> m.rpWgHandler.AddPeer. generateConfig will
// replace it with a fresh handler on each Run() to clear stale peer
// state from previous engine sessions.
rpWgHandler: NewNetbirdHandler(),
rpWgHandler: NewNetbirdHandler((*[32]byte)(preSharedKey), localWgKey),
lock: sync.Mutex{},
}, nil
}
@@ -161,7 +164,7 @@ func (m *Manager) generateConfig() (rp.Config, error) {
cfg.Peers = []rp.PeerConfig{}
m.lock.Lock()
m.rpWgHandler = NewNetbirdHandler()
m.rpWgHandler = NewNetbirdHandler(m.preSharedKey, m.localWgKey)
if m.wgIface != nil {
m.rpWgHandler.SetInterface(m.wgIface)
}

View File

@@ -85,7 +85,7 @@ func newTestManager(spkFirstByte byte, mock *mockServer) *Manager {
ssk: make([]byte, 32),
rpKeyHash: "test-hash",
rpPeerIDs: make(map[string]*rp.PeerID),
rpWgHandler: NewNetbirdHandler(),
rpWgHandler: NewNetbirdHandler(nil, wgtypes.Key{0x01}),
server: mock,
}
}
@@ -255,7 +255,7 @@ func TestAddPeer_NilServer_ReturnsErrorNoCrash(t *testing.T) {
// issue #4341 cannot occur in the window between NewManager and Run().
func TestNewManager_PreInitializesHandler(t *testing.T) {
psk := wgtypes.Key{}
m, err := NewManager(&psk, "wt0")
m, err := NewManager(&psk, "wt0", wgtypes.Key{0x01})
require.NoError(t, err)
require.NotNil(t, m.rpWgHandler, "rpWgHandler must be initialized in NewManager")
}
@@ -329,10 +329,10 @@ func TestIsPresharedKeyInitialized_AddedButNotHandshaken_ReturnsFalse(t *testing
require.False(t, m.IsPresharedKeyInitialized(wgKey))
}
// --- NetbirdHandler.outputKey ----------------------------------------------
// --- NetbirdHandler.applyKey ----------------------------------------------
func TestHandler_OutputKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) {
h := NewNetbirdHandler()
func TestHandler_ApplyKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) {
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
iface := &mockIface{}
h.SetInterface(iface)
@@ -348,8 +348,8 @@ func TestHandler_OutputKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) {
require.Equal(t, wgKey.String(), iface.calls[0].peerKey)
}
func TestHandler_OutputKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) {
h := NewNetbirdHandler()
func TestHandler_ApplyKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) {
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
iface := &mockIface{}
h.SetInterface(iface)
@@ -364,8 +364,8 @@ func TestHandler_OutputKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) {
require.True(t, iface.calls[1].updateOnly, "subsequent rotations must use updateOnly=true")
}
func TestHandler_OutputKey_NilInterface_NoCrashNoCall(t *testing.T) {
h := NewNetbirdHandler()
func TestHandler_ApplyKey_NilInterface_NoCrashNoCall(t *testing.T) {
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
// no SetInterface — iface remains nil
pid := rp.PeerID{0x03}
h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{}))
@@ -374,8 +374,8 @@ func TestHandler_OutputKey_NilInterface_NoCrashNoCall(t *testing.T) {
h.HandshakeCompleted(pid, rp.Key{})
}
func TestHandler_OutputKey_UnknownPeer_NoCall(t *testing.T) {
h := NewNetbirdHandler()
func TestHandler_ApplyKey_UnknownPeer_NoCall(t *testing.T) {
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
iface := &mockIface{}
h.SetInterface(iface)
@@ -384,7 +384,7 @@ func TestHandler_OutputKey_UnknownPeer_NoCall(t *testing.T) {
}
func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) {
h := NewNetbirdHandler()
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
iface := &mockIface{}
h.SetInterface(iface)
@@ -398,7 +398,7 @@ func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) {
}
func TestHandler_SetInterfaceAfterAddPeer_StillReceivesKey(t *testing.T) {
h := NewNetbirdHandler()
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
pid := rp.PeerID{0x05}
wgKey := wgtypes.Key{0xEE}
h.AddPeer(pid, "wt0", rp.Key(wgKey))

View File

@@ -18,19 +18,34 @@ type PresharedKeySetter interface {
type wireGuardPeer struct {
Interface string
PublicKey rp.Key
// initialized is true once a completed exchange has set a
// Rosenpass-managed PSK for this peer.
initialized bool
// chainKey is the key output by the last completed exchange, advanced by
// one ratchet step on expiry. Nil until the first exchange completes and
// after the peer has fallen back to the rendezvous key.
chainKey *wgtypes.Key
// expiries counts failed renewals since the last completed exchange.
expiries int
}
type NetbirdHandler struct {
mu sync.Mutex
iface PresharedKeySetter
peers map[rp.PeerID]wireGuardPeer
initializedPeers map[rp.PeerID]bool
mu sync.Mutex
iface PresharedKeySetter
// preSharedKey is the account-level preshared key, used as the rendezvous
// key when set. Nil means the deterministic seed key is used instead.
preSharedKey *[32]byte
// localWgKey is the local WireGuard public key, one of the two inputs to
// the deterministic seed key.
localWgKey wgtypes.Key
peers map[rp.PeerID]*wireGuardPeer
}
func NewNetbirdHandler() *NetbirdHandler {
func NewNetbirdHandler(preSharedKey *[32]byte, localWgKey wgtypes.Key) *NetbirdHandler {
return &NetbirdHandler{
peers: map[rp.PeerID]wireGuardPeer{},
initializedPeers: map[rp.PeerID]bool{},
preSharedKey: preSharedKey,
localWgKey: localWgKey,
peers: map[rp.PeerID]*wireGuardPeer{},
}
}
@@ -42,10 +57,16 @@ func (h *NetbirdHandler) SetInterface(iface PresharedKeySetter) {
h.iface = iface
}
// AddPeer registers a peer with the handler. Re-adding a known peer (every
// reconnection does) keeps its key recovery state.
func (h *NetbirdHandler) AddPeer(pid rp.PeerID, intf string, pk rp.Key) {
h.mu.Lock()
defer h.mu.Unlock()
h.peers[pid] = wireGuardPeer{
if existing, ok := h.peers[pid]; ok && existing.PublicKey == pk {
existing.Interface = intf
return
}
h.peers[pid] = &wireGuardPeer{
Interface: intf,
PublicKey: pk,
}
@@ -55,7 +76,6 @@ func (h *NetbirdHandler) RemovePeer(pid rp.PeerID) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.peers, pid)
delete(h.initializedPeers, pid)
}
// IsPeerInitialized returns true if Rosenpass has completed a handshake
@@ -63,50 +83,120 @@ func (h *NetbirdHandler) RemovePeer(pid rp.PeerID) {
func (h *NetbirdHandler) IsPeerInitialized(pid rp.PeerID) bool {
h.mu.Lock()
defer h.mu.Unlock()
return h.initializedPeers[pid]
peer, ok := h.peers[pid]
return ok && peer.initialized
}
// HandshakeCompleted programs the freshly exchanged output key and resets the
// peer's key recovery state.
func (h *NetbirdHandler) HandshakeCompleted(pid rp.PeerID, key rp.Key) {
h.outputKey(rp.KeyOutputReasonStale, pid, key)
}
psk := wgtypes.Key(key)
func (h *NetbirdHandler) HandshakeExpired(pid rp.PeerID) {
key, _ := rp.GeneratePresharedKey()
h.outputKey(rp.KeyOutputReasonStale, pid, key)
}
func (h *NetbirdHandler) outputKey(_ rp.KeyOutputReason, pid rp.PeerID, psk rp.Key) {
h.mu.Lock()
iface := h.iface
wg, ok := h.peers[pid]
isInitialized := h.initializedPeers[pid]
h.mu.Unlock()
defer h.mu.Unlock()
if iface == nil {
log.Warn("rosenpass: interface not set, cannot update preshared key")
peer, ok := h.peers[pid]
if !ok {
return
}
if peer.expiries > 0 {
log.Infof("rosenpass exchange completed for peer %s after %d expired renewals", wgtypes.Key(peer.PublicKey), peer.expiries)
}
// chainKey tracks the shared exchange output regardless of the local write
// outcome, so both ends still converge on the next expiry.
peer.chainKey = &psk
peer.expiries = 0
if !h.applyKeyLocked(pid, psk, peer.initialized) {
return
}
peer.initialized = true
}
// HandshakeExpired replaces the expired key. The renewal exchange runs over
// the tunnel keyed by the PSK itself, so the replacement must be derivable on
// both ends without communication: the first expiry ratchets the last shared
// key forward, repeated expiries (and expiries without a completed exchange)
// fall back to the rendezvous key and drop the peer out of the initialized
// state so connection reconfigurations reprogram the rendezvous key as well.
func (h *NetbirdHandler) HandshakeExpired(pid rp.PeerID) {
h.mu.Lock()
defer h.mu.Unlock()
peer, ok := h.peers[pid]
if !ok {
return
}
peerKey := wgtypes.Key(wg.PublicKey).String()
pskKey := wgtypes.Key(psk)
peer.expiries++
// Use updateOnly=true for later rotations (peer already has Rosenpass PSK)
// Use updateOnly=false for first rotation (peer has original/empty PSK)
if err := iface.SetPresharedKey(peerKey, pskKey, isInitialized); err != nil {
var psk wgtypes.Key
if peer.chainKey != nil && peer.expiries == 1 {
log.Infof("rosenpass key for peer %s expired without renewal, advancing to ratcheted key", wgtypes.Key(peer.PublicKey))
psk = RatchetKey(*peer.chainKey)
peer.chainKey = &psk
} else {
rendezvous, err := h.rendezvousKey(peer)
if err != nil {
// Fail closed: without a rendezvous key the expired key must
// still be rotated out, even if the replacement is unusable.
log.Errorf("failed to derive rendezvous key, replacing expired key with a random one: %v", err)
h.applyRandomKeyLocked(pid)
return
}
log.Warnf("rosenpass key for peer %s expired %d times without renewal, falling back to the rendezvous key", wgtypes.Key(peer.PublicKey), peer.expiries)
psk = rendezvous
peer.chainKey = nil
peer.initialized = false
}
h.applyKeyLocked(pid, psk, true)
}
// rendezvousKey returns the key both ends converge on without communication:
// the account-level preshared key when configured, the deterministic seed key
// otherwise. It mirrors the key that peer connections program when Rosenpass
// does not manage the peer yet.
func (h *NetbirdHandler) rendezvousKey(peer *wireGuardPeer) (wgtypes.Key, error) {
if h.preSharedKey != nil {
return *h.preSharedKey, nil
}
seed, err := DeterministicSeedKey(h.localWgKey.String(), wgtypes.Key(peer.PublicKey).String())
if err != nil {
return wgtypes.Key{}, err
}
return *seed, nil
}
// applyKeyLocked writes the preshared key for the peer to the WireGuard
// interface and reports whether the write succeeded. Callers must hold h.mu
// for the whole state-mutation-plus-write so that a concurrent completion and
// expiry cannot reorder their writes relative to the in-memory chain key.
func (h *NetbirdHandler) applyKeyLocked(pid rp.PeerID, psk wgtypes.Key, updateOnly bool) bool {
peer, ok := h.peers[pid]
if !ok {
return false
}
if h.iface == nil {
log.Warn("rosenpass: interface not set, cannot update preshared key")
return false
}
peerKey := wgtypes.Key(peer.PublicKey).String()
if err := h.iface.SetPresharedKey(peerKey, psk, updateOnly); err != nil {
log.Errorf("Failed to apply rosenpass key: %v", err)
return false
}
return true
}
func (h *NetbirdHandler) applyRandomKeyLocked(pid rp.PeerID) {
key, err := rp.GeneratePresharedKey()
if err != nil {
log.Errorf("failed to generate random preshared key: %v", err)
return
}
// Mark peer as isInitialized after the successful first rotation
if !isInitialized {
h.mu.Lock()
if _, exists := h.peers[pid]; exists {
h.initializedPeers[pid] = true
}
h.mu.Unlock()
}
h.applyKeyLocked(pid, wgtypes.Key(key), true)
}

View File

@@ -0,0 +1,250 @@
package rosenpass
import (
"testing"
rp "cunicu.li/go-rosenpass"
"github.com/stretchr/testify/require"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
// handlerTestLink wires two NetbirdHandlers as the two ends of a single
// tunnel: handler A manages the rosenpass peer B and vice versa, the way two
// NetBird clients see each other.
type handlerTestLink struct {
handlerA, handlerB *NetbirdHandler
ifaceA, ifaceB *mockIface
pidA, pidB rp.PeerID
wgKeyA, wgKeyB wgtypes.Key
}
func newHandlerTestLink(t *testing.T, preSharedKey *[32]byte) *handlerTestLink {
t.Helper()
link := &handlerTestLink{
ifaceA: &mockIface{},
ifaceB: &mockIface{},
}
link.pidA[0] = 0xaa
link.pidB[0] = 0xbb
link.wgKeyA[31] = 1
link.wgKeyB[31] = 2
link.handlerA = NewNetbirdHandler(preSharedKey, link.wgKeyA)
link.handlerB = NewNetbirdHandler(preSharedKey, link.wgKeyB)
link.handlerA.SetInterface(link.ifaceA)
link.handlerB.SetInterface(link.ifaceB)
link.handlerA.AddPeer(link.pidB, "wt0", rp.Key(link.wgKeyB))
link.handlerB.AddPeer(link.pidA, "wt0", rp.Key(link.wgKeyA))
return link
}
// complete simulates a completed rosenpass exchange: both ends derive the
// same output key.
func (l *handlerTestLink) complete(osk rp.Key) {
l.handlerA.HandshakeCompleted(l.pidB, osk)
l.handlerB.HandshakeCompleted(l.pidA, osk)
}
// expire simulates a failed key renewal on both ends.
func (l *handlerTestLink) expire() {
l.handlerA.HandshakeExpired(l.pidB)
l.handlerB.HandshakeExpired(l.pidA)
}
func lastPSK(t *testing.T, m *mockIface) wgtypes.Key {
t.Helper()
m.mu.Lock()
defer m.mu.Unlock()
require.NotEmpty(t, m.calls, "expected at least one SetPresharedKey call")
return m.calls[len(m.calls)-1].psk
}
func TestHandshakeCompleted_SetsKeyAndInitializes(t *testing.T) {
link := newHandlerTestLink(t, nil)
var osk rp.Key
osk[0] = 0x42
link.complete(osk)
require.Equal(t, wgtypes.Key(osk), lastPSK(t, link.ifaceA), "completed exchange must program the osk")
require.False(t, link.ifaceA.calls[0].updateOnly, "first rotation must not be update-only")
require.True(t, link.handlerA.IsPeerInitialized(link.pidB), "peer must be initialized after first completed exchange")
link.complete(osk)
require.True(t, link.ifaceA.calls[1].updateOnly, "later rotations must be update-only")
}
// TestHandshakeExpired_BothSidesConverge encodes the core recovery invariant:
// rosenpass renewals run over the tunnel that the PSK itself keys, so when a
// renewal fails on both ends, both ends must fall back to the same key or the
// tunnel can never handshake again.
func TestHandshakeExpired_BothSidesConverge(t *testing.T) {
link := newHandlerTestLink(t, nil)
var osk rp.Key
osk[0] = 0x42
link.complete(osk)
link.expire()
keyA := lastPSK(t, link.ifaceA)
keyB := lastPSK(t, link.ifaceB)
require.NotEqual(t, wgtypes.Key(osk), keyA, "expired key must be rotated out")
require.Equal(t, keyA, keyB, "both ends must converge on the same key after expiry")
link.expire()
require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB),
"both ends must still converge after repeated expiries")
}
// TestHandshakeExpired_ExpiryWithoutCompletionConverges covers the bootstrap
// case: the initial exchange never completed (the tunnel ran on the rendezvous
// key), so an expiry must not replace the working key with an unrecoverable
// one on either end.
func TestHandshakeExpired_ExpiryWithoutCompletionConverges(t *testing.T) {
link := newHandlerTestLink(t, nil)
link.expire()
require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB),
"both ends must converge when the exchange never completed")
}
// TestHandshakeExpired_RepeatedExpiryClearsInitialized: once renewals keep
// failing, the peer must drop out of the initialized state so the next
// connection reconfiguration reprograms the rendezvous key instead of
// preserving a poisoned rosenpass-managed key.
func TestHandshakeExpired_RepeatedExpiryClearsInitialized(t *testing.T) {
link := newHandlerTestLink(t, nil)
var osk rp.Key
osk[0] = 0x42
link.complete(osk)
link.expire()
link.expire()
require.False(t, link.handlerA.IsPeerInitialized(link.pidB),
"repeated expiries must clear the initialized state")
require.False(t, link.handlerB.IsPeerInitialized(link.pidA),
"repeated expiries must clear the initialized state")
}
// TestHandshakeCompleted_AfterExpiryRecovers: a completed exchange after a
// desync must fully reset the recovery state.
func TestHandshakeCompleted_AfterExpiryRecovers(t *testing.T) {
link := newHandlerTestLink(t, nil)
var osk1, osk2 rp.Key
osk1[0] = 1
osk2[0] = 2
link.complete(osk1)
link.expire()
link.expire()
link.complete(osk2)
require.Equal(t, wgtypes.Key(osk2), lastPSK(t, link.ifaceA), "new exchange must program the fresh osk")
require.True(t, link.handlerA.IsPeerInitialized(link.pidB), "peer must be initialized again after recovery")
link.expire()
require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB),
"recovered link must converge again on the next expiry")
require.NotEqual(t, wgtypes.Key(osk2), lastPSK(t, link.ifaceA), "expired key must be rotated out")
}
// TestHandshakeExpired_FirstExpiryRatchetsLastKey: the first expiry must
// derive the replacement from the last shared key, so an attacker who only
// blocks the renewal exchange gains nothing over the previous key.
func TestHandshakeExpired_FirstExpiryRatchetsLastKey(t *testing.T) {
link := newHandlerTestLink(t, nil)
var osk rp.Key
osk[0] = 0x42
link.complete(osk)
link.expire()
require.Equal(t, RatchetKey(wgtypes.Key(osk)), lastPSK(t, link.ifaceA),
"first expiry must program the ratcheted key")
require.True(t, link.handlerA.IsPeerInitialized(link.pidB),
"ratchet step must keep the peer initialized so reconfigurations preserve the key")
}
// TestHandshakeExpired_RepeatedExpiryFallsBackToSeed: once the ratchet key
// also fails, both ends must land on the same key that peer connections
// program for uninitialized peers, so a reconnect completes the recovery.
func TestHandshakeExpired_RepeatedExpiryFallsBackToSeed(t *testing.T) {
link := newHandlerTestLink(t, nil)
var osk rp.Key
osk[0] = 0x42
link.complete(osk)
link.expire()
link.expire()
seed, err := DeterministicSeedKey(link.wgKeyA.String(), link.wgKeyB.String())
require.NoError(t, err)
require.Equal(t, *seed, lastPSK(t, link.ifaceA), "repeated expiry must fall back to the seed key")
require.Equal(t, *seed, lastPSK(t, link.ifaceB), "repeated expiry must fall back to the seed key")
}
// TestHandshakeExpired_ConfiguredPSKUsedAsRendezvous: with an account-level
// preshared key configured, the fallback must be that key, matching what peer
// connections program for uninitialized peers.
func TestHandshakeExpired_ConfiguredPSKUsedAsRendezvous(t *testing.T) {
psk := &[32]byte{0x77}
link := newHandlerTestLink(t, psk)
var osk rp.Key
osk[0] = 0x42
link.complete(osk)
link.expire()
link.expire()
require.Equal(t, wgtypes.Key(*psk), lastPSK(t, link.ifaceA),
"fallback must be the configured preshared key")
require.Equal(t, wgtypes.Key(*psk), lastPSK(t, link.ifaceB),
"fallback must be the configured preshared key on both ends")
}
// TestHandshakeExpired_ExpiryWritesAreUpdateOnly: expiry replacements must
// never create a WireGuard peer that connection management has removed.
func TestHandshakeExpired_ExpiryWritesAreUpdateOnly(t *testing.T) {
link := newHandlerTestLink(t, nil)
var osk rp.Key
osk[0] = 0x42
link.complete(osk)
link.expire()
link.expire()
for _, call := range link.ifaceA.calls[1:] {
require.True(t, call.updateOnly, "expiry writes must be update-only")
}
}
// TestAddPeer_ReAddKeepsRecoveryState: reconnections re-add the peer on every
// OnConnected; that must not reset the expiry chain state.
func TestAddPeer_ReAddKeepsRecoveryState(t *testing.T) {
link := newHandlerTestLink(t, nil)
var osk rp.Key
osk[0] = 0x42
link.complete(osk)
link.expire()
link.handlerA.AddPeer(link.pidB, "wt0", rp.Key(link.wgKeyB))
require.True(t, link.handlerA.IsPeerInitialized(link.pidB),
"re-adding a known peer must keep its state")
link.expire()
seed, err := DeterministicSeedKey(link.wgKeyA.String(), link.wgKeyB.String())
require.NoError(t, err)
require.Equal(t, *seed, lastPSK(t, link.ifaceA),
"second expiry after re-add must continue to the seed fallback")
}

View File

@@ -1,11 +1,28 @@
package rosenpass
import (
"crypto/sha256"
"fmt"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
// ratchetLabel domain-separates the expiry ratchet from other uses of the
// rosenpass output key.
const ratchetLabel = "netbird-rosenpass-expiry-ratchet"
// RatchetKey derives the successor preshared key from the previous Rosenpass
// output key. When a key expires without a completed renewal, both peers
// advance their last shared key by one ratchet step: the expired key is
// rotated out while both ends still converge on an identical, non-public
// replacement without communicating.
func RatchetKey(prev wgtypes.Key) wgtypes.Key {
input := make([]byte, 0, len(ratchetLabel)+len(prev))
input = append(input, ratchetLabel...)
input = append(input, prev[:]...)
return sha256.Sum256(input)
}
// DeterministicSeedKey derives a 32-byte WireGuard preshared key from a pair
// of peer public keys. Both peers, given the same key pair, produce the same
// output regardless of which side runs the function: the inputs are ordered

View File

@@ -12,6 +12,7 @@ import (
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/google/uuid"
@@ -264,7 +265,11 @@ func (m *DefaultManager) initSelector() *routeselector.RouteSelector {
// restore selector state if it exists
if err := m.stateManager.LoadState(state); err != nil {
log.Warnf("failed to load state: %v", err)
if errors.Is(err, syscall.ENOSYS) {
log.Debugf("route selector state unavailable on this platform: %v", err)
} else {
log.Warnf("failed to load state: %v", err)
}
return routeselector.NewRouteSelector()
}

View File

@@ -22,6 +22,7 @@ var allKeys = []string{
KeyDisableMetricsCollection,
KeyAllowServerSSH,
KeyDisableAutoConnect,
KeyDisableAutostart,
KeyPreSharedKey,
KeyRosenpassEnabled,
KeyRosenpassPermissive,

View File

@@ -20,10 +20,10 @@ import (
// names (lowerCamelCase) so the daemon can map a Policy key directly to a
// configuration field.
const (
KeyManagementURL = "managementURL"
KeyDisableUpdateSettings = "disableUpdateSettings"
KeyDisableProfiles = "disableProfiles"
KeyDisableNetworks = "disableNetworks"
KeyManagementURL = "managementURL"
KeyDisableUpdateSettings = "disableUpdateSettings"
KeyDisableProfiles = "disableProfiles"
KeyDisableNetworks = "disableNetworks"
// KeyDisableAdvancedView gates the advanced-view section in the
// upcoming UI revision. UI-only: NOT stored on Config, not
// applied by applyMDMPolicy, not rejectable via SetConfig. The
@@ -37,10 +37,16 @@ const (
KeyDisableMetricsCollection = "disableMetricsCollection"
KeyAllowServerSSH = "allowServerSSH"
KeyDisableAutoConnect = "disableAutoConnect"
KeyPreSharedKey = "preSharedKey"
KeyRosenpassEnabled = "rosenpassEnabled"
KeyRosenpassPermissive = "rosenpassPermissive"
KeyWireguardPort = "wireguardPort"
// KeyDisableAutostart suppresses the GUI's fresh-install
// launch-on-login default and marks the Settings toggle as
// MDM-managed. UI-only: NOT stored on Config and not applied by
// applyMDMPolicy; the GUI reads it directly and it appears in
// GetConfigResponse.mDMManagedFields when set.
KeyDisableAutostart = "disableAutostart"
KeyPreSharedKey = "preSharedKey"
KeyRosenpassEnabled = "rosenpassEnabled"
KeyRosenpassPermissive = "rosenpassPermissive"
KeyWireguardPort = "wireguardPort"
// Split tunnel is modeled as a single conceptual policy with two
// registry/plist values. KeySplitTunnelMode is the discriminator

View File

@@ -828,6 +828,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
return nil, err
}
log.Infof("SSO login flow finished, returning success to caller")
return &proto.WaitSSOLoginResponse{
Email: tokenInfo.Email,
}, nil
@@ -835,6 +836,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
// Up starts engine work in the daemon.
func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpResponse, error) {
log.Infof("up request received")
s.mutex.Lock()
// clientRunning is the daemon-intent flag (set by previous Up/Start, cleared
// by Down). connectionGoroutineRunning() reports whether the previous retry-loop

View File

@@ -746,6 +746,8 @@ func ToProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus {
pbFullStatus.DnsServers = append(pbFullStatus.DnsServers, pbDnsState)
}
pbFullStatus.Events = fullStatus.Events
return &pbFullStatus
}

View File

@@ -0,0 +1,107 @@
//go:build !android && !ios && !freebsd && !js
package main
import (
"context"
"os"
"path/filepath"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/mdm"
"github.com/netbirdio/netbird/client/ui/preferences"
"github.com/netbirdio/netbird/client/ui/services"
)
// autostartDefaultState carries the guard inputs of the one-time autostart
// default decision so the decision itself stays a pure, testable function.
type autostartDefaultState struct {
supported bool
mdmDisabled bool
priorInstall bool
}
// shouldEnableAutostartDefault applies the first-run guards in order and
// returns whether autostart may be enabled, plus the reason when it may not.
func shouldEnableAutostartDefault(s autostartDefaultState) (bool, string) {
switch {
case !s.supported:
return false, "autostart not supported on this platform"
case s.mdmDisabled:
return false, "autostart disabled by MDM policy"
case s.priorInstall:
return false, "existing NetBird installation"
}
return true, ""
}
// autostartDisabledByMDM reports whether the MDM policy manages the
// disableAutostart key in a way that must suppress the default. An
// unparseable managed value is treated as disabled to stay on the safe side.
func autostartDisabledByMDM(policy *mdm.Policy) bool {
if !policy.HasKey(mdm.KeyDisableAutostart) {
return false
}
disabled, ok := policy.GetBool(mdm.KeyDisableAutostart)
return !ok || disabled
}
// netbirdFootprintExists reports whether the machine already carries NetBird
// daemon config or state, meaning this is not a genuinely fresh install. It is
// the update-safety gate for the autostart default: upgrading users always
// have a footprint, so an update can never trigger a login-item write.
func netbirdFootprintExists() bool {
candidates := []string{
profilemanager.DefaultConfigPath,
filepath.Join(profilemanager.DefaultConfigPathDir, "config.json"),
filepath.Join(profilemanager.DefaultConfigPathDir, "state.json"),
}
for _, path := range candidates {
if path != "" && fileExists(path) {
return true
}
}
return false
}
// applyAutostartDefault runs the one-time launch-on-login default for genuinely
// fresh installs. The autostartInitialized marker is persisted before any
// enable attempt so a crash mid-flow degrades to "never enabled" instead of
// retrying login-item writes on every launch. A user's later disable in
// Settings is never overridden: the marker guarantees at-most-once, ever.
func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, prefs *preferences.Store, prefsFileExisted bool) {
priorFootprint := netbirdFootprintExists() || prefsFileExisted
if prefs.Get().AutostartInitialized {
return
}
if err := prefs.SetAutostartInitialized(true); err != nil {
log.Warnf("persist autostart marker, skipping autostart default: %v", err)
return
}
state := autostartDefaultState{
supported: autostart.Supported(ctx),
mdmDisabled: autostartDisabledByMDM(mdm.LoadPolicy()),
priorInstall: priorFootprint,
}
enable, reason := shouldEnableAutostartDefault(state)
if !enable {
log.Debugf("skipping autostart default: %s", reason)
return
}
if err := autostart.SetEnabled(ctx, true); err != nil {
log.Warnf("enable autostart on fresh install: %v", err)
return
}
log.Info("autostart enabled by default on fresh install")
}
// fileExists reports whether path exists.
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}

View File

@@ -0,0 +1,125 @@
//go:build !android && !ios && !freebsd && !js
package main
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/netbirdio/netbird/client/mdm"
)
func TestShouldEnableAutostartDefault(t *testing.T) {
allPass := autostartDefaultState{
supported: true,
mdmDisabled: false,
priorInstall: false,
}
tests := []struct {
name string
mutate func(*autostartDefaultState)
wantEnable bool
wantReason string
}{
{
name: "fresh install with all guards passing enables",
mutate: func(*autostartDefaultState) {},
wantEnable: true,
},
{
name: "unsupported platform skips",
mutate: func(s *autostartDefaultState) { s.supported = false },
wantReason: "autostart not supported on this platform",
},
{
name: "MDM disable skips",
mutate: func(s *autostartDefaultState) { s.mdmDisabled = true },
wantReason: "autostart disabled by MDM policy",
},
{
name: "existing installation (upgrade) skips",
mutate: func(s *autostartDefaultState) { s.priorInstall = true },
wantReason: "existing NetBird installation",
},
{
name: "unsupported wins over every other guard",
mutate: func(s *autostartDefaultState) {
s.supported = false
s.mdmDisabled = true
s.priorInstall = true
},
wantReason: "autostart not supported on this platform",
},
{
name: "MDM disable wins over prior install",
mutate: func(s *autostartDefaultState) {
s.mdmDisabled = true
s.priorInstall = true
},
wantReason: "autostart disabled by MDM policy",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
state := allPass
tc.mutate(&state)
enable, reason := shouldEnableAutostartDefault(state)
assert.Equal(t, tc.wantEnable, enable, "enable decision should match for state %+v", state)
assert.Equal(t, tc.wantReason, reason, "skip reason should identify the failing guard")
})
}
}
func TestAutostartDisabledByMDM(t *testing.T) {
tests := []struct {
name string
values map[string]any
want bool
}{
{
name: "empty policy does not disable",
values: nil,
want: false,
},
{
name: "unrelated managed keys do not disable",
values: map[string]any{mdm.KeyDisableAutoConnect: true},
want: false,
},
{
name: "disableAutostart true disables",
values: map[string]any{mdm.KeyDisableAutostart: true},
want: true,
},
{
name: "disableAutostart registry DWORD 1 disables",
values: map[string]any{mdm.KeyDisableAutostart: int64(1)},
want: true,
},
{
name: "disableAutostart string true disables",
values: map[string]any{mdm.KeyDisableAutostart: "true"},
want: true,
},
{
name: "disableAutostart explicit false allows",
values: map[string]any{mdm.KeyDisableAutostart: false},
want: false,
},
{
name: "unparseable managed value is treated as disabled",
values: map[string]any{mdm.KeyDisableAutostart: "not-a-bool"},
want: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := autostartDisabledByMDM(mdm.NewPolicy(tc.values))
assert.Equal(t, tc.want, got, "MDM disable decision should match for values %v", tc.values)
})
}
}

View File

@@ -16,10 +16,13 @@ import LoginWaitingForBrowserDialog from "@/modules/login/LoginWaitingForBrowser
import { initI18n } from "@/lib/i18n";
import { initPlatform } from "@/lib/platform";
import { initLogForwarding } from "@/lib/logs";
import { initStallWatch } from "@/lib/stallwatch";
// Must run first so even init-time logs reach the Go log pipeline.
initLogForwarding();
initStallWatch();
welcome();
Promise.all([

View File

@@ -51,7 +51,14 @@ async function runSsoLogin(
if (uri) await openBrowserLoginUri(uri);
const cancelPromise = buildSsoCancelPromise(state, signal);
const waitPromise = Connection.WaitSSOLogin({ userCode: result.userCode, hostname: "" });
// Combine wait + up in Go so the connection comes up the moment SSO
// completes. During SSO the tray window is hidden and the webview is
// suspended, so a frontend-driven Up (a promise continuation) would not
// fire until the user woke the window (e.g. hovering the tray icon).
const waitPromise = Connection.WaitSSOLoginAndUp(
{ userCode: result.userCode, hostname: "" },
{ profileName: "", username: "" },
);
try {
await Promise.race([waitPromise, cancelPromise]);
@@ -89,13 +96,13 @@ export async function startConnection(onSettled?: () => void, signal?: AbortSign
if (signal?.aborted) state.cancelled = true;
if (!state.cancelled && result.needsSsoLogin) {
// runSsoLogin brings the connection up in Go once SSO completes.
await runSsoLogin(result, state, signal);
}
if (!state.cancelled && signal?.aborted) state.cancelled = true;
if (!state.cancelled) {
await Connection.Up({ profileName: "", username: "" });
} else {
if (!state.cancelled && signal?.aborted) state.cancelled = true;
if (!state.cancelled) {
await Connection.Up({ profileName: "", username: "" });
}
}
} catch (e) {
WindowManager.CloseBrowserLogin().catch(console.error);

View File

@@ -0,0 +1,31 @@
// Detects webview suspension (macOS App Nap / hidden-window timer throttling).
// While the webview is suspended no JS runs at all, so detection happens on
// resume: a 1s interval measures wall-clock drift and reports how long timers
// were frozen. Silent unless a stall actually occurred; a stalled webview is
// what delays promise continuations such as the WaitSSOLogin → Up handoff.
const INTERVAL_MS = 1000;
const STALL_THRESHOLD_MS = 5000;
const REPORT_COOLDOWN_MS = 60_000;
let started = false;
export function initStallWatch() {
if (started) return;
started = true;
let last = Date.now();
let lastReport = 0;
setInterval(() => {
const now = Date.now();
const stall = now - last - INTERVAL_MS;
last = now;
if (stall < STALL_THRESHOLD_MS) return;
if (now - lastReport < REPORT_COOLDOWN_MS) return;
lastReport = now;
console.warn(
`webview timers were suspended for ${(stall / 1000).toFixed(1)}s ` +
`(App Nap / hidden-window throttling); pending UI work ran late`,
);
}, INTERVAL_MS);
}

View File

@@ -197,6 +197,9 @@ func main() {
// daemon may keep the main window from showing, so the OS toast is the
// only reliable signal the user gets.
go notifyIfDaemonOutdated(compat, notifier, localizer)
// One-time launch-on-login default for fresh installs; gated by the
// NetBird footprint check, MDM policy, and the persisted marker.
go applyAutostartDefault(context.Background(), services.NewAutostart(app.Autostart), prefStore, prefStore.ExistedAtLoad())
})
if err := app.Run(); err != nil {

View File

@@ -54,6 +54,10 @@ type UIPreferences struct {
Language i18n.LanguageCode `json:"language"`
ViewMode ViewMode `json:"viewMode"`
OnboardingCompleted bool `json:"onboardingCompleted"`
// AutostartInitialized records that the one-time autostart default
// decision has run for this OS user. It only ever transitions to true
// and is never reset, so the default-on flow runs at most once, ever.
AutostartInitialized bool `json:"autostartInitialized"`
}
// LanguageValidator rejects SetLanguage inputs with no shipped bundle.
@@ -72,8 +76,9 @@ type Emitter interface {
type Store struct {
path string
mu sync.RWMutex
current UIPreferences
mu sync.RWMutex
current UIPreferences
existedAtLoad bool
subsMu sync.Mutex
subs []chan UIPreferences
@@ -157,6 +162,27 @@ func (s *Store) SetOnboardingCompleted(done bool) error {
return nil
}
// SetAutostartInitialized persists the one-time autostart decision marker.
// No-op if unchanged.
func (s *Store) SetAutostartInitialized(done bool) error {
s.mu.Lock()
if s.current.AutostartInitialized == done {
s.mu.Unlock()
return nil
}
next := s.current
next.AutostartInitialized = done
if err := s.persistLocked(next); err != nil {
s.mu.Unlock()
return fmt.Errorf("persist preferences: %w", err)
}
s.current = next
s.mu.Unlock()
s.broadcast(next)
return nil
}
// SetLanguage validates, persists, and broadcasts. No-op if unchanged.
func (s *Store) SetLanguage(lang i18n.LanguageCode) error {
if lang == "" {
@@ -206,13 +232,29 @@ func (s *Store) Subscribe() (<-chan UIPreferences, func()) {
return ch, unsubscribe
}
// ExistedAtLoad reports whether the backing preferences file was present on
// disk when the store loaded. It distinguishes a user who ran a prior GUI
// version from a brand-new OS user with no preferences yet.
func (s *Store) ExistedAtLoad() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.existedAtLoad
}
// load reads the file into current. A missing file is not an error (the
// in-memory default stands); malformed contents return an error.
func (s *Store) load() error {
if _, err := os.Stat(s.path); errors.Is(err, os.ErrNotExist) {
return nil
if _, err := os.Stat(s.path); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("stat preferences: %w", err)
}
s.mu.Lock()
s.existedAtLoad = true
s.mu.Unlock()
var loaded UIPreferences
if _, err := util.ReadJson(s.path, &loaded); err != nil {
return err

View File

@@ -215,6 +215,46 @@ func TestStore_FileShapeIsJSON(t *testing.T) {
assert.Equal(t, i18n.LanguageCode("hu"), parsed.Language)
}
func TestStore_SetAutostartInitializedPersistsAcrossReload(t *testing.T) {
withTempConfigDir(t)
emitter := &recordingEmitter{}
s, err := NewStore(nil, emitter)
require.NoError(t, err)
assert.False(t, s.Get().AutostartInitialized, "marker must default to false when no file is on disk")
require.NoError(t, s.SetAutostartInitialized(true))
assert.True(t, s.Get().AutostartInitialized, "Get should reflect the persisted marker")
require.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "first marker write should broadcast")
// Re-setting the same value must be a no-op: no disk write, no broadcast.
require.NoError(t, s.SetAutostartInitialized(true))
assert.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "idempotent marker write should not broadcast again")
// A fresh Store (new GUI launch) must see the marker so the autostart
// default decision never runs twice.
reloaded, err := NewStore(nil, nil)
require.NoError(t, err)
assert.True(t, reloaded.Get().AutostartInitialized, "marker must survive a reload from disk")
}
func TestStore_ExistedAtLoad(t *testing.T) {
withTempConfigDir(t)
// Brand-new OS user: no preferences file on disk yet.
fresh, err := NewStore(nil, nil)
require.NoError(t, err)
assert.False(t, fresh.ExistedAtLoad(), "ExistedAtLoad must be false when no file is on disk")
// Persisting a value writes the file to disk.
require.NoError(t, fresh.SetLanguage("en"))
// A subsequent GUI launch reopens the now-present file.
reopened, err := NewStore(nil, nil)
require.NoError(t, err)
assert.True(t, reopened.ExistedAtLoad(), "ExistedAtLoad must be true after the store has persisted and is reopened")
}
func TestStore_ErrUnsupportedSentinel(t *testing.T) {
// Verifies callers can match on the sentinel error rather than parsing
// strings — protects against accidental %v -> %w changes that would

View File

@@ -35,7 +35,7 @@ type LoginResult struct {
VerificationURIComplete string `json:"verificationUriComplete"`
}
// WaitSSOParams are the inputs to WaitSSOLogin.
// WaitSSOParams are the inputs to waitSSOLogin.
type WaitSSOParams struct {
UserCode string `json:"userCode"`
Hostname string `json:"hostname"`
@@ -116,6 +116,7 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
if err != nil {
return LoginResult{}, s.classifyDaemonError(err)
}
log.Infof("daemon login response received, needs SSO login: %v", resp.GetNeedsSSOLogin())
return LoginResult{
NeedsSSOLogin: resp.GetNeedsSSOLogin(),
UserCode: resp.GetUserCode(),
@@ -124,26 +125,12 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
}, nil
}
func (s *Connection) WaitSSOLogin(ctx context.Context, p WaitSSOParams) (string, error) {
cli, err := s.conn.Client()
if err != nil {
return "", err
}
resp, err := cli.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{
UserCode: p.UserCode,
Hostname: p.Hostname,
})
if err != nil {
return "", s.classifyDaemonError(err)
}
return resp.GetEmail(), nil
}
func (s *Connection) Up(ctx context.Context, p UpParams) error {
cli, err := s.conn.Client()
if err != nil {
return err
}
log.Infof("sending up request to daemon")
// Always async: status updates flow via SubscribeStatus.
req := &proto.UpRequest{Async: true}
if p.ProfileName != "" {
@@ -158,6 +145,27 @@ func (s *Connection) Up(ctx context.Context, p UpParams) error {
return nil
}
// WaitSSOLoginAndUp blocks until the SSO login completes and then brings the
// connection up, both from the Go side. Keeping the post-login Up here rather
// than as a frontend continuation is deliberate: during SSO the tray window is
// hidden and the webview is suspended (macOS App Nap / hidden-window timer
// throttling), so a frontend-driven Up would not run until the user woke the
// window (e.g. by hovering the tray icon). Doing it in Go connects the moment
// the daemon reports SSO success. Returns the authenticated user's email.
func (s *Connection) WaitSSOLoginAndUp(ctx context.Context, wait WaitSSOParams, up UpParams) (string, error) {
email, err := s.waitSSOLogin(ctx, wait)
if err != nil {
return "", err
}
if err := ctx.Err(); err != nil {
return "", err
}
if err := s.Up(ctx, up); err != nil {
return "", err
}
return email, nil
}
func (s *Connection) Down(ctx context.Context) error {
cli, err := s.conn.Client()
if err != nil {
@@ -217,6 +225,26 @@ func (s *Connection) Logout(ctx context.Context, p LogoutParams) error {
return nil
}
// waitSSOLogin blocks until the daemon reports the SSO login result and returns
// the authenticated user's email. It is unexported because the frontend drives
// SSO through the exported WaitSSOLoginAndUp.
func (s *Connection) waitSSOLogin(ctx context.Context, p WaitSSOParams) (string, error) {
cli, err := s.conn.Client()
if err != nil {
return "", err
}
log.Infof("waiting for SSO login to complete")
resp, err := cli.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{
UserCode: p.UserCode,
Hostname: p.Hostname,
})
if err != nil {
return "", s.classifyDaemonError(err)
}
log.Infof("SSO login completed, daemon reported success")
return resp.GetEmail(), nil
}
// classifyDaemonError maps a gRPC error to a localised ClientError.
func (s *Connection) classifyDaemonError(err error) *ClientError {
return s.classifier.classify(err)

View File

@@ -20,11 +20,12 @@ type MDMFields struct {
DisableServerRoutes bool `json:"disableServerRoutes"`
AllowServerSSH *bool `json:"allowServerSSH"`
DisableAutoConnect bool `json:"disableAutoConnect"`
DisableAutostart bool `json:"disableAutostart"`
BlockInbound bool `json:"blockInbound"`
DisableMetricsCollection bool `json:"disableMetricsCollection"`
SplitTunnelMode bool `json:"splitTunnelMode"`
SplitTunnelApps bool `json:"splitTunnelApps"`
DisableAdvancedView bool `json:"disableAdvancedView"`
DisableAdvancedView bool `json:"disableAdvancedView"`
}
type Features struct {

View File

@@ -23,7 +23,7 @@ const (
RDCleanPathProxyHost = "rdcleanpath.proxy.local"
RDCleanPathProxyScheme = "ws"
rdpDialTimeout = 15 * time.Second
rdpDialTimeout = 30 * time.Second
GeneralErrorCode = 1
WSAETimedOut = 10060

18
go.mod
View File

@@ -2,7 +2,7 @@ module github.com/netbirdio/netbird
go 1.25.5
toolchain go1.25.11
toolchain go1.25.12
require (
cunicu.li/go-rosenpass v0.5.42
@@ -19,8 +19,8 @@ require (
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/vishvananda/netlink v1.3.1
golang.org/x/crypto v0.50.0
golang.org/x/sys v0.43.0
golang.org/x/crypto v0.54.0
golang.org/x/sys v0.47.0
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
golang.zx2c4.com/wireguard/windows v0.5.3
@@ -126,11 +126,11 @@ require (
goauthentik.io/api/v3 v3.2023051.3
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
golang.org/x/mobile v0.0.0-20251113184115-a159579294ab
golang.org/x/mod v0.35.0
golang.org/x/net v0.53.0
golang.org/x/mod v0.37.0
golang.org/x/net v0.56.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.20.0
golang.org/x/term v0.42.0
golang.org/x/sync v0.22.0
golang.org/x/term v0.45.0
golang.org/x/time v0.15.0
google.golang.org/api v0.276.0
gopkg.in/yaml.v3 v3.0.1
@@ -314,8 +314,8 @@ require (
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.47.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect

32
go.sum
View File

@@ -732,8 +732,8 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
@@ -748,8 +748,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
@@ -768,8 +768,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
@@ -784,8 +784,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -821,8 +821,8 @@ golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -835,8 +835,8 @@ golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -848,8 +848,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -863,8 +863,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -215,7 +215,7 @@ func (e *EphemeralManager) cleanup(ctx context.Context) {
}
for accountID, peerIDs := range peerIDsPerAccount {
log.WithContext(ctx).Tracef("cleanup: deleting %d ephemeral peers for account %s", len(peerIDs), accountID)
log.WithContext(ctx).Debugf("cleanup: deleting %d ephemeral peers for account %s: %s", len(peerIDs), accountID, peerIDs)
err := e.peersManager.DeletePeers(ctx, accountID, peerIDs, activity.SystemInitiator, true)
if err != nil {
log.WithContext(ctx).Errorf("failed to delete ephemeral peers: %s", err)

View File

@@ -184,6 +184,8 @@ func (m *managerImpl) DeletePeers(ctx context.Context, accountID string, peerIDs
return err
}
log.WithContext(ctx).Debugf("DeletePeers: deleted peer %s", peerID)
if !(peer.ProxyMeta.Embedded || peer.Meta.KernelVersion == "wasm") {
eventsToStore = append(eventsToStore, func() {
m.accountManager.StoreEvent(ctx, userID, peer.ID, accountID, activity.PeerRemovedByUser, peer.EventMeta(dnsDomain))
@@ -243,6 +245,7 @@ func (m *managerImpl) CreateProxyPeer(ctx context.Context, accountID string, pee
// proxy, not by an end user; the stale peer may still be
// marked Connected from its prior session, but its session is
// dead by definition (its key no longer exists).
log.WithContext(ctx).Debugf("removing %d stale embedded proxy peer records [%s]", len(staleIDs), staleIDs)
if err := m.DeletePeers(ctx, accountID, staleIDs, "", false); err != nil {
return fmt.Errorf("delete stale embedded proxy peers %v: %w", staleIDs, err)
}
@@ -280,12 +283,12 @@ func (m *managerImpl) CreateProxyPeer(ctx context.Context, accountID string, pee
// to garbage-collect stale records left behind when the proxy restarts with a
// regenerated keypair.
func (m *managerImpl) findStaleEmbeddedProxyPeers(ctx context.Context, accountID, cluster, newKey string) ([]string, error) {
account, err := m.store.GetAccount(ctx, accountID)
peers, err := m.store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "")
if err != nil {
return nil, err
}
var stale []string
for _, p := range account.Peers {
for _, p := range peers {
if p == nil || !p.ProxyMeta.Embedded {
continue
}
@@ -297,5 +300,9 @@ func (m *managerImpl) findStaleEmbeddedProxyPeers(ctx context.Context, accountID
}
stale = append(stale, p.ID)
}
return stale, nil
if len(stale) > 0 {
log.WithContext(ctx).Tracef("found stale embedded proxy peer(s) with cluster: %s, and pubkey: %s but voided", cluster, newKey)
}
// returning empty for validating
return []string{}, nil
}

View File

@@ -15,7 +15,7 @@ import (
"go.opentelemetry.io/otel/metric"
"golang.org/x/crypto/acme/autocert"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"golang.org/x/net/http2/h2c" //nolint:staticcheck
"google.golang.org/grpc"
"github.com/netbirdio/netbird/encryption"
@@ -382,6 +382,7 @@ func (s *BaseServer) serveGRPCWithHTTP(ctx context.Context, listener net.Listene
// the following magic is needed to support HTTP2 without TLS
// and still share a single port between gRPC and HTTP APIs
h1s := &http.Server{
//nolint:staticcheck // h2c also handles the HTTP/1 Upgrade mechanism, which http.Server's UnencryptedHTTP2 does not
Handler: h2c.NewHandler(handler, &http2.Server{}),
}
err = h1s.Serve(listener)

View File

@@ -161,6 +161,8 @@ func (m *TimeBasedAuthSecretsManager) SetupRefresh(ctx context.Context, accountI
m.turnCancelMap[peerID] = turnCancel
go m.refreshTURNTokens(ctx, accountID, peerID, turnCancel)
log.WithContext(ctx).Debugf("starting TURN refresh for %s", peerID)
} else {
log.WithContext(ctx).Debugf("no TURN configuration, skipping TURN refresh for %s", peerID)
}
if m.relayCfg != nil {
@@ -168,6 +170,8 @@ func (m *TimeBasedAuthSecretsManager) SetupRefresh(ctx context.Context, accountI
m.relayCancelMap[peerID] = relayCancel
go m.refreshRelayTokens(ctx, accountID, peerID, relayCancel)
log.WithContext(ctx).Tracef("starting relay refresh for %s", peerID)
} else {
log.WithContext(ctx).Tracef("no relay configuration, skipping relay refresh for %s", peerID)
}
}

View File

@@ -286,6 +286,21 @@ func (h *handler) updateAccountRequestSettings(req api.PutApiAccountsAccountIdJS
if req.Settings.MetricsPushEnabled != nil {
returnSettings.MetricsPushEnabled = *req.Settings.MetricsPushEnabled
}
if req.Settings.AgentNetworkOnly != nil {
returnSettings.AgentNetworkOnly = *req.Settings.AgentNetworkOnly
}
if req.Settings.DashboardFeatures != nil {
returnSettings.DashboardFeatures = &types.DashboardFeatures{
AgentNetwork: req.Settings.DashboardFeatures.AgentNetwork,
}
}
if returnSettings.AgentNetworkOnly &&
(returnSettings.DashboardFeatures == nil ||
returnSettings.DashboardFeatures.AgentNetwork == nil ||
!*returnSettings.DashboardFeatures.AgentNetwork) {
return nil, status.Errorf(status.InvalidArgument, "agent network only mode requires dashboard_features.agent_network to be enabled")
}
return returnSettings, nil
}
@@ -417,6 +432,7 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A
AutoUpdateAlways: &settings.AutoUpdateAlways,
Ipv6EnabledGroups: &settings.IPv6EnabledGroups,
MetricsPushEnabled: &settings.MetricsPushEnabled,
AgentNetworkOnly: &settings.AgentNetworkOnly,
EmbeddedIdpEnabled: &settings.EmbeddedIdpEnabled,
LocalAuthDisabled: &settings.LocalAuthDisabled,
LocalMfaEnabled: &settings.LocalMfaEnabled,
@@ -430,6 +446,11 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A
networkRangeV6Str := settings.NetworkRangeV6.String()
apiSettings.NetworkRangeV6 = &networkRangeV6Str
}
if settings.DashboardFeatures != nil {
apiSettings.DashboardFeatures = &api.AccountDashboardFeatures{
AgentNetwork: settings.DashboardFeatures.AgentNetwork,
}
}
apiOnboarding := api.AccountOnboarding{
OnboardingFlowPending: onboarding.OnboardingFlowPending,

View File

@@ -130,6 +130,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
AutoUpdateAlways: br(false),
AutoUpdateVersion: sr(""),
MetricsPushEnabled: br(false),
AgentNetworkOnly: br(false),
EmbeddedIdpEnabled: br(false),
LocalAuthDisabled: br(false),
LocalMfaEnabled: br(false),
@@ -158,6 +159,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
AutoUpdateAlways: br(false),
AutoUpdateVersion: sr(""),
MetricsPushEnabled: br(false),
AgentNetworkOnly: br(false),
EmbeddedIdpEnabled: br(false),
LocalAuthDisabled: br(false),
LocalMfaEnabled: br(false),
@@ -186,6 +188,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
AutoUpdateAlways: br(false),
AutoUpdateVersion: sr("latest"),
MetricsPushEnabled: br(false),
AgentNetworkOnly: br(false),
EmbeddedIdpEnabled: br(false),
LocalAuthDisabled: br(false),
LocalMfaEnabled: br(false),
@@ -214,6 +217,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
AutoUpdateAlways: br(false),
AutoUpdateVersion: sr(""),
MetricsPushEnabled: br(false),
AgentNetworkOnly: br(false),
EmbeddedIdpEnabled: br(false),
LocalAuthDisabled: br(false),
LocalMfaEnabled: br(false),
@@ -242,6 +246,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
AutoUpdateAlways: br(false),
AutoUpdateVersion: sr(""),
MetricsPushEnabled: br(false),
AgentNetworkOnly: br(false),
EmbeddedIdpEnabled: br(false),
LocalAuthDisabled: br(false),
LocalMfaEnabled: br(false),
@@ -270,6 +275,109 @@ func TestAccounts_AccountsHandler(t *testing.T) {
AutoUpdateAlways: br(false),
AutoUpdateVersion: sr(""),
MetricsPushEnabled: br(false),
AgentNetworkOnly: br(false),
EmbeddedIdpEnabled: br(false),
LocalAuthDisabled: br(false),
LocalMfaEnabled: br(false),
},
expectedArray: false,
expectedID: accountID,
},
{
name: "PutAccount OK enabling agent_network_only",
expectedBody: true,
requestType: http.MethodPut,
requestPath: "/api/accounts/" + accountID,
requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"agent_network_only\": true,\"dashboard_features\": {\"agent_network\": true}},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"),
expectedStatus: http.StatusOK,
expectedSettings: api.AccountSettings{
PeerLoginExpiration: 15552000,
PeerLoginExpirationEnabled: true,
GroupsPropagationEnabled: br(false),
JwtGroupsClaimName: sr(""),
JwtGroupsEnabled: br(false),
JwtAllowGroups: &[]string{},
RegularUsersViewBlocked: false,
RoutingPeerDnsResolutionEnabled: br(false),
LazyConnectionEnabled: br(false),
DnsDomain: sr(""),
AutoUpdateAlways: br(false),
AutoUpdateVersion: sr(""),
MetricsPushEnabled: br(false),
AgentNetworkOnly: br(true),
DashboardFeatures: &api.AccountDashboardFeatures{
AgentNetwork: br(true),
},
EmbeddedIdpEnabled: br(false),
LocalAuthDisabled: br(false),
LocalMfaEnabled: br(false),
},
expectedArray: false,
expectedID: accountID,
},
{
name: "PutAccount fails enabling agent_network_only without dashboard_features",
expectedBody: true,
requestType: http.MethodPut,
requestPath: "/api/accounts/" + accountID,
requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"agent_network_only\": true},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"),
expectedStatus: http.StatusUnprocessableEntity,
expectedArray: false,
},
{
name: "PutAccount OK setting dashboard_features agent_network",
expectedBody: true,
requestType: http.MethodPut,
requestPath: "/api/accounts/" + accountID,
requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"dashboard_features\": {\"agent_network\": true}},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"),
expectedStatus: http.StatusOK,
expectedSettings: api.AccountSettings{
PeerLoginExpiration: 15552000,
PeerLoginExpirationEnabled: true,
GroupsPropagationEnabled: br(false),
JwtGroupsClaimName: sr(""),
JwtGroupsEnabled: br(false),
JwtAllowGroups: &[]string{},
RegularUsersViewBlocked: false,
RoutingPeerDnsResolutionEnabled: br(false),
LazyConnectionEnabled: br(false),
DnsDomain: sr(""),
AutoUpdateAlways: br(false),
AutoUpdateVersion: sr(""),
MetricsPushEnabled: br(false),
AgentNetworkOnly: br(false),
DashboardFeatures: &api.AccountDashboardFeatures{
AgentNetwork: br(true),
},
EmbeddedIdpEnabled: br(false),
LocalAuthDisabled: br(false),
LocalMfaEnabled: br(false),
},
expectedArray: false,
expectedID: accountID,
},
{
name: "PutAccount OK disabling agent_network_only again",
expectedBody: true,
requestType: http.MethodPut,
requestPath: "/api/accounts/" + accountID,
requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"agent_network_only\": false},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"),
expectedStatus: http.StatusOK,
expectedSettings: api.AccountSettings{
PeerLoginExpiration: 15552000,
PeerLoginExpirationEnabled: true,
GroupsPropagationEnabled: br(false),
JwtGroupsClaimName: sr(""),
JwtGroupsEnabled: br(false),
JwtAllowGroups: &[]string{},
RegularUsersViewBlocked: false,
RoutingPeerDnsResolutionEnabled: br(false),
LazyConnectionEnabled: br(false),
DnsDomain: sr(""),
AutoUpdateAlways: br(false),
AutoUpdateVersion: sr(""),
MetricsPushEnabled: br(false),
AgentNetworkOnly: br(false),
EmbeddedIdpEnabled: br(false),
LocalAuthDisabled: br(false),
LocalMfaEnabled: br(false),

View File

@@ -106,11 +106,13 @@ func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubK
}
if !updated {
am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusConnect, telemetry.PeerStatusStale)
log.WithContext(ctx).Tracef("peer %s already has a newer session in store, skipping connect", peer.ID)
log.WithContext(ctx).Debugf("peer %s already has a newer session in store, skipping connect", peer.ID)
return nil
}
am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusConnect, telemetry.PeerStatusApplied)
log.WithContext(ctx).Debugf("mark peer %s connected", peer.ID)
if err = am.schedulePeerExpirations(ctx, accountID, peer); err != nil {
return err
}
@@ -180,12 +182,14 @@ func (am *DefaultAccountManager) MarkPeerDisconnected(ctx context.Context, peerP
}
if !updated {
am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusDisconnect, telemetry.PeerStatusStale)
log.WithContext(ctx).Tracef("peer %s session token mismatch on disconnect (token=%d), skipping",
log.WithContext(ctx).Debugf("peer %s session token mismatch on disconnect (token=%d), skipping",
peer.ID, sessionStartedAt)
return nil
}
am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusDisconnect, telemetry.PeerStatusApplied)
log.WithContext(ctx).Debugf("mark peer %s disconnected", peer.ID)
// Symmetric with MarkPeerConnected: when an embedded proxy peer goes
// offline, refresh the peers that had synthesized records pointing at
// it so they pull the stale entries instead of waiting out TTL.

View File

@@ -1605,7 +1605,8 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
settings_jwt_groups_enabled, settings_jwt_groups_claim_name, settings_jwt_allow_groups,
settings_routing_peer_dns_resolution_enabled, settings_dns_domain, settings_network_range,
settings_network_range_v6, settings_ipv6_enabled_groups, settings_lazy_connection_enabled,
settings_local_mfa_enabled, settings_metrics_push_enabled,
settings_local_mfa_enabled, settings_metrics_push_enabled, settings_agent_network_only,
settings_dashboard_features,
-- Embedded ExtraSettings
settings_extra_peer_approval_enabled, settings_extra_user_approval_required,
settings_extra_integrated_validator, settings_extra_integrated_validator_groups
@@ -1629,6 +1630,8 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
sLazyConnectionEnabled sql.NullBool
sLocalMFAEnabled sql.NullBool
sMetricsPushEnabled sql.NullBool
sAgentNetworkOnly sql.NullBool
sDashboardFeatures sql.NullString
sExtraPeerApprovalEnabled sql.NullBool
sExtraUserApprovalRequired sql.NullBool
sExtraIntegratedValidator sql.NullString
@@ -1651,7 +1654,8 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
&sJWTGroupsEnabled, &sJWTGroupsClaimName, &sJWTAllowGroups,
&sRoutingPeerDNSResolutionEnabled, &sDNSDomain, &sNetworkRange,
&sNetworkRangeV6, &sIPv6EnabledGroups, &sLazyConnectionEnabled,
&sLocalMFAEnabled, &sMetricsPushEnabled,
&sLocalMFAEnabled, &sMetricsPushEnabled, &sAgentNetworkOnly,
&sDashboardFeatures,
&sExtraPeerApprovalEnabled, &sExtraUserApprovalRequired,
&sExtraIntegratedValidator, &sExtraIntegratedValidatorGroups,
)
@@ -1720,6 +1724,14 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
if sMetricsPushEnabled.Valid {
account.Settings.MetricsPushEnabled = sMetricsPushEnabled.Bool
}
if sAgentNetworkOnly.Valid {
account.Settings.AgentNetworkOnly = sAgentNetworkOnly.Bool
}
if sDashboardFeatures.Valid && sDashboardFeatures.String != "" {
if err := json.Unmarshal([]byte(sDashboardFeatures.String), &account.Settings.DashboardFeatures); err != nil {
log.WithContext(ctx).Warnf("failed to unmarshal dashboard features for account %s: %v", accountID, err)
}
}
if sJWTAllowGroups.Valid {
_ = json.Unmarshal([]byte(sJWTAllowGroups.String), &account.Settings.JWTAllowGroups)
}

View File

@@ -1245,6 +1245,61 @@ func TestSqlite_CreateAndGetObjectInTransaction(t *testing.T) {
assert.NoError(t, err)
}
func TestSqlStore_SaveAccountPersistsAgentNetworkOnly(t *testing.T) {
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir())
t.Cleanup(cleanup)
require.NoError(t, err)
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
account, err := store.GetAccount(context.Background(), accountID)
require.NoError(t, err)
require.False(t, account.Settings.AgentNetworkOnly, "setting should default to false")
account.Settings.AgentNetworkOnly = true
require.NoError(t, store.SaveAccount(context.Background(), account))
reloaded, err := store.GetAccount(context.Background(), accountID)
require.NoError(t, err)
require.True(t, reloaded.Settings.AgentNetworkOnly, "setting should survive a save/load round-trip")
reloaded.Settings.AgentNetworkOnly = false
require.NoError(t, store.SaveAccount(context.Background(), reloaded))
disabled, err := store.GetAccount(context.Background(), accountID)
require.NoError(t, err)
require.False(t, disabled.Settings.AgentNetworkOnly, "disabling should persist")
}
func TestSqlStore_SaveAccountPersistsDashboardFeatures(t *testing.T) {
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir())
t.Cleanup(cleanup)
require.NoError(t, err)
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
account, err := store.GetAccount(context.Background(), accountID)
require.NoError(t, err)
require.Nil(t, account.Settings.DashboardFeatures, "dashboard features should default to unset")
agentNetwork := true
account.Settings.DashboardFeatures = &types.DashboardFeatures{AgentNetwork: &agentNetwork}
require.NoError(t, store.SaveAccount(context.Background(), account))
reloaded, err := store.GetAccount(context.Background(), accountID)
require.NoError(t, err)
require.NotNil(t, reloaded.Settings.DashboardFeatures, "dashboard features should survive a save/load round-trip")
require.NotNil(t, reloaded.Settings.DashboardFeatures.AgentNetwork, "agent network flag should be set")
require.True(t, *reloaded.Settings.DashboardFeatures.AgentNetwork, "agent network flag should persist as true")
disabled := false
reloaded.Settings.DashboardFeatures = &types.DashboardFeatures{AgentNetwork: &disabled}
require.NoError(t, store.SaveAccount(context.Background(), reloaded))
reloadedDisabled, err := store.GetAccount(context.Background(), accountID)
require.NoError(t, err)
require.NotNil(t, reloadedDisabled.Settings.DashboardFeatures.AgentNetwork, "agent network flag should remain set")
require.False(t, *reloadedDisabled.Settings.DashboardFeatures.AgentNetwork, "explicit false should persist")
}
func TestSqlStore_GetAccountUsers(t *testing.T) {
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
t.Cleanup(cleanup)

View File

@@ -305,7 +305,8 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
zone = &nbdns.CustomZone{
Domain: dns.Fqdn(serviceDomainZone),
Records: []nbdns.SimpleRecord{},
NonAuthoritative: true,
NonAuthoritative: true,
SearchDomainDisabled: true,
}
zonesByApex[serviceDomainZone] = zone
}

View File

@@ -76,6 +76,15 @@ type Settings struct {
// MetricsPushEnabled globally enables or disables client metrics push for the account
MetricsPushEnabled bool `gorm:"default:false"`
// AgentNetworkOnly limits the dashboard to the Agent Network surface for this account.
// Set for accounts created via netbird.ai signups; users can disable it later.
AgentNetworkOnly bool `gorm:"default:false"`
// DashboardFeatures holds per-account dashboard section visibility overrides.
// It serializes to a single JSON column so new sections can be added without
// a schema change.
DashboardFeatures *DashboardFeatures `gorm:"serializer:json"`
// EmbeddedIdpEnabled indicates if the embedded identity provider is enabled.
// This is a runtime-only field, not stored in the database.
EmbeddedIdpEnabled bool `gorm:"-"`
@@ -114,6 +123,7 @@ func (s *Settings) Copy() *Settings {
AutoUpdateAlways: s.AutoUpdateAlways,
IPv6EnabledGroups: slices.Clone(s.IPv6EnabledGroups),
MetricsPushEnabled: s.MetricsPushEnabled,
AgentNetworkOnly: s.AgentNetworkOnly,
EmbeddedIdpEnabled: s.EmbeddedIdpEnabled,
LocalAuthDisabled: s.LocalAuthDisabled,
LocalMfaEnabled: s.LocalMfaEnabled,
@@ -121,9 +131,31 @@ func (s *Settings) Copy() *Settings {
if s.Extra != nil {
settings.Extra = s.Extra.Copy()
}
if s.DashboardFeatures != nil {
settings.DashboardFeatures = s.DashboardFeatures.Copy()
}
return settings
}
// DashboardFeatures holds per-account dashboard section visibility overrides.
// Nil fields are unset and follow the default dashboard behavior; an explicit
// value forces that section shown or hidden for the account.
type DashboardFeatures struct {
// AgentNetwork, when set, forces the Agent Network menu shown (true) or
// hidden (false) regardless of the deployment feature flag.
AgentNetwork *bool `json:"agent_network,omitempty"`
}
// Copy returns a deep copy of the DashboardFeatures struct.
func (d *DashboardFeatures) Copy() *DashboardFeatures {
c := &DashboardFeatures{}
if d.AgentNetwork != nil {
v := *d.AgentNetwork
c.AgentNetwork = &v
}
return c
}
type ExtraSettings struct {
// PeerApprovalEnabled enables or disables the need for peers bo be approved by an administrator
PeerApprovalEnabled bool

View File

@@ -375,6 +375,12 @@ components:
description: Enables or disables client metrics push for all peers in the account
type: boolean
example: false
agent_network_only:
description: Limits the dashboard to the Agent Network surface for this account. Set for accounts created via netbird.ai signups and can be disabled later. Enabling this requires dashboard_features.agent_network to be true in the same request.
type: boolean
example: false
dashboard_features:
$ref: '#/components/schemas/AccountDashboardFeatures'
embedded_idp_enabled:
description: Indicates whether the embedded identity provider (Dex) is enabled for this account. This is a read-only field.
type: boolean
@@ -403,6 +409,14 @@ components:
- regular_users_view_blocked
- peer_expose_enabled
- peer_expose_groups
AccountDashboardFeatures:
description: Per-account dashboard section visibility overrides. Omitted keys follow the default dashboard behavior.
type: object
properties:
agent_network:
description: Controls the Agent Network menu for the account regardless of the deployment feature flag. When true the menu is shown, when false it is hidden, and when omitted the default behavior applies. Must be true when agent_network_only is enabled.
type: boolean
example: true
AccountExtraSettings:
type: object
properties:
@@ -9327,6 +9341,18 @@ paths:
required: false
schema:
type: string
- name: source_id
in: query
description: Filter by source endpoint ID
required: false
schema:
type: string
- name: destination_id
in: query
description: Filter by destination endpoint ID
required: false
schema:
type: string
- name: protocol
in: query
description: Filter by protocol
@@ -10478,7 +10504,7 @@ paths:
- EDR Intune Integrations
summary: Delete EDR Intune Integration
description: Deletes an EDR Intune Integration by its ID.
operationId: deleteIntegration
operationId: deleteEDRIntuneIntegration
responses:
'200':
description: Integration deleted successfully. Returns an empty object.
@@ -12562,7 +12588,7 @@ paths:
- Event Streaming Integrations
summary: Delete Event Streaming Integration
description: Deletes an event streaming integration by its ID.
operationId: deleteIntegration
operationId: deleteEventStreamingIntegration
responses:
'200':
description: Integration deleted successfully. Returns an empty object.

View File

@@ -1612,6 +1612,12 @@ type Account struct {
Settings AccountSettings `json:"settings"`
}
// AccountDashboardFeatures Per-account dashboard section visibility overrides. Omitted keys follow the default dashboard behavior.
type AccountDashboardFeatures struct {
// AgentNetwork Controls the Agent Network menu for the account regardless of the deployment feature flag. When true the menu is shown, when false it is hidden, and when omitted the default behavior applies. Must be true when agent_network_only is enabled.
AgentNetwork *bool `json:"agent_network,omitempty"`
}
// AccountExtraSettings defines model for AccountExtraSettings.
type AccountExtraSettings struct {
// NetworkTrafficLogsEnabled Enables or disables network traffic logging. If enabled, all network traffic events from peers will be stored.
@@ -1647,12 +1653,18 @@ type AccountRequest struct {
// AccountSettings defines model for AccountSettings.
type AccountSettings struct {
// AgentNetworkOnly Limits the dashboard to the Agent Network surface for this account. Set for accounts created via netbird.ai signups and can be disabled later. Enabling this requires dashboard_features.agent_network to be true in the same request.
AgentNetworkOnly *bool `json:"agent_network_only,omitempty"`
// AutoUpdateAlways When true, updates are installed automatically in the background. When false, updates require user interaction from the UI.
AutoUpdateAlways *bool `json:"auto_update_always,omitempty"`
// AutoUpdateVersion Set Clients auto-update version. "latest", "disabled", or a specific version (e.g "0.50.1")
AutoUpdateVersion *string `json:"auto_update_version,omitempty"`
// DashboardFeatures Per-account dashboard section visibility overrides. Omitted keys follow the default dashboard behavior.
DashboardFeatures *AccountDashboardFeatures `json:"dashboard_features,omitempty"`
// DnsDomain Allows to define a custom dns domain for the account
DnsDomain *string `json:"dns_domain,omitempty"`
@@ -5857,6 +5869,12 @@ type GetApiEventsNetworkTrafficParams struct {
// ReporterId Filter by reporter ID
ReporterId *string `form:"reporter_id,omitempty" json:"reporter_id,omitempty"`
// SourceId Filter by source endpoint ID
SourceId *string `form:"source_id,omitempty" json:"source_id,omitempty"`
// DestinationId Filter by destination endpoint ID
DestinationId *string `form:"destination_id,omitempty" json:"destination_id,omitempty"`
// Protocol Filter by protocol
Protocol *int `form:"protocol,omitempty" json:"protocol,omitempty"`

View File

@@ -0,0 +1,9 @@
//go:build !js
package ws
// closeConn closes the underlying WebSocket immediately, skipping the close
// handshake.
func (c *Conn) closeConn() error {
return c.Conn.CloseNow()
}

View File

@@ -0,0 +1,25 @@
//go:build js
package ws
import (
"github.com/coder/websocket"
log "github.com/sirupsen/logrus"
)
// closeConn closes the browser WebSocket without blocking the caller.
//
// The browser close API only accepts codes 1000 and 3000-4999, so CloseNow's
// 1001 (going away) throws an InvalidAccessError. Close with a valid code
// waits for the browser close event before returning, which can park the
// calling goroutine (the relay teardown path holds its mutexes while closing)
// until the close handshake finishes. Run the close in the background and
// report success; a teardown close error is not actionable.
func (c *Conn) closeConn() error {
go func() {
if err := c.Conn.Close(websocket.StatusNormalClosure, ""); err != nil {
log.Debugf("failed to close relay websocket: %v", err)
}
}()
return nil
}

View File

@@ -77,5 +77,5 @@ func (c *Conn) SetDeadline(t time.Time) error {
}
func (c *Conn) Close() error {
return c.Conn.CloseNow()
return c.closeConn()
}

View File

@@ -16,7 +16,7 @@ import (
"go.opentelemetry.io/otel/metric"
"golang.org/x/crypto/acme/autocert"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"golang.org/x/net/http2/h2c" //nolint:staticcheck
"github.com/netbirdio/netbird/shared/metrics"
@@ -281,6 +281,7 @@ func serveHTTP(httpListener net.Listener, handler http.Handler) {
go func() {
// Use h2c to support HTTP/2 without TLS (needed for gRPC)
h1s := &http.Server{
//nolint:staticcheck // h2c also handles the HTTP/1 Upgrade mechanism, which http.Server's UnencryptedHTTP2 does not
Handler: h2c.NewHandler(handler, &http2.Server{}),
}
err := h1s.Serve(httpListener)