mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-22 16:31:28 +02:00
Compare commits
3 Commits
fix/cli-up
...
client-loc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e93fd6c1eb | ||
|
|
8280696e6e | ||
|
|
9d2a7e330a |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,10 @@ 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"
|
||||
"github.com/netbirdio/netbird/client/internal/localmetrics"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
@@ -33,6 +32,8 @@ const (
|
||||
dnsResolverAddress = "dns-resolver-address"
|
||||
enableRosenpassFlag = "enable-rosenpass"
|
||||
rosenpassPermissiveFlag = "rosenpass-permissive"
|
||||
enableLocalMetricsFlag = "enable-local-metrics"
|
||||
localMetricsAddressFlag = "local-metrics-address"
|
||||
preSharedKeyFlag = "preshared-key"
|
||||
interfaceNameFlag = "interface-name"
|
||||
wireguardPortFlag = "wireguard-port"
|
||||
@@ -81,6 +82,8 @@ var (
|
||||
updateSettingsDisabled bool
|
||||
captureEnabled bool
|
||||
networksDisabled bool
|
||||
localMetricsEnabled bool
|
||||
localMetricsAddr string
|
||||
|
||||
rootCmd = &cobra.Command{
|
||||
Use: "netbird",
|
||||
@@ -214,6 +217,8 @@ func init() {
|
||||
upCmd.PersistentFlags().BoolVar(&rosenpassEnabled, enableRosenpassFlag, false, "[Experimental] Enable Rosenpass feature. If enabled, the connection will be post-quantum secured via Rosenpass.")
|
||||
upCmd.PersistentFlags().BoolVar(&rosenpassPermissive, rosenpassPermissiveFlag, false, "[Experimental] Enable Rosenpass in permissive mode to allow this peer to accept WireGuard connections without requiring Rosenpass functionality from peers that do not have Rosenpass enabled.")
|
||||
upCmd.PersistentFlags().BoolVar(&autoConnectDisabled, disableAutoConnectFlag, false, "Disables auto-connect feature. If enabled, then the client won't connect automatically when the service starts.")
|
||||
upCmd.PersistentFlags().BoolVar(&localMetricsEnabled, enableLocalMetricsFlag, false, "Enables a local Prometheus /metrics endpoint exposing connection state (peers, latency, P2P vs relay).")
|
||||
upCmd.PersistentFlags().StringVar(&localMetricsAddr, localMetricsAddressFlag, localmetrics.DefaultListenAddress, "Listen address of the local Prometheus /metrics endpoint.")
|
||||
upCmd.PersistentFlags().BoolVar(&lazyConnEnabled, enableLazyConnectionFlag, false, "Deprecated: no longer used. Lazy connections are controlled by the server and the NB_LAZY_CONN environment variable.")
|
||||
_ = upCmd.PersistentFlags().MarkDeprecated(enableLazyConnectionFlag, "no longer used; lazy connections are controlled by the server and the NB_LAZY_CONN environment variable")
|
||||
|
||||
@@ -266,70 +271,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
100
client/cmd/up.go
100
client/cmd/up.go
@@ -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()
|
||||
@@ -570,6 +499,13 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
|
||||
req.DisableIpv6 = &disableIPv6
|
||||
}
|
||||
|
||||
if cmd.Flag(enableLocalMetricsFlag).Changed {
|
||||
req.EnableLocalMetrics = &localMetricsEnabled
|
||||
}
|
||||
if cmd.Flag(localMetricsAddressFlag).Changed {
|
||||
req.LocalMetricsAddress = &localMetricsAddr
|
||||
}
|
||||
|
||||
return &req
|
||||
}
|
||||
|
||||
@@ -687,6 +623,14 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
|
||||
ic.DisableIPv6 = &disableIPv6
|
||||
}
|
||||
|
||||
if cmd.Flag(enableLocalMetricsFlag).Changed {
|
||||
ic.LocalMetricsEnabled = &localMetricsEnabled
|
||||
}
|
||||
|
||||
if cmd.Flag(localMetricsAddressFlag).Changed {
|
||||
ic.LocalMetricsAddress = &localMetricsAddr
|
||||
}
|
||||
|
||||
return &ic, nil
|
||||
}
|
||||
|
||||
@@ -749,6 +693,14 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
|
||||
loginRequest.DisableAutoConnect = &autoConnectDisabled
|
||||
}
|
||||
|
||||
if cmd.Flag(enableLocalMetricsFlag).Changed {
|
||||
loginRequest.EnableLocalMetrics = &localMetricsEnabled
|
||||
}
|
||||
|
||||
if cmd.Flag(localMetricsAddressFlag).Changed {
|
||||
loginRequest.LocalMetricsAddress = &localMetricsAddr
|
||||
}
|
||||
|
||||
if cmd.Flag(interfaceNameFlag).Changed {
|
||||
if err := parseInterfaceName(interfaceName); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -676,6 +676,8 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
|
||||
configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess))
|
||||
configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound))
|
||||
configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6))
|
||||
configContent.WriteString(fmt.Sprintf("LocalMetricsEnabled: %v\n", g.internalConfig.LocalMetricsEnabled))
|
||||
configContent.WriteString(fmt.Sprintf("LocalMetricsAddress: %s\n", g.internalConfig.LocalMetricsAddress))
|
||||
|
||||
if g.internalConfig.DisableNotifications != nil {
|
||||
configContent.WriteString(fmt.Sprintf("DisableNotifications: %v\n", *g.internalConfig.DisableNotifications))
|
||||
|
||||
@@ -292,16 +292,18 @@ func (s *serviceViaListener) generateFreePort() (uint16, error) {
|
||||
return customPort, nil
|
||||
}
|
||||
|
||||
probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{})
|
||||
udpAddr := net.UDPAddrFromAddrPort(netip.MustParseAddrPort("0.0.0.0:0"))
|
||||
probeListener, err := net.ListenUDP("udp", udpAddr)
|
||||
if err != nil {
|
||||
log.Debugf("failed to bind random port for DNS: %s", err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port)
|
||||
if err = probeListener.Close(); err != nil {
|
||||
addrPort := netip.MustParseAddrPort(probeListener.LocalAddr().String()) // might panic if address is incorrect
|
||||
err = probeListener.Close()
|
||||
if err != nil {
|
||||
log.Debugf("failed to free up DNS port: %s", err)
|
||||
return 0, err
|
||||
}
|
||||
return port, nil
|
||||
return addrPort.Port(), nil
|
||||
}
|
||||
|
||||
243
client/internal/localmetrics/localmetrics.go
Normal file
243
client/internal/localmetrics/localmetrics.go
Normal file
@@ -0,0 +1,243 @@
|
||||
// Package localmetrics exposes client connection state as a local
|
||||
// Prometheus /metrics endpoint.
|
||||
package localmetrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
)
|
||||
|
||||
// DefaultListenAddress is used when local metrics are enabled without an explicit address.
|
||||
const DefaultListenAddress = "127.0.0.1:9191"
|
||||
|
||||
const (
|
||||
shutdownTimeout = 3 * time.Second
|
||||
readHeaderTimeout = 5 * time.Second
|
||||
readTimeout = 10 * time.Second
|
||||
writeTimeout = 30 * time.Second
|
||||
idleTimeout = time.Minute
|
||||
)
|
||||
|
||||
// statusSource provides the connection state snapshots the collector reads on scrape.
|
||||
type statusSource interface {
|
||||
GetPeerStates() []peer.State
|
||||
GetManagementState() peer.ManagementState
|
||||
GetSignalState() peer.SignalState
|
||||
}
|
||||
|
||||
// GathererProvider returns the current client metrics gatherer, or nil when
|
||||
// no engine is running. It is called on every scrape.
|
||||
type GathererProvider func() prometheus.Gatherer
|
||||
|
||||
// Manager runs the local /metrics HTTP endpoint according to the active
|
||||
// client configuration. Reconcile is safe to call on every config change.
|
||||
type Manager struct {
|
||||
status statusSource
|
||||
clientMetrics GathererProvider
|
||||
|
||||
mu sync.Mutex
|
||||
srv *http.Server
|
||||
addr string
|
||||
}
|
||||
|
||||
// NewManager creates a manager that serves metrics from status and
|
||||
// clientMetrics and shuts down when ctx is canceled.
|
||||
func NewManager(ctx context.Context, status statusSource, clientMetrics GathererProvider) *Manager {
|
||||
m := &Manager{status: status, clientMetrics: clientMetrics}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
m.Stop()
|
||||
}()
|
||||
return m
|
||||
}
|
||||
|
||||
// Reconcile starts, stops, or restarts the metrics endpoint to match the
|
||||
// desired state. An empty addr falls back to DefaultListenAddress.
|
||||
func (m *Manager) Reconcile(enabled bool, addr string) {
|
||||
if addr == "" {
|
||||
addr = DefaultListenAddress
|
||||
}
|
||||
warnIfNotLoopback(addr)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if !enabled {
|
||||
m.stop()
|
||||
return
|
||||
}
|
||||
if m.srv != nil && m.addr == addr {
|
||||
return
|
||||
}
|
||||
m.stop()
|
||||
|
||||
registry := prometheus.NewRegistry()
|
||||
registry.MustRegister(newCollector(m.status))
|
||||
|
||||
gatherers := prometheus.Gatherers{registry, prometheus.GathererFunc(func() ([]*dto.MetricFamily, error) {
|
||||
if m.clientMetrics == nil {
|
||||
return nil, nil
|
||||
}
|
||||
g := m.clientMetrics()
|
||||
if g == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return g.Gather()
|
||||
})}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/metrics", promhttp.HandlerFor(gatherers, promhttp.HandlerOpts{}))
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
ReadTimeout: readTimeout,
|
||||
WriteTimeout: writeTimeout,
|
||||
IdleTimeout: idleTimeout,
|
||||
}
|
||||
m.srv = srv
|
||||
m.addr = addr
|
||||
|
||||
log.Infof("serving local metrics on http://%s/metrics", addr)
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Errorf("failed to serve local metrics on %s: %v", addr, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop shuts down the metrics endpoint if it is running.
|
||||
func (m *Manager) Stop() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.stop()
|
||||
}
|
||||
|
||||
// stop shuts down the running server. Callers must hold m.mu.
|
||||
func (m *Manager) stop() {
|
||||
if m.srv == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
defer cancel()
|
||||
if err := m.srv.Shutdown(ctx); err != nil {
|
||||
log.Debugf("failed to shut down local metrics server: %v", err)
|
||||
}
|
||||
m.srv = nil
|
||||
m.addr = ""
|
||||
}
|
||||
|
||||
// collector converts status recorder snapshots into Prometheus metrics at scrape time.
|
||||
type collector struct {
|
||||
status statusSource
|
||||
|
||||
managementConnected *prometheus.Desc
|
||||
signalConnected *prometheus.Desc
|
||||
peersTotal *prometheus.Desc
|
||||
peersConnected *prometheus.Desc
|
||||
peerLatency *prometheus.Desc
|
||||
}
|
||||
|
||||
func newCollector(status statusSource) *collector {
|
||||
return &collector{
|
||||
status: status,
|
||||
managementConnected: prometheus.NewDesc(
|
||||
"netbird_management_connected",
|
||||
"Whether the client is connected to the management service (1 connected, 0 disconnected).",
|
||||
nil, nil,
|
||||
),
|
||||
signalConnected: prometheus.NewDesc(
|
||||
"netbird_signal_connected",
|
||||
"Whether the client is connected to the signal service (1 connected, 0 disconnected).",
|
||||
nil, nil,
|
||||
),
|
||||
peersTotal: prometheus.NewDesc(
|
||||
"netbird_peers",
|
||||
"Number of peers known to this client.",
|
||||
nil, nil,
|
||||
),
|
||||
peersConnected: prometheus.NewDesc(
|
||||
"netbird_peers_connected",
|
||||
"Number of connected peers by connection type.",
|
||||
[]string{"connection_type"}, nil,
|
||||
),
|
||||
peerLatency: prometheus.NewDesc(
|
||||
"netbird_peer_latency_seconds",
|
||||
"Round-trip latency per directly connected peer; relayed connections have no latency measurement.",
|
||||
[]string{"peer"}, nil,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// Describe implements prometheus.Collector.
|
||||
func (c *collector) Describe(ch chan<- *prometheus.Desc) {
|
||||
ch <- c.managementConnected
|
||||
ch <- c.signalConnected
|
||||
ch <- c.peersTotal
|
||||
ch <- c.peersConnected
|
||||
ch <- c.peerLatency
|
||||
}
|
||||
|
||||
// Collect implements prometheus.Collector.
|
||||
func (c *collector) Collect(ch chan<- prometheus.Metric) {
|
||||
ch <- prometheus.MustNewConstMetric(c.managementConnected, prometheus.GaugeValue, boolToFloat(c.status.GetManagementState().Connected))
|
||||
ch <- prometheus.MustNewConstMetric(c.signalConnected, prometheus.GaugeValue, boolToFloat(c.status.GetSignalState().Connected))
|
||||
|
||||
peers := c.status.GetPeerStates()
|
||||
ch <- prometheus.MustNewConstMetric(c.peersTotal, prometheus.GaugeValue, float64(len(peers)))
|
||||
|
||||
var p2p, relayed float64
|
||||
for _, p := range peers {
|
||||
if p.ConnStatus != peer.StatusConnected {
|
||||
continue
|
||||
}
|
||||
if p.Relayed {
|
||||
relayed++
|
||||
continue
|
||||
}
|
||||
p2p++
|
||||
|
||||
if latency := p.Latency.Seconds(); latency > 0 {
|
||||
ch <- prometheus.MustNewConstMetric(c.peerLatency, prometheus.GaugeValue, latency, p.FQDN)
|
||||
}
|
||||
}
|
||||
ch <- prometheus.MustNewConstMetric(c.peersConnected, prometheus.GaugeValue, p2p, "p2p")
|
||||
ch <- prometheus.MustNewConstMetric(c.peersConnected, prometheus.GaugeValue, relayed, "relay")
|
||||
}
|
||||
|
||||
func boolToFloat(b bool) float64 {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// warnIfNotLoopback logs a warning when the listen address cannot be
|
||||
// confirmed to be local-only, since the endpoint exposes peer and
|
||||
// connectivity details without authentication.
|
||||
func warnIfNotLoopback(addr string) {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if host == "localhost" {
|
||||
return
|
||||
}
|
||||
if ip, err := netip.ParseAddr(host); err == nil && ip.Unmap().IsLoopback() {
|
||||
return
|
||||
}
|
||||
log.Warnf("local metrics endpoint listens on non-loopback address %s and is reachable from the network without authentication", addr)
|
||||
}
|
||||
97
client/internal/localmetrics/localmetrics_test.go
Normal file
97
client/internal/localmetrics/localmetrics_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package localmetrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
)
|
||||
|
||||
type stubStatus struct {
|
||||
peers []peer.State
|
||||
management peer.ManagementState
|
||||
signal peer.SignalState
|
||||
}
|
||||
|
||||
func (s *stubStatus) GetPeerStates() []peer.State { return s.peers }
|
||||
func (s *stubStatus) GetManagementState() peer.ManagementState { return s.management }
|
||||
func (s *stubStatus) GetSignalState() peer.SignalState { return s.signal }
|
||||
|
||||
func testStatus() *stubStatus {
|
||||
return &stubStatus{
|
||||
management: peer.ManagementState{Connected: true},
|
||||
signal: peer.SignalState{Connected: true},
|
||||
peers: []peer.State{
|
||||
{FQDN: "peer-a.netbird.cloud", IP: "100.90.0.1", ConnStatus: peer.StatusConnected, Relayed: false, Latency: 12 * time.Millisecond},
|
||||
{FQDN: "peer-b.netbird.cloud", IP: "100.90.0.2", ConnStatus: peer.StatusConnected, Relayed: false, Latency: 36 * time.Millisecond},
|
||||
{FQDN: "peer-c.netbird.cloud", IP: "100.90.0.3", ConnStatus: peer.StatusConnected, Relayed: true},
|
||||
{FQDN: "peer-d.netbird.cloud", IP: "100.90.0.4", ConnStatus: peer.StatusIdle},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollector(t *testing.T) {
|
||||
c := newCollector(testStatus())
|
||||
|
||||
expected := `
|
||||
# HELP netbird_management_connected Whether the client is connected to the management service (1 connected, 0 disconnected).
|
||||
# TYPE netbird_management_connected gauge
|
||||
netbird_management_connected 1
|
||||
# HELP netbird_peer_latency_seconds Round-trip latency per directly connected peer; relayed connections have no latency measurement.
|
||||
# TYPE netbird_peer_latency_seconds gauge
|
||||
netbird_peer_latency_seconds{peer="peer-a.netbird.cloud"} 0.012
|
||||
netbird_peer_latency_seconds{peer="peer-b.netbird.cloud"} 0.036
|
||||
# HELP netbird_peers Number of peers known to this client.
|
||||
# TYPE netbird_peers gauge
|
||||
netbird_peers 4
|
||||
# HELP netbird_peers_connected Number of connected peers by connection type.
|
||||
# TYPE netbird_peers_connected gauge
|
||||
netbird_peers_connected{connection_type="p2p"} 2
|
||||
netbird_peers_connected{connection_type="relay"} 1
|
||||
# HELP netbird_signal_connected Whether the client is connected to the signal service (1 connected, 0 disconnected).
|
||||
# TYPE netbird_signal_connected gauge
|
||||
netbird_signal_connected 1
|
||||
`
|
||||
require.NoError(t, testutil.CollectAndCompare(c, strings.NewReader(expected)))
|
||||
}
|
||||
|
||||
func TestServe(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err, "must find a free port")
|
||||
addr := ln.Addr().String()
|
||||
require.NoError(t, ln.Close())
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
m := NewManager(ctx, testStatus(), nil)
|
||||
m.Reconcile(true, addr)
|
||||
|
||||
var body string
|
||||
require.Eventually(t, func() bool {
|
||||
resp, err := http.Get(fmt.Sprintf("http://%s/metrics", addr))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
body = string(data)
|
||||
return true
|
||||
}, 2*time.Second, 50*time.Millisecond, "metrics endpoint should come up")
|
||||
|
||||
assert.Contains(t, body, "netbird_peers 4")
|
||||
assert.Contains(t, body, `netbird_peers_connected{connection_type="relay"} 1`)
|
||||
assert.Contains(t, body, `netbird_peer_latency_seconds{peer="peer-a.netbird.cloud"} 0.012`)
|
||||
}
|
||||
@@ -45,30 +45,13 @@ func (m *influxDBMetrics) RecordConnectionStages(
|
||||
isReconnection bool,
|
||||
timestamps ConnectionStageTimestamps,
|
||||
) {
|
||||
var signalingReceivedToConnection, connectionToWgHandshake, totalDuration float64
|
||||
|
||||
if !timestamps.SignalingReceived.IsZero() && !timestamps.ConnectionReady.IsZero() {
|
||||
signalingReceivedToConnection = timestamps.ConnectionReady.Sub(timestamps.SignalingReceived).Seconds()
|
||||
}
|
||||
|
||||
if !timestamps.ConnectionReady.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() {
|
||||
connectionToWgHandshake = timestamps.WgHandshakeSuccess.Sub(timestamps.ConnectionReady).Seconds()
|
||||
}
|
||||
|
||||
if !timestamps.SignalingReceived.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() {
|
||||
totalDuration = timestamps.WgHandshakeSuccess.Sub(timestamps.SignalingReceived).Seconds()
|
||||
}
|
||||
|
||||
attemptType := "initial"
|
||||
if isReconnection {
|
||||
attemptType = "reconnection"
|
||||
}
|
||||
signalingReceivedToConnection, connectionToWgHandshake, totalDuration := timestamps.Durations()
|
||||
|
||||
connTypeStr := connectionType.String()
|
||||
tags := fmt.Sprintf("deployment_type=%s,connection_type=%s,attempt_type=%s,version=%s,os=%s,arch=%s,peer_id=%s,connection_pair_id=%s",
|
||||
agentInfo.DeploymentType.String(),
|
||||
connTypeStr,
|
||||
attemptType,
|
||||
attemptType(isReconnection),
|
||||
agentInfo.Version,
|
||||
agentInfo.OS,
|
||||
agentInfo.Arch,
|
||||
@@ -94,7 +77,7 @@ func (m *influxDBMetrics) RecordConnectionStages(
|
||||
m.trimLocked()
|
||||
|
||||
log.Tracef("peer connection metrics [%s, %s, %s]: signalingReceived→connection: %.3fs, connection→wg_handshake: %.3fs, total: %.3fs",
|
||||
agentInfo.DeploymentType.String(), connTypeStr, attemptType, signalingReceivedToConnection, connectionToWgHandshake, totalDuration)
|
||||
agentInfo.DeploymentType.String(), connTypeStr, attemptType(isReconnection), signalingReceivedToConnection, connectionToWgHandshake, totalDuration)
|
||||
}
|
||||
|
||||
func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration) {
|
||||
|
||||
@@ -89,6 +89,21 @@ type ConnectionStageTimestamps struct {
|
||||
WgHandshakeSuccess time.Time
|
||||
}
|
||||
|
||||
// Durations returns the stage durations in seconds. A duration is zero when
|
||||
// either of its timestamps is missing.
|
||||
func (c ConnectionStageTimestamps) Durations() (signalingToConnection, connectionToWgHandshake, total float64) {
|
||||
if !c.SignalingReceived.IsZero() && !c.ConnectionReady.IsZero() {
|
||||
signalingToConnection = c.ConnectionReady.Sub(c.SignalingReceived).Seconds()
|
||||
}
|
||||
if !c.ConnectionReady.IsZero() && !c.WgHandshakeSuccess.IsZero() {
|
||||
connectionToWgHandshake = c.WgHandshakeSuccess.Sub(c.ConnectionReady).Seconds()
|
||||
}
|
||||
if !c.SignalingReceived.IsZero() && !c.WgHandshakeSuccess.IsZero() {
|
||||
total = c.WgHandshakeSuccess.Sub(c.SignalingReceived).Seconds()
|
||||
}
|
||||
return signalingToConnection, connectionToWgHandshake, total
|
||||
}
|
||||
|
||||
// String returns a human-readable representation of the connection stage timestamps
|
||||
func (c ConnectionStageTimestamps) String() string {
|
||||
return fmt.Sprintf("ConnectionStageTimestamps{SignalingReceived=%v, ConnectionReady=%v, WgHandshakeSuccess=%v}",
|
||||
@@ -279,3 +294,11 @@ func (c *ClientMetrics) stopPushLocked() {
|
||||
c.wg.Wait()
|
||||
c.push.Store(nil)
|
||||
}
|
||||
|
||||
// attemptType returns the metric label for an initial vs reconnection attempt.
|
||||
func attemptType(isReconnection bool) string {
|
||||
if isReconnection {
|
||||
return "reconnection"
|
||||
}
|
||||
return "initial"
|
||||
}
|
||||
|
||||
@@ -2,10 +2,24 @@
|
||||
|
||||
package metrics
|
||||
|
||||
import "github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
// NewClientMetrics creates a new ClientMetrics instance
|
||||
func NewClientMetrics(agentInfo AgentInfo) *ClientMetrics {
|
||||
return &ClientMetrics{
|
||||
impl: newInfluxDBMetrics(),
|
||||
impl: newPrometheusMetrics(newInfluxDBMetrics()),
|
||||
agentInfo: agentInfo,
|
||||
}
|
||||
}
|
||||
|
||||
// PrometheusGatherer returns the registry with the mirrored Prometheus
|
||||
// metrics, or nil when unavailable.
|
||||
func (c *ClientMetrics) PrometheusGatherer() prometheus.Gatherer {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if pm, ok := c.impl.(*prometheusMetrics); ok {
|
||||
return pm.Gatherer()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
119
client/internal/metrics/prometheus.go
Normal file
119
client/internal/metrics/prometheus.go
Normal file
@@ -0,0 +1,119 @@
|
||||
//go:build !js
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
// prometheusMetrics mirrors recorded client metrics into a Prometheus
|
||||
// registry for the local /metrics endpoint, then delegates to the wrapped
|
||||
// implementation. Export and Reset pass through untouched: Prometheus
|
||||
// metrics are cumulative and pull-based.
|
||||
type prometheusMetrics struct {
|
||||
next metricsImplementation
|
||||
registry *prometheus.Registry
|
||||
|
||||
connectionStages *prometheus.HistogramVec
|
||||
syncDuration prometheus.Histogram
|
||||
syncPhaseDuration *prometheus.HistogramVec
|
||||
loginDuration *prometheus.HistogramVec
|
||||
}
|
||||
|
||||
func newPrometheusMetrics(next metricsImplementation) *prometheusMetrics {
|
||||
connectionBuckets := []float64{.05, .1, .25, .5, 1, 2.5, 5, 10, 30, 60}
|
||||
|
||||
m := &prometheusMetrics{
|
||||
next: next,
|
||||
registry: prometheus.NewRegistry(),
|
||||
connectionStages: prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "netbird_peer_connection_stage_duration_seconds",
|
||||
Help: "Duration of peer connection establishment stages.",
|
||||
Buckets: connectionBuckets,
|
||||
}, []string{"stage", "connection_type", "attempt_type"}),
|
||||
syncDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
||||
Name: "netbird_sync_duration_seconds",
|
||||
Help: "Duration of management sync message processing.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}),
|
||||
syncPhaseDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "netbird_sync_phase_duration_seconds",
|
||||
Help: "Duration of individual sync processing phases.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"phase"}),
|
||||
loginDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "netbird_login_duration_seconds",
|
||||
Help: "Duration of logins to the management service.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"success"}),
|
||||
}
|
||||
|
||||
m.registry.MustRegister(m.connectionStages, m.syncDuration, m.syncPhaseDuration, m.loginDuration)
|
||||
return m
|
||||
}
|
||||
|
||||
// Gatherer returns the registry holding the mirrored metrics.
|
||||
func (m *prometheusMetrics) Gatherer() prometheus.Gatherer {
|
||||
return m.registry
|
||||
}
|
||||
|
||||
// RecordConnectionStages implements metricsImplementation.
|
||||
func (m *prometheusMetrics) RecordConnectionStages(
|
||||
ctx context.Context,
|
||||
agentInfo AgentInfo,
|
||||
connectionPairID string,
|
||||
connectionType ConnectionType,
|
||||
isReconnection bool,
|
||||
timestamps ConnectionStageTimestamps,
|
||||
) {
|
||||
attempt := attemptType(isReconnection)
|
||||
connType := connectionType.String()
|
||||
|
||||
signalingToConnection, connectionToWgHandshake, total := timestamps.Durations()
|
||||
if signalingToConnection > 0 {
|
||||
m.connectionStages.WithLabelValues("signaling_to_connection", connType, attempt).Observe(signalingToConnection)
|
||||
}
|
||||
if connectionToWgHandshake > 0 {
|
||||
m.connectionStages.WithLabelValues("connection_to_wg_handshake", connType, attempt).Observe(connectionToWgHandshake)
|
||||
}
|
||||
if total > 0 {
|
||||
m.connectionStages.WithLabelValues("total", connType, attempt).Observe(total)
|
||||
}
|
||||
|
||||
m.next.RecordConnectionStages(ctx, agentInfo, connectionPairID, connectionType, isReconnection, timestamps)
|
||||
}
|
||||
|
||||
// RecordSyncDuration implements metricsImplementation.
|
||||
func (m *prometheusMetrics) RecordSyncDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration) {
|
||||
m.syncDuration.Observe(duration.Seconds())
|
||||
m.next.RecordSyncDuration(ctx, agentInfo, duration)
|
||||
}
|
||||
|
||||
// RecordSyncPhase implements metricsImplementation.
|
||||
func (m *prometheusMetrics) RecordSyncPhase(ctx context.Context, agentInfo AgentInfo, phase string, duration time.Duration) {
|
||||
m.syncPhaseDuration.WithLabelValues(phase).Observe(duration.Seconds())
|
||||
m.next.RecordSyncPhase(ctx, agentInfo, phase, duration)
|
||||
}
|
||||
|
||||
// RecordLoginDuration implements metricsImplementation.
|
||||
func (m *prometheusMetrics) RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool) {
|
||||
m.loginDuration.WithLabelValues(strconv.FormatBool(success)).Observe(duration.Seconds())
|
||||
m.next.RecordLoginDuration(ctx, agentInfo, duration, success)
|
||||
}
|
||||
|
||||
// Export implements metricsImplementation by delegating to the wrapped
|
||||
// implementation; Prometheus metrics are pulled via the registry instead.
|
||||
func (m *prometheusMetrics) Export(w io.Writer) error {
|
||||
return m.next.Export(w)
|
||||
}
|
||||
|
||||
// Reset implements metricsImplementation by delegating to the wrapped
|
||||
// implementation; Prometheus metrics must not be cleared on push.
|
||||
func (m *prometheusMetrics) Reset() {
|
||||
m.next.Reset()
|
||||
}
|
||||
@@ -1172,6 +1172,18 @@ func (d *Status) GetResolvedDomainsStates() map[domain.Domain]ResolvedDomainInfo
|
||||
return maps.Clone(d.resolvedDomainsStates)
|
||||
}
|
||||
|
||||
// GetPeerStates returns a snapshot of all known peer states.
|
||||
func (d *Status) GetPeerStates() []State {
|
||||
d.mux.RLock()
|
||||
defer d.mux.RUnlock()
|
||||
|
||||
states := make([]State, 0, len(d.peers))
|
||||
for _, state := range d.peers {
|
||||
states = append(states, state)
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// GetFullStatus gets full status
|
||||
func (d *Status) GetFullStatus() FullStatus {
|
||||
fullStatus := FullStatus{
|
||||
|
||||
@@ -102,6 +102,9 @@ type ConfigInput struct {
|
||||
DNSLabels domain.List
|
||||
|
||||
MTU *uint16
|
||||
|
||||
LocalMetricsEnabled *bool
|
||||
LocalMetricsAddress *string
|
||||
}
|
||||
|
||||
// Config Configuration type
|
||||
@@ -142,6 +145,11 @@ type Config struct {
|
||||
|
||||
DNSLabels domain.List
|
||||
|
||||
// LocalMetricsEnabled enables the local Prometheus /metrics endpoint.
|
||||
LocalMetricsEnabled bool
|
||||
// LocalMetricsAddress is the listen address of the local /metrics endpoint.
|
||||
LocalMetricsAddress string
|
||||
|
||||
// SSHKey is a private SSH key in a PEM format
|
||||
SSHKey string
|
||||
|
||||
@@ -386,6 +394,18 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.LocalMetricsEnabled != nil && *input.LocalMetricsEnabled != config.LocalMetricsEnabled {
|
||||
log.Infof("switching local metrics to %t", *input.LocalMetricsEnabled)
|
||||
config.LocalMetricsEnabled = *input.LocalMetricsEnabled
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.LocalMetricsAddress != nil && *input.LocalMetricsAddress != config.LocalMetricsAddress {
|
||||
log.Infof("switching local metrics address to %s", *input.LocalMetricsAddress)
|
||||
config.LocalMetricsAddress = *input.LocalMetricsAddress
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.NetworkMonitor != nil && (config.NetworkMonitor == nil || *input.NetworkMonitor != *config.NetworkMonitor) {
|
||||
log.Infof("switching Network Monitor to %t", *input.NetworkMonitor)
|
||||
config.NetworkMonitor = input.NetworkMonitor
|
||||
@@ -710,6 +730,12 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
|
||||
applyBool(mdm.KeyDisableAutoConnect, func(v bool) { config.DisableAutoConnect = v })
|
||||
applyBool(mdm.KeyRosenpassEnabled, func(v bool) { config.RosenpassEnabled = v })
|
||||
applyBool(mdm.KeyRosenpassPermissive, func(v bool) { config.RosenpassPermissive = v })
|
||||
applyBool(mdm.KeyEnableLocalMetrics, func(v bool) { config.LocalMetricsEnabled = v })
|
||||
|
||||
if v, ok := policy.GetString(mdm.KeyLocalMetricsAddress); ok {
|
||||
config.LocalMetricsAddress = v
|
||||
logApplied(mdm.KeyLocalMetricsAddress, v)
|
||||
}
|
||||
|
||||
if v, ok := policy.GetInt(mdm.KeyWireguardPort); ok {
|
||||
// REG_DWORD is 32-bit; UDP port range is 1-65535. Clamp at the
|
||||
|
||||
@@ -130,6 +130,32 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) {
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled))
|
||||
}
|
||||
|
||||
func TestApply_MDMLocalMetrics(t *testing.T) {
|
||||
tmp := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
// Seed without MDM.
|
||||
withMDMPolicy(t, mdm.NewPolicy(nil))
|
||||
_, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: tmp,
|
||||
LocalMetricsEnabled: boolPtr(false),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyEnableLocalMetrics: true,
|
||||
mdm.KeyLocalMetricsAddress: "127.0.0.1:9292",
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.True(t, cfg.LocalMetricsEnabled, "MDM override should flip on-disk false to true")
|
||||
assert.Equal(t, "127.0.0.1:9292", cfg.LocalMetricsAddress)
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyEnableLocalMetrics))
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyLocalMetricsAddress))
|
||||
}
|
||||
|
||||
func TestApply_MDMLazyConnection(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -44,25 +44,10 @@ type Auth struct {
|
||||
// NewAuth instantiate Auth struct and validate the management URL
|
||||
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
||||
inputCfg := profilemanager.ConfigInput{
|
||||
ConfigPath: cfgPath,
|
||||
ManagementURL: mgmURL,
|
||||
}
|
||||
|
||||
// Load the existing config when a config file is already present so an
|
||||
// interactive re-login reuses the peer's persisted WireGuard private key
|
||||
// (and thus its identity) instead of generating a fresh one. Generating a
|
||||
// new key registers a brand-new peer on the management server on every
|
||||
// re-auth (named after the fallback hostname). Only fall back to a fresh
|
||||
// in-memory config for the first-time login when no config file exists yet.
|
||||
// DirectUpdateOrCreateConfig uses non-atomic writes so it also works inside
|
||||
// the tvOS App Group sandbox where atomic temp-file+rename is blocked.
|
||||
var cfg *profilemanager.Config
|
||||
var err error
|
||||
if cfgPath != "" {
|
||||
cfg, err = profilemanager.DirectUpdateOrCreateConfig(inputCfg)
|
||||
} else {
|
||||
cfg, err = profilemanager.CreateInMemoryConfig(inputCfg)
|
||||
}
|
||||
cfg, err := profilemanager.CreateInMemoryConfig(inputCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ const (
|
||||
KeyRosenpassEnabled = "rosenpassEnabled"
|
||||
KeyRosenpassPermissive = "rosenpassPermissive"
|
||||
KeyWireguardPort = "wireguardPort"
|
||||
KeyEnableLocalMetrics = "enableLocalMetrics"
|
||||
KeyLocalMetricsAddress = "localMetricsAddress"
|
||||
|
||||
// Split tunnel is modeled as a single conceptual policy with two
|
||||
// registry/plist values. KeySplitTunnelMode is the discriminator
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -343,6 +343,8 @@ type LoginRequest struct {
|
||||
DisableSSHAuth *bool `protobuf:"varint,38,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"`
|
||||
SshJWTCacheTTL *int32 `protobuf:"varint,39,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"`
|
||||
DisableIpv6 *bool `protobuf:"varint,40,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"`
|
||||
EnableLocalMetrics *bool `protobuf:"varint,41,opt,name=enable_local_metrics,json=enableLocalMetrics,proto3,oneof" json:"enable_local_metrics,omitempty"`
|
||||
LocalMetricsAddress *string `protobuf:"bytes,42,opt,name=local_metrics_address,json=localMetricsAddress,proto3,oneof" json:"local_metrics_address,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -658,6 +660,20 @@ func (x *LoginRequest) GetDisableIpv6() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *LoginRequest) GetEnableLocalMetrics() bool {
|
||||
if x != nil && x.EnableLocalMetrics != nil {
|
||||
return *x.EnableLocalMetrics
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *LoginRequest) GetLocalMetricsAddress() string {
|
||||
if x != nil && x.LocalMetricsAddress != nil {
|
||||
return *x.LocalMetricsAddress
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
NeedsSSOLogin bool `protobuf:"varint,1,opt,name=needsSSOLogin,proto3" json:"needsSSOLogin,omitempty"`
|
||||
@@ -995,13 +1011,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 +1073,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
|
||||
@@ -4222,6 +4226,8 @@ type SetConfigRequest struct {
|
||||
DisableSSHAuth *bool `protobuf:"varint,33,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"`
|
||||
SshJWTCacheTTL *int32 `protobuf:"varint,34,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"`
|
||||
DisableIpv6 *bool `protobuf:"varint,35,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"`
|
||||
EnableLocalMetrics *bool `protobuf:"varint,36,opt,name=enable_local_metrics,json=enableLocalMetrics,proto3,oneof" json:"enable_local_metrics,omitempty"`
|
||||
LocalMetricsAddress *string `protobuf:"bytes,37,opt,name=local_metrics_address,json=localMetricsAddress,proto3,oneof" json:"local_metrics_address,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -4501,6 +4507,20 @@ func (x *SetConfigRequest) GetDisableIpv6() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *SetConfigRequest) GetEnableLocalMetrics() bool {
|
||||
if x != nil && x.EnableLocalMetrics != nil {
|
||||
return *x.EnableLocalMetrics
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *SetConfigRequest) GetLocalMetricsAddress() string {
|
||||
if x != nil && x.LocalMetricsAddress != nil {
|
||||
return *x.LocalMetricsAddress
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type SetConfigResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
@@ -6999,7 +7019,7 @@ var File_daemon_proto protoreflect.FileDescriptor
|
||||
const file_daemon_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\fdaemon.proto\x12\x06daemon\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/duration.proto\"\x0e\n" +
|
||||
"\fEmptyRequest\"\xef\x12\n" +
|
||||
"\fEmptyRequest\"\x92\x14\n" +
|
||||
"\fLoginRequest\x12\x1a\n" +
|
||||
"\bsetupKey\x18\x01 \x01(\tR\bsetupKey\x12&\n" +
|
||||
"\fpreSharedKey\x18\x02 \x01(\tB\x02\x18\x01R\fpreSharedKey\x12$\n" +
|
||||
@@ -7044,7 +7064,9 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\x1denableSSHRemotePortForwarding\x18% \x01(\bH\x18R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" +
|
||||
"\x0edisableSSHAuth\x18& \x01(\bH\x19R\x0edisableSSHAuth\x88\x01\x01\x12+\n" +
|
||||
"\x0esshJWTCacheTTL\x18' \x01(\x05H\x1aR\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" +
|
||||
"\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01B\x13\n" +
|
||||
"\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01\x125\n" +
|
||||
"\x14enable_local_metrics\x18) \x01(\bH\x1cR\x12enableLocalMetrics\x88\x01\x01\x127\n" +
|
||||
"\x15local_metrics_address\x18* \x01(\tH\x1dR\x13localMetricsAddress\x88\x01\x01B\x13\n" +
|
||||
"\x11_rosenpassEnabledB\x10\n" +
|
||||
"\x0e_interfaceNameB\x10\n" +
|
||||
"\x0e_wireguardPortB\x17\n" +
|
||||
@@ -7072,7 +7094,9 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\x1e_enableSSHRemotePortForwardingB\x11\n" +
|
||||
"\x0f_disableSSHAuthB\x11\n" +
|
||||
"\x0f_sshJWTCacheTTLB\x0f\n" +
|
||||
"\r_disable_ipv6\"\xb5\x01\n" +
|
||||
"\r_disable_ipv6B\x17\n" +
|
||||
"\x15_enable_local_metricsB\x18\n" +
|
||||
"\x16_local_metrics_address\"\xb5\x01\n" +
|
||||
"\rLoginResponse\x12$\n" +
|
||||
"\rneedsSSOLogin\x18\x01 \x01(\bR\rneedsSSOLogin\x12\x1a\n" +
|
||||
"\buserCode\x18\x02 \x01(\tR\buserCode\x12(\n" +
|
||||
@@ -7095,15 +7119,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" +
|
||||
@@ -7366,7 +7389,7 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\f_profileNameB\v\n" +
|
||||
"\t_username\"'\n" +
|
||||
"\x15SwitchProfileResponse\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\tR\x02id\"\x98\x11\n" +
|
||||
"\x02id\x18\x01 \x01(\tR\x02id\"\xbb\x12\n" +
|
||||
"\x10SetConfigRequest\x12\x1a\n" +
|
||||
"\busername\x18\x01 \x01(\tR\busername\x12 \n" +
|
||||
"\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" +
|
||||
@@ -7406,7 +7429,9 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\x1denableSSHRemotePortForwarding\x18 \x01(\bH\x15R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" +
|
||||
"\x0edisableSSHAuth\x18! \x01(\bH\x16R\x0edisableSSHAuth\x88\x01\x01\x12+\n" +
|
||||
"\x0esshJWTCacheTTL\x18\" \x01(\x05H\x17R\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" +
|
||||
"\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01B\x13\n" +
|
||||
"\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01\x125\n" +
|
||||
"\x14enable_local_metrics\x18$ \x01(\bH\x19R\x12enableLocalMetrics\x88\x01\x01\x127\n" +
|
||||
"\x15local_metrics_address\x18% \x01(\tH\x1aR\x13localMetricsAddress\x88\x01\x01B\x13\n" +
|
||||
"\x11_rosenpassEnabledB\x10\n" +
|
||||
"\x0e_interfaceNameB\x10\n" +
|
||||
"\x0e_wireguardPortB\x17\n" +
|
||||
@@ -7431,7 +7456,9 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\x1e_enableSSHRemotePortForwardingB\x11\n" +
|
||||
"\x0f_disableSSHAuthB\x11\n" +
|
||||
"\x0f_sshJWTCacheTTLB\x0f\n" +
|
||||
"\r_disable_ipv6\"\x13\n" +
|
||||
"\r_disable_ipv6B\x17\n" +
|
||||
"\x15_enable_local_metricsB\x18\n" +
|
||||
"\x16_local_metrics_address\"\x13\n" +
|
||||
"\x11SetConfigResponse\"Q\n" +
|
||||
"\x11AddProfileRequest\x12\x1a\n" +
|
||||
"\busername\x18\x01 \x01(\tR\busername\x12 \n" +
|
||||
|
||||
@@ -242,6 +242,9 @@ message LoginRequest {
|
||||
optional bool disableSSHAuth = 38;
|
||||
optional int32 sshJWTCacheTTL = 39;
|
||||
optional bool disable_ipv6 = 40;
|
||||
|
||||
optional bool enable_local_metrics = 41;
|
||||
optional string local_metrics_address = 42;
|
||||
}
|
||||
|
||||
message LoginResponse {
|
||||
@@ -291,11 +294,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 {}
|
||||
@@ -762,6 +760,9 @@ message SetConfigRequest {
|
||||
optional bool disableSSHAuth = 33;
|
||||
optional int32 sshJWTCacheTTL = 34;
|
||||
optional bool disable_ipv6 = 35;
|
||||
|
||||
optional bool enable_local_metrics = 36;
|
||||
optional string local_metrics_address = 37;
|
||||
}
|
||||
|
||||
message SetConfigResponse{}
|
||||
|
||||
@@ -301,6 +301,8 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [
|
||||
conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
|
||||
conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
|
||||
conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
|
||||
conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
|
||||
conflictString(mdm.KeyLocalMetricsAddress, msg.GetLocalMetricsAddress()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -346,7 +348,9 @@ func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool {
|
||||
msg.EnableSSHLocalPortForwarding != nil ||
|
||||
msg.EnableSSHRemotePortForwarding != nil ||
|
||||
msg.DisableSSHAuth != nil ||
|
||||
msg.SshJWTCacheTTL != nil
|
||||
msg.SshJWTCacheTTL != nil ||
|
||||
msg.EnableLocalMetrics != nil ||
|
||||
msg.LocalMetricsAddress != nil
|
||||
}
|
||||
|
||||
// loginRequestHasConfigOverrides reports whether the LoginRequest
|
||||
@@ -381,7 +385,9 @@ func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool {
|
||||
msg.BlockLanAccess != nil ||
|
||||
msg.DisableNotifications != nil ||
|
||||
len(msg.DnsLabels) > 0 || msg.CleanDNSLabels ||
|
||||
msg.BlockInbound != nil
|
||||
msg.BlockInbound != nil ||
|
||||
msg.EnableLocalMetrics != nil ||
|
||||
msg.LocalMetricsAddress != nil
|
||||
}
|
||||
|
||||
// loginRequestMDMConflicts mirrors mdmManagedFieldConflicts but for the
|
||||
@@ -422,6 +428,8 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str
|
||||
conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
|
||||
conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
|
||||
conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
|
||||
conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
|
||||
conflictString(mdm.KeyLocalMetricsAddress, msg.GetLocalMetricsAddress()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/expose"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/localmetrics"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
@@ -99,17 +102,12 @@ type Server struct {
|
||||
|
||||
statusRecorder *peer.Status
|
||||
sessionWatcher *internal.SessionWatcher
|
||||
localMetrics *localmetrics.Manager
|
||||
|
||||
probeThrottle *probeThrottle
|
||||
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
|
||||
|
||||
@@ -161,15 +159,26 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable
|
||||
s.sleepHandler = sleephandler.New(agent)
|
||||
s.startSleepDetector()
|
||||
|
||||
s.localMetrics = localmetrics.NewManager(ctx, s.statusRecorder, s.clientMetricsGatherer)
|
||||
|
||||
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)
|
||||
// clientMetricsGatherer returns the Prometheus gatherer of the running
|
||||
// engine's client metrics, or nil when no engine is running.
|
||||
func (s *Server) clientMetricsGatherer() prometheus.Gatherer {
|
||||
s.mutex.Lock()
|
||||
connectClient := s.connectClient
|
||||
s.mutex.Unlock()
|
||||
|
||||
if connectClient == nil {
|
||||
return nil
|
||||
}
|
||||
engine := connectClient.Engine()
|
||||
if engine == nil {
|
||||
return nil
|
||||
}
|
||||
return engine.GetClientMetrics().PrometheusGatherer()
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
@@ -252,6 +261,7 @@ func (s *Server) Start() error {
|
||||
|
||||
s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String())
|
||||
s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive)
|
||||
s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress)
|
||||
|
||||
if s.sessionWatcher == nil {
|
||||
s.sessionWatcher = internal.NewSessionWatcher(s.rootCtx, s.statusRecorder)
|
||||
@@ -430,11 +440,18 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := profilemanager.UpdateConfig(config); err != nil {
|
||||
updatedConf, err := profilemanager.UpdateConfig(config)
|
||||
if err != nil {
|
||||
log.Errorf("failed to update profile config: %v", err)
|
||||
return nil, fmt.Errorf("failed to update profile config: %w", err)
|
||||
}
|
||||
|
||||
if activeProf, err := s.profileManager.GetActiveProfileState(); err == nil {
|
||||
if activePath, err := activeProf.FilePath(); err == nil && activePath == config.ConfigPath {
|
||||
s.localMetrics.Reconcile(updatedConf.LocalMetricsEnabled, updatedConf.LocalMetricsAddress)
|
||||
}
|
||||
}
|
||||
|
||||
return &proto.SetConfigResponse{}, nil
|
||||
}
|
||||
|
||||
@@ -504,6 +521,8 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profile
|
||||
|
||||
config.RosenpassEnabled = msg.RosenpassEnabled
|
||||
config.RosenpassPermissive = msg.RosenpassPermissive
|
||||
config.LocalMetricsEnabled = msg.EnableLocalMetrics
|
||||
config.LocalMetricsAddress = msg.LocalMetricsAddress
|
||||
config.DisableAutoConnect = msg.DisableAutoConnect
|
||||
config.ServerSSHAllowed = msg.ServerSSHAllowed
|
||||
config.NetworkMonitor = msg.NetworkMonitor
|
||||
@@ -959,6 +978,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
|
||||
|
||||
s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String())
|
||||
s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive)
|
||||
s.localMetrics.Reconcile(s.config.LocalMetricsEnabled, s.config.LocalMetricsAddress)
|
||||
|
||||
s.clientRunning = true
|
||||
s.clientRunningChan = make(chan struct{})
|
||||
@@ -1436,7 +1456,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)
|
||||
|
||||
@@ -132,6 +132,30 @@ func TestSetConfig_MDMReject_MultipleFields(t *testing.T) {
|
||||
}, v.GetFields())
|
||||
}
|
||||
|
||||
func TestSetConfig_MDMReject_LocalMetrics(t *testing.T) {
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyEnableLocalMetrics: true,
|
||||
mdm.KeyLocalMetricsAddress: "127.0.0.1:9191",
|
||||
}))
|
||||
|
||||
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
||||
|
||||
enabled := false
|
||||
addr := "0.0.0.0:9999"
|
||||
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
||||
ProfileName: profName,
|
||||
Username: username,
|
||||
EnableLocalMetrics: &enabled,
|
||||
LocalMetricsAddress: &addr,
|
||||
})
|
||||
|
||||
v := extractViolation(t, err)
|
||||
assert.ElementsMatch(t, []string{
|
||||
mdm.KeyEnableLocalMetrics,
|
||||
mdm.KeyLocalMetricsAddress,
|
||||
}, v.GetFields())
|
||||
}
|
||||
|
||||
func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) {
|
||||
// MDM enforces ManagementURL only; user request touches both the
|
||||
// enforced field AND a non-enforced field (RosenpassEnabled).
|
||||
|
||||
@@ -73,6 +73,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
||||
disableIPv6 := true
|
||||
mtu := int64(1280)
|
||||
sshJWTCacheTTL := int32(300)
|
||||
enableLocalMetrics := true
|
||||
localMetricsAddress := "127.0.0.1:9292"
|
||||
|
||||
req := &proto.SetConfigRequest{
|
||||
ProfileName: profName,
|
||||
@@ -104,6 +106,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
||||
DnsRouteInterval: durationpb.New(2 * time.Minute),
|
||||
Mtu: &mtu,
|
||||
SshJWTCacheTTL: &sshJWTCacheTTL,
|
||||
EnableLocalMetrics: &enableLocalMetrics,
|
||||
LocalMetricsAddress: &localMetricsAddress,
|
||||
}
|
||||
|
||||
_, err = s.SetConfig(ctx, req)
|
||||
@@ -150,6 +154,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
||||
require.Equal(t, uint16(mtu), cfg.MTU)
|
||||
require.NotNil(t, cfg.SSHJWTCacheTTL)
|
||||
require.Equal(t, int(sshJWTCacheTTL), *cfg.SSHJWTCacheTTL)
|
||||
require.Equal(t, enableLocalMetrics, cfg.LocalMetricsEnabled)
|
||||
require.Equal(t, localMetricsAddress, cfg.LocalMetricsAddress)
|
||||
|
||||
verifyAllFieldsCovered(t, req)
|
||||
}
|
||||
@@ -202,6 +208,8 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) {
|
||||
"EnableSSHRemotePortForwarding": true,
|
||||
"DisableSSHAuth": true,
|
||||
"SshJWTCacheTTL": true,
|
||||
"EnableLocalMetrics": true,
|
||||
"LocalMetricsAddress": true,
|
||||
}
|
||||
|
||||
val := reflect.ValueOf(req).Elem()
|
||||
@@ -261,6 +269,8 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) {
|
||||
"enable-ssh-remote-port-forwarding": "EnableSSHRemotePortForwarding",
|
||||
"disable-ssh-auth": "DisableSSHAuth",
|
||||
"ssh-jwt-cache-ttl": "SshJWTCacheTTL",
|
||||
"enable-local-metrics": "EnableLocalMetrics",
|
||||
"local-metrics-address": "LocalMetricsAddress",
|
||||
}
|
||||
|
||||
// SetConfigRequest fields that don't have CLI flags (settable only via UI or other means).
|
||||
|
||||
@@ -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
|
||||
|
||||
3
go.mod
3
go.mod
@@ -98,6 +98,7 @@ require (
|
||||
github.com/pires/go-proxyproto v0.11.0
|
||||
github.com/pkg/sftp v1.13.9
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/client_model v0.6.2
|
||||
github.com/quic-go/quic-go v0.55.0
|
||||
github.com/redis/go-redis/v9 v9.7.3
|
||||
github.com/rs/xid v1.3.0
|
||||
@@ -249,6 +250,7 @@ require (
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/koron/go-ssdp v0.0.4 // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/lib/pq v1.12.3 // indirect
|
||||
github.com/libdns/libdns v0.2.2 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae // indirect
|
||||
@@ -289,7 +291,6 @@ require (
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/pquerna/otp v1.5.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/otlptranslator v1.0.0 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
|
||||
1107
infrastructure_files/observability/grafana/dashboards/client.json
Normal file
1107
infrastructure_files/observability/grafana/dashboards/client.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user