From ccb271b5bb211708bc21361d546e8b6283121741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Fri, 17 Jul 2026 10:28:58 +0200 Subject: [PATCH] [client] Make netbird up wait for the daemon to become ready The CLI up path only tolerated a not-yet-ready daemon via a 10s blocking dial, so "netbird service start" immediately followed by "netbird up" (e.g. a container entrypoint) failed with a generic "daemon not running" error. The container entrypoint worked around this with a shell poll loop (status --check live) before running up. Move the readiness wait into the CLI, mirroring how the GUI already dials: - DialClientGRPCServer now uses grpc.NewClient with a tuned reconnect backoff and waits for the connection to reach READY (retrying on TRANSIENT_FAILURE) up to a 30s deadline, instead of grpc.DialContext + WithBlock with a hard 10s timeout. - up now polls Status via waitForDaemonStatus, retrying while the RPC is Unavailable (socket up but service not yet registered). Add an explicit daemon-ready signal so clients can wait deterministically instead of heuristically: - New optional StatusResponse.daemonReady field (field 5, wire-compatible with older GUIs/daemons which leave it unset). Regenerated with the pinned protoc v33.1 toolchain so no version churn leaks into the diff. - The server sets ready once Start succeeds and the DaemonService is registered (SetReady, called from the service controller). - waitForDaemonStatus waits for daemonReady=true (or an already-Connected status), with a bounded grace window so older daemons that never set the field are not blocked. Simplify the container entrypoint accordingly: drop the readiness poll loop (up now waits) and the now-dead NB_ENTRYPOINT_SERVICE_TIMEOUT env, keeping only the daemon+up process glue and SIGTERM forwarding for clean shutdown. --- client/Dockerfile | 3 +- client/Dockerfile-rootless | 3 +- client/cmd/dial_test.go | 176 +++++++++++++++++++++++++++++++ client/cmd/root.go | 69 ++++++++++-- client/cmd/service_controller.go | 4 + client/cmd/up.go | 77 +++++++++++++- client/netbird-entrypoint.sh | 80 ++++---------- client/proto/daemon.pb.go | 21 +++- client/proto/daemon.proto | 5 + client/server/server.go | 16 ++- e2e/harness/Dockerfile.client | 3 +- 11 files changed, 374 insertions(+), 83 deletions(-) create mode 100644 client/cmd/dial_test.go diff --git a/client/Dockerfile b/client/Dockerfile index 478b2d0e2..0ea4c6555 100644 --- a/client/Dockerfile +++ b/client/Dockerfile @@ -17,8 +17,7 @@ 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_ENTRYPOINT_SERVICE_TIMEOUT="30" + NB_ENABLE_CAPTURE="false" ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ] ARG TARGETPLATFORM diff --git a/client/Dockerfile-rootless b/client/Dockerfile-rootless index 8141af6ed..b8868eb3a 100644 --- a/client/Dockerfile-rootless +++ b/client/Dockerfile-rootless @@ -23,8 +23,7 @@ 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_ENTRYPOINT_SERVICE_TIMEOUT="30" + NB_ENABLE_CAPTURE="false" ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ] ARG TARGETPLATFORM diff --git a/client/cmd/dial_test.go b/client/cmd/dial_test.go new file mode 100644 index 000000000..4119b44be --- /dev/null +++ b/client/cmd/dial_test.go @@ -0,0 +1,176 @@ +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) + } +} diff --git a/client/cmd/root.go b/client/cmd/root.go index f1ef32717..08bea4aff 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -20,6 +20,8 @@ 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" @@ -264,17 +266,70 @@ func FlagNameToEnvVar(cmdFlag string, prefix string) string { return prefix + upper } -// DialClientGRPCServer returns client connection to the daemon server. -func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, error) { - ctx, cancel := context.WithTimeout(ctx, time.Second*10) - defer cancel() +// 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 - return grpc.DialContext( - ctx, +// 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. +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()), - grpc.WithBlock(), + // 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) + 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) + } + } } // WithBackOff execute function in backoff cycle. diff --git a/client/cmd/service_controller.go b/client/cmd/service_controller.go index 5ef13a0a6..4a5216c10 100644 --- a/client/cmd/service_controller.go +++ b/client/cmd/service_controller.go @@ -78,6 +78,10 @@ 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 diff --git a/client/cmd/up.go b/client/cmd/up.go index 2d9731f26..da0fcfd4a 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -295,9 +295,7 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager client := proto.NewDaemonServiceClient(conn) - status, err := client.Status(ctx, &proto.StatusRequest{ - WaitForReady: func() *bool { b := true; return &b }(), - }) + status, err := waitForDaemonStatus(ctx, client) if err != nil { return fmt.Errorf("unable to get daemon status: %v", err) } @@ -336,6 +334,79 @@ 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() diff --git a/client/netbird-entrypoint.sh b/client/netbird-entrypoint.sh index 0e330bdac..5f4cabb91 100755 --- a/client/netbird-entrypoint.sh +++ b/client/netbird-entrypoint.sh @@ -1,71 +1,27 @@ #!/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=() -_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 -} +daemon="" +cleanup() { [[ -n "${daemon}" ]] && kill -TERM "${daemon}" 2>/dev/null || true; } +trap cleanup SIGTERM SIGINT EXIT -info() { - _log INFO "$@" -} +"${NETBIRD_BIN}" service run & +daemon=$! -warn() { - _log WARN "$@" -} +"${NETBIRD_BIN}" up -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 "$@" +wait "${daemon}" diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 6fbb09958..5b73d2de8 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -995,8 +995,13 @@ 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"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // 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 } func (x *StatusResponse) Reset() { @@ -1057,6 +1062,13 @@ 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 @@ -7083,14 +7095,15 @@ 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\"\xca\x01\n" + + "\r_waitForReady\"\xec\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\"\r\n" + + "\x10sessionExpiresAt\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x10sessionExpiresAt\x12 \n" + + "\vdaemonReady\x18\x05 \x01(\bR\vdaemonReady\"\r\n" + "\vDownRequest\"\x0e\n" + "\fDownResponse\"P\n" + "\x10GetConfigRequest\x12 \n" + diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 8d5294eb7..7e237a57b 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -291,6 +291,11 @@ 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 {} diff --git a/client/server/server.go b/client/server/server.go index 2b919d58d..46deb5fc3 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -104,6 +104,12 @@ 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 @@ -158,6 +164,14 @@ 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() @@ -1422,7 +1436,7 @@ func (s *Server) buildStatusResponse(ctx context.Context, msg *proto.StatusReque s.isSessionActive.Store(false) } - statusResponse := proto.StatusResponse{Status: string(status), DaemonVersion: version.NetbirdVersion()} + statusResponse := proto.StatusResponse{Status: string(status), DaemonVersion: version.NetbirdVersion(), DaemonReady: s.ready.Load()} if deadline := s.statusRecorder.GetSessionExpiresAt(); !deadline.IsZero() { statusResponse.SessionExpiresAt = timestamppb.New(deadline) diff --git a/e2e/harness/Dockerfile.client b/e2e/harness/Dockerfile.client index 114577d60..5ec40b3e4 100644 --- a/e2e/harness/Dockerfile.client +++ b/e2e/harness/Dockerfile.client @@ -17,8 +17,7 @@ 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_ENTRYPOINT_SERVICE_TIMEOUT="30" + NB_ENABLE_CAPTURE="false" 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