Compare commits

..

6 Commits

Author SHA1 Message Date
Viktor Liu
25fe7d13d9 Keep Windows power notification handle when unregister fails 2026-07-17 12:51:43 +02:00
Viktor Liu
c533dead28 Merge branch 'main' into windows-sleep-detector 2026-07-17 19:47:21 +09:00
Pascal Fischer
21fc5b81f6 [management] allow disabling device code flow when using dex (#6809) 2026-07-17 12:41:08 +02:00
Viktor Liu
67af1b2ef2 Add Windows sleep detection for pre-sleep down/wake up 2026-07-17 12:40:02 +02:00
dmitri-netbird
9906b9b1a1 [management] fix a flake in account_test (#6811)
## Describe your changes
In
"TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved",
in the beginning of the test, during account creation a random /16
subnet from 10.64.0./10 network is used. Later in the test a new range
(10.99.0.0/16) is assigned to the account, but it's one of the possible
subnets used during account creation, which sometimes leads to a
collision and failed test.
Using a network outside of the range of networks used during account
creation fixes the issue.

## Issue ticket number and link

## Stack

<!-- branch-stack -->

### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [ ] Created tests that fail without the change (if possible)
- [x] This change does **not** modify the public API, gRPC protocols,
functionality behavior, CLI / service flags, or introduce a new feature
— **OR** I have discussed it with the NetBird team beforehand (link the
issue / Slack thread in the description). See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first).

> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6811"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img
alt="View with Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a>
<a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1786868463&installation_id=146802194&pr_number=6811&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6811&signature=c0ded1a8ecc05686b6ba19eb366519852ac35e170da4f8522fb7aec2062d4a3e"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>/codesmith</code> with what you
need. Autofix is disabled.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Tests**
* Updated account network range test coverage to verify peer IP
reallocation with a distinct network range.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-17 11:21:11 +02:00
dmitri-netbird
877e889250 [management] fix fetching of missing settings in GetAccount call (#6800)
## Describe your changes

## Issue ticket number and link

## Stack

<!-- branch-stack -->

### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [ ] Created tests that fail without the change (if possible)
- [x] This change does **not** modify the public API, gRPC protocols,
functionality behavior, CLI / service flags, or introduce a new feature
— **OR** I have discussed it with the NetBird team beforehand (link the
issue / Slack thread in the description). See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first).

> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6800"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img
alt="View with Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a>
<a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1786797566&installation_id=146802194&pr_number=6800&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6800&signature=36a7053e1029e5de1d496fbd9c428a1745d0ec22b3e6479d64aafb1b8350b0ef"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>/codesmith</code> with what you
need. Autofix is disabled.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Ensure account settings are fully preserved through save/load,
including automatic update and peer exposure preferences.

* **Tests**
* Added coverage to verify account settings remain unchanged after
database persistence and retrieval (skipped on Windows due to SQLite
limitations).
* Introduced deterministic test-data population helpers to reliably set
struct fields for deeper settings verification.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-17 10:38:43 +02:00
24 changed files with 693 additions and 383 deletions

View File

@@ -17,7 +17,8 @@ ENV \
NETBIRD_BIN="/usr/local/bin/netbird" \
NB_LOG_FILE="console,/var/log/netbird/client.log" \
NB_DAEMON_ADDR="unix:///var/run/netbird.sock" \
NB_ENABLE_CAPTURE="false"
NB_ENABLE_CAPTURE="false" \
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
ARG TARGETPLATFORM

View File

@@ -23,7 +23,8 @@ ENV \
NB_DAEMON_ADDR="unix:///var/lib/netbird/netbird.sock" \
NB_LOG_FILE="console,/var/lib/netbird/client.log" \
NB_DISABLE_DNS="true" \
NB_ENABLE_CAPTURE="false"
NB_ENABLE_CAPTURE="false" \
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
ARG TARGETPLATFORM

View File

@@ -1,176 +0,0 @@
package cmd
import (
"context"
"net"
"path/filepath"
"sync/atomic"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/connectivity"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/proto"
)
// startUnixGRPCServer starts a bare gRPC server listening on a unix socket at path
// and returns a stop function. No services are registered; the connectivity-state
// wait only cares about the transport becoming READY.
func startUnixGRPCServer(t *testing.T, path string) func() {
t.Helper()
lis, err := net.Listen("unix", path)
if err != nil {
t.Fatalf("listen unix %s: %v", path, err)
}
srv := grpc.NewServer()
go func() { _ = srv.Serve(lis) }()
return srv.Stop
}
func TestDialClientGRPCServer_ConnectsWhenServing(t *testing.T) {
sock := filepath.Join(t.TempDir(), "nb.sock")
stop := startUnixGRPCServer(t, sock)
defer stop()
conn, err := dialClientGRPCServer(context.Background(), "unix://"+sock, 5*time.Second)
if err != nil {
t.Fatalf("expected connection, got error: %v", err)
}
defer conn.Close()
if state := conn.GetState(); state != connectivity.Ready {
t.Fatalf("expected READY, got %s", state)
}
}
// TestDialClientGRPCServer_WaitsForLateServer is the core regression test: the
// daemon socket appears only after the dial has already started, mirroring
// "netbird service start" immediately followed by "netbird up".
func TestDialClientGRPCServer_WaitsForLateServer(t *testing.T) {
sock := filepath.Join(t.TempDir(), "nb.sock")
var stop func()
timer := time.AfterFunc(1*time.Second, func() {
stop = startUnixGRPCServer(t, sock)
})
defer timer.Stop()
defer func() {
if stop != nil {
stop()
}
}()
start := time.Now()
conn, err := dialClientGRPCServer(context.Background(), "unix://"+sock, 10*time.Second)
if err != nil {
t.Fatalf("expected connection after late server start, got error: %v", err)
}
defer conn.Close()
if elapsed := time.Since(start); elapsed < 500*time.Millisecond {
t.Fatalf("connected too fast (%s); server should not have been up yet", elapsed)
}
if state := conn.GetState(); state != connectivity.Ready {
t.Fatalf("expected READY, got %s", state)
}
}
// fakeStatusServer serves the Status RPC with a programmable response so we can
// exercise waitForDaemonStatus without spinning up a real engine.
type fakeStatusServer struct {
proto.UnimplementedDaemonServiceServer
resp func() *proto.StatusResponse
}
func (f *fakeStatusServer) Status(context.Context, *proto.StatusRequest) (*proto.StatusResponse, error) {
return f.resp(), nil
}
func startFakeStatusServer(t *testing.T, sock string, resp func() *proto.StatusResponse) func() {
t.Helper()
lis, err := net.Listen("unix", sock)
if err != nil {
t.Fatalf("listen unix %s: %v", sock, err)
}
srv := grpc.NewServer()
proto.RegisterDaemonServiceServer(srv, &fakeStatusServer{resp: resp})
go func() { _ = srv.Serve(lis) }()
return srv.Stop
}
func dialFake(t *testing.T, sock string) proto.DaemonServiceClient {
t.Helper()
conn, err := dialClientGRPCServer(context.Background(), "unix://"+sock, 5*time.Second)
if err != nil {
t.Fatalf("dial fake daemon: %v", err)
}
t.Cleanup(func() { conn.Close() })
return proto.NewDaemonServiceClient(conn)
}
// New daemon that flips DaemonReady=true after a couple of polls: waitForDaemonStatus
// must block until the flag is set, then return.
func TestWaitForDaemonStatus_WaitsForDaemonReady(t *testing.T) {
sock := filepath.Join(t.TempDir(), "nb.sock")
var polls int32
stop := startFakeStatusServer(t, sock, func() *proto.StatusResponse {
n := atomic.AddInt32(&polls, 1)
return &proto.StatusResponse{
Status: string(internal.StatusConnecting),
DaemonReady: n >= 3, // ready only from the 3rd poll on
}
})
defer stop()
client := dialFake(t, sock)
status, err := waitForDaemonStatus(context.Background(), client)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !status.GetDaemonReady() {
t.Fatalf("expected DaemonReady=true, got false")
}
if got := atomic.LoadInt32(&polls); got < 3 {
t.Fatalf("expected at least 3 polls before ready, got %d", got)
}
}
// Older daemon that never sets DaemonReady but reports a healthy (Connected)
// status: waitForDaemonStatus must return promptly via the readiness fallback,
// not block for the whole grace window.
func TestWaitForDaemonStatus_OlderDaemonHealthyStatus(t *testing.T) {
sock := filepath.Join(t.TempDir(), "nb.sock")
stop := startFakeStatusServer(t, sock, func() *proto.StatusResponse {
return &proto.StatusResponse{Status: string(internal.StatusConnected)} // DaemonReady unset
})
defer stop()
client := dialFake(t, sock)
start := time.Now()
status, err := waitForDaemonStatus(context.Background(), client)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status.GetDaemonReady() {
t.Fatalf("expected DaemonReady=false from older daemon")
}
if elapsed := time.Since(start); elapsed > 2*time.Second {
t.Fatalf("returned too slowly (%s); healthy status should short-circuit the grace", elapsed)
}
}
func TestDialClientGRPCServer_TimesOutWhenAbsent(t *testing.T) {
sock := filepath.Join(t.TempDir(), "never.sock")
start := time.Now()
conn, err := dialClientGRPCServer(context.Background(), "unix://"+sock, 1*time.Second)
if err == nil {
conn.Close()
t.Fatal("expected timeout error, got nil")
}
if elapsed := time.Since(start); elapsed < 900*time.Millisecond {
t.Fatalf("returned too early (%s); should have waited ~timeout", elapsed)
}
}

View File

@@ -20,8 +20,6 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"google.golang.org/grpc"
gbackoff "google.golang.org/grpc/backoff"
"google.golang.org/grpc/connectivity"
"google.golang.org/grpc/credentials/insecure"
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
@@ -266,70 +264,17 @@ func FlagNameToEnvVar(cmdFlag string, prefix string) string {
return prefix + upper
}
// defaultDaemonDialTimeout is how long DialClientGRPCServer waits for the daemon
// to become reachable. It is intentionally generous so that invoking the CLI
// right after "netbird service start" (e.g. from a container entrypoint) tolerates
// the window where the daemon has created its socket but is not yet serving.
const defaultDaemonDialTimeout = 30 * time.Second
// DialClientGRPCServer returns a client connection to the daemon server. It waits
// for the daemon to become reachable, retrying with backoff until the connection
// reports READY or defaultDaemonDialTimeout elapses. This handles the startup race
// where the daemon socket exists (or is about to) before the gRPC server is serving.
// DialClientGRPCServer returns client connection to the daemon server.
func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, error) {
return dialClientGRPCServer(ctx, addr, defaultDaemonDialTimeout)
}
func dialClientGRPCServer(ctx context.Context, addr string, timeout time.Duration) (*grpc.ClientConn, error) {
conn, err := grpc.NewClient(
strings.TrimPrefix(addr, "tcp://"),
grpc.WithTransportCredentials(insecure.NewCredentials()),
// Cap reconnect backoff at 5s; gRPC's default 120s MaxDelay would leave the
// CLI waiting far too long to notice a freshly-started daemon. Mirrors the GUI.
grpc.WithConnectParams(grpc.ConnectParams{
Backoff: gbackoff.Config{
BaseDelay: 1 * time.Second,
Multiplier: 1.6,
Jitter: 0.2,
MaxDelay: 5 * time.Second,
},
}),
)
if err != nil {
return nil, fmt.Errorf("create daemon gRPC client: %w", err)
}
// grpc.NewClient is lazy: it does not connect until the first RPC or until we
// nudge it. Trigger connection attempts and wait until the channel reaches READY.
if err := waitForConnReady(ctx, conn, timeout); err != nil {
_ = conn.Close()
return nil, err
}
return conn, nil
}
// waitForConnReady drives the gRPC channel out of IDLE and blocks until it becomes
// READY, or until timeout/ctx expires. TRANSIENT_FAILURE (daemon not yet serving)
// is treated as retryable so the caller keeps waiting within the deadline.
func waitForConnReady(ctx context.Context, conn *grpc.ClientConn, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
ctx, cancel := context.WithTimeout(ctx, time.Second*10)
defer cancel()
for {
state := conn.GetState()
switch state {
case connectivity.Ready:
return nil
case connectivity.Idle:
// Kick the lazy channel into connecting.
conn.Connect()
}
if !conn.WaitForStateChange(ctx, state) {
// ctx expired while in `state`.
return fmt.Errorf("timed out after %s waiting for daemon to become ready (last state: %s)", timeout, state)
}
}
return grpc.DialContext(
ctx,
strings.TrimPrefix(addr, "tcp://"),
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
)
}
// WithBackOff execute function in backoff cycle.

View File

@@ -78,10 +78,6 @@ func (p *program) Start(svc service.Service) error {
log.Fatalf("failed to start daemon: %v", err)
}
proto.RegisterDaemonServiceServer(p.serv, serverInstance)
// The engine is started and the service is registered: from here on the
// daemon serves RPCs backed by a running engine. Report readiness so
// clients (e.g. netbird up) can wait deterministically.
serverInstance.SetReady()
p.serverInstanceMu.Lock()
p.serverInstance = serverInstance

View File

@@ -295,7 +295,9 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
client := proto.NewDaemonServiceClient(conn)
status, err := waitForDaemonStatus(ctx, client)
status, err := client.Status(ctx, &proto.StatusRequest{
WaitForReady: func() *bool { b := true; return &b }(),
})
if err != nil {
return fmt.Errorf("unable to get daemon status: %v", err)
}
@@ -334,79 +336,6 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
return nil
}
// daemonStatusPollTimeout bounds how long we poll the Status RPC waiting for the
// daemon to answer coherently. The transport is already READY at this point (see
// DialClientGRPCServer), so this only covers the brief window where the gRPC server
// is serving but the daemon engine is still starting up and the Status RPC races
// against server.Start().
const daemonStatusPollTimeout = 15 * time.Second
// daemonReadyGrace bounds how long we keep polling once the daemon answers but
// still reports DaemonReady=false. A freshly-started daemon flips it to true
// within this window; an older daemon that never sets the field simply falls
// through after the grace elapses, preserving backward compatibility.
const daemonReadyGrace = 10 * time.Second
// waitForDaemonStatus fetches the daemon status, waiting for the daemon to become
// ready. It handles two startup races:
//
// 1. The gRPC server is not yet serving: Status fails with Unavailable — retry.
// 2. The server serves but the engine is still starting: a DaemonReady-aware
// daemon reports DaemonReady=false until Start finishes; poll until it flips
// true (bounded by daemonReadyGrace). Older daemons never set DaemonReady, so
// we stop waiting on it after the grace and use the status as-is.
//
// It gives up after daemonStatusPollTimeout.
func waitForDaemonStatus(ctx context.Context, client proto.DaemonServiceClient) (*proto.StatusResponse, error) {
ctx, cancel := context.WithTimeout(ctx, daemonStatusPollTimeout)
defer cancel()
waitForReady := true
req := &proto.StatusRequest{WaitForReady: &waitForReady}
var lastErr error
var firstAnswer time.Time
for {
status, err := client.Status(ctx, req)
if err != nil {
lastErr = err
// Only retry while the daemon is not yet answering; surface real errors.
if s, ok := gstatus.FromError(err); !ok || s.Code() != codes.Unavailable {
return nil, err
}
} else {
// Daemon answered. Explicitly ready (DaemonReady-aware daemon), or
// already fully connected — either way, done. Connected is the only
// status unambiguous enough to short-circuit on: a DaemonReady-aware
// daemon sets the flag at startup, so trusting Connected here can only
// help an older daemon that never sets the flag, without overriding a
// new daemon that legitimately reports DaemonReady=false while starting.
if status.GetDaemonReady() || internal.StatusType(status.GetStatus()) == internal.StatusConnected {
return status, nil
}
// Answered but neither ready-flagged nor connected yet: give a
// DaemonReady-aware daemon a bounded window to finish starting, then
// fall through so an older daemon that never sets the flag isn't
// blocked here.
if firstAnswer.IsZero() {
firstAnswer = time.Now()
} else if time.Since(firstAnswer) >= daemonReadyGrace {
return status, nil
}
lastErr = nil
}
select {
case <-ctx.Done():
if lastErr != nil {
return nil, fmt.Errorf("daemon did not become ready: %w", lastErr)
}
return nil, fmt.Errorf("daemon did not become ready within %s", daemonStatusPollTimeout)
case <-time.After(500 * time.Millisecond):
}
}
}
func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServiceClient, pm *profilemanager.ProfileManager, activeProf *profilemanager.Profile, customDNSAddressConverted []byte, username string) error {
providedSetupKey, err := getSetupKey()

View File

@@ -1,4 +1,4 @@
//go:build !darwin || ios
//go:build (!darwin && !windows) || ios
package sleep

View File

@@ -0,0 +1,255 @@
//go:build windows
package sleep
import (
"fmt"
"sync"
"time"
"unsafe"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)
// Power broadcast event types delivered to the notification callback.
// https://learn.microsoft.com/en-us/windows/win32/power/power-management-events
const (
pbtAPMSuspend uintptr = 0x0004
pbtAPMResumeSuspend uintptr = 0x0007
pbtAPMResumeAutomatic uintptr = 0x0012
deviceNotifyCallback uintptr = 0x00000002
)
var (
// The callback flavor of these functions is exported by user32.dll, not
// powrprof.dll (which exports the differently-shaped Power* variants).
user32 = windows.NewLazySystemDLL("user32.dll")
// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registersuspendresumenotification
registerSuspendResumeNotification = user32.NewProc("RegisterSuspendResumeNotification")
// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-unregistersuspendresumenotification
unregisterSuspendResumeNotification = user32.NewProc("UnregisterSuspendResumeNotification")
libInitOnce sync.Once
libInitErr error
// callbackThunk is the single C-callable trampoline registered with the OS.
// windows.NewCallback slots are a finite, non-reclaimable resource, so a
// single thunk dispatches to the Detector identified by the callback Context.
callbackThunk uintptr
// registry maps the Context value handed to the OS callback back to the
// Detector that registered it, mirroring darwin's serviceRegistry.
registry = make(map[int]*Detector)
registryMu sync.Mutex
nextHandle int
// lifecycleMu serializes Register/Deregister so concurrent lifecycle calls
// can't race on the shared registry or the OS registration handle.
lifecycleMu sync.Mutex
)
// deviceNotifySubscribeParameters is DEVICE_NOTIFY_SUBSCRIBE_PARAMETERS.
// https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-device_notify_subscribe_parameters
type deviceNotifySubscribeParameters struct {
Callback uintptr
Context uintptr
}
// Detector delivers sleep and wake events to a registered callback.
type Detector struct {
callback func(event EventType)
done chan struct{}
// handle keys this detector in the package registry and is passed to the OS
// as the callback Context. Zero means the detector is not registered.
handle int
// hPowerNotify is the HPOWERNOTIFY returned by RegisterSuspendResumeNotification.
hPowerNotify uintptr
// params is kept alive for the lifetime of the registration so the OS never
// dereferences freed memory.
params deviceNotifySubscribeParameters
}
// NewDetector resolves powrprof.dll symbols and returns a Detector.
func NewDetector() (*Detector, error) {
if err := initLibs(); err != nil {
return nil, err
}
return &Detector{}, nil
}
func initLibs() error {
libInitOnce.Do(func() {
if err := registerSuspendResumeNotification.Find(); err != nil {
libInitErr = fmt.Errorf("resolve RegisterSuspendResumeNotification: %w", err)
return
}
if err := unregisterSuspendResumeNotification.Find(); err != nil {
libInitErr = fmt.Errorf("resolve UnregisterSuspendResumeNotification: %w", err)
return
}
callbackThunk = windows.NewCallback(powerCallback)
})
return libInitErr
}
// Register installs callback for power events and subscribes to suspend/resume
// notifications via powrprof.dll.
func (d *Detector) Register(callback func(event EventType)) error {
lifecycleMu.Lock()
defer lifecycleMu.Unlock()
registryMu.Lock()
if d.handle != 0 {
registryMu.Unlock()
return fmt.Errorf("detector service already registered")
}
d.callback = callback
d.done = make(chan struct{})
nextHandle++
handle := nextHandle
d.handle = handle
registry[handle] = d
registryMu.Unlock()
d.params = deviceNotifySubscribeParameters{
Callback: callbackThunk,
Context: uintptr(handle),
}
ret, _, callErr := registerSuspendResumeNotification.Call(
uintptr(unsafe.Pointer(&d.params)),
deviceNotifyCallback,
)
if ret == 0 {
registryMu.Lock()
delete(registry, handle)
close(d.done)
d.done = nil
d.handle = 0
registryMu.Unlock()
return fmt.Errorf("RegisterSuspendResumeNotification failed: %w", callErr)
}
d.hPowerNotify = ret
log.Info("sleep detection service started on Windows")
return nil
}
// Deregister unsubscribes from power notifications and removes the detector.
func (d *Detector) Deregister() error {
lifecycleMu.Lock()
defer lifecycleMu.Unlock()
registryMu.Lock()
if d.handle == 0 {
registryMu.Unlock()
return nil
}
handle := d.handle
hPowerNotify := d.hPowerNotify
done := d.done
registryMu.Unlock()
log.Info("sleep detection service stopping (deregister)")
// Unregister the OS subscription first. If it fails, leave handle and
// hPowerNotify intact so a later call can retry the cleanup.
if hPowerNotify != 0 {
ret, _, callErr := unregisterSuspendResumeNotification.Call(hPowerNotify)
if ret == 0 {
return fmt.Errorf("UnregisterSuspendResumeNotification failed: %w", callErr)
}
}
registryMu.Lock()
close(done)
delete(registry, handle)
d.handle = 0
d.hPowerNotify = 0
registryMu.Unlock()
return nil
}
func (d *Detector) triggerCallback(event EventType, cb func(event EventType), done <-chan struct{}) {
if cb == nil || done == nil {
return
}
select {
case <-done:
return
default:
}
doneChan := make(chan struct{})
// The OS invokes this callback synchronously on the suspend path, so run the
// teardown inline with a bounded budget (mirroring the macOS detector) so
// Down completes before the machine suspends without blocking indefinitely.
timeout := time.NewTimer(20 * time.Second)
defer timeout.Stop()
go func() {
defer close(doneChan)
defer func() {
if r := recover(); r != nil {
log.Errorf("panic in sleep callback: %v", r)
}
}()
log.Info("sleep detection event fired")
cb(event)
}()
select {
case <-doneChan:
case <-done:
case <-timeout.C:
log.Warn("sleep callback timed out")
}
}
// powerCallback is the DEVICE_NOTIFY_CALLBACK_ROUTINE trampoline, invoked by the
// OS on a system thread. A Go panic crossing the syscall boundary has undefined
// behavior, so contain it here. It must return ERROR_SUCCESS (0).
func powerCallback(context uintptr, msgType uintptr, setting uintptr) uintptr {
defer func() {
if r := recover(); r != nil {
log.Errorf("panic in sleep powerCallback: %v", r)
}
}()
var event EventType
switch msgType {
case pbtAPMSuspend:
event = EventTypeSleep
case pbtAPMResumeAutomatic, pbtAPMResumeSuspend:
event = EventTypeWakeUp
default:
return 0
}
dispatchEvent(int(context), event)
return 0
}
func dispatchEvent(handle int, event EventType) {
registryMu.Lock()
d := registry[handle]
var (
cb func(event EventType)
done <-chan struct{}
)
if d != nil {
cb = d.callback
done = d.done
}
registryMu.Unlock()
if d == nil {
return
}
d.triggerCallback(event, cb, done)
}

View File

@@ -0,0 +1,83 @@
//go:build windows
package sleep
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// registerFake inserts a detector into the registry without touching the OS,
// so dispatch routing can be exercised in isolation. It returns the handle and
// a cleanup that removes the entry.
func registerFake(t *testing.T, cb func(EventType)) (int, func()) {
t.Helper()
registryMu.Lock()
nextHandle++
handle := nextHandle
d := &Detector{callback: cb, done: make(chan struct{}), handle: handle}
registry[handle] = d
registryMu.Unlock()
return handle, func() {
registryMu.Lock()
delete(registry, handle)
registryMu.Unlock()
}
}
func TestPowerCallback_MapsMessageTypes(t *testing.T) {
tests := []struct {
name string
msgType uintptr
want EventType
fires bool
}{
{"suspend", pbtAPMSuspend, EventTypeSleep, true},
{"resume automatic", pbtAPMResumeAutomatic, EventTypeWakeUp, true},
{"resume suspend", pbtAPMResumeSuspend, EventTypeWakeUp, true},
{"unknown", 0x9999, EventTypeUnknown, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := make(chan EventType, 1)
handle, cleanup := registerFake(t, func(e EventType) { got <- e })
defer cleanup()
ret := powerCallback(uintptr(handle), tt.msgType, 0)
require.Equal(t, uintptr(0), ret, "callback must return ERROR_SUCCESS")
if !tt.fires {
assert.Empty(t, got, "no event should fire for unhandled message type")
return
}
select {
case e := <-got:
assert.Equal(t, tt.want, e, "mapped event type should match")
default:
t.Fatal("expected callback to fire")
}
})
}
}
func TestDispatchEvent_UnknownHandleNoPanic(t *testing.T) {
require.NotPanics(t, func() {
dispatchEvent(-1, EventTypeSleep)
}, "dispatch for an unregistered handle must be a no-op")
}
func TestTriggerCallback_SkipsAfterDone(t *testing.T) {
done := make(chan struct{})
close(done)
fired := false
d := &Detector{}
d.triggerCallback(EventTypeSleep, func(EventType) { fired = true }, done)
assert.False(t, fired, "callback must not run once the detector is done")
}

View File

@@ -1,27 +1,71 @@
#!/usr/bin/env bash
# Runs the NetBird daemon and brings the connection up in one container process.
#
# A thin wrapper is needed (rather than a one-line ENTRYPOINT) for two reasons:
# 1. Two processes must run: the daemon (`service run`, long-lived) and a
# one-shot `up` that brings the connection up.
# 2. Signal handling: as PID 1 the wrapper must forward SIGTERM/SIGINT to the
# daemon so it tears down WireGuard and deregisters ephemeral peers on
# `docker stop`. Without this the daemon would be killed uncleanly.
#
# `netbird up` waits for the daemon to become ready on its own, so no readiness
# poll is needed here.
set -eEuo pipefail
: ${NB_ENTRYPOINT_SERVICE_TIMEOUT:="30"}
NETBIRD_BIN="${NETBIRD_BIN:-"netbird"}"
export NB_LOG_FILE="${NB_LOG_FILE:-"console,/var/log/netbird/client.log"}"
service_pids=()
daemon=""
cleanup() { [[ -n "${daemon}" ]] && kill -TERM "${daemon}" 2>/dev/null || true; }
trap cleanup SIGTERM SIGINT EXIT
_log() {
# mimic Go logger's output for easier parsing
# 2025-04-15T21:32:00+08:00 INFO client/internal/config.go:495: setting notifications to disabled by default
printf "$(date -Isec) ${1} ${BASH_SOURCE[1]}:${BASH_LINENO[1]}: ${2}\n" "${@:3}" >&2
}
"${NETBIRD_BIN}" service run &
daemon=$!
info() {
_log INFO "$@"
}
"${NETBIRD_BIN}" up
warn() {
_log WARN "$@"
}
wait "${daemon}"
on_exit() {
info "Shutting down NetBird daemon..."
if test "${#service_pids[@]}" -gt 0; then
info "terminating service process IDs: ${service_pids[@]@Q}"
kill -TERM "${service_pids[@]}" 2>/dev/null || true
wait "${service_pids[@]}" 2>/dev/null || true
else
info "there are no service processes to terminate"
fi
}
wait_for_daemon_startup() {
local timeout="${1}"
if [[ "${timeout}" -eq 0 ]]; then
info "not waiting for daemon startup due to zero timeout."
return
fi
local deadline=$((SECONDS + timeout))
while [[ "${SECONDS}" -lt "${deadline}" ]]; do
if "${NETBIRD_BIN}" status --check live 2>/dev/null; then
return
fi
sleep 1
done
warn "daemon did not become responsive after ${timeout} seconds, exiting..."
exit 1
}
connect() {
info "running 'netbird up'..."
"${NETBIRD_BIN}" up
return $?
}
main() {
trap 'on_exit' SIGTERM SIGINT EXIT
"${NETBIRD_BIN}" service run &
service_pids+=("$!")
info "registered new service process 'netbird service run', currently running: ${service_pids[@]@Q}"
wait_for_daemon_startup "${NB_ENTRYPOINT_SERVICE_TIMEOUT}"
connect
wait "${service_pids[@]}"
}
main "$@"

View File

@@ -995,13 +995,8 @@ type StatusResponse struct {
// Unset when the peer is not SSO-registered or login expiration is disabled.
// The UI derives "warning active" from this value and its own clock.
SessionExpiresAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=sessionExpiresAt,proto3" json:"sessionExpiresAt,omitempty"`
// daemonReady reports whether the daemon has finished starting up and is
// serving RPCs backed by a running engine. Older daemons never set this
// (it defaults to false); clients must treat an unset value as "unknown"
// and fall back to their previous readiness heuristics for compatibility.
DaemonReady bool `protobuf:"varint,5,opt,name=daemonReady,proto3" json:"daemonReady,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StatusResponse) Reset() {
@@ -1062,13 +1057,6 @@ func (x *StatusResponse) GetSessionExpiresAt() *timestamppb.Timestamp {
return nil
}
func (x *StatusResponse) GetDaemonReady() bool {
if x != nil {
return x.DaemonReady
}
return false
}
type DownRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@@ -7095,15 +7083,14 @@ const file_daemon_proto_rawDesc = "" +
"\x11getFullPeerStatus\x18\x01 \x01(\bR\x11getFullPeerStatus\x12(\n" +
"\x0fshouldRunProbes\x18\x02 \x01(\bR\x0fshouldRunProbes\x12'\n" +
"\fwaitForReady\x18\x03 \x01(\bH\x00R\fwaitForReady\x88\x01\x01B\x0f\n" +
"\r_waitForReady\"\xec\x01\n" +
"\r_waitForReady\"\xca\x01\n" +
"\x0eStatusResponse\x12\x16\n" +
"\x06status\x18\x01 \x01(\tR\x06status\x122\n" +
"\n" +
"fullStatus\x18\x02 \x01(\v2\x12.daemon.FullStatusR\n" +
"fullStatus\x12$\n" +
"\rdaemonVersion\x18\x03 \x01(\tR\rdaemonVersion\x12F\n" +
"\x10sessionExpiresAt\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x10sessionExpiresAt\x12 \n" +
"\vdaemonReady\x18\x05 \x01(\bR\vdaemonReady\"\r\n" +
"\x10sessionExpiresAt\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x10sessionExpiresAt\"\r\n" +
"\vDownRequest\"\x0e\n" +
"\fDownResponse\"P\n" +
"\x10GetConfigRequest\x12 \n" +

View File

@@ -291,11 +291,6 @@ message StatusResponse{
// Unset when the peer is not SSO-registered or login expiration is disabled.
// The UI derives "warning active" from this value and its own clock.
google.protobuf.Timestamp sessionExpiresAt = 4;
// daemonReady reports whether the daemon has finished starting up and is
// serving RPCs backed by a running engine. Older daemons never set this
// (it defaults to false); clients must treat an unset value as "unknown"
// and fall back to their previous readiness heuristics for compatibility.
bool daemonReady = 5;
}
message DownRequest {}

View File

@@ -104,12 +104,6 @@ type Server struct {
persistSyncResponse bool
isSessionActive atomic.Bool
// ready is set once the daemon has finished startup and is serving RPCs
// backed by a running engine (see SetReady, called after Start succeeds and
// the service is registered). Reported via StatusResponse.DaemonReady so
// clients can wait deterministically instead of polling heuristically.
ready atomic.Bool
cpuProfileBuf *bytes.Buffer
cpuProfiling bool
@@ -164,14 +158,6 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable
return s
}
// SetReady marks the daemon as fully started and serving RPCs. It is called by
// the service controller once Start has succeeded and the DaemonService is
// registered, so a subsequent Status RPC reports DaemonReady=true. Safe for
// concurrent use.
func (s *Server) SetReady() {
s.ready.Store(true)
}
func (s *Server) Start() error {
s.mutex.Lock()
defer s.mutex.Unlock()
@@ -1436,7 +1422,7 @@ func (s *Server) buildStatusResponse(ctx context.Context, msg *proto.StatusReque
s.isSessionActive.Store(false)
}
statusResponse := proto.StatusResponse{Status: string(status), DaemonVersion: version.NetbirdVersion(), DaemonReady: s.ready.Load()}
statusResponse := proto.StatusResponse{Status: string(status), DaemonVersion: version.NetbirdVersion()}
if deadline := s.statusRecorder.GetSessionExpiresAt(); !deadline.IsZero() {
statusResponse.SessionExpiresAt = timestamppb.New(deadline)

View File

@@ -17,7 +17,8 @@ RUN apk add --no-cache bash ca-certificates ip6tables iproute2 iptables
ENV NETBIRD_BIN="/usr/local/bin/netbird" \
NB_LOG_FILE="console,/var/log/netbird/client.log" \
NB_DAEMON_ADDR="unix:///var/run/netbird.sock" \
NB_ENABLE_CAPTURE="false"
NB_ENABLE_CAPTURE="false" \
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
COPY --from=builder /out/netbird /usr/local/bin/netbird

2
go.mod
View File

@@ -335,7 +335,7 @@ replace github.com/cloudflare/circl => codeberg.org/cunicu/circl v0.0.0-20230801
replace github.com/pion/ice/v4 => github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51
replace github.com/dexidp/dex => github.com/netbirdio/dex v0.244.1-0.20260512110716-8d70ad8647c1
replace github.com/dexidp/dex => github.com/netbirdio/dex v0.244.1-0.20260716205454-a163de3129e5
replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1

4
go.sum
View File

@@ -476,8 +476,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/netbirdio/dex v0.244.1-0.20260512110716-8d70ad8647c1 h1:4TaYr9O4xX0D2kszeOLclTiCbA3eHq3xWV+9ILJbIYs=
github.com/netbirdio/dex v0.244.1-0.20260512110716-8d70ad8647c1/go.mod h1:IHH+H8vK2GfqtIt5u/5OdPh18yk0oDHuj2vz5+Goetg=
github.com/netbirdio/dex v0.244.1-0.20260716205454-a163de3129e5 h1:3PwQv8aR46qN2u16+Dv6udnH3sbVKX5KrGwF35CKSI0=
github.com/netbirdio/dex v0.244.1-0.20260716205454-a163de3129e5/go.mod h1:IHH+H8vK2GfqtIt5u/5OdPh18yk0oDHuj2vz5+Goetg=
github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1 h1:neE7z+FPUkldl3faK/Jt+hJK2L+1XfQ1W33TQhU9m88=
github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1/go.mod h1:awuTyT29CYALpEyET0S307EgNlPWrc7fFKRAyhsO45M=
github.com/netbirdio/easyjson v0.9.0 h1:6Nw2lghSVuy8RSkAYDhDv1thBVEmfVbKZnV7T7Z6Aus=

View File

@@ -613,6 +613,10 @@ func (c *YAMLConfig) ToServerConfig(stor storage.Storage, logger *slog.Logger) s
cfg.SupportedResponseTypes = c.OAuth2.ResponseTypes
}
if len(c.OAuth2.GrantTypes) > 0 {
cfg.AllowedGrantTypes = c.OAuth2.GrantTypes
}
// Apply expiry settings
if c.Expiry.IDTokens != "" {
if d, err := parseDuration(c.Expiry.IDTokens); err == nil {

View File

@@ -21,7 +21,7 @@ import (
"github.com/dexidp/dex/server/signer"
"github.com/dexidp/dex/storage"
"github.com/dexidp/dex/storage/sql"
jose "github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"golang.org/x/crypto/bcrypt"

View File

@@ -595,3 +595,90 @@ enablePasswordDB: true
assert.True(t, cfg.ContinueOnConnectorFailure,
"buildDexConfig must set ContinueOnConnectorFailure to true so management starts even if an external IdP is down")
}
func TestToServerConfig_WiresGrantTypes(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "dex-grants-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
stor := openTestStorage(t, tmpDir)
defer stor.Close()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
grants := []string{"authorization_code", "refresh_token"}
cfg := &YAMLConfig{Issuer: "http://localhost:5599/oauth2", OAuth2: OAuth2{GrantTypes: grants}}
assert.Equal(t, grants, cfg.ToServerConfig(stor, logger).AllowedGrantTypes)
empty := &YAMLConfig{Issuer: "http://localhost:5599/oauth2"}
assert.Empty(t, empty.ToServerConfig(stor, logger).AllowedGrantTypes)
}
func newDeviceGuardProvider(t *testing.T, grantTypesYAML string) *Provider {
t.Helper()
tmpDir, err := os.MkdirTemp("", "dex-devguard-*")
require.NoError(t, err)
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
yamlContent := `
issuer: http://localhost:5599/oauth2
storage:
type: sqlite3
config:
file: ` + filepath.Join(tmpDir, "dex.db") + `
web:
http: 127.0.0.1:5599
enablePasswordDB: true
` + grantTypesYAML
configPath := filepath.Join(tmpDir, "config.yaml")
require.NoError(t, os.WriteFile(configPath, []byte(yamlContent), 0644))
yamlConfig, err := LoadConfig(configPath)
require.NoError(t, err)
provider, err := NewProviderFromYAML(context.Background(), yamlConfig)
require.NoError(t, err)
t.Cleanup(func() { _ = provider.Stop(context.Background()) })
return provider
}
func TestHandler_BlocksDeviceEndpointsWhenDeviceGrantDisabled(t *testing.T) {
provider := newDeviceGuardProvider(t, `
oauth2:
grantTypes:
- authorization_code
- refresh_token
`)
devicePaths := []string{
"/oauth2/device",
"/oauth2/device/code",
"/oauth2/device/token",
"/oauth2/device/auth/verify_code",
"/oauth2/device/callback",
}
for _, path := range devicePaths {
for _, method := range []string{http.MethodGet, http.MethodPost} {
req := httptest.NewRequest(method, path, nil)
rec := httptest.NewRecorder()
provider.Handler().ServeHTTP(rec, req)
assert.Equal(t, http.StatusNotFound, rec.Code, "%s %s must be blocked", method, path)
}
}
req := httptest.NewRequest(http.MethodGet, "/oauth2/.well-known/openid-configuration", nil)
rec := httptest.NewRecorder()
provider.Handler().ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
}
func TestHandler_AllowsDeviceEndpointsWhenGrantsDefault(t *testing.T) {
provider := newDeviceGuardProvider(t, "")
req := httptest.NewRequest(http.MethodPost, "/oauth2/device/code", nil)
rec := httptest.NewRecorder()
provider.Handler().ServeHTTP(rec, req)
assert.NotEqual(t, http.StatusNotFound, rec.Code)
}

View File

@@ -4269,7 +4269,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *te
}
// Sanity: an actually different range still triggers reallocation.
newRange := netip.MustParsePrefix("100.99.0.0/16")
newRange := netip.MustParsePrefix("100.60.0.0/16")
_, err = manager.UpdateAccountSettings(ctx, account.Id, userID, &types.Settings{
PeerLoginExpirationEnabled: true,
PeerLoginExpiration: types.DefaultPeerLoginExpiration,

View File

@@ -76,6 +76,9 @@ type EmbeddedIdPConfig struct {
DashboardPostLogoutRedirectURIs []string
// StaticConnectors are additional connectors to seed during initialization
StaticConnectors []dex.Connector
// GrantTypes restricts allowed OAuth2 grants; empty means all (Dex default). Omit the
// device_code grant to disable the device flow; keep authorization_code and refresh_token.
GrantTypes []string
}
// EmbeddedStorageConfig holds storage configuration for the embedded IdP.
@@ -175,6 +178,7 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) {
},
OAuth2: dex.OAuth2{
SkipApprovalScreen: true,
GrantTypes: c.GrantTypes,
},
Frontend: dex.Frontend{
Issuer: "NetBird",

View File

@@ -1606,7 +1606,8 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
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_agent_network_only,
settings_dashboard_features,
settings_dashboard_features, settings_auto_update_version, settings_auto_update_always,
settings_peer_expose_enabled, settings_peer_expose_groups,
-- Embedded ExtraSettings
settings_extra_peer_approval_enabled, settings_extra_user_approval_required,
settings_extra_integrated_validator, settings_extra_integrated_validator_groups
@@ -1632,6 +1633,10 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
sMetricsPushEnabled sql.NullBool
sAgentNetworkOnly sql.NullBool
sDashboardFeatures sql.NullString
autoUpdateVersion sql.NullString
autoUpdateAlways sql.NullBool
peerExposeEnabled sql.NullBool
peerExposeGroups sql.NullString
sExtraPeerApprovalEnabled sql.NullBool
sExtraUserApprovalRequired sql.NullBool
sExtraIntegratedValidator sql.NullString
@@ -1655,7 +1660,8 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
&sRoutingPeerDNSResolutionEnabled, &sDNSDomain, &sNetworkRange,
&sNetworkRangeV6, &sIPv6EnabledGroups, &sLazyConnectionEnabled,
&sLocalMFAEnabled, &sMetricsPushEnabled, &sAgentNetworkOnly,
&sDashboardFeatures,
&sDashboardFeatures, &autoUpdateVersion, &autoUpdateAlways,
&peerExposeEnabled, &peerExposeGroups,
&sExtraPeerApprovalEnabled, &sExtraUserApprovalRequired,
&sExtraIntegratedValidator, &sExtraIntegratedValidatorGroups,
)
@@ -1747,6 +1753,18 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
if sIPv6EnabledGroups.Valid {
_ = json.Unmarshal([]byte(sIPv6EnabledGroups.String), &account.Settings.IPv6EnabledGroups)
}
if autoUpdateAlways.Valid {
account.Settings.AutoUpdateAlways = autoUpdateAlways.Bool
}
if autoUpdateVersion.Valid {
account.Settings.AutoUpdateVersion = autoUpdateVersion.String
}
if peerExposeEnabled.Valid {
account.Settings.PeerExposeEnabled = peerExposeEnabled.Bool
}
if peerExposeGroups.Valid {
_ = json.Unmarshal([]byte(peerExposeGroups.String), &account.Settings.PeerExposeGroups)
}
if sExtraPeerApprovalEnabled.Valid {
account.Settings.Extra.PeerApprovalEnabled = sExtraPeerApprovalEnabled.Bool

View File

@@ -9,6 +9,7 @@ import (
"net"
"net/netip"
"os"
"reflect"
"runtime"
"sort"
"sync"
@@ -34,6 +35,7 @@ import (
"github.com/netbirdio/netbird/management/server/util"
nbroute "github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/status"
"github.com/netbirdio/netbird/shared/testing_helpers"
"github.com/netbirdio/netbird/util/crypt"
)
@@ -296,6 +298,53 @@ func Test_SaveAccount(t *testing.T) {
})
}
func Test_AccountSettings_SaveAndRetrieve(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("The SQLite store is not properly supported by Windows yet")
}
populateFields := testing_helpers.NewPopulateFields().WithCustomFieldSetter(
reflect.PointerTo(reflect.TypeOf(types.ExtraSettings{})), func(this *testing_helpers.PopulateFields, field reflect.Value) (int, error) {
es := types.ExtraSettings{}
reflectedEs := reflect.ValueOf(&es).Elem()
n, err := this.PopulateAll(reflectedEs)
if err != nil {
return n, err
}
field.Set(reflectedEs.Addr())
return n, nil
}).WithCustomFieldSetter(
reflect.PointerTo(reflect.TypeOf(types.DashboardFeatures{})), func(this *testing_helpers.PopulateFields, field reflect.Value) (int, error) {
t := true
df := types.DashboardFeatures{AgentNetwork: &t}
reflectedDf := reflect.ValueOf(&df).Elem()
field.Set(reflectedDf.Addr())
return 1, nil
}).WithSkippedTag("gorm", "-")
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
account := newAccountWithId(context.Background(), "account_id", "testuser", "")
setupKey, _ := types.GenerateDefaultSetupKey()
account.SetupKeys[setupKey.Key] = setupKey
settings := types.Settings{}
numOfExportedFields, err := populateFields.PopulateAll(reflect.ValueOf(&settings).Elem())
assert.NoError(t, err)
assert.Equal(t, 27, numOfExportedFields)
account.Settings = &settings
err = store.SaveAccount(context.Background(), account)
assert.NoError(t, err)
accountFromDb, err := store.GetAccount(context.Background(), account.Id)
assert.NoError(t, err)
assert.NotNil(t, accountFromDb)
assert.NotNil(t, accountFromDb.Settings)
assert.True(t, reflect.DeepEqual(&settings, accountFromDb.Settings), "created settings and settings retrieved from the db should match")
})
}
func TestSqlite_DeleteAccount(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("The SQLite store is not properly supported by Windows yet")

View File

@@ -0,0 +1,101 @@
package testing_helpers
import (
"fmt"
"net/netip"
"reflect"
)
type PopulateFields struct {
CustomFieldSetters map[reflect.Type]func(this *PopulateFields, field reflect.Value) (int, error)
TagsToSkip map[string]string
}
func NewPopulateFields() *PopulateFields {
return &PopulateFields{CustomFieldSetters: defaultCustomFieldSetters(), TagsToSkip: make(map[string]string)}
}
func (p *PopulateFields) WithCustomFieldSetter(t reflect.Type, f func(this *PopulateFields, field reflect.Value) (int, error)) *PopulateFields {
p.CustomFieldSetters[t] = f
return p
}
func (p *PopulateFields) WithSkippedTag(tag, value string) *PopulateFields {
p.TagsToSkip[tag] = value
return p
}
func (p *PopulateFields) PopulateAll(v reflect.Value) (int, error) {
typ := v.Type()
totalExportedFields := 0
for i := 0; i < typ.NumField(); i++ {
f := typ.Field(i)
if f.PkgPath != "" { // unexported
continue
}
if p.skippedTagPresent(f.Tag) {
continue
}
numOfExportedFields, err := p.setNonZero(v.Field(i))
totalExportedFields += numOfExportedFields
if err != nil {
return totalExportedFields, err
}
}
return totalExportedFields, nil
}
// setNonZero assigns a deterministic non-zero value to a field based on its kind,
// recursing into nested structs and populating one element of slice fields.
func (p *PopulateFields) setNonZero(field reflect.Value) (int, error) {
if f, ok := p.CustomFieldSetters[field.Type()]; ok {
return f(p, field)
}
switch field.Kind() {
case reflect.String:
field.SetString("non-zero")
case reflect.Bool:
field.SetBool(true)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
field.SetInt(7)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
field.SetUint(7)
case reflect.Float32, reflect.Float64:
field.SetFloat(7)
case reflect.Struct:
n, err := p.PopulateAll(field)
return n + 1, err
case reflect.Slice:
s := reflect.MakeSlice(field.Type(), 1, 1)
_, err := p.setNonZero(s.Index(0))
if err != nil {
return 0, err
}
field.Set(s)
default:
return 0, fmt.Errorf("unhandled field kind %s; extend setNonZero", field.Kind())
}
return 1, nil
}
func defaultCustomFieldSetters() map[reflect.Type]func(this *PopulateFields, field reflect.Value) (int, error) {
return map[reflect.Type]func(this *PopulateFields, field reflect.Value) (int, error){
reflect.TypeOf(netip.Prefix{}): func(_ *PopulateFields, field reflect.Value) (int, error) {
field.Set(reflect.ValueOf(netip.MustParsePrefix("10.0.0.0/24")))
return 1, nil
},
}
}
func (p *PopulateFields) skippedTagPresent(t reflect.StructTag) bool {
for tag, value := range p.TagsToSkip {
if v := t.Get(tag); v == value {
return true
}
}
return false
}