From 6df01756079d3aa771ab335fe4e949ec48b78ef9 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 16 Jun 2026 16:15:19 +0200 Subject: [PATCH 01/20] [client] Add IsLoginRequiredCached for iOS mobile client (#6447) Expose a network-free login-required check backed by the in-memory status recorder. Unlike IsLoginRequired(), which creates a fresh auth client and performs a blocking network call, IsLoginRequiredCached() reports whether the LAST observed management error was an auth failure (PermissionDenied/ InvalidArgument). This lets the iOS connection listener detect a mid-session token expiry from within onDisconnected during teardown without blocking on a slow or unavailable network. --- client/ios/NetBirdSDK/client.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index 132ee8d9d..359a83556 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -320,6 +320,16 @@ func (c *Client) RemoveConnectionListener() { c.recorder.RemoveConnectionListener() } +// IsLoginRequiredCached reports whether the LAST observed management error was an +// auth failure (PermissionDenied/InvalidArgument), using the in-memory status +// recorder. Unlike IsLoginRequired() it performs NO network call, so it is safe to +// call from the connection listener during teardown (e.g. onDisconnected) without +// blocking on a slow or unavailable network. Returns false while connected to +// management or when the last error was not auth-related. +func (c *Client) IsLoginRequiredCached() bool { + return c.recorder.IsLoginRequired() +} + func (c *Client) IsLoginRequired() bool { var ctx context.Context //nolint From 5095e17cc5ab3c960be07853e569934ed5552958 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:00:50 +0200 Subject: [PATCH 02/20] [management] fix flaky Test_SaveAccount_Large from random IP collision (#6452) --- management/server/store/sql_store_test.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index 0c90eaf5f..ac136987e 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -6,7 +6,6 @@ import ( b64 "encoding/base64" "encoding/binary" "fmt" - "math/rand" "net" "net/netip" "os" @@ -92,7 +91,7 @@ func runLargeTest(t *testing.T, store Store) { account.SetupKeys[setupKey.Key] = setupKey const numPerAccount = 6000 for n := 0; n < numPerAccount; n++ { - netIP := randomIPv4() + netIP := sequentialIPv4(n) peerID := fmt.Sprintf("%s-peer-%d", account.Id, n) addr, _ := netip.AddrFromSlice(netIP) @@ -216,12 +215,12 @@ func runLargeTest(t *testing.T, store Store) { } } -func randomIPv4() net.IP { - rand.New(rand.NewSource(time.Now().UnixNano())) +// sequentialIPv4 returns a unique IPv4 address for the given index, avoiding +// the random collisions that would otherwise violate the unique (account_id, ip) +// index when generating a large number of peers. +func sequentialIPv4(n int) net.IP { b := make([]byte, 4) - for i := range b { - b[i] = byte(rand.Intn(256)) - } + binary.BigEndian.PutUint32(b, 0x0A000000+uint32(n)) return net.IP(b) } From 6fbc90b4d376f6f8e82d43e71d8f9ae938b5c8ed Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:41:48 +0900 Subject: [PATCH 03/20] [client, relay] Expose relay transport and connection errors in status and metrics (#6342) --- client/internal/peer/status.go | 26 +++++--- client/internal/relay/relay.go | 3 + client/proto/daemon.pb.go | 23 +++++-- client/proto/daemon.proto | 3 + client/status/status.go | 12 +++- client/status/status_test.go | 10 +++ relay/metrics/realy.go | 9 +-- relay/server/listener/conn.go | 2 + relay/server/listener/quic/conn.go | 5 ++ relay/server/listener/ws/conn.go | 5 ++ relay/server/relay.go | 5 +- shared/relay/client/client.go | 21 ++++++ shared/relay/client/dialer/quic/conn.go | 5 ++ shared/relay/client/dialer/quic/quic.go | 8 +-- shared/relay/client/dialer/race_dialer.go | 38 +++++++++-- shared/relay/client/dialer/ws/conn.go | 5 ++ shared/relay/client/dialer/ws/ws.go | 9 ++- shared/relay/client/dialers_generic_test.go | 18 +++--- shared/relay/client/guard.go | 22 +++++++ shared/relay/client/manager.go | 72 +++++++++++++++++++++ shared/relay/client/picker.go | 22 +++++-- 21 files changed, 277 insertions(+), 46 deletions(-) diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index 31e0d6e25..3e5c56dd2 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -1024,14 +1024,17 @@ func (d *Status) GetRelayStates() []relay.ProbeResult { return d.relayStates } - // extend the list of stun, turn servers with relay address + // extend the list of stun, turn servers with the relay server connections relayStates := slices.Clone(d.relayStates) - // if the server connection is not established then we will use the general address - // in case of connection we will use the instance specific address - instanceAddr, _, err := d.relayMgr.RelayInstanceAddress() - if err != nil { - // TODO add their status + states := d.relayMgr.RelayStates() + if len(states) == 0 { + // no relay connection tracked yet; surface configured servers as + // unavailable with the real reconnect error when known + err := relayClient.ErrRelayClientNotConnected + if connErr := d.relayMgr.RelayConnectError(); connErr != nil { + err = connErr + } for _, r := range d.relayMgr.ServerURLs() { relayStates = append(relayStates, relay.ProbeResult{ URI: r, @@ -1041,10 +1044,14 @@ func (d *Status) GetRelayStates() []relay.ProbeResult { return relayStates } - relayState := relay.ProbeResult{ - URI: instanceAddr, + for _, rs := range states { + relayStates = append(relayStates, relay.ProbeResult{ + URI: rs.URL, + Err: rs.Err, + Transport: rs.Transport, + }) } - return append(relayStates, relayState) + return relayStates } func (d *Status) ForwardingRules() []firewall.ForwardRule { @@ -1405,6 +1412,7 @@ func (fs FullStatus) ToProto() *proto.FullStatus { pbRelayState := &proto.RelayState{ URI: relayState.URI, Available: relayState.Err == nil, + Transport: relayState.Transport, } if err := relayState.Err; err != nil { pbRelayState.Error = err.Error() diff --git a/client/internal/relay/relay.go b/client/internal/relay/relay.go index f00a8d93a..051717608 100644 --- a/client/internal/relay/relay.go +++ b/client/internal/relay/relay.go @@ -32,6 +32,9 @@ type ProbeResult struct { URI string Err error Addr string + // Transport is the negotiated relay transport, empty + // for stun/turn probes or when not connected. + Transport string } type StunTurnProbe struct { diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 70d9e8212..6b5a37658 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -1849,10 +1849,13 @@ func (x *ManagementState) GetError() string { // RelayState contains the latest state of the relay type RelayState struct { - state protoimpl.MessageState `protogen:"open.v1"` - URI string `protobuf:"bytes,1,opt,name=URI,proto3" json:"URI,omitempty"` - Available bool `protobuf:"varint,2,opt,name=available,proto3" json:"available,omitempty"` - Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + URI string `protobuf:"bytes,1,opt,name=URI,proto3" json:"URI,omitempty"` + Available bool `protobuf:"varint,2,opt,name=available,proto3" json:"available,omitempty"` + Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` + // transport is the negotiated relay transport (e.g. "ws", "quic"), + // empty for stun/turn probes or when not connected. + Transport string `protobuf:"bytes,4,opt,name=transport,proto3" json:"transport,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1908,6 +1911,13 @@ func (x *RelayState) GetError() string { return "" } +func (x *RelayState) GetTransport() string { + if x != nil { + return x.Transport + } + return "" +} + type NSGroupState struct { state protoimpl.MessageState `protogen:"open.v1"` Servers []string `protobuf:"bytes,1,rep,name=servers,proto3" json:"servers,omitempty"` @@ -6486,12 +6496,13 @@ const file_daemon_proto_rawDesc = "" + "\x0fManagementState\x12\x10\n" + "\x03URL\x18\x01 \x01(\tR\x03URL\x12\x1c\n" + "\tconnected\x18\x02 \x01(\bR\tconnected\x12\x14\n" + - "\x05error\x18\x03 \x01(\tR\x05error\"R\n" + + "\x05error\x18\x03 \x01(\tR\x05error\"p\n" + "\n" + "RelayState\x12\x10\n" + "\x03URI\x18\x01 \x01(\tR\x03URI\x12\x1c\n" + "\tavailable\x18\x02 \x01(\bR\tavailable\x12\x14\n" + - "\x05error\x18\x03 \x01(\tR\x05error\"r\n" + + "\x05error\x18\x03 \x01(\tR\x05error\x12\x1c\n" + + "\ttransport\x18\x04 \x01(\tR\ttransport\"r\n" + "\fNSGroupState\x12\x18\n" + "\aservers\x18\x01 \x03(\tR\aservers\x12\x18\n" + "\adomains\x18\x02 \x03(\tR\adomains\x12\x18\n" + diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 265ab40bb..ea668f629 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -378,6 +378,9 @@ message RelayState { string URI = 1; bool available = 2; string error = 3; + // transport is the negotiated relay transport (e.g. "ws", "quic"), + // empty for stun/turn probes or when not connected. + string transport = 4; } message NSGroupState { diff --git a/client/status/status.go b/client/status/status.go index e7e8ee11c..5b815aaa3 100644 --- a/client/status/status.go +++ b/client/status/status.go @@ -98,6 +98,7 @@ type RelayStateOutputDetail struct { URI string `json:"uri" yaml:"uri"` Available bool `json:"available" yaml:"available"` Error string `json:"error" yaml:"error"` + Transport string `json:"transport,omitempty" yaml:"transport,omitempty"` } type RelayStateOutput struct { @@ -219,7 +220,8 @@ func mapRelays(relays []*proto.RelayState) RelayStateOutput { RelayStateOutputDetail{ URI: relay.URI, Available: available, - Error: relay.GetError(), + Error: relayErrorString(relay.GetError()), + Transport: relay.GetTransport(), }, ) @@ -235,6 +237,12 @@ func mapRelays(relays []*proto.RelayState) RelayStateOutput { } } +// relayErrorString flattens a newline-joined aggregated relay error onto a +// single line for status output. +func relayErrorString(s string) string { + return strings.ReplaceAll(s, "\n", "; ") +} + func mapNSGroups(servers []*proto.NSGroupState) []NsServerGroupStateOutput { mappedNSGroups := make([]NsServerGroupStateOutput, 0, len(servers)) for _, pbNsGroupServer := range servers { @@ -441,6 +449,8 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS available = "Unavailable" reason = fmt.Sprintf(", reason: %s", relay.Error) } + } else if relay.Transport != "" { + available = fmt.Sprintf("%s via %s", available, relay.Transport) } relaysString += fmt.Sprintf("\n [%s] is %s%s", relay.URI, available, reason) diff --git a/client/status/status_test.go b/client/status/status_test.go index 1ae7157c0..44fc30baf 100644 --- a/client/status/status_test.go +++ b/client/status/status_test.go @@ -647,3 +647,13 @@ func TestTimeAgo(t *testing.T) { }) } } + +func TestMapRelaysTransport(t *testing.T) { + out := mapRelays([]*proto.RelayState{ + {URI: "rels://relay.example:443", Available: true, Transport: "quic"}, + {URI: "rels://relay2.example:443", Available: true, Transport: "ws"}, + }) + require.Len(t, out.Details, 2) + assert.Equal(t, "quic", out.Details[0].Transport) + assert.Equal(t, "ws", out.Details[1].Transport) +} diff --git a/relay/metrics/realy.go b/relay/metrics/realy.go index efb597ff5..49a357557 100644 --- a/relay/metrics/realy.go +++ b/relay/metrics/realy.go @@ -6,6 +6,7 @@ import ( "time" log "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" ) @@ -119,8 +120,8 @@ func NewMetrics(ctx context.Context, meter metric.Meter) (*Metrics, error) { } // PeerConnected increments the number of connected peers and increments number of idle connections -func (m *Metrics) PeerConnected(id string) { - m.peers.Add(m.ctx, 1) +func (m *Metrics) PeerConnected(id, transport string) { + m.peers.Add(m.ctx, 1, metric.WithAttributes(attribute.String("transport", transport))) m.mutexActivity.Lock() defer m.mutexActivity.Unlock() @@ -138,8 +139,8 @@ func (m *Metrics) RecordPeerStoreTime(duration time.Duration) { } // PeerDisconnected decrements the number of connected peers and decrements number of idle or active connections -func (m *Metrics) PeerDisconnected(id string) { - m.peers.Add(m.ctx, -1) +func (m *Metrics) PeerDisconnected(id, transport string) { + m.peers.Add(m.ctx, -1, metric.WithAttributes(attribute.String("transport", transport))) m.mutexActivity.Lock() defer m.mutexActivity.Unlock() diff --git a/relay/server/listener/conn.go b/relay/server/listener/conn.go index ef0869594..d86f7f58b 100644 --- a/relay/server/listener/conn.go +++ b/relay/server/listener/conn.go @@ -11,4 +11,6 @@ type Conn interface { Write(ctx context.Context, b []byte) (n int, err error) RemoteAddr() net.Addr Close() error + // Protocol returns the transport name. + Protocol() string } diff --git a/relay/server/listener/quic/conn.go b/relay/server/listener/quic/conn.go index d8dafcd1f..da5e12d36 100644 --- a/relay/server/listener/quic/conn.go +++ b/relay/server/listener/quic/conn.go @@ -42,6 +42,11 @@ func (c *Conn) RemoteAddr() net.Addr { return c.session.RemoteAddr() } +// Protocol returns the transport name for this connection. +func (c *Conn) Protocol() string { + return "quic" +} + func (c *Conn) Close() error { c.closedMu.Lock() if c.closed { diff --git a/relay/server/listener/ws/conn.go b/relay/server/listener/ws/conn.go index c22b5719d..b1b64fe8e 100644 --- a/relay/server/listener/ws/conn.go +++ b/relay/server/listener/ws/conn.go @@ -64,6 +64,11 @@ func (c *Conn) RemoteAddr() net.Addr { return c.rAddr } +// Protocol returns the transport name for this connection. +func (c *Conn) Protocol() string { + return "ws" +} + func (c *Conn) Close() error { c.closedMu.Lock() c.closed = true diff --git a/relay/server/relay.go b/relay/server/relay.go index 56add8bea..84c424b8e 100644 --- a/relay/server/relay.go +++ b/relay/server/relay.go @@ -154,15 +154,16 @@ func (r *Relay) Accept(conn listener.Conn) { } r.notifier.PeerCameOnline(peer.ID()) + transport := conn.Protocol() r.metrics.RecordPeerStoreTime(time.Since(storeTime)) - r.metrics.PeerConnected(peer.String()) + r.metrics.PeerConnected(peer.String(), transport) go func() { peer.Work() if deleted := r.store.DeletePeer(peer); deleted { r.notifier.PeerWentOffline(peer.ID()) } peer.log.Debugf("relay connection closed") - r.metrics.PeerDisconnected(peer.String()) + r.metrics.PeerDisconnected(peer.String(), transport) }() if err := h.handshakeResponse(hsCtx); err != nil { diff --git a/shared/relay/client/client.go b/shared/relay/client/client.go index 002b8d134..8d4aa6020 100644 --- a/shared/relay/client/client.go +++ b/shared/relay/client/client.go @@ -145,6 +145,11 @@ func (cc *connContainer) close() { } } +// transportConn is implemented by relay connections that know their transport. +type transportConn interface { + Protocol() string +} + // Client is a client for the relay server. It is responsible for establishing a connection to the relay server and // managing connections to other peers. All exported functions are safe to call concurrently. After close the connection, // the client can be reused by calling Connect again. When the client is closed, all connections are closed too. @@ -182,6 +187,18 @@ type Client struct { // datagramFallbackTriggered guards a single fallback per connection so a // burst of oversized datagrams triggers one reconnect, not many. datagramFallbackTriggered atomic.Bool + + // transport is the negotiated relay transport of the + // current connection, guarded by mu. + transport string +} + +// Transport returns the negotiated relay transport of the current connection, +// or an empty string when not connected. +func (c *Client) Transport() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.transport } // SetTransportFallback wires the shared datagram-transport fallback tracker. @@ -402,6 +419,9 @@ func (c *Client) connect(ctx context.Context) (*RelayAddr, error) { } c.relayConn = conn c.datagramFallbackTriggered.Store(false) + if tc, ok := conn.(transportConn); ok { + c.transport = tc.Protocol() + } instanceURL, err := c.handShake(ctx) if err != nil { @@ -792,6 +812,7 @@ func (c *Client) close(gracefullyExit bool) error { return nil } c.serviceIsRunning = false + c.transport = "" c.muInstanceURL.Lock() c.instanceURL = nil diff --git a/shared/relay/client/dialer/quic/conn.go b/shared/relay/client/dialer/quic/conn.go index a5c982551..e5ad77b29 100644 --- a/shared/relay/client/dialer/quic/conn.go +++ b/shared/relay/client/dialer/quic/conn.go @@ -57,6 +57,11 @@ func (c *Conn) Write(b []byte) (int, error) { return len(b), nil } +// Protocol returns the transport name for this connection. +func (c *Conn) Protocol() string { + return Network +} + func (c *Conn) RemoteAddr() net.Addr { return c.session.RemoteAddr() } diff --git a/shared/relay/client/dialer/quic/quic.go b/shared/relay/client/dialer/quic/quic.go index 5e1758a1c..2e8de8af3 100644 --- a/shared/relay/client/dialer/quic/quic.go +++ b/shared/relay/client/dialer/quic/quic.go @@ -59,14 +59,12 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn, udpConn, err := nbnet.ListenUDP("udp", &net.UDPAddr{Port: 0}) if err != nil { - log.Errorf("failed to listen on UDP: %s", err) - return nil, err + return nil, fmt.Errorf("listen udp: %w", err) } udpAddr, err := net.ResolveUDPAddr("udp", quicURL) if err != nil { - log.Errorf("failed to resolve UDP address: %s", err) - return nil, err + return nil, fmt.Errorf("resolve %s: %w", quicURL, err) } session, err := quic.Dial(ctx, udpConn, udpAddr, tlsClientConfig, quicConfig) @@ -74,7 +72,7 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn, if errors.Is(err, context.Canceled) { return nil, err } - log.Errorf("failed to dial to Relay server via QUIC '%s': %s", quicURL, err) + log.Debugf("failed to dial to Relay server via QUIC '%s': %s", quicURL, err) return nil, err } diff --git a/shared/relay/client/dialer/race_dialer.go b/shared/relay/client/dialer/race_dialer.go index aef1ef464..d183802d0 100644 --- a/shared/relay/client/dialer/race_dialer.go +++ b/shared/relay/client/dialer/race_dialer.go @@ -3,6 +3,7 @@ package dialer import ( "context" "errors" + "fmt" "net" "time" @@ -71,6 +72,7 @@ func (r *RaceDial) Dial(ctx context.Context) (net.Conn, error) { connChan := make(chan dialResult, len(r.dialerFns)) winnerConn := make(chan net.Conn, 1) + errChan := make(chan error, 1) abortCtx, abort := context.WithCancel(ctx) defer abort() @@ -78,11 +80,11 @@ func (r *RaceDial) Dial(ctx context.Context) (net.Conn, error) { go r.dial(dfn, abortCtx, connChan) } - go r.processResults(connChan, winnerConn, abort) + go r.processResults(connChan, winnerConn, errChan, abort) conn, ok := <-winnerConn if !ok { - return nil, errors.New("failed to dial to Relay server on any protocol") + return nil, <-errChan } return conn, nil } @@ -90,6 +92,7 @@ func (r *RaceDial) Dial(ctx context.Context) (net.Conn, error) { // dialSequential tries each dialer in order, returning the first connection and // falling back to the next on failure. func (r *RaceDial) dialSequential(ctx context.Context) (net.Conn, error) { + var errs []error for _, dfn := range r.dialerFns { if err := ctx.Err(); err != nil { return nil, err @@ -103,12 +106,13 @@ func (r *RaceDial) dialSequential(ctx context.Context) (net.Conn, error) { return nil, err } r.log.Errorf("failed to dial via %s: %s", dfn.Protocol(), err) + errs = append(errs, fmt.Errorf("%s: %w", dfn.Protocol(), err)) continue } r.log.Infof("successfully dialed via: %s", dfn.Protocol()) return conn, nil } - return nil, errors.New("failed to dial to Relay server on any protocol") + return nil, dialErr(errs) } func (r *RaceDial) dial(dfn DialeFn, abortCtx context.Context, connChan chan dialResult) { @@ -120,8 +124,9 @@ func (r *RaceDial) dial(dfn DialeFn, abortCtx context.Context, connChan chan dia connChan <- dialResult{Conn: conn, Protocol: dfn.Protocol(), Err: err} } -func (r *RaceDial) processResults(connChan chan dialResult, winnerConn chan net.Conn, abort context.CancelFunc) { +func (r *RaceDial) processResults(connChan chan dialResult, winnerConn chan net.Conn, errChan chan error, abort context.CancelFunc) { var hasWinner bool + errsByProtocol := make(map[string]error) for i := 0; i < len(r.dialerFns); i++ { dr := <-connChan if dr.Err != nil { @@ -129,6 +134,7 @@ func (r *RaceDial) processResults(connChan chan dialResult, winnerConn chan net. r.log.Infof("connection attempt aborted via: %s", dr.Protocol) } else { r.log.Errorf("failed to dial via %s: %s", dr.Protocol, dr.Err) + errsByProtocol[dr.Protocol] = fmt.Errorf("%s: %w", dr.Protocol, dr.Err) } continue } @@ -146,5 +152,29 @@ func (r *RaceDial) processResults(connChan chan dialResult, winnerConn chan net. hasWinner = true winnerConn <- dr.Conn } + if !hasWinner { + errChan <- dialErr(r.orderedErrs(errsByProtocol)) + } close(winnerConn) } + +// orderedErrs returns the per-protocol errors in dialer order, so the combined +// error is stable regardless of which attempt failed first. +func (r *RaceDial) orderedErrs(byProtocol map[string]error) []error { + errs := make([]error, 0, len(byProtocol)) + for _, dfn := range r.dialerFns { + if err, ok := byProtocol[dfn.Protocol()]; ok { + errs = append(errs, err) + } + } + return errs +} + +// dialErr combines per-dialer failures, preserving the underlying reasons +// (e.g. "connection refused") rather than a generic message. +func dialErr(errs []error) error { + if len(errs) == 0 { + return errors.New("no relay transport available") + } + return errors.Join(errs...) +} diff --git a/shared/relay/client/dialer/ws/conn.go b/shared/relay/client/dialer/ws/conn.go index 9497fab89..eec417c50 100644 --- a/shared/relay/client/dialer/ws/conn.go +++ b/shared/relay/client/dialer/ws/conn.go @@ -33,6 +33,11 @@ func NewConn(wsConn *websocket.Conn, serverAddress string, underlying net.Conn) } } +// Protocol returns the transport name for this connection. +func (c *Conn) Protocol() string { + return Network +} + func (c *Conn) Read(b []byte) (n int, err error) { t, ioReader, err := c.Conn.Reader(c.ctx) if err != nil { diff --git a/shared/relay/client/dialer/ws/ws.go b/shared/relay/client/dialer/ws/ws.go index 8a13ba126..6b310b73d 100644 --- a/shared/relay/client/dialer/ws/ws.go +++ b/shared/relay/client/dialer/ws/ws.go @@ -22,7 +22,7 @@ type Dialer struct { } func (d Dialer) Protocol() string { - return "WS" + return Network } func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn, error) { @@ -39,7 +39,12 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn, if errors.Is(err, context.Canceled) { return nil, err } - log.Errorf("failed to dial to Relay server '%s': %s", wsURL, err) + // websocket.Dial wraps the cause in verbose layers; surface the + // underlying network error when present. + var opErr *net.OpError + if errors.As(err, &opErr) { + return nil, opErr + } return nil, err } if resp.Body != nil { diff --git a/shared/relay/client/dialers_generic_test.go b/shared/relay/client/dialers_generic_test.go index c4ef9cc59..f6c885108 100644 --- a/shared/relay/client/dialers_generic_test.go +++ b/shared/relay/client/dialers_generic_test.go @@ -41,14 +41,14 @@ func TestGetDialers(t *testing.T) { preferWS bool want []string }{ - {name: "auto races quic and ws", mode: "auto", mtu: iface.DefaultMTU, want: []string{"quic", "WS"}}, - {name: "ws pinned", mode: "ws", mtu: iface.DefaultMTU, want: []string{"WS"}}, + {name: "auto races quic and ws", mode: "auto", mtu: iface.DefaultMTU, want: []string{"quic", "ws"}}, + {name: "ws pinned", mode: "ws", mtu: iface.DefaultMTU, want: []string{"ws"}}, {name: "quic pinned", mode: "quic", mtu: iface.DefaultMTU, want: []string{"quic"}}, - {name: "prefer-quic orders quic first", mode: "prefer-quic", mtu: iface.DefaultMTU, want: []string{"quic", "WS"}}, - {name: "prefer-ws orders ws first", mode: "prefer-ws", mtu: iface.DefaultMTU, want: []string{"WS", "quic"}}, - {name: "mtu above default forces ws", mode: "auto", mtu: iface.DefaultMTU + 100, want: []string{"WS"}}, - {name: "sticky fallback forces ws in auto", mode: "auto", mtu: iface.DefaultMTU, preferWS: true, want: []string{"WS"}}, - {name: "sticky fallback forces ws in prefer-quic", mode: "prefer-quic", mtu: iface.DefaultMTU, preferWS: true, want: []string{"WS"}}, + {name: "prefer-quic orders quic first", mode: "prefer-quic", mtu: iface.DefaultMTU, want: []string{"quic", "ws"}}, + {name: "prefer-ws orders ws first", mode: "prefer-ws", mtu: iface.DefaultMTU, want: []string{"ws", "quic"}}, + {name: "mtu above default forces ws", mode: "auto", mtu: iface.DefaultMTU + 100, want: []string{"ws"}}, + {name: "sticky fallback forces ws in auto", mode: "auto", mtu: iface.DefaultMTU, preferWS: true, want: []string{"ws"}}, + {name: "sticky fallback forces ws in prefer-quic", mode: "prefer-quic", mtu: iface.DefaultMTU, preferWS: true, want: []string{"ws"}}, {name: "quic pin overrides sticky fallback", mode: "quic", mtu: iface.DefaultMTU, preferWS: true, want: []string{"quic"}}, } @@ -91,11 +91,11 @@ func TestStickyFallbackAfterDatagramTooLarge(t *testing.T) { } // First dial races both transports. - assert.Equal(t, []string{"quic", "WS"}, protocols(c.getDialers(transportModeFromEnv()))) + assert.Equal(t, []string{"quic", "ws"}, protocols(c.getDialers(transportModeFromEnv()))) // An oversized datagram records the fallback for this server. c.onDatagramTooLarge(&closeTrackingConn{}, netErr.ErrDatagramTooLarge) // The reconnect now sticks to WebSocket. - assert.Equal(t, []string{"WS"}, protocols(c.getDialers(transportModeFromEnv()))) + assert.Equal(t, []string{"ws"}, protocols(c.getDialers(transportModeFromEnv()))) } diff --git a/shared/relay/client/guard.go b/shared/relay/client/guard.go index d7892d0ce..98b1b333e 100644 --- a/shared/relay/client/guard.go +++ b/shared/relay/client/guard.go @@ -2,6 +2,7 @@ package client import ( "context" + "sync/atomic" "time" "github.com/cenkalti/backoff/v4" @@ -20,6 +21,10 @@ type Guard struct { // maxBackoffInterval caps the exponential backoff between reconnect // attempts. maxBackoffInterval time.Duration + + // lastErr is the error from the most recent failed reconnect attempt, + // surfaced as the home relay status while disconnected. + lastErr atomic.Pointer[error] } // NewGuard creates a new guard for the relay client. A non-positive @@ -37,6 +42,15 @@ func NewGuard(sp *ServerPicker, maxBackoffInterval time.Duration) *Guard { return g } +// LastError returns the error from the most recent failed reconnect attempt, or +// nil if reconnection last succeeded. +func (g *Guard) LastError() error { + if p := g.lastErr.Load(); p != nil { + return *p + } + return nil +} + // StartReconnectTrys is called when the relay client is disconnected from the relay server. // It attempts to reconnect to the relay server. The function first tries a quick reconnect // to the same server that was used before, if the server URL is still valid. If the quick @@ -63,6 +77,7 @@ func (g *Guard) StartReconnectTrys(ctx context.Context, relayClient *Client) { case <-ticker.C: if err := g.retry(ctx); err != nil { log.Errorf("failed to pick new Relay server: %s", err) + g.setLastError(err) continue } return @@ -72,6 +87,10 @@ func (g *Guard) StartReconnectTrys(ctx context.Context, relayClient *Client) { } } +func (g *Guard) setLastError(err error) { + g.lastErr.Store(&err) +} + func (g *Guard) tryToQuickReconnect(parentCtx context.Context, rc *Client) bool { if rc == nil { return false @@ -89,6 +108,7 @@ func (g *Guard) tryToQuickReconnect(parentCtx context.Context, rc *Client) bool if err := rc.Connect(parentCtx); err != nil { log.Errorf("failed to reconnect to relay server: %s", err) + g.setLastError(err) return false } return true @@ -100,6 +120,7 @@ func (g *Guard) retry(ctx context.Context) error { if err != nil { return err } + g.setLastError(nil) // prevent to work with a deprecated Relay client instance g.drainRelayClientChan() @@ -125,6 +146,7 @@ func (g *Guard) isServerURLStillValid(rc *Client) bool { } func (g *Guard) notifyReconnected() { + g.setLastError(nil) select { case g.OnReconnected <- struct{}{}: default: diff --git a/shared/relay/client/manager.go b/shared/relay/client/manager.go index f87da15de..e1515401e 100644 --- a/shared/relay/client/manager.go +++ b/shared/relay/client/manager.go @@ -43,6 +43,17 @@ type OnServerCloseListener func() // ManagerOption configures a Manager at construction time. type ManagerOption func(*Manager) +// RelayConnState is the connection state of a single relay server. +type RelayConnState struct { + // URL is the server's instance address when connected, otherwise the + // configured server URL. + URL string + // Transport is the negotiated transport, empty if not connected. + Transport string + // Err is set when the relay is not connected. + Err error +} + // WithMaxBackoffInterval caps the exponential backoff between reconnect // attempts to the home relay. A non-positive value keeps the default. func WithMaxBackoffInterval(d time.Duration) ManagerOption { @@ -130,6 +141,9 @@ func (m *Manager) Serve() error { client, err := m.serverPicker.PickServer(m.ctx) if err != nil { + // record the initial failure so status shows the real reason before + // the guard's first retry tick + m.reconnectGuard.setLastError(err) go m.reconnectGuard.StartReconnectTrys(m.ctx, nil) } else { m.storeClient(client) @@ -242,6 +256,56 @@ func (m *Manager) ServerURLs() []string { return m.serverPicker.ServerURLs.Load().([]string) } +// RelayConnectError returns the error from the most recent failed home relay +// reconnect attempt, or nil if the relay last connected successfully. +func (m *Manager) RelayConnectError() error { + return m.reconnectGuard.LastError() +} + +// RelayStates returns the connection state of the home relay and every foreign +// relay the manager currently tracks. +func (m *Manager) RelayStates() []RelayConnState { + var states []RelayConnState + + m.relayClientMu.RLock() + home := m.relayClient + m.relayClientMu.RUnlock() + if home != nil { + st := relayConnState(home) + // The home relay reconnects through the guard, so the real failure + // reason lives there rather than on the (stale) client. + if st.Err != nil { + if gErr := m.reconnectGuard.LastError(); gErr != nil { + st.Err = gErr + } + } + states = append(states, st) + } + + // Snapshot the tracks, then query each outside the map lock: a track can be + // held by an in-progress Connect, and blocking on it must not stall other + // relay operations. + m.relayClientsMutex.RLock() + tracks := make([]*RelayTrack, 0, len(m.relayClients)) + for _, rt := range m.relayClients { + tracks = append(tracks, rt) + } + m.relayClientsMutex.RUnlock() + + // Only connected foreign relays carry state; a failed connect is evicted + // immediately (openConnVia), so there is no error state to surface. + for _, rt := range tracks { + rt.RLock() + rc := rt.relayClient + rt.RUnlock() + if rc != nil { + states = append(states, relayConnState(rc)) + } + } + + return states +} + // HasRelayAddress returns true if the manager is serving. With this method can check if the peer can communicate with // Relay service. func (m *Manager) HasRelayAddress() bool { @@ -460,3 +524,11 @@ func (m *Manager) notifyOnDisconnectListeners(serverAddress string) { } delete(m.onDisconnectedListeners, serverAddress) } + +func relayConnState(c *Client) RelayConnState { + addr, err := c.ServerInstanceURL() + if err != nil { + return RelayConnState{URL: c.connectionURL, Err: err} + } + return RelayConnState{URL: addr, Transport: c.Transport()} +} diff --git a/shared/relay/client/picker.go b/shared/relay/client/picker.go index 992e48114..bb721e4ad 100644 --- a/shared/relay/client/picker.go +++ b/shared/relay/client/picker.go @@ -40,6 +40,7 @@ func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) { connResultChan := make(chan connResult, totalServers) successChan := make(chan connResult, 1) + errChan := make(chan error, 1) concurrentLimiter := make(chan struct{}, maxConcurrentServers) log.Debugf("pick server from list: %v", sp.ServerURLs.Load().([]string)) @@ -54,17 +55,17 @@ func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) { }(url) } - go sp.processConnResults(connResultChan, successChan) + go sp.processConnResults(connResultChan, successChan, errChan) select { case cr, ok := <-successChan: if !ok { - return nil, errors.New("failed to connect to any relay server: all attempts failed") + return nil, <-errChan } log.Infof("chosen home Relay server: %s", cr.Url) return cr.RelayClient, nil case <-ctx.Done(): - return nil, fmt.Errorf("failed to connect to any relay server: %w", ctx.Err()) + return nil, fmt.Errorf("connect to relay server: %w", ctx.Err()) } } @@ -80,12 +81,14 @@ func (sp *ServerPicker) startConnection(ctx context.Context, resultChan chan con } } -func (sp *ServerPicker) processConnResults(resultChan chan connResult, successChan chan connResult) { +func (sp *ServerPicker) processConnResults(resultChan chan connResult, successChan chan connResult, errChan chan error) { var hasSuccess bool + var errs []error for numOfResults := 0; numOfResults < cap(resultChan); numOfResults++ { cr := <-resultChan if cr.Err != nil { log.Tracef("failed to connect to Relay server: %s: %v", cr.Url, cr.Err) + errs = append(errs, cr.Err) continue } log.Infof("connected to Relay server: %s", cr.Url) @@ -101,5 +104,16 @@ func (sp *ServerPicker) processConnResults(resultChan chan connResult, successCh hasSuccess = true successChan <- cr } + if !hasSuccess { + errChan <- pickErr(errs) + } close(successChan) } + +// pickErr combines per-server connection failures into a single error. +func pickErr(errs []error) error { + if len(errs) == 0 { + return errors.New("no relay server available") + } + return errors.Join(errs...) +} From e4397d4d4614295343e9935b51b9bd10e0b794f1 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:37:24 +0200 Subject: [PATCH 04/20] [management] remove nmap calc from login (#6449) --- .../network_map/controller/controller.go | 26 ++-- .../controllers/network_map/interface.go | 2 +- .../controllers/network_map/interface_mock.go | 19 ++- management/internals/modules/peers/manager.go | 2 +- management/internals/shared/grpc/server.go | 8 +- management/server/account/manager.go | 4 +- management/server/account/manager_mock.go | 18 ++- management/server/account_test.go | 28 ++-- management/server/affected_peers_test.go | 2 +- management/server/dns_test.go | 4 +- management/server/group_ipv6_test.go | 2 +- .../http/handlers/peers/peers_handler.go | 2 +- management/server/management_proto_test.go | 4 +- management/server/mock_server/account_mock.go | 12 +- management/server/nameserver_test.go | 4 +- management/server/peer.go | 138 ++++++++++-------- management/server/peer_test.go | 64 ++++---- management/server/types/account.go | 41 ++++++ .../networkmap_components_correctness_test.go | 94 ++++++++++++ management/server/user_test.go | 2 +- 20 files changed, 318 insertions(+), 158 deletions(-) diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index 9adf594cd..d271c499d 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -585,66 +585,66 @@ func (b *bufferAffectedUpdate) setTimer(d time.Duration, f func()) { b.next.Reset(d) } -func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peer *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) { +func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error) { if isRequiresApproval { network, err := c.repo.GetAccountNetwork(ctx, accountID) if err != nil { - return nil, nil, nil, 0, err + return nil, nil, 0, err } emptyMap := &types.NetworkMap{ Network: network.Copy(), } - return peer, emptyMap, nil, 0, nil + return emptyMap, nil, 0, nil } account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID) if err != nil { - return nil, nil, nil, 0, err + return nil, nil, 0, err } account.InjectProxyPolicies(ctx) approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra) if err != nil { - return nil, nil, nil, 0, err + return nil, nil, 0, err } startPosture := time.Now() - postureChecks, err := c.getPeerPostureChecks(account, peer.ID) + postureChecks, err := c.getPeerPostureChecks(account, peerID) if err != nil { - return nil, nil, nil, 0, err + return nil, nil, 0, err } log.WithContext(ctx).Debugf("getPeerPostureChecks took %s", time.Since(startPosture)) accountZones, err := c.repo.GetAccountZones(ctx, account.Id) if err != nil { log.WithContext(ctx).Errorf("failed to get account zones: %v", err) - return nil, nil, nil, 0, err + return nil, nil, 0, err } dnsDomain := c.GetDNSDomain(account.Settings) peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain) - proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peer.ID, account.Peers) + proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peerID, account.Peers) if err != nil { log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err) - return nil, nil, nil, 0, err + return nil, nil, 0, err } resourcePolicies := account.GetResourcePoliciesMap() routers := account.GetResourceRoutersMap() groupIDToUserIDs := account.GetActiveGroupUsers() - networkMap := account.GetPeerNetworkMapFromComponents(ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) + networkMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) - proxyNetworkMap, ok := proxyNetworkMaps[peer.ID] + proxyNetworkMap, ok := proxyNetworkMaps[peerID] if ok { networkMap.Merge(proxyNetworkMap) } dnsFwdPort := computeForwarderPort(maps.Values(account.Peers), network_map.DnsForwarderPortMinVersion) - return peer, networkMap, postureChecks, dnsFwdPort, nil + return networkMap, postureChecks, dnsFwdPort, nil } // GetDNSDomain returns the configured dnsDomain diff --git a/management/internals/controllers/network_map/interface.go b/management/internals/controllers/network_map/interface.go index dbdd87708..14b12aba6 100644 --- a/management/internals/controllers/network_map/interface.go +++ b/management/internals/controllers/network_map/interface.go @@ -23,7 +23,7 @@ type Controller interface { BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error UpdateAccountPeer(ctx context.Context, accountId string, peerId string) error BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error - GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, p *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) + GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error) GetDNSDomain(settings *types.Settings) string StartWarmup(context.Context) GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error) diff --git a/management/internals/controllers/network_map/interface_mock.go b/management/internals/controllers/network_map/interface_mock.go index a67156719..bfff32e6f 100644 --- a/management/internals/controllers/network_map/interface_mock.go +++ b/management/internals/controllers/network_map/interface_mock.go @@ -127,21 +127,20 @@ func (mr *MockControllerMockRecorder) GetNetworkMap(ctx, peerID any) *gomock.Cal } // GetValidatedPeerWithMap mocks base method. -func (m *MockController) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, p *peer.Peer) (*peer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) { +func (m *MockController) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetValidatedPeerWithMap", ctx, isRequiresApproval, accountID, p) - ret0, _ := ret[0].(*peer.Peer) - ret1, _ := ret[1].(*types.NetworkMap) - ret2, _ := ret[2].([]*posture.Checks) - ret3, _ := ret[3].(int64) - ret4, _ := ret[4].(error) - return ret0, ret1, ret2, ret3, ret4 + ret := m.ctrl.Call(m, "GetValidatedPeerWithMap", ctx, isRequiresApproval, accountID, peerID) + ret0, _ := ret[0].(*types.NetworkMap) + ret1, _ := ret[1].([]*posture.Checks) + ret2, _ := ret[2].(int64) + ret3, _ := ret[3].(error) + return ret0, ret1, ret2, ret3 } // GetValidatedPeerWithMap indicates an expected call of GetValidatedPeerWithMap. -func (mr *MockControllerMockRecorder) GetValidatedPeerWithMap(ctx, isRequiresApproval, accountID, p any) *gomock.Call { +func (mr *MockControllerMockRecorder) GetValidatedPeerWithMap(ctx, isRequiresApproval, accountID, peerID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetValidatedPeerWithMap", reflect.TypeOf((*MockController)(nil).GetValidatedPeerWithMap), ctx, isRequiresApproval, accountID, p) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetValidatedPeerWithMap", reflect.TypeOf((*MockController)(nil).GetValidatedPeerWithMap), ctx, isRequiresApproval, accountID, peerID) } // OnPeerConnected mocks base method. diff --git a/management/internals/modules/peers/manager.go b/management/internals/modules/peers/manager.go index 8f3253063..e22d1e6e0 100644 --- a/management/internals/modules/peers/manager.go +++ b/management/internals/modules/peers/manager.go @@ -242,7 +242,7 @@ func (m *managerImpl) CreateProxyPeer(ctx context.Context, accountID string, pee }, } - _, _, _, err = m.accountManager.AddPeer(ctx, accountID, "", "", peer, true) + _, _, _, _, err = m.accountManager.AddPeer(ctx, accountID, "", "", peer, true) if err != nil { return fmt.Errorf("failed to create proxy peer: %w", err) } diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index 2d19ca32b..7283cae6c 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -778,7 +778,7 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto sshKey = loginReq.GetPeerKeys().GetSshPubKey() } - peer, netMap, postureChecks, err := s.accountManager.LoginPeer(ctx, types.PeerLogin{ + peer, network, postureChecks, enableSSH, err := s.accountManager.LoginPeer(ctx, types.PeerLogin{ WireGuardPubKey: peerKey.String(), SSHKey: string(sshKey), Meta: peerMeta, @@ -792,7 +792,7 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto return nil, mapError(ctx, err) } - loginResp, err := s.prepareLoginResponse(ctx, peer, netMap, postureChecks) + loginResp, err := s.prepareLoginResponse(ctx, peer, network, postureChecks, enableSSH) if err != nil { log.WithContext(ctx).Warnf("failed preparing login response for peer %s: %s", peerKey, err) return nil, status.Errorf(codes.Internal, "failed logging in peer") @@ -895,7 +895,7 @@ func (s *Server) ExtendAuthSession(ctx context.Context, req *proto.EncryptedMess }, nil } -func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, netMap *types.NetworkMap, postureChecks []*posture.Checks) (*proto.LoginResponse, error) { +func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, network *types.Network, postureChecks []*posture.Checks, enableSSH bool) (*proto.LoginResponse, error) { var relayToken *Token var err error if s.config.Relay != nil && len(s.config.Relay.Addresses) > 0 { @@ -914,7 +914,7 @@ func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, ne // if peer has reached this point then it has logged in loginResp := &proto.LoginResponse{ NetbirdConfig: toNetbirdConfig(s.config, nil, relayToken, nil), - PeerConfig: toPeerConfig(peer, netMap.Network, s.networkMapController.GetDNSDomain(settings), settings, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, netMap.EnableSSH), + PeerConfig: toPeerConfig(peer, network, s.networkMapController.GetDNSDomain(settings), settings, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, enableSSH), Checks: toProtocolChecks(ctx, postureChecks), } diff --git a/management/server/account/manager.go b/management/server/account/manager.go index 2fdfdba5a..784e432f6 100644 --- a/management/server/account/manager.go +++ b/management/server/account/manager.go @@ -70,7 +70,7 @@ type Manager interface { UpdatePeerIPv6(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error) GetPeerNetwork(ctx context.Context, peerID string) (*types.Network, error) - AddPeer(ctx context.Context, accountID, setupKey, userID string, p *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) + AddPeer(ctx context.Context, accountID, setupKey, userID string, p *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) CreatePAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) DeletePAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) error GetPAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) (*types.PersonalAccessToken, error) @@ -109,7 +109,7 @@ type Manager interface { GetPeer(ctx context.Context, accountID, peerID, userID string) (*nbpeer.Peer, error) UpdateAccountSettings(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error) UpdateAccountOnboarding(ctx context.Context, accountID, userID string, newOnboarding *types.AccountOnboarding) (*types.AccountOnboarding, error) - LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) // used by peer gRPC API + LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) // used by peer gRPC API ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error) // used by peer gRPC API for ExtendAuthSession SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) // used by peer gRPC API GetExternalCacheManager() ExternalCacheManager diff --git a/management/server/account/manager_mock.go b/management/server/account/manager_mock.go index 0e06ebf91..145e6e00f 100644 --- a/management/server/account/manager_mock.go +++ b/management/server/account/manager_mock.go @@ -80,14 +80,15 @@ func (mr *MockManagerMockRecorder) AccountExists(ctx, accountID interface{}) *go } // AddPeer mocks base method. -func (m *MockManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, p *peer.Peer, temporary bool) (*peer.Peer, *types.NetworkMap, []*posture.Checks, error) { +func (m *MockManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, p *peer.Peer, temporary bool) (*peer.Peer, *types.Network, []*posture.Checks, bool, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "AddPeer", ctx, accountID, setupKey, userID, p, temporary) ret0, _ := ret[0].(*peer.Peer) - ret1, _ := ret[1].(*types.NetworkMap) + ret1, _ := ret[1].(*types.Network) ret2, _ := ret[2].([]*posture.Checks) - ret3, _ := ret[3].(error) - return ret0, ret1, ret2, ret3 + ret3, _ := ret[3].(bool) + ret4, _ := ret[4].(error) + return ret0, ret1, ret2, ret3, ret4 } // AddPeer indicates an expected call of AddPeer. @@ -1289,14 +1290,15 @@ func (mr *MockManagerMockRecorder) ListUsers(ctx, accountID interface{}) *gomock } // LoginPeer mocks base method. -func (m *MockManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*peer.Peer, *types.NetworkMap, []*posture.Checks, error) { +func (m *MockManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*peer.Peer, *types.Network, []*posture.Checks, bool, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "LoginPeer", ctx, login) ret0, _ := ret[0].(*peer.Peer) - ret1, _ := ret[1].(*types.NetworkMap) + ret1, _ := ret[1].(*types.Network) ret2, _ := ret[2].([]*posture.Checks) - ret3, _ := ret[3].(error) - return ret0, ret1, ret2, ret3 + ret3, _ := ret[3].(bool) + ret4, _ := ret[4].(error) + return ret0, ret1, ret2, ret3, ret4 } // LoginPeer indicates an expected call of LoginPeer. diff --git a/management/server/account_test.go b/management/server/account_test.go index 51f079a57..256b71f18 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -84,7 +84,7 @@ func verifyCanAddPeerToAccount(t *testing.T, manager nbAccount.Manager, account setupKey = key.Key } - _, _, _, err := manager.AddPeer(context.Background(), "", setupKey, userID, peer, false) + _, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey, userID, peer, false) if err != nil { t.Error("expected to add new peer successfully after creating new account, but failed", err) } @@ -1092,7 +1092,7 @@ func TestAccountManager_AddPeer(t *testing.T) { } expectedPeerKey := key.PublicKey().String() - peer, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + peer, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: expectedPeerKey, Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, }, false) @@ -1156,7 +1156,7 @@ func TestAccountManager_AddPeerWithUserID(t *testing.T) { expectedPeerKey := key.PublicKey().String() expectedUserID := userID - peer, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + peer, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: expectedPeerKey, Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, }, false) @@ -1504,7 +1504,7 @@ func TestAccountManager_DeletePeer(t *testing.T) { peerKey := key.PublicKey().String() - peer, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + peer, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: peerKey, Meta: nbpeer.PeerSystemMeta{Hostname: peerKey}, }, false) @@ -1826,7 +1826,7 @@ func TestDefaultAccountManager_UpdatePeer_PeerLoginExpiration(t *testing.T) { key, err := wgtypes.GenerateKey() require.NoError(t, err, "unable to generate WireGuard key") - peer, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + peer, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: key.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, LoginExpirationEnabled: true, @@ -1882,7 +1882,7 @@ func TestDefaultAccountManager_MarkPeerConnected_PeerLoginExpiration(t *testing. key, err := wgtypes.GenerateKey() require.NoError(t, err, "unable to generate WireGuard key") - _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: key.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, LoginExpirationEnabled: true, @@ -1927,7 +1927,7 @@ func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) { require.NoError(t, err, "unable to generate WireGuard key") peerPubKey := key.PublicKey().String() - _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: peerPubKey, Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, }, false) @@ -2017,7 +2017,7 @@ func TestDefaultAccountManager_MarkPeerConnected_ConcurrentRace(t *testing.T) { require.NoError(t, err, "unable to generate WireGuard key") peerPubKey := key.PublicKey().String() - _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: peerPubKey, Meta: nbpeer.PeerSystemMeta{Hostname: "race-peer"}, }, false) @@ -2080,7 +2080,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_PeerLoginExpiration(t *test key, err := wgtypes.GenerateKey() require.NoError(t, err, "unable to generate WireGuard key") - _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: key.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, LoginExpirationEnabled: true, @@ -3276,7 +3276,7 @@ func setupNetworkMapTest(t *testing.T) (*DefaultAccountManager, *update_channel. } expectedPeerKey := key.PublicKey().String() - peer, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + peer, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: expectedPeerKey, Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, Status: &nbpeer.PeerStatus{ @@ -3444,7 +3444,7 @@ func BenchmarkLoginPeer_ExistingPeer(b *testing.B) { b.ResetTimer() start := time.Now() for i := 0; i < b.N; i++ { - _, _, _, err := manager.LoginPeer(context.Background(), types.PeerLogin{ + _, _, _, _, err := manager.LoginPeer(context.Background(), types.PeerLogin{ WireGuardPubKey: account.Peers["peer-1"].Key, SSHKey: "someKey", Meta: nbpeer.PeerSystemMeta{Hostname: strconv.Itoa(i)}, @@ -3513,7 +3513,7 @@ func BenchmarkLoginPeer_NewPeer(b *testing.B) { b.ResetTimer() start := time.Now() for i := 0; i < b.N; i++ { - _, _, _, err := manager.LoginPeer(context.Background(), types.PeerLogin{ + _, _, _, _, err := manager.LoginPeer(context.Background(), types.PeerLogin{ WireGuardPubKey: "some-new-key" + strconv.Itoa(i), SSHKey: "someKey", Meta: nbpeer.PeerSystemMeta{Hostname: strconv.Itoa(i)}, @@ -3908,13 +3908,13 @@ func TestDefaultAccountManager_UpdatePeerIP(t *testing.T) { key2, err := wgtypes.GenerateKey() require.NoError(t, err, "unable to generate WireGuard key") - peer1, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + peer1, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: key1.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-1"}, }, false) require.NoError(t, err, "unable to add peer1") - peer2, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + peer2, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: key2.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"}, }, false) diff --git a/management/server/affected_peers_test.go b/management/server/affected_peers_test.go index b66eeb3b5..e2dcd830b 100644 --- a/management/server/affected_peers_test.go +++ b/management/server/affected_peers_test.go @@ -1663,7 +1663,7 @@ func addPeerToAccount(t *testing.T, manager *DefaultAccountManager, _, setupKeyK key, err := wgtypes.GeneratePrivateKey() require.NoError(t, err) - peer, _, _, err := manager.AddPeer(context.Background(), "", setupKeyKey, "", &nbpeer.Peer{ + peer, _, _, _, err := manager.AddPeer(context.Background(), "", setupKeyKey, "", &nbpeer.Peer{ Key: key.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: key.PublicKey().String()}, }, false) diff --git a/management/server/dns_test.go b/management/server/dns_test.go index c443223c6..8917902d9 100644 --- a/management/server/dns_test.go +++ b/management/server/dns_test.go @@ -298,11 +298,11 @@ func initTestDNSAccount(t *testing.T, am *DefaultAccountManager) (*types.Account return nil, err } - savedPeer1, _, _, err := am.AddPeer(context.Background(), "", "", dnsAdminUserID, peer1, false) + savedPeer1, _, _, _, err := am.AddPeer(context.Background(), "", "", dnsAdminUserID, peer1, false) if err != nil { return nil, err } - _, _, _, err = am.AddPeer(context.Background(), "", "", dnsAdminUserID, peer2, false) + _, _, _, _, err = am.AddPeer(context.Background(), "", "", dnsAdminUserID, peer2, false) if err != nil { return nil, err } diff --git a/management/server/group_ipv6_test.go b/management/server/group_ipv6_test.go index e4603c879..dfb436060 100644 --- a/management/server/group_ipv6_test.go +++ b/management/server/group_ipv6_test.go @@ -55,7 +55,7 @@ func TestGroupIPv6Assignment(t *testing.T) { key, err := wgtypes.GeneratePrivateKey() require.NoError(t, err) - peer, _, _, err := am.AddPeer(ctx, "", setupKey.Key, "", &nbpeer.Peer{ + peer, _, _, _, err := am.AddPeer(ctx, "", setupKey.Key, "", &nbpeer.Peer{ Key: key.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "ipv6-test-host"}, }, false) diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go index 1d4af95e9..310f90653 100644 --- a/management/server/http/handlers/peers/peers_handler.go +++ b/management/server/http/handlers/peers/peers_handler.go @@ -479,7 +479,7 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) return } - peer, _, _, err := h.accountManager.AddPeer(r.Context(), userAuth.AccountId, "", userAuth.UserId, newPeer, true) + peer, _, _, _, err := h.accountManager.AddPeer(r.Context(), userAuth.AccountId, "", userAuth.UserId, newPeer, true) if err != nil { util.WriteError(r.Context(), err, w) return diff --git a/management/server/management_proto_test.go b/management/server/management_proto_test.go index 1b77ea335..45d4ab8c9 100644 --- a/management/server/management_proto_test.go +++ b/management/server/management_proto_test.go @@ -728,7 +728,7 @@ func Test_LoginPerformance(t *testing.T) { } login := func() error { - _, _, _, err = am.LoginPeer(context.Background(), peerLogin) + _, _, _, _, err = am.LoginPeer(context.Background(), peerLogin) if err != nil { t.Logf("failed to login peer: %v", err) return err @@ -746,7 +746,7 @@ func Test_LoginPerformance(t *testing.T) { go func(peerLogin types.PeerLogin, counterStart *int32) { defer wgPeer.Done() - _, _, _, err = am.LoginPeer(context.Background(), peerLogin) + _, _, _, _, err = am.LoginPeer(context.Background(), peerLogin) if err != nil { t.Logf("failed to login peer: %v", err) return diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go index 15eb9b190..f81139f24 100644 --- a/management/server/mock_server/account_mock.go +++ b/management/server/mock_server/account_mock.go @@ -45,7 +45,7 @@ type MockAccountManager struct { DeletePeerFunc func(ctx context.Context, accountID, peerKey, userID string) error GetNetworkMapFunc func(ctx context.Context, peerKey string) (*types.NetworkMap, error) GetPeerNetworkFunc func(ctx context.Context, peerKey string) (*types.Network, error) - AddPeerFunc func(ctx context.Context, accountID string, setupKey string, userId string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) + AddPeerFunc func(ctx context.Context, accountID string, setupKey string, userId string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) GetGroupFunc func(ctx context.Context, accountID, groupID, userID string) (*types.Group, error) GetAllGroupsFunc func(ctx context.Context, accountID, userID string) ([]*types.Group, error) GetGroupByNameFunc func(ctx context.Context, groupName, accountID, userID string) (*types.Group, error) @@ -98,7 +98,7 @@ type MockAccountManager struct { SaveDNSSettingsFunc func(ctx context.Context, accountID, userID string, dnsSettingsToSave *types.DNSSettings) error GetPeerFunc func(ctx context.Context, accountID, peerID, userID string) (*nbpeer.Peer, error) UpdateAccountSettingsFunc func(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error) - LoginPeerFunc func(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) + LoginPeerFunc func(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) ExtendPeerSessionFunc func(ctx context.Context, peerPubKey, userID string) (time.Time, error) SyncPeerFunc func(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) InviteUserFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserEmail string) error @@ -424,11 +424,11 @@ func (am *MockAccountManager) AddPeer( userId string, peer *nbpeer.Peer, temporary bool, -) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) { +) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) { if am.AddPeerFunc != nil { return am.AddPeerFunc(ctx, accountID, setupKey, userId, peer, temporary) } - return nil, nil, nil, status.Errorf(codes.Unimplemented, "method AddPeer is not implemented") + return nil, nil, nil, false, status.Errorf(codes.Unimplemented, "method AddPeer is not implemented") } // GetGroupByName mock implementation of GetGroupByName from server.AccountManager interface @@ -862,11 +862,11 @@ func (am *MockAccountManager) UpdateAccountSettings(ctx context.Context, account } // LoginPeer mocks LoginPeer of the AccountManager interface -func (am *MockAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) { +func (am *MockAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) { if am.LoginPeerFunc != nil { return am.LoginPeerFunc(ctx, login) } - return nil, nil, nil, status.Errorf(codes.Unimplemented, "method LoginPeer is not implemented") + return nil, nil, nil, false, status.Errorf(codes.Unimplemented, "method LoginPeer is not implemented") } // ExtendPeerSession mocks ExtendPeerSession of the AccountManager interface diff --git a/management/server/nameserver_test.go b/management/server/nameserver_test.go index b2c8300d6..e13b0bb19 100644 --- a/management/server/nameserver_test.go +++ b/management/server/nameserver_test.go @@ -896,11 +896,11 @@ func initTestNSAccount(t *testing.T, am *DefaultAccountManager) (*types.Account, return nil, err } - _, _, _, err = am.AddPeer(context.Background(), "", "", userID, peer1, false) + _, _, _, _, err = am.AddPeer(context.Background(), "", "", userID, peer1, false) if err != nil { return nil, err } - _, _, _, err = am.AddPeer(context.Background(), "", "", userID, peer2, false) + _, _, _, _, err = am.AddPeer(context.Background(), "", "", userID, peer2, false) if err != nil { return nil, err } diff --git a/management/server/peer.go b/management/server/peer.go index baf62a7eb..9d78f597b 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -718,10 +718,10 @@ func (am *DefaultAccountManager) handleSetupKeyAddedPeer(ctx context.Context, en // to it. We also add the User ID to the peer metadata to identify registrant. If no userID provided, then fail with status.PermissionDenied // Each new Peer will be assigned a new next net.IP from the Account.Network and Account.Network.LastIP will be updated (IP's are not reused). // The peer property is just a placeholder for the Peer properties to pass further -func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) { +func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) { if setupKey == "" && userID == "" && !peer.ProxyMeta.Embedded { // no auth method provided => reject access - return nil, nil, nil, status.Errorf(status.Unauthenticated, "no peer auth method provided, please use a setup key or interactive SSO login") + return nil, nil, nil, false, status.Errorf(status.Unauthenticated, "no peer auth method provided, please use a setup key or interactive SSO login") } upperKey := strings.ToUpper(setupKey) @@ -737,7 +737,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe // The connecting peer should be able to recover with a retry. _, err := am.Store.GetPeerByPeerPubKey(ctx, store.LockingStrengthNone, peer.Key) if err == nil { - return nil, nil, nil, status.Errorf(status.PreconditionFailed, "peer has been already registered") + return nil, nil, nil, false, status.Errorf(status.PreconditionFailed, "peer has been already registered") } opEvent := &activity.Event{ @@ -748,7 +748,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe peerAddConfig, err := am.processPeerAddAuth(ctx, accountID, userID, encodedHashedKey, peer, temporary, addedByUser, addedBySetupKey, opEvent) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, false, err } accountID = peerAddConfig.AccountID ephemeral := peerAddConfig.Ephemeral @@ -763,7 +763,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe } if err := domain.ValidateDomainsList(peer.ExtraDNSLabels); err != nil { - return nil, nil, nil, status.Errorf(status.InvalidArgument, "invalid extra DNS labels: %v", err) + return nil, nil, nil, false, status.Errorf(status.InvalidArgument, "invalid extra DNS labels: %v", err) } registrationTime := time.Now().UTC() @@ -789,7 +789,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe } settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to get account settings: %w", err) + return nil, nil, nil, false, fmt.Errorf("failed to get account settings: %w", err) } if am.geo != nil && newPeer.Location.ConnectionIP != nil { @@ -807,30 +807,30 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe network, err := am.Store.GetAccountNetwork(ctx, store.LockingStrengthNone, accountID) if err != nil { - return nil, nil, nil, fmt.Errorf("failed getting network: %w", err) + return nil, nil, nil, false, fmt.Errorf("failed getting network: %w", err) } maxAttempts := 10 for attempt := 1; attempt <= maxAttempts; attempt++ { netPrefix, err := netip.ParsePrefix(network.Net.String()) if err != nil { - return nil, nil, nil, fmt.Errorf("parse network prefix: %w", err) + return nil, nil, nil, false, fmt.Errorf("parse network prefix: %w", err) } freeIP, err := types.AllocateRandomPeerIP(netPrefix) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to get free IP: %w", err) + return nil, nil, nil, false, fmt.Errorf("failed to get free IP: %w", err) } var freeLabel string if ephemeral || attempt > 1 { freeLabel, err = getPeerIPDNSLabel(freeIP, peer.Meta.Hostname) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to get free DNS label: %w", err) + return nil, nil, nil, false, fmt.Errorf("failed to get free DNS label: %w", err) } } else { freeLabel, err = nbdns.GetParsedDomainLabel(peer.Meta.Hostname) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to get free DNS label: %w", err) + return nil, nil, nil, false, fmt.Errorf("failed to get free DNS label: %w", err) } } newPeer.DNSLabel = freeLabel @@ -852,11 +852,11 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe if allocate { v6Prefix, err := netip.ParsePrefix(network.NetV6.String()) if err != nil { - return nil, nil, nil, fmt.Errorf("parse IPv6 prefix: %w", err) + return nil, nil, nil, false, fmt.Errorf("parse IPv6 prefix: %w", err) } freeIPv6, err := types.AllocateRandomPeerIPv6(v6Prefix) if err != nil { - return nil, nil, nil, fmt.Errorf("allocate peer IPv6: %w", err) + return nil, nil, nil, false, fmt.Errorf("allocate peer IPv6: %w", err) } newPeer.IPv6 = freeIPv6 } @@ -929,10 +929,10 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe continue } - return nil, nil, nil, fmt.Errorf("failed to add peer to database: %w", err) + return nil, nil, nil, false, fmt.Errorf("failed to add peer to database: %w", err) } if newPeer == nil { - return nil, nil, nil, fmt.Errorf("new peer is nil") + return nil, nil, nil, false, fmt.Errorf("new peer is nil") } opEvent.TargetID = newPeer.ID @@ -940,7 +940,8 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe if !addedByUser { opEvent.Meta["setup_key_name"] = peerAddConfig.SetupKeyName } - if newPeer.Status != nil && newPeer.Status.RequiresApproval { + requiresApproval := newPeer.Status != nil && newPeer.Status.RequiresApproval + if requiresApproval { opEvent.Meta["pending_approval"] = true } @@ -948,18 +949,18 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe am.StoreEvent(ctx, opEvent.InitiatorID, opEvent.TargetID, opEvent.AccountID, opEvent.Activity, opEvent.Meta) } - p, nmap, pc, _, err := am.networkMapController.GetValidatedPeerWithMap(ctx, false, accountID, newPeer) + network, postureChecks, enableSSH, err := getPeerLoginInfo(ctx, am.Store, accountID, newPeer, !requiresApproval) if err != nil { - return p, nmap, pc, err + return nil, nil, nil, false, err } changedPeerIDs := []string{newPeer.ID} - affectedPeerIDs := affectedPeerIDsFromNetworkMap(nmap, newPeer.ID) + affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs) if err := am.networkMapController.OnPeersAdded(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil { log.WithContext(ctx).Errorf("failed to update network map cache for peer %s: %v", newPeer.ID, err) } - return p, nmap, pc, nil + return newPeer, network, postureChecks, enableSSH, nil } func getPeerIPDNSLabel(ip netip.Addr, peerHostName string) (string, error) { @@ -1041,7 +1042,7 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy return nil, nil, nil, 0, err } - resPeer, nmap, resPostureChecks, dnsFwdPort, err := am.networkMapController.GetValidatedPeerWithMap(ctx, peerNotValid, accountID, peer) + nmap, resPostureChecks, dnsFwdPort, err := am.networkMapController.GetValidatedPeerWithMap(ctx, peerNotValid, accountID, peer.ID) if err != nil { return nil, nil, nil, 0, err } @@ -1054,7 +1055,7 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy } } - return resPeer, nmap, resPostureChecks, dnsFwdPort, nil + return peer, nmap, resPostureChecks, dnsFwdPort, nil } // syncPeerAffectedPeers resolves the peers affected by a SyncPeer change. The @@ -1085,7 +1086,7 @@ func (am *DefaultAccountManager) markConnectedAffectedPeers(ctx context.Context, return affectedPeerIDsFromNetworkMap(nmap, peerID) } -func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, login types.PeerLogin, err error) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) { +func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, login types.PeerLogin, err error) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) { if errStatus, ok := status.FromError(err); ok && errStatus.Type() == status.NotFound { // we couldn't find this peer by its public key which can mean that peer hasn't been registered yet. // Try registering it. @@ -1101,12 +1102,12 @@ func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, lo } log.WithContext(ctx).Errorf("failed while logging in peer %s: %v", login.WireGuardPubKey, err) - return nil, nil, nil, status.Errorf(status.Internal, "failed while logging in peer") + return nil, nil, nil, false, status.Errorf(status.Internal, "failed while logging in peer") } // LoginPeer logs in or registers a peer. // If peer doesn't exist the function checks whether a setup key or a user is present and registers a new peer if so. -func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) { +func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) { accountID, err := am.Store.GetAccountIDByPeerPubKey(ctx, login.WireGuardPubKey) if err != nil { return am.handlePeerLoginNotFound(ctx, login, err) @@ -1118,20 +1119,17 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer if login.UserID == "" { err = am.checkIFPeerNeedsLoginWithoutLock(ctx, accountID, login) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, false, err } } var peer *nbpeer.Peer - var updateRemotePeers bool - var isPeerUpdated bool - var ipv6CapabilityChanged bool - var postureChecks []*posture.Checks + var shouldStorePeer bool var peerGroupIDs []string settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, false, err } err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { @@ -1140,9 +1138,6 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer return err } - // this flag prevents unnecessary calls to the persistent store. - shouldStorePeer := false - if login.UserID != "" { if peer.UserID != login.UserID { log.Warnf("user mismatch when logging in peer %s: peer user %s, login user %s ", peer.ID, peer.UserID, login.UserID) @@ -1156,7 +1151,6 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer if changed { shouldStorePeer = true - updateRemotePeers = true } } @@ -1165,23 +1159,9 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer return err } - oldHasIPv6Cap := peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay) - isPeerUpdated, _ = peer.UpdateMetaIfNew(login.Meta) - ipv6CapabilityChanged = oldHasIPv6Cap != peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay) - if isPeerUpdated { - am.metrics.AccountManagerMetrics().CountPeerMetUpdate() - shouldStorePeer = true - - postureChecks, err = getPeerPostureChecks(ctx, transaction, accountID, peer.ID) - if err != nil { - return err - } - } - if peer.SSHKey != login.SSHKey { peer.SSHKey = login.SSHKey shouldStorePeer = true - updateRemotePeers = true } if !peer.AllowExtraDNSLabels && len(login.ExtraDNSLabels) > 0 { @@ -1197,28 +1177,28 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer return nil }) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, false, err } isRequiresApproval, isStatusChanged, err := am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, false, err } - p, nmap, pc, _, err := am.networkMapController.GetValidatedPeerWithMap(ctx, isRequiresApproval, accountID, peer) + network, postureChecks, enableSSH, err := getPeerLoginInfo(ctx, am.Store, accountID, peer, !isRequiresApproval) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, false, err } - if updateRemotePeers || isStatusChanged || ipv6CapabilityChanged || (isPeerUpdated && len(postureChecks) > 0) { + if isStatusChanged || shouldStorePeer { changedPeerIDs := []string{peer.ID} - affectedPeerIDs := am.syncPeerAffectedPeers(ctx, accountID, peer.ID, nmap, isRequiresApproval, isPeerUpdated, len(postureChecks) > 0) + affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs) if err = am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil { - return nil, nil, nil, fmt.Errorf("notify network map controller of peer update: %w", err) + return nil, nil, nil, false, fmt.Errorf("notify network map controller of peer update: %w", err) } } - return p, nmap, pc, nil + return peer, network, postureChecks, enableSSH, nil } // ExtendPeerSession refreshes the peer's SSO session deadline by updating @@ -1294,6 +1274,50 @@ func (am *DefaultAccountManager) ExtendPeerSession(ctx context.Context, peerPubK return refreshed.SessionExpiresAt(settings.PeerLoginExpirationEnabled, settings.PeerLoginExpiration), nil } +// getPeerLoginInfo computes the login/register response data (network, posture +// checks, SSH) from the store without building the peer's full network map. +func getPeerLoginInfo(ctx context.Context, transaction store.Store, accountID string, peer *nbpeer.Peer, isValid bool) (*types.Network, []*posture.Checks, bool, error) { + network, err := transaction.GetAccountNetwork(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return nil, nil, false, fmt.Errorf("get account network: %w", err) + } + + if !isValid { + return network, nil, false, nil + } + + postureChecks, err := getPeerPostureChecks(ctx, transaction, accountID, peer.ID) + if err != nil { + return nil, nil, false, err + } + + enableSSH, err := isPeerSSHEnabled(ctx, transaction, accountID, peer) + if err != nil { + return nil, nil, false, err + } + + return network, postureChecks, enableSSH, nil +} + +func isPeerSSHEnabled(ctx context.Context, transaction store.Store, accountID string, peer *nbpeer.Peer) (bool, error) { + policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return false, err + } + + peerGroups, err := transaction.GetPeerGroups(ctx, store.LockingStrengthNone, accountID, peer.ID) + if err != nil { + return false, err + } + + peerGroupIDs := make(map[string]struct{}, len(peerGroups)) + for _, g := range peerGroups { + peerGroupIDs[g.ID] = struct{}{} + } + + return types.PeerSSHEnabledFromPolicies(policies, peer.ID, peerGroupIDs, peer.SSHEnabled), nil +} + // getPeerPostureChecks returns the posture checks for the peer. func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountID, peerID string) ([]*posture.Checks, error) { policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) diff --git a/management/server/peer_test.go b/management/server/peer_test.go index ee1b33da2..98cf10acf 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -205,7 +205,7 @@ func testGetNetworkMapGeneral(t *testing.T) { return } - peer1, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + peer1, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: peerKey1.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-1"}, }, false) @@ -219,7 +219,7 @@ func testGetNetworkMapGeneral(t *testing.T) { t.Fatal(err) return } - _, _, _, err = manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + _, _, _, _, err = manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: peerKey2.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"}, }, false) @@ -278,7 +278,7 @@ func TestAccountManager_GetNetworkMapWithPolicy(t *testing.T) { return } - peer1, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + peer1, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: peerKey1.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-1"}, }, false) @@ -292,7 +292,7 @@ func TestAccountManager_GetNetworkMapWithPolicy(t *testing.T) { t.Fatal(err) return } - peer2, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + peer2, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: peerKey2.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"}, }, false) @@ -454,7 +454,7 @@ func TestAccountManager_GetPeerNetwork(t *testing.T) { return } - peer1, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + peer1, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: peerKey1.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-1"}, }, false) @@ -468,7 +468,7 @@ func TestAccountManager_GetPeerNetwork(t *testing.T) { t.Fatal(err) return } - _, _, _, err = manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + _, _, _, _, err = manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: peerKey2.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"}, }, false) @@ -526,7 +526,7 @@ func TestDefaultAccountManager_GetPeer(t *testing.T) { return } - peer1, _, _, err := manager.AddPeer(context.Background(), "", "", someUser, &nbpeer.Peer{ + peer1, _, _, _, err := manager.AddPeer(context.Background(), "", "", someUser, &nbpeer.Peer{ Key: peerKey1.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"}, }, false) @@ -542,7 +542,7 @@ func TestDefaultAccountManager_GetPeer(t *testing.T) { } // the second peer added with a setup key - peer2, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + peer2, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: peerKey2.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"}, }, false) @@ -698,7 +698,7 @@ func TestDefaultAccountManager_GetPeers(t *testing.T) { return } - _, _, _, err = manager.AddPeer(context.Background(), "", "", someUser, &nbpeer.Peer{ + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", someUser, &nbpeer.Peer{ Key: peerKey1.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-1"}, }, false) @@ -707,7 +707,7 @@ func TestDefaultAccountManager_GetPeers(t *testing.T) { return } - _, _, _, err = manager.AddPeer(context.Background(), "", "", adminUser, &nbpeer.Peer{ + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", adminUser, &nbpeer.Peer{ Key: peerKey2.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"}, }, false) @@ -1332,7 +1332,7 @@ func Test_RegisterPeerByUser(t *testing.T) { }, } - addedPeer, _, _, err := am.AddPeer(context.Background(), "", "", existingUserID, newPeer, false) + addedPeer, _, _, _, err := am.AddPeer(context.Background(), "", "", existingUserID, newPeer, false) require.NoError(t, err) assert.Equal(t, newPeer.ExtraDNSLabels, addedPeer.ExtraDNSLabels) @@ -1465,7 +1465,7 @@ func Test_RegisterPeerBySetupKey(t *testing.T) { ExtraDNSLabels: newPeerTemplate.ExtraDNSLabels, } - addedPeer, _, _, err := am.AddPeer(context.Background(), "", tc.existingSetupKeyID, "", currentPeer, false) + addedPeer, _, _, _, err := am.AddPeer(context.Background(), "", tc.existingSetupKeyID, "", currentPeer, false) if tc.expectAddPeerError { require.Error(t, err, "Expected an error when adding peer with setup key: %s", tc.existingSetupKeyID) @@ -1577,7 +1577,7 @@ func Test_RegisterPeerRollbackOnFailure(t *testing.T) { SSHEnabled: false, } - _, _, _, err = am.AddPeer(context.Background(), "", faultyKey, "", newPeer, false) + _, _, _, _, err = am.AddPeer(context.Background(), "", faultyKey, "", newPeer, false) require.Error(t, err) _, err = s.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, newPeer.Key) @@ -1723,7 +1723,7 @@ func Test_LoginPeer(t *testing.T) { if sk.AllowExtraDNSLabels { currentPeer.ExtraDNSLabels = newPeerTemplate.ExtraDNSLabels } - _, _, _, err = am.AddPeer(context.Background(), "", tc.setupKey, "", currentPeer, false) + _, _, _, _, err = am.AddPeer(context.Background(), "", tc.setupKey, "", currentPeer, false) require.NoError(t, err, "Expected no error when adding peer with setup key: %s", tc.setupKey) loginInput := types.PeerLogin{ @@ -1739,12 +1739,12 @@ func Test_LoginPeer(t *testing.T) { loginInput.ExtraDNSLabels = tc.extraDNSLabels } - loggedinPeer, networkMap, postureChecks, loginErr := am.LoginPeer(context.Background(), loginInput) + loggedinPeer, network, postureChecks, _, loginErr := am.LoginPeer(context.Background(), loginInput) if tc.expectLoginError { require.Error(t, loginErr, "Expected an error during LoginPeer with setup key: %s", tc.setupKey) assert.Contains(t, loginErr.Error(), tc.expectedErrorMsgSubstring, "Error message mismatch") assert.Nil(t, loggedinPeer, "LoggedinPeer should be nil on error") - assert.Nil(t, networkMap, "NetworkMap should be nil on error") + assert.Nil(t, network, "Network should be nil on error") assert.Nil(t, postureChecks, "PostureChecks should be empty or nil on error") return } @@ -1757,7 +1757,7 @@ func Test_LoginPeer(t *testing.T) { } else { assert.Equal(t, currentPeer.ExtraDNSLabels, loggedinPeer.ExtraDNSLabels, "ExtraDNSLabels mismatch on loggedinPeer") } - assert.NotNil(t, networkMap, "networkMap should not be nil on success") + assert.NotNil(t, network, "network should not be nil on success") assert.Equal(t, existingAccountID, loggedinPeer.AccountID, "AccountID mismatch for logged peer") @@ -1863,7 +1863,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) { require.NoError(t, err) expectedPeerKey := key.PublicKey().String() - peer4, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{ + peer4, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{ Key: expectedPeerKey, Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, }, false) @@ -1986,7 +1986,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) { require.NoError(t, err) expectedPeerKey := key.PublicKey().String() - peer4, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{ + peer4, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{ Key: expectedPeerKey, LoginExpirationEnabled: true, Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, @@ -2053,7 +2053,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) { require.NoError(t, err) expectedPeerKey := key.PublicKey().String() - peer5, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{ + peer5, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{ Key: expectedPeerKey, LoginExpirationEnabled: true, Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, @@ -2108,7 +2108,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) { require.NoError(t, err) expectedPeerKey := key.PublicKey().String() - peer6, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser3", &nbpeer.Peer{ + peer6, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser3", &nbpeer.Peer{ Key: expectedPeerKey, LoginExpirationEnabled: true, Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, @@ -2286,7 +2286,7 @@ func Test_AddPeer(t *testing.T) { <-start - _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", newPeer, false) + _, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", newPeer, false) if err != nil { errs <- fmt.Errorf("AddPeer failed for peer %d: %w", i, err) return @@ -2366,7 +2366,7 @@ func TestAddPeer_UserPendingApprovalBlocked(t *testing.T) { }, } - _, _, _, err = manager.AddPeer(context.Background(), "", "", pendingUser.Id, peer, false) + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", pendingUser.Id, peer, false) require.Error(t, err) assert.Contains(t, err.Error(), "user pending approval cannot add peers") } @@ -2401,7 +2401,7 @@ func TestAddPeer_ApprovedUserCanAddPeers(t *testing.T) { }, } - _, _, _, err = manager.AddPeer(context.Background(), "", "", regularUser.Id, peer, false) + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", regularUser.Id, peer, false) require.NoError(t, err, "Regular user should be able to add peers") } @@ -2444,7 +2444,7 @@ func TestLoginPeer_UserPendingApprovalBlocked(t *testing.T) { WtVersion: "0.28.0", }, } - existingPeer, _, _, err := manager.AddPeer(context.Background(), "", "", pendingUser.Id, newPeer, false) + existingPeer, _, _, _, err := manager.AddPeer(context.Background(), "", "", pendingUser.Id, newPeer, false) require.NoError(t, err) // Now set the user back to pending approval after peer was created @@ -2463,7 +2463,7 @@ func TestLoginPeer_UserPendingApprovalBlocked(t *testing.T) { }, } - _, _, _, err = manager.LoginPeer(context.Background(), login) + _, _, _, _, err = manager.LoginPeer(context.Background(), login) require.Error(t, err) e, ok := status.FromError(err) require.True(t, ok, "error is not a gRPC status error") @@ -2500,7 +2500,7 @@ func TestLoginPeer_ApprovedUserCanLogin(t *testing.T) { WtVersion: "0.28.0", }, } - existingPeer, _, _, err := manager.AddPeer(context.Background(), "", "", regularUser.Id, newPeer, false) + existingPeer, _, _, _, err := manager.AddPeer(context.Background(), "", "", regularUser.Id, newPeer, false) require.NoError(t, err) // Try to login with regular user @@ -2513,7 +2513,7 @@ func TestLoginPeer_ApprovedUserCanLogin(t *testing.T) { }, } - _, _, _, err = manager.LoginPeer(context.Background(), login) + _, _, _, _, err = manager.LoginPeer(context.Background(), login) require.NoError(t, err, "Regular user should be able to login peers") } @@ -2837,7 +2837,7 @@ func TestUpdatePeer_DnsLabelCollisionWithFQDN(t *testing.T) { // Add first peer with hostname that produces DNS label "netbird1" key1, err := wgtypes.GenerateKey() require.NoError(t, err) - peer1, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + peer1, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: key1.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "netbird1.netbird.cloud"}, }, false) @@ -2847,7 +2847,7 @@ func TestUpdatePeer_DnsLabelCollisionWithFQDN(t *testing.T) { // Add second peer with a different hostname key2, err := wgtypes.GenerateKey() require.NoError(t, err) - peer2, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + peer2, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: key2.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "ip-10-29-5-130"}, }, false) @@ -2871,7 +2871,7 @@ func TestUpdatePeer_DnsLabelUniqueName(t *testing.T) { key1, err := wgtypes.GenerateKey() require.NoError(t, err) - peer1, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + peer1, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: key1.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "web-server"}, }, false) @@ -2881,7 +2881,7 @@ func TestUpdatePeer_DnsLabelUniqueName(t *testing.T) { // Add second peer and rename it to a unique FQDN whose first label doesn't collide key2, err := wgtypes.GenerateKey() require.NoError(t, err) - peer2, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + peer2, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: key2.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "old-name"}, }, false) diff --git a/management/server/types/account.go b/management/server/types/account.go index d658f605d..7a0a0054f 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -1156,6 +1156,47 @@ func policyRuleImpliesLegacySSH(rule *PolicyRule) bool { return rule.Protocol == PolicyRuleProtocolALL || (rule.Protocol == PolicyRuleProtocolTCP && (portsIncludesSSH(rule.Ports) || portRangeIncludesSSH(rule.PortRanges))) } +// PeerSSHEnabledFromPolicies is the network-map-free equivalent of the sshEnabled +// determination in GetPeerConnectionResources / CalculateNetworkMapFromComponents. +func PeerSSHEnabledFromPolicies(policies []*Policy, peerID string, peerGroupIDs map[string]struct{}, peerSSHEnabled bool) bool { + for _, policy := range policies { + if !policy.Enabled { + continue + } + + for _, rule := range policy.Rules { + if !rule.Enabled { + continue + } + + isSSHRule := rule.Protocol == PolicyRuleProtocolNetbirdSSH || + (policyRuleImpliesLegacySSH(rule) && peerSSHEnabled) + if !isSSHRule { + continue + } + + if ruleHasDestination(rule, peerID, peerGroupIDs) { + return true + } + } + } + + return false +} + +func ruleHasDestination(rule *PolicyRule, peerID string, peerGroupIDs map[string]struct{}) bool { + if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" { + return rule.DestinationResource.ID == peerID + } + + for _, groupID := range rule.Destinations { + if _, ok := peerGroupIDs[groupID]; ok { + return true + } + } + return false +} + func portRangeIncludesSSH(portRanges []RulePortRange) bool { for _, pr := range portRanges { if (pr.Start <= defaultSSHPortNumber && pr.End >= defaultSSHPortNumber) || (pr.Start <= nativeSSHPortNumber && pr.End >= nativeSSHPortNumber) { diff --git a/management/server/types/networkmap_components_correctness_test.go b/management/server/types/networkmap_components_correctness_test.go index 3785a7399..1e3035300 100644 --- a/management/server/types/networkmap_components_correctness_test.go +++ b/management/server/types/networkmap_components_correctness_test.go @@ -1233,3 +1233,97 @@ func TestComponents_DisabledRuleInEnabledPolicy(t *testing.T) { assert.True(t, has3000, "enabled rule should generate firewall rule for port 3000") assert.False(t, has3001, "disabled rule should NOT generate firewall rule for port 3001") } + +func peerGroupIDSet(account *types.Account, peerID string) map[string]struct{} { + return account.GetPeerGroups(peerID) +} + +func assertSSHEquivalence(t *testing.T, account *types.Account, peerID string, validatedPeers map[string]struct{}) { + t.Helper() + nm := componentsNetworkMap(account, peerID, validatedPeers) + require.NotNil(t, nm) + + got := types.PeerSSHEnabledFromPolicies(account.Policies, peerID, peerGroupIDSet(account, peerID), account.Peers[peerID].SSHEnabled) + assert.Equalf(t, nm.EnableSSH, got, "PeerSSHEnabledFromPolicies mismatch for %s", peerID) +} + +func TestPeerSSHEnabledFromPolicies_MatchesMap_NetbirdSSHProtocol(t *testing.T) { + account, validatedPeers := scalableTestAccount(20, 2) + account.Groups["ssh-users"] = &types.Group{ID: "ssh-users", Name: "SSH Users", Peers: []string{}} + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-ssh", Name: "SSH Access", Enabled: true, AccountID: "test-account", + Rules: []*types.PolicyRule{{ + ID: "rule-ssh", Name: "Allow SSH", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Bidirectional: false, + Sources: []string{"group-0"}, Destinations: []string{"group-1"}, + AuthorizedGroups: map[string][]string{"ssh-users": {"root"}}, + }}, + }) + + assertSSHEquivalence(t, account, "peer-10", validatedPeers) + assertSSHEquivalence(t, account, "peer-0", validatedPeers) +} + +func TestPeerSSHEnabledFromPolicies_MatchesMap_NoSSHPolicy(t *testing.T) { + account, validatedPeers := scalableTestAccount(20, 2) + assertSSHEquivalence(t, account, "peer-0", validatedPeers) +} + +func TestPeerSSHEnabledFromPolicies_MatchesMap_LegacyImpliedSSH(t *testing.T) { + account, validatedPeers := scalableTestAccount(20, 2) + account.Peers["peer-10"].SSHEnabled = true + assertSSHEquivalence(t, account, "peer-10", validatedPeers) + assertSSHEquivalence(t, account, "peer-11", validatedPeers) +} + +func TestPeerSSHEnabledFromPolicies_MatchesMap_PeerAsDestinationResource(t *testing.T) { + account, validatedPeers := scalableTestAccountWithoutDefaultPolicy(20, 2) + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-ssh-res", Name: "SSH to peer", Enabled: true, AccountID: "test-account", + Rules: []*types.PolicyRule{{ + ID: "rule-ssh-res", Name: "SSH to peer-5", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Sources: []string{"group-0"}, + DestinationResource: types.Resource{ID: "peer-5", Type: types.ResourceTypePeer}, + }}, + }) + + assertSSHEquivalence(t, account, "peer-5", validatedPeers) + assertSSHEquivalence(t, account, "peer-6", validatedPeers) +} + +func TestPeerSSHEnabledFromPolicies_MatchesMap_DisabledSSHPolicy(t *testing.T) { + account, validatedPeers := scalableTestAccountWithoutDefaultPolicy(20, 2) + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-ssh-off", Name: "SSH disabled", Enabled: false, AccountID: "test-account", + Rules: []*types.PolicyRule{{ + ID: "rule-ssh-off", Name: "Allow SSH", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Sources: []string{"group-0"}, Destinations: []string{"group-1"}, + }}, + }) + assertSSHEquivalence(t, account, "peer-10", validatedPeers) +} + +func TestPeerSSHEnabledFromPolicies_MatchesMap_Sweep(t *testing.T) { + account, validatedPeers := scalableTestAccount(60, 6) + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-ssh-sweep", Name: "SSH sweep", Enabled: true, AccountID: "test-account", + Rules: []*types.PolicyRule{{ + ID: "rule-ssh-sweep", Name: "Allow SSH", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Sources: []string{"group-0"}, Destinations: []string{"group-2"}, + }}, + }) + for peerID := range account.Peers { + account.Peers[peerID].SSHEnabled = len(peerID)%2 == 0 + } + + for peerID := range account.Peers { + if _, ok := validatedPeers[peerID]; !ok { + continue + } + assertSSHEquivalence(t, account, peerID, validatedPeers) + } +} diff --git a/management/server/user_test.go b/management/server/user_test.go index d46519396..f32a6b3a1 100644 --- a/management/server/user_test.go +++ b/management/server/user_test.go @@ -1565,7 +1565,7 @@ func TestUserAccountPeersUpdate(t *testing.T) { require.NoError(t, err) expectedPeerKey := key.PublicKey().String() - peer4, _, _, err := manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{ + peer4, _, _, _, err := manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{ Key: expectedPeerKey, Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, }, false) From 8ae2cd0a08af0a5311cc3d3d52656448141afbb3 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 17 Jun 2026 18:29:33 +0200 Subject: [PATCH 05/20] [client] Fix ios route notify ordering (#6454) * [client] fix iOS route-update reordering that black-holed IPv6 on exit-node disable On iOS the route notifier delivered each prefix update from its own fire-and-forget goroutine (notify -> `go func`), so Go provided no ordering guarantee between consecutive updates. It also read currentPrefixes inside that goroutine without holding the lock, racing the next OnNewPrefixes write. On exit-node disable the core removes the default routes as two separate prefix updates (0.0.0.0/0, then the synthesized ::/0). When the two goroutines were reordered, the stale snapshot still containing ::/0 was delivered last and clobbered the correct default-free one. iOS then kept the ::/0 default route on the tunnel with no exit node to carry it, black-holing all IPv6 traffic while IPv4 recovered correctly. Fix: deliver updates through a single worker goroutine fed by a buffered channel, preserving production order, and snapshot the joined prefix string under the mutex so it can't race a concurrent update. Buffered so producers (which run under the route manager lock) don't block on the listener callback. * [client] close iOS notifier delivery goroutine on Stop, unbounded queue The delivery goroutine was never stopped, leaking on every engine restart. Add Notifier.Close, called from the route manager Stop after routing cleanup. Replace the buffered update channel with a cond-driven linked-list queue so route-update producers (running under the route manager lock) never block when the listener callback is slow. --- client/internal/routemanager/manager.go | 2 + .../routemanager/notifier/notifier_android.go | 6 +- .../routemanager/notifier/notifier_ios.go | 64 +++++++++++++------ .../routemanager/notifier/notifier_other.go | 4 ++ 4 files changed, 57 insertions(+), 19 deletions(-) diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 0edf4607f..22458d575 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -333,6 +333,8 @@ func (m *DefaultManager) Stop(stateManager *statemanager.Manager) { } } + m.notifier.Close() + m.mux.Lock() defer m.mux.Unlock() m.clientRoutes = nil diff --git a/client/internal/routemanager/notifier/notifier_android.go b/client/internal/routemanager/notifier/notifier_android.go index 140a583f7..49300dbb2 100644 --- a/client/internal/routemanager/notifier/notifier_android.go +++ b/client/internal/routemanager/notifier/notifier_android.go @@ -16,7 +16,7 @@ import ( type Notifier struct { initialRoutes []*route.Route currentRoutes []*route.Route - fakeIPRoutes []*route.Route + fakeIPRoutes []*route.Route listener listener.NetworkChangeListener listenerMux sync.Mutex @@ -119,3 +119,7 @@ func (n *Notifier) GetInitialRouteRanges() []string { sort.Strings(initialStrings) return initialStrings } + +func (n *Notifier) Close() { + // unused +} diff --git a/client/internal/routemanager/notifier/notifier_ios.go b/client/internal/routemanager/notifier/notifier_ios.go index 27a2a722d..d0888f3a1 100644 --- a/client/internal/routemanager/notifier/notifier_ios.go +++ b/client/internal/routemanager/notifier/notifier_ios.go @@ -3,6 +3,7 @@ package notifier import ( + "container/list" "net/netip" "slices" "sort" @@ -14,19 +15,26 @@ import ( ) type Notifier struct { + mu sync.Mutex + cond *sync.Cond currentPrefixes []string - - listener listener.NetworkChangeListener - listenerMux sync.Mutex + listener listener.NetworkChangeListener + queue *list.List + closed bool } func NewNotifier() *Notifier { - return &Notifier{} + n := &Notifier{ + queue: list.New(), + } + n.cond = sync.NewCond(&n.mu) + go n.deliverLoop() + return n } func (n *Notifier) SetListener(listener listener.NetworkChangeListener) { - n.listenerMux.Lock() - defer n.listenerMux.Unlock() + n.mu.Lock() + defer n.mu.Unlock() n.listener = listener } @@ -43,32 +51,52 @@ func (n *Notifier) OnNewRoutes(route.HAMap) { } func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) { - newNets := make([]string, 0) + newNets := make([]string, 0, len(prefixes)) for _, prefix := range prefixes { newNets = append(newNets, prefix.String()) } sort.Strings(newNets) + n.mu.Lock() if slices.Equal(n.currentPrefixes, newNets) { + n.mu.Unlock() return } - n.currentPrefixes = newNets - n.notify() + routes := strings.Join(n.currentPrefixes, ",") + n.queue.PushBack(routes) + n.cond.Signal() + n.mu.Unlock() } -func (n *Notifier) notify() { - n.listenerMux.Lock() - defer n.listenerMux.Unlock() - if n.listener == nil { - return - } - go func(l listener.NetworkChangeListener) { - l.OnNetworkChanged(strings.Join(n.currentPrefixes, ",")) - }(n.listener) +func (n *Notifier) Close() { + n.mu.Lock() + n.closed = true + n.cond.Signal() + n.mu.Unlock() } func (n *Notifier) GetInitialRouteRanges() []string { return nil } + +func (n *Notifier) deliverLoop() { + for { + n.mu.Lock() + for n.queue.Len() == 0 && !n.closed { + n.cond.Wait() + } + if n.closed && n.queue.Len() == 0 { + n.mu.Unlock() + return + } + routes := n.queue.Remove(n.queue.Front()).(string) + l := n.listener + n.mu.Unlock() + + if l != nil { + l.OnNetworkChanged(routes) + } + } +} diff --git a/client/internal/routemanager/notifier/notifier_other.go b/client/internal/routemanager/notifier/notifier_other.go index f57cadb0b..71b1096c2 100644 --- a/client/internal/routemanager/notifier/notifier_other.go +++ b/client/internal/routemanager/notifier/notifier_other.go @@ -38,3 +38,7 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) { func (n *Notifier) GetInitialRouteRanges() []string { return []string{} } + +func (n *Notifier) Close() { + // unused +} From 5bd7c6c7ea0c8cebe78fdf8ecff9b80511660ec0 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 18 Jun 2026 01:48:09 +0900 Subject: [PATCH 06/20] [client] Detect and recover from a stalled signal receive stream (#6459) --- client/internal/engine.go | 7 ++ shared/signal/client/grpc.go | 121 ++++++++++++++++++++--- shared/signal/client/watchdog_test.go | 84 ++++++++++++++++ shared/signal/proto/signalexchange.pb.go | 64 ++++++------ shared/signal/proto/signalexchange.proto | 1 + 5 files changed, 233 insertions(+), 44 deletions(-) create mode 100644 shared/signal/client/watchdog_test.go diff --git a/client/internal/engine.go b/client/internal/engine.go index cf40d8983..42712da92 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -1714,6 +1714,13 @@ func (e *Engine) receiveSignalEvents() { return e.ctx.Err() } + // Self-addressed heartbeat: the signal client's receive watchdog + // round-trips this through the server to confirm the receive stream + // is delivering. Liveness is already recorded before this handler. + if msg.GetBody().GetType() == sProto.Body_HEARTBEAT { + return nil + } + conn, ok := e.peerStore.PeerConn(msg.Key) if !ok { return fmt.Errorf("wrongly addressed message %s", msg.Key) diff --git a/shared/signal/client/grpc.go b/shared/signal/client/grpc.go index b245b2296..eb18cea05 100644 --- a/shared/signal/client/grpc.go +++ b/shared/signal/client/grpc.go @@ -2,9 +2,11 @@ package client import ( "context" + "errors" "fmt" "io" "sync" + "sync/atomic" "time" "github.com/cenkalti/backoff/v4" @@ -23,7 +25,23 @@ import ( "github.com/netbirdio/netbird/util/wsproxy" ) -const healthCheckTimeout = 5 * time.Second +const ( + // receiveInactivityThreshold is how long the receive stream may be silent + // before the watchdog actively probes it. The gRPC transport can stay + // healthy (keepalive satisfied) while the server stops delivering messages, + // which the transport layer cannot detect. + receiveInactivityThreshold = 30 * time.Second + // receiveProbeTimeout is how long the watchdog waits for its self-addressed + // probe to round-trip back on the stream before declaring the receive + // direction dead. + receiveProbeTimeout = 10 * time.Second + // receiveWatchdogInterval is how often the watchdog evaluates the stream. + receiveWatchdogInterval = 10 * time.Second +) + +// errReceiveStreamStalled is reported when the receive stream is transport-alive +// but no longer delivering messages, so the stream is torn down to reconnect. +var errReceiveStreamStalled = errors.New("signal receive stream stalled") // ConnStateNotifier is a wrapper interface of the status recorder type ConnStateNotifier interface { @@ -52,6 +70,14 @@ type GrpcClient struct { decryptionWorker *Worker decryptionWorkerCancel context.CancelFunc decryptionWg sync.WaitGroup + + // lastReceived holds the Unix-nano timestamp of the last message read from + // the receive stream, used by the receive watchdog. + lastReceived atomic.Int64 + // receiveStalled is set by the receive watchdog when the stream is + // transport-alive but no longer delivering messages. It is the source of + // truth IsHealthy reads, and is cleared once any frame is received again. + receiveStalled atomic.Bool } // NewClient creates a new Signal client @@ -148,9 +174,9 @@ func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Mes // connect to Signal stream identifying ourselves with a public WireGuard key // todo once the key rotation logic has been implemented, consider changing to some other identifier (received from management) - ctx, cancelStream := context.WithCancel(ctx) + streamCtx, cancelStream := context.WithCancel(ctx) defer cancelStream() - stream, err := c.connect(ctx, c.key.PublicKey().String()) + stream, err := c.connect(streamCtx, c.key.PublicKey().String()) if err != nil { log.Warnf("disconnected from the Signal Exchange due to an error: %v", err) return err @@ -164,9 +190,16 @@ func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Mes // Start worker pool if not already started c.startEncryptionWorker(msgHandler) + // Guard the receive direction: the transport can stay healthy while the + // server stops delivering messages. The watchdog reconnects via cancelStream. + c.markReceived() + go c.watchReceiveStream(streamCtx, cancelStream) + // start receiving messages from the Signal stream (from other peers through signal) err = c.receive(stream) if err != nil { + // Check the parent context, not streamCtx: a watchdog-triggered + // cancelStream must reconnect, only a parent cancel is shutdown. if ctx.Err() != nil { log.Debugf("signal connection context has been canceled, this usually indicates shutdown") return nil @@ -252,7 +285,10 @@ func (c *GrpcClient) Ready() bool { return c.signalConn.GetState() == connectivity.Ready || c.signalConn.GetState() == connectivity.Idle } -// IsHealthy probes the gRPC connection and returns false on errors +// IsHealthy reports whether the Signal connection is usable, based on the +// transport state plus the receive watchdog's verdict, and updates the status +// recorder accordingly. It does not actively probe: the watchdog +// (watchReceiveStream) owns probing the receive path and reconnecting. func (c *GrpcClient) IsHealthy() bool { switch c.signalConn.GetState() { case connectivity.TransientFailure: @@ -265,16 +301,8 @@ func (c *GrpcClient) IsHealthy() bool { case connectivity.Ready: } - ctx, cancel := context.WithTimeout(c.ctx, healthCheckTimeout) - defer cancel() - _, err := c.realClient.Send(ctx, &proto.EncryptedMessage{ - Key: c.key.PublicKey().String(), - RemoteKey: "dummy", - Body: nil, - }) - if err != nil { - c.notifyDisconnected(err) - log.Warnf("health check returned: %s", err) + if c.receiveStalled.Load() { + c.notifyDisconnected(errReceiveStreamStalled) return false } c.notifyConnected() @@ -398,6 +426,68 @@ func (c *GrpcClient) Send(msg *proto.Message) error { return err } +// markReceived records that a frame was just read from the receive stream and +// clears the stalled flag. +func (c *GrpcClient) markReceived() { + c.lastReceived.Store(time.Now().UnixNano()) + c.receiveStalled.Store(false) +} + +// idleSinceReceive returns how long the receive stream has been silent. +func (c *GrpcClient) idleSinceReceive() time.Duration { + return time.Since(time.Unix(0, c.lastReceived.Load())) +} + +// watchReceiveStream guards against a receive stream that is transport-alive but +// no longer delivering messages. While the stream is idle past +// receiveInactivityThreshold it sends a self-addressed probe that the Signal +// server routes back to this client. If the probe does not round-trip within +// receiveProbeTimeout the receive direction is considered dead and cancelStream +// is called so the retry loop reconnects. +func (c *GrpcClient) watchReceiveStream(ctx context.Context, cancelStream context.CancelFunc) { + ticker := time.NewTicker(receiveWatchdogInterval) + defer ticker.Stop() + + var probeSentAt time.Time + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if c.idleSinceReceive() < receiveInactivityThreshold { + probeSentAt = time.Time{} + continue + } + + if !probeSentAt.IsZero() && time.Since(probeSentAt) >= receiveProbeTimeout { + log.Warnf("signal receive stream stalled: no messages for %s and probe did not return, reconnecting", c.idleSinceReceive().Round(time.Second)) + c.receiveStalled.Store(true) + c.notifyDisconnected(errReceiveStreamStalled) + cancelStream() + return + } + + if probeSentAt.IsZero() { + if err := c.sendReceiveProbe(); err != nil { + log.Debugf("failed to send signal receive probe: %v", err) + } + probeSentAt = time.Now() + } + } + } +} + +// sendReceiveProbe sends a self-addressed heartbeat. The Signal server routes it +// back to this client, exercising the exact receive path the watchdog guards. +func (c *GrpcClient) sendReceiveProbe() error { + self := c.key.PublicKey().String() + return c.Send(&proto.Message{ + Key: self, + RemoteKey: self, + Body: &proto.Body{Type: proto.Body_HEARTBEAT}, + }) +} + // receive receives messages from other peers coming through the Signal Exchange // and distributes them to worker threads for processing func (c *GrpcClient) receive(stream proto.SignalExchange_ConnectStreamClient) error { @@ -419,6 +509,9 @@ func (c *GrpcClient) receive(stream proto.SignalExchange_ConnectStreamClient) er return err } + // Any frame from the server proves the receive direction is alive. + c.markReceived() + if msg == nil { continue } diff --git a/shared/signal/client/watchdog_test.go b/shared/signal/client/watchdog_test.go new file mode 100644 index 000000000..1905e7562 --- /dev/null +++ b/shared/signal/client/watchdog_test.go @@ -0,0 +1,84 @@ +package client + +import ( + "context" + "net" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "google.golang.org/grpc" + + sigProto "github.com/netbirdio/netbird/shared/signal/proto" + "github.com/netbirdio/netbird/signal/server" +) + +func startTestSignalServer(t *testing.T) string { + t.Helper() + + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + s := grpc.NewServer() + srv, err := server.NewServer(context.Background(), otel.Meter("")) + require.NoError(t, err) + sigProto.RegisterSignalExchangeServer(s, srv) + + go func() { + _ = s.Serve(lis) + }() + t.Cleanup(s.Stop) + + return lis.Addr().String() +} + +// TestReceiveProbeRoundTrips verifies that the watchdog's self-addressed heartbeat +// is routed back to the same client through the signal server. This round-trip is +// what lets the watchdog confirm the receive direction is still delivering. +func TestReceiveProbeRoundTrips(t *testing.T) { + addr := startTestSignalServer(t) + + key, err := wgtypes.GenerateKey() + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + client, err := NewClient(ctx, addr, key, false) + require.NoError(t, err) + t.Cleanup(func() { _ = client.Close() }) + + received := make(chan struct{}, 1) + go func() { + _ = client.Receive(ctx, func(msg *sigProto.Message) error { + if msg.GetBody().GetType() == sigProto.Body_HEARTBEAT && msg.GetKey() == key.PublicKey().String() { + select { + case received <- struct{}{}: + default: + } + } + return nil + }) + }() + + streamReady := make(chan struct{}) + go func() { + client.WaitStreamConnected() + close(streamReady) + }() + select { + case <-streamReady: + case <-time.After(5 * time.Second): + t.Fatal("signal stream did not connect within timeout") + } + + require.NoError(t, client.sendReceiveProbe()) + + select { + case <-received: + case <-time.After(3 * time.Second): + t.Fatal("self-addressed heartbeat did not round-trip back through the signal server") + } +} diff --git a/shared/signal/proto/signalexchange.pb.go b/shared/signal/proto/signalexchange.pb.go index 0c80fb489..8e07977f0 100644 --- a/shared/signal/proto/signalexchange.pb.go +++ b/shared/signal/proto/signalexchange.pb.go @@ -30,6 +30,7 @@ const ( Body_CANDIDATE Body_Type = 2 Body_MODE Body_Type = 4 Body_GO_IDLE Body_Type = 5 + Body_HEARTBEAT Body_Type = 6 ) // Enum value maps for Body_Type. @@ -40,6 +41,7 @@ var ( 2: "CANDIDATE", 4: "MODE", 5: "GO_IDLE", + 6: "HEARTBEAT", } Body_Type_value = map[string]int32{ "OFFER": 0, @@ -47,6 +49,7 @@ var ( "CANDIDATE": 2, "MODE": 4, "GO_IDLE": 5, + "HEARTBEAT": 6, } ) @@ -463,7 +466,7 @@ var file_signalexchange_proto_rawDesc = []byte{ 0x52, 0x09, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x28, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x42, 0x6f, 0x64, 0x79, 0x52, - 0x04, 0x62, 0x6f, 0x64, 0x79, 0x22, 0xc3, 0x04, 0x0a, 0x04, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x2d, + 0x04, 0x62, 0x6f, 0x64, 0x79, 0x22, 0xd2, 0x04, 0x0a, 0x04, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x2d, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x42, 0x6f, 0x64, 0x79, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, @@ -491,38 +494,39 @@ var file_signalexchange_proto_rawDesc = []byte{ 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x29, 0x0a, 0x0d, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x02, 0x52, 0x0d, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, - 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x88, 0x01, 0x01, 0x22, 0x43, 0x0a, 0x04, 0x54, 0x79, 0x70, + 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x88, 0x01, 0x01, 0x22, 0x52, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x09, 0x0a, 0x05, 0x4f, 0x46, 0x46, 0x45, 0x52, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x4e, 0x53, 0x57, 0x45, 0x52, 0x10, 0x01, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x41, 0x4e, 0x44, 0x49, 0x44, 0x41, 0x54, 0x45, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x4d, 0x4f, 0x44, 0x45, 0x10, - 0x04, 0x12, 0x0b, 0x0a, 0x07, 0x47, 0x4f, 0x5f, 0x49, 0x44, 0x4c, 0x45, 0x10, 0x05, 0x42, 0x15, - 0x0a, 0x13, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, - 0x64, 0x72, 0x65, 0x73, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, - 0x6e, 0x49, 0x64, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, - 0x76, 0x65, 0x72, 0x49, 0x50, 0x4a, 0x04, 0x08, 0x09, 0x10, 0x0a, 0x22, 0x2e, 0x0a, 0x04, 0x4d, - 0x6f, 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x88, 0x01, 0x01, - 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x6d, 0x0a, 0x0f, 0x52, - 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x28, - 0x0a, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, - 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, - 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65, - 0x6e, 0x70, 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, - 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x32, 0xb9, 0x01, 0x0a, 0x0e, 0x53, - 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x4c, 0x0a, - 0x04, 0x53, 0x65, 0x6e, 0x64, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, - 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, - 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, - 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x59, 0x0a, 0x0d, 0x43, - 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x20, 0x2e, 0x73, - 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, - 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, - 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, - 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x22, 0x00, 0x28, 0x01, 0x30, 0x01, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x04, 0x12, 0x0b, 0x0a, 0x07, 0x47, 0x4f, 0x5f, 0x49, 0x44, 0x4c, 0x45, 0x10, 0x05, 0x12, 0x0d, + 0x0a, 0x09, 0x48, 0x45, 0x41, 0x52, 0x54, 0x42, 0x45, 0x41, 0x54, 0x10, 0x06, 0x42, 0x15, 0x0a, + 0x13, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, + 0x72, 0x65, 0x73, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x49, 0x64, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x49, 0x50, 0x4a, 0x04, 0x08, 0x09, 0x10, 0x0a, 0x22, 0x2e, 0x0a, 0x04, 0x4d, 0x6f, + 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x08, 0x48, 0x00, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x88, 0x01, 0x01, 0x42, + 0x09, 0x0a, 0x07, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x6d, 0x0a, 0x0f, 0x52, 0x6f, + 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x28, 0x0a, + 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, + 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, + 0x70, 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x53, + 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x32, 0xb9, 0x01, 0x0a, 0x0e, 0x53, 0x69, + 0x67, 0x6e, 0x61, 0x6c, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x4c, 0x0a, 0x04, + 0x53, 0x65, 0x6e, 0x64, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, + 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, + 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, + 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x59, 0x0a, 0x0d, 0x43, 0x6f, + 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x20, 0x2e, 0x73, 0x69, + 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, + 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, + 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, + 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, + 0x00, 0x28, 0x01, 0x30, 0x01, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, + 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/shared/signal/proto/signalexchange.proto b/shared/signal/proto/signalexchange.proto index 96a4001e3..8c304e37c 100644 --- a/shared/signal/proto/signalexchange.proto +++ b/shared/signal/proto/signalexchange.proto @@ -48,6 +48,7 @@ message Body { CANDIDATE = 2; MODE = 4; GO_IDLE = 5; + HEARTBEAT = 6; } Type type = 1; string payload = 2; From 8d9580e49112857c99e44f3c877ececec2d20e4c Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Wed, 17 Jun 2026 20:13:13 +0200 Subject: [PATCH 07/20] [misc] improve goreleaser with RC handling and update docker builds (#6438) - introduce variables to avoid publishing latest docker tags and installers - Refactor .goreleaser.yaml to simplify docker configurations and add environment-driven flags - removed management debug containers (it was doing only log var) - Stopped building arm v6 32bits in favor of v7 32 bits for services (not client) - Add target argument to docker files --- .github/workflows/release.yml | 53 ++- .goreleaser.yaml | 862 ++++++++-------------------------- .goreleaser_ui.yaml | 5 +- client/Dockerfile | 6 +- client/Dockerfile-rootless | 6 +- combined/Dockerfile | 3 +- management/Dockerfile | 3 +- management/Dockerfile.debug | 5 - proxy/Dockerfile | 3 +- relay/Dockerfile | 3 +- signal/Dockerfile | 3 +- upload-server/Dockerfile | 3 +- 12 files changed, 268 insertions(+), 687 deletions(-) delete mode 100644 management/Dockerfile.debug diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b335aad72..bd3514d27 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,10 +9,13 @@ on: pull_request: env: - SIGN_PIPE_VER: "v0.1.5" - GORELEASER_VER: "v2.14.3" + SIGN_PIPE_VER: "v0.1.6" + GORELEASER_VER: "v2.16.0" PRODUCT_NAME: "NetBird" COPYRIGHT: "NetBird GmbH" + flags: "" + SKIP_PUBLISH: "true" + SKIP_DOCKER_PUSH: "false" concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} @@ -130,8 +133,6 @@ jobs: windows_packages_artifact_url: ${{ steps.upload_windows_packages.outputs.artifact-url }} macos_packages_artifact_url: ${{ steps.upload_macos_packages.outputs.artifact-url }} ghcr_images: ${{ steps.tag_and_push_images.outputs.images_markdown }} - env: - flags: "" steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -143,8 +144,27 @@ jobs: id: semver_parser uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2 - - if: ${{ !startsWith(github.ref, 'refs/tags/v') }} - run: echo "flags=--snapshot" >> $GITHUB_ENV + - name: Set snapshot flag + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} + run: | + echo "flags=--snapshot" >> $GITHUB_ENV + + - name: Set build vars + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + run: | + if [[ "x-${{ steps.semver_parser.outputs.prerelease }}" == "x-" && "x-${{ github.repository }}" == "x-netbirdio/netbird" ]]; then + echo "x-${{ github.repository }}" + echo "x-${{ steps.semver_parser.outputs.prerelease }}" + echo "SKIP_PUBLISH=false" >> $GITHUB_ENV + else + echo "x-${{ github.repository }}" + echo "x-${{ steps.semver_parser.outputs.prerelease }}" + fi + + if [[ "x-${{ github.repository }}" != "x-netbirdio/netbird" ]]; then + echo "SKIP_DOCKER_PUSH=true" >> $GITHUB_ENV + fi + - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: @@ -212,6 +232,8 @@ jobs: UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }} GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }} NFPM_NETBIRD_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }} + SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }} + SKIP_DOCKER_PUSH: ${{ env.SKIP_DOCKER_PUSH }} - name: Verify RPM signatures run: | docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c ' @@ -334,8 +356,22 @@ jobs: id: semver_parser uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2 - - if: ${{ !startsWith(github.ref, 'refs/tags/v') }} - run: echo "flags=--snapshot" >> $GITHUB_ENV + - name: Set snapshot flag + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} + run: | + echo "flags=--snapshot" >> $GITHUB_ENV + + - name: Set build vars + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + run: | + if [[ "x-${{ steps.semver_parser.outputs.prerelease }}" == "x-" && "x-${{ github.repository }}" == "x-netbirdio/netbird" ]]; then + echo "x-${{ github.repository }}" + echo "x-${{ steps.semver_parser.outputs.prerelease }}" + echo "SKIP_PUBLISH=false" >> $GITHUB_ENV + else + echo "x-${{ github.repository }}" + echo "x-${{ steps.semver_parser.outputs.prerelease }}" + fi - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 @@ -395,6 +431,7 @@ jobs: UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }} GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }} NFPM_NETBIRD_UI_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }} + SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }} - name: Verify RPM signatures run: | docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c ' diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 5ea479148..5031ef446 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,5 +1,7 @@ version: 2 - +env: + - SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }} + - SKIP_DOCKER_PUSH={{ if index .Env "SKIP_DOCKER_PUSH" }}{{ .Env.SKIP_DOCKER_PUSH }}{{ else }}false{{ end }} project_name: netbird builds: - id: netbird-wasm @@ -74,6 +76,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" @@ -88,6 +92,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" @@ -102,6 +108,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" @@ -122,6 +130,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" @@ -136,6 +146,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" @@ -150,6 +162,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 ldflags: - -s -w -X main.Version={{.Version}} -X main.Commit={{.Commit}} -X main.BuildDate={{.CommitDate}} mod_timestamp: "{{ .CommitTimestamp }}" @@ -170,6 +184,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" @@ -222,670 +238,192 @@ nfpms: rpm: signature: key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}' -dockers: - - image_templates: - - netbirdio/netbird:{{ .Version }}-amd64 - - ghcr.io/netbirdio/netbird:{{ .Version }}-amd64 - ids: - - netbird - goarch: amd64 - use: buildx - dockerfile: client/Dockerfile - extra_files: - - client/netbird-entrypoint.sh - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/netbird:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8 - ids: - - netbird - goarch: arm64 - use: buildx - dockerfile: client/Dockerfile - extra_files: - - client/netbird-entrypoint.sh - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/netbird:{{ .Version }}-arm - - ghcr.io/netbirdio/netbird:{{ .Version }}-arm - ids: - - netbird - goarch: arm - goarm: 6 - use: buildx - dockerfile: client/Dockerfile - extra_files: - - client/netbird-entrypoint.sh - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - - image_templates: - - netbirdio/netbird:{{ .Version }}-rootless-amd64 - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64 - ids: - - netbird - goarch: amd64 - use: buildx - dockerfile: client/Dockerfile-rootless - extra_files: - - client/netbird-entrypoint.sh - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/netbird:{{ .Version }}-rootless-arm64v8 - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8 - ids: - - netbird - goarch: arm64 - use: buildx - dockerfile: client/Dockerfile-rootless - extra_files: - - client/netbird-entrypoint.sh - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/netbird:{{ .Version }}-rootless-arm - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm - ids: - - netbird - goarch: arm - goarm: 6 - use: buildx - dockerfile: client/Dockerfile-rootless - extra_files: - - client/netbird-entrypoint.sh - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - - image_templates: - - netbirdio/relay:{{ .Version }}-amd64 - - ghcr.io/netbirdio/relay:{{ .Version }}-amd64 - ids: - - netbird-relay - goarch: amd64 - use: buildx - dockerfile: relay/Dockerfile - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/relay:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8 - ids: - - netbird-relay - goarch: arm64 - use: buildx - dockerfile: relay/Dockerfile - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/relay:{{ .Version }}-arm - - ghcr.io/netbirdio/relay:{{ .Version }}-arm - ids: - - netbird-relay - goarch: arm - goarm: 6 - use: buildx - dockerfile: relay/Dockerfile - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/signal:{{ .Version }}-amd64 - - ghcr.io/netbirdio/signal:{{ .Version }}-amd64 - ids: - - netbird-signal - goarch: amd64 - use: buildx - dockerfile: signal/Dockerfile - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/signal:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8 - ids: - - netbird-signal - goarch: arm64 - use: buildx - dockerfile: signal/Dockerfile - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/signal:{{ .Version }}-arm - - ghcr.io/netbirdio/signal:{{ .Version }}-arm - ids: - - netbird-signal - goarch: arm - goarm: 6 - use: buildx - dockerfile: signal/Dockerfile - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/management:{{ .Version }}-amd64 - - ghcr.io/netbirdio/management:{{ .Version }}-amd64 - ids: - - netbird-mgmt - goarch: amd64 - use: buildx - dockerfile: management/Dockerfile - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/management:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/management:{{ .Version }}-arm64v8 - ids: - - netbird-mgmt - goarch: arm64 - use: buildx - dockerfile: management/Dockerfile - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/management:{{ .Version }}-arm - - ghcr.io/netbirdio/management:{{ .Version }}-arm - ids: - - netbird-mgmt - goarch: arm - goarm: 6 - use: buildx - dockerfile: management/Dockerfile - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/management:{{ .Version }}-debug-amd64 - - ghcr.io/netbirdio/management:{{ .Version }}-debug-amd64 - ids: - - netbird-mgmt - goarch: amd64 - use: buildx - dockerfile: management/Dockerfile.debug - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/management:{{ .Version }}-debug-arm64v8 - - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm64v8 - ids: - - netbird-mgmt - goarch: arm64 - use: buildx - dockerfile: management/Dockerfile.debug - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - - image_templates: - - netbirdio/management:{{ .Version }}-debug-arm - - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm - ids: - - netbird-mgmt - goarch: arm - goarm: 6 - use: buildx - dockerfile: management/Dockerfile.debug - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/upload:{{ .Version }}-amd64 - - ghcr.io/netbirdio/upload:{{ .Version }}-amd64 - ids: - - netbird-upload - goarch: amd64 - use: buildx - dockerfile: upload-server/Dockerfile - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/upload:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8 - ids: - - netbird-upload - goarch: arm64 - use: buildx - dockerfile: upload-server/Dockerfile - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/upload:{{ .Version }}-arm - - ghcr.io/netbirdio/upload:{{ .Version }}-arm - ids: - - netbird-upload - goarch: arm - goarm: 6 - use: buildx - dockerfile: upload-server/Dockerfile - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/netbird-server:{{ .Version }}-amd64 - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64 - ids: - - netbird-server - goarch: amd64 - use: buildx - dockerfile: combined/Dockerfile - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/netbird-server:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8 - ids: - - netbird-server - goarch: arm64 - use: buildx - dockerfile: combined/Dockerfile - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/netbird-server:{{ .Version }}-arm - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm - ids: - - netbird-server - goarch: arm - goarm: 6 - use: buildx - dockerfile: combined/Dockerfile - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/reverse-proxy:{{ .Version }}-amd64 - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64 - ids: - - netbird-proxy - goarch: amd64 - use: buildx - dockerfile: proxy/Dockerfile - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/reverse-proxy:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8 - ids: - - netbird-proxy - goarch: arm64 - use: buildx - dockerfile: proxy/Dockerfile - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/reverse-proxy:{{ .Version }}-arm - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm - ids: - - netbird-proxy - goarch: arm - goarm: 6 - use: buildx - dockerfile: proxy/Dockerfile - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" -docker_manifests: - - name_template: netbirdio/netbird:{{ .Version }} - image_templates: - - netbirdio/netbird:{{ .Version }}-arm64v8 - - netbirdio/netbird:{{ .Version }}-arm - - netbirdio/netbird:{{ .Version }}-amd64 - - - name_template: netbirdio/netbird:latest - image_templates: - - netbirdio/netbird:{{ .Version }}-arm64v8 - - netbirdio/netbird:{{ .Version }}-arm - - netbirdio/netbird:{{ .Version }}-amd64 - - - name_template: netbirdio/netbird:{{ .Version }}-rootless - image_templates: - - netbirdio/netbird:{{ .Version }}-rootless-arm64v8 - - netbirdio/netbird:{{ .Version }}-rootless-arm - - netbirdio/netbird:{{ .Version }}-rootless-amd64 - - - name_template: netbirdio/netbird:rootless-latest - image_templates: - - netbirdio/netbird:{{ .Version }}-rootless-arm64v8 - - netbirdio/netbird:{{ .Version }}-rootless-arm - - netbirdio/netbird:{{ .Version }}-rootless-amd64 - - - name_template: netbirdio/relay:{{ .Version }} - image_templates: - - netbirdio/relay:{{ .Version }}-arm64v8 - - netbirdio/relay:{{ .Version }}-arm - - netbirdio/relay:{{ .Version }}-amd64 - - - name_template: netbirdio/relay:latest - image_templates: - - netbirdio/relay:{{ .Version }}-arm64v8 - - netbirdio/relay:{{ .Version }}-arm - - netbirdio/relay:{{ .Version }}-amd64 - - - name_template: netbirdio/signal:{{ .Version }} - image_templates: - - netbirdio/signal:{{ .Version }}-arm64v8 - - netbirdio/signal:{{ .Version }}-arm - - netbirdio/signal:{{ .Version }}-amd64 - - - name_template: netbirdio/signal:latest - image_templates: - - netbirdio/signal:{{ .Version }}-arm64v8 - - netbirdio/signal:{{ .Version }}-arm - - netbirdio/signal:{{ .Version }}-amd64 - - - name_template: netbirdio/management:{{ .Version }} - image_templates: - - netbirdio/management:{{ .Version }}-arm64v8 - - netbirdio/management:{{ .Version }}-arm - - netbirdio/management:{{ .Version }}-amd64 - - - name_template: netbirdio/management:latest - image_templates: - - netbirdio/management:{{ .Version }}-arm64v8 - - netbirdio/management:{{ .Version }}-arm - - netbirdio/management:{{ .Version }}-amd64 - - - name_template: netbirdio/management:debug-latest - image_templates: - - netbirdio/management:{{ .Version }}-debug-arm64v8 - - netbirdio/management:{{ .Version }}-debug-arm - - netbirdio/management:{{ .Version }}-debug-amd64 - - name_template: netbirdio/upload:{{ .Version }} - image_templates: - - netbirdio/upload:{{ .Version }}-arm64v8 - - netbirdio/upload:{{ .Version }}-arm - - netbirdio/upload:{{ .Version }}-amd64 - - - name_template: netbirdio/upload:latest - image_templates: - - netbirdio/upload:{{ .Version }}-arm64v8 - - netbirdio/upload:{{ .Version }}-arm - - netbirdio/upload:{{ .Version }}-amd64 - - - name_template: netbirdio/netbird-server:{{ .Version }} - image_templates: - - netbirdio/netbird-server:{{ .Version }}-arm64v8 - - netbirdio/netbird-server:{{ .Version }}-arm - - netbirdio/netbird-server:{{ .Version }}-amd64 - - - name_template: netbirdio/netbird-server:latest - image_templates: - - netbirdio/netbird-server:{{ .Version }}-arm64v8 - - netbirdio/netbird-server:{{ .Version }}-arm - - netbirdio/netbird-server:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/netbird:{{ .Version }} - image_templates: - - ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/netbird:{{ .Version }}-arm - - ghcr.io/netbirdio/netbird:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/netbird:latest - image_templates: - - ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/netbird:{{ .Version }}-arm - - ghcr.io/netbirdio/netbird:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/netbird:{{ .Version }}-rootless - image_templates: - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8 - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64 - - - name_template: ghcr.io/netbirdio/netbird:rootless-latest - image_templates: - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8 - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64 - - - name_template: ghcr.io/netbirdio/relay:{{ .Version }} - image_templates: - - ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/relay:{{ .Version }}-arm - - ghcr.io/netbirdio/relay:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/relay:latest - image_templates: - - ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/relay:{{ .Version }}-arm - - ghcr.io/netbirdio/relay:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/signal:{{ .Version }} - image_templates: - - ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/signal:{{ .Version }}-arm - - ghcr.io/netbirdio/signal:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/signal:latest - image_templates: - - ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/signal:{{ .Version }}-arm - - ghcr.io/netbirdio/signal:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/management:{{ .Version }} - image_templates: - - ghcr.io/netbirdio/management:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/management:{{ .Version }}-arm - - ghcr.io/netbirdio/management:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/management:latest - image_templates: - - ghcr.io/netbirdio/management:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/management:{{ .Version }}-arm - - ghcr.io/netbirdio/management:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/management:debug-latest - image_templates: - - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm64v8 - - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm - - ghcr.io/netbirdio/management:{{ .Version }}-debug-amd64 - - - name_template: ghcr.io/netbirdio/upload:{{ .Version }} - image_templates: - - ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/upload:{{ .Version }}-arm - - ghcr.io/netbirdio/upload:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/upload:latest - image_templates: - - ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/upload:{{ .Version }}-arm - - ghcr.io/netbirdio/upload:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/netbird-server:{{ .Version }} - image_templates: - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/netbird-server:latest - image_templates: - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64 - - - name_template: netbirdio/reverse-proxy:{{ .Version }} - image_templates: - - netbirdio/reverse-proxy:{{ .Version }}-arm64v8 - - netbirdio/reverse-proxy:{{ .Version }}-arm - - netbirdio/reverse-proxy:{{ .Version }}-amd64 - - - name_template: netbirdio/reverse-proxy:latest - image_templates: - - netbirdio/reverse-proxy:{{ .Version }}-arm64v8 - - netbirdio/reverse-proxy:{{ .Version }}-arm - - netbirdio/reverse-proxy:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/reverse-proxy:{{ .Version }} - image_templates: - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/reverse-proxy:latest - image_templates: - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64 +dockers_v2: + - id: netbird + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird + images: + - netbirdio/netbird + - ghcr.io/netbirdio/netbird + tags: + - "v{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: client/Dockerfile + extra_files: + - client/netbird-entrypoint.sh + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm/6 + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: netbird-rootless + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird + images: + - netbirdio/netbird + - ghcr.io/netbirdio/netbird + tags: + - "v{{ .Version }}-rootless" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: client/Dockerfile-rootless + extra_files: + - client/netbird-entrypoint.sh + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm/6 + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: relay + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-relay + images: + - netbirdio/relay + - ghcr.io/netbirdio/relay + tags: + - "v{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: relay/Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: signal + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-signal + images: + - netbirdio/signal + - ghcr.io/netbirdio/signal + tags: + - "v{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: signal/Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: management + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-mgmt + images: + - netbirdio/management + - ghcr.io/netbirdio/management + tags: + - "v{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: management/Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: upload + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-upload + images: + - netbirdio/upload + - ghcr.io/netbirdio/upload + tags: + - "v{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: upload-server/Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: netbird-server + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-server + images: + - netbirdio/netbird-server + - ghcr.io/netbirdio/netbird-server + tags: + - "v{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: combined/Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: netbird-proxy + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-proxy + images: + - netbirdio/reverse-proxy + - ghcr.io/netbirdio/reverse-proxy + tags: + - "v{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: proxy/Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" brews: - ids: - default + skip_upload: "{{ .Env.SKIP_PUBLISH }}" repository: owner: netbirdio name: homebrew-tap @@ -902,6 +440,7 @@ brews: uploads: - name: debian + skip: "{{ .Env.SKIP_PUBLISH }}" ids: - netbird_deb mode: archive @@ -910,6 +449,7 @@ uploads: method: PUT - name: yum + skip: "{{ .Env.SKIP_PUBLISH }}" ids: - netbird_rpm mode: archive diff --git a/.goreleaser_ui.yaml b/.goreleaser_ui.yaml index 470f1deaa..6f9b7c059 100644 --- a/.goreleaser_ui.yaml +++ b/.goreleaser_ui.yaml @@ -1,5 +1,6 @@ version: 2 - +env: + - SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }} project_name: netbird-ui builds: - id: netbird-ui @@ -101,6 +102,7 @@ nfpms: uploads: - name: debian + skip: "{{ .Env.SKIP_PUBLISH }}" ids: - netbird_ui_deb mode: archive @@ -109,6 +111,7 @@ uploads: method: PUT - name: yum + skip: "{{ .Env.SKIP_PUBLISH }}" ids: - netbird_ui_rpm mode: archive diff --git a/client/Dockerfile b/client/Dockerfile index 53e4555ef..478b2d0e2 100644 --- a/client/Dockerfile +++ b/client/Dockerfile @@ -4,7 +4,7 @@ # sudo podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client . # sudo podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest -FROM alpine:3.23.3 +FROM alpine:3.24 # iproute2: busybox doesn't display ip rules properly RUN apk add --no-cache \ bash \ @@ -21,7 +21,7 @@ ENV \ NB_ENTRYPOINT_SERVICE_TIMEOUT="30" ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ] - -ARG NETBIRD_BINARY=netbird +ARG TARGETPLATFORM +ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh COPY "${NETBIRD_BINARY}" /usr/local/bin/netbird diff --git a/client/Dockerfile-rootless b/client/Dockerfile-rootless index 706bf40de..8141af6ed 100644 --- a/client/Dockerfile-rootless +++ b/client/Dockerfile-rootless @@ -4,7 +4,7 @@ # podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client . # podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest -FROM alpine:3.22.0 +FROM alpine:3.24 RUN apk add --no-cache \ bash \ @@ -27,7 +27,7 @@ ENV \ NB_ENTRYPOINT_SERVICE_TIMEOUT="30" ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ] - -ARG NETBIRD_BINARY=netbird +ARG TARGETPLATFORM +ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh COPY "${NETBIRD_BINARY}" /usr/local/bin/netbird diff --git a/combined/Dockerfile b/combined/Dockerfile index 357e10cf8..ac88b8509 100644 --- a/combined/Dockerfile +++ b/combined/Dockerfile @@ -2,4 +2,5 @@ FROM ubuntu:24.04 RUN apt update && apt install -y ca-certificates && rm -fr /var/cache/apt ENTRYPOINT [ "/go/bin/netbird-server" ] CMD ["--config", "/etc/netbird/config.yaml"] -COPY netbird-server /go/bin/netbird-server \ No newline at end of file +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/netbird-server /go/bin/netbird-server diff --git a/management/Dockerfile b/management/Dockerfile index 3b2df2623..fe414158c 100644 --- a/management/Dockerfile +++ b/management/Dockerfile @@ -2,4 +2,5 @@ FROM ubuntu:24.04 RUN apt update && apt install -y ca-certificates && rm -fr /var/cache/apt ENTRYPOINT [ "/go/bin/netbird-mgmt","management"] CMD ["--log-file", "console"] -COPY netbird-mgmt /go/bin/netbird-mgmt +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/netbird-mgmt /go/bin/netbird-mgmt diff --git a/management/Dockerfile.debug b/management/Dockerfile.debug deleted file mode 100644 index 4d9730bd7..000000000 --- a/management/Dockerfile.debug +++ /dev/null @@ -1,5 +0,0 @@ -FROM ubuntu:24.04 -RUN apt update && apt install -y ca-certificates && rm -fr /var/cache/apt -ENTRYPOINT [ "/go/bin/netbird-mgmt","management","--log-level","debug"] -CMD ["--log-file", "console"] -COPY netbird-mgmt /go/bin/netbird-mgmt diff --git a/proxy/Dockerfile b/proxy/Dockerfile index e64680fd6..22c4cbfaa 100644 --- a/proxy/Dockerfile +++ b/proxy/Dockerfile @@ -7,7 +7,8 @@ RUN echo "netbird:x:1000:1000:netbird:/var/lib/netbird:/sbin/nologin" > /tmp/pas mkdir -p /tmp/certs FROM gcr.io/distroless/base:debug -COPY netbird-proxy /go/bin/netbird-proxy +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/netbird-proxy /go/bin/netbird-proxy COPY --from=builder /tmp/passwd /etc/passwd COPY --from=builder /tmp/group /etc/group COPY --from=builder --chown=1000:1000 /tmp/var/lib/netbird /var/lib/netbird diff --git a/relay/Dockerfile b/relay/Dockerfile index f750027c3..757ee7b59 100644 --- a/relay/Dockerfile +++ b/relay/Dockerfile @@ -1,4 +1,5 @@ FROM gcr.io/distroless/base:debug ENTRYPOINT [ "/go/bin/netbird-relay" ] ENV NB_LOG_FILE=console -COPY netbird-relay /go/bin/netbird-relay +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/netbird-relay /go/bin/netbird-relay diff --git a/signal/Dockerfile b/signal/Dockerfile index 4fd5fe4a3..f6504dc74 100644 --- a/signal/Dockerfile +++ b/signal/Dockerfile @@ -1,4 +1,5 @@ FROM gcr.io/distroless/base:debug ENTRYPOINT [ "/go/bin/netbird-signal","run" ] CMD ["--log-file", "console"] -COPY netbird-signal /go/bin/netbird-signal +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/netbird-signal /go/bin/netbird-signal diff --git a/upload-server/Dockerfile b/upload-server/Dockerfile index a38c6fbb8..3713d6f2a 100644 --- a/upload-server/Dockerfile +++ b/upload-server/Dockerfile @@ -1,3 +1,4 @@ FROM gcr.io/distroless/base:debug ENTRYPOINT [ "/go/bin/netbird-upload" ] -COPY netbird-upload /go/bin/netbird-upload +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/netbird-upload /go/bin/netbird-upload From ee360963f96f5feec295102f4f8a1cabc71f1410 Mon Sep 17 00:00:00 2001 From: Theodor Midtlien Date: Thu, 18 Jun 2026 08:49:19 +0200 Subject: [PATCH 08/20] [client] Migrate profile identity from display name to ID and allow renaming of profiles (#6367) * Migrate to profile ids * Migrate android profile manager * Clean up * Fix review * Add ID type * Fix test and runes in ShortID() * Fix profile switch on up and android comments * Revert android profile to string id * Fix feedback * Fix UI feedback * Fix id assignment * Add renaming of profiles * Fix review * Remove ui binary * Fix getProfileConfigPath not validating id * Change resolve handle order and fix server merge problems * Fix mdm test --- client/android/profile_manager.go | 102 +-- client/cmd/login.go | 39 +- client/cmd/login_test.go | 2 +- client/cmd/profile.go | 202 ++++-- client/cmd/root.go | 1 + client/cmd/up.go | 18 +- client/cmd/up_daemon_test.go | 4 +- client/internal/debug/debug_test.go | 1 + client/internal/profilemanager/config.go | 14 + client/internal/profilemanager/id.go | 118 ++++ .../internal/profilemanager/profilemanager.go | 61 +- .../profilemanager/profilemanager_test.go | 8 +- client/internal/profilemanager/service.go | 425 ++++++++--- .../internal/profilemanager/service_test.go | 230 ++++++ client/internal/profilemanager/state.go | 18 +- client/proto/daemon.pb.go | 666 +++++++++++------- client/proto/daemon.proto | 42 +- client/proto/daemon_grpc.pb.go | 38 + client/server/login_overrides_test.go | 2 +- client/server/server.go | 247 ++++--- client/server/server_test.go | 6 +- client/server/setconfig_mdm_test.go | 8 +- client/server/setconfig_test.go | 6 +- client/ui/client_ui.go | 14 +- client/ui/profile.go | 64 +- 25 files changed, 1712 insertions(+), 624 deletions(-) create mode 100644 client/internal/profilemanager/id.go create mode 100644 client/internal/profilemanager/service_test.go diff --git a/client/android/profile_manager.go b/client/android/profile_manager.go index 60e4d5c32..87c001396 100644 --- a/client/android/profile_manager.go +++ b/client/android/profile_manager.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "path/filepath" - "strings" log "github.com/sirupsen/logrus" @@ -24,6 +23,7 @@ const ( // Profile represents a profile for gomobile type Profile struct { + ID string Name string IsActive bool } @@ -53,10 +53,10 @@ func (p *ProfileArray) Get(i int) *Profile { ├── state.json ← Default profile state ├── active_profile.json ← Active profile tracker (JSON with Name + Username) └── profiles/ ← Subdirectory for non-default profiles - ├── work.json ← Work profile config - ├── work.state.json ← Work profile state - ├── personal.json ← Personal profile config - └── personal.state.json ← Personal profile state + ├── work.json ← Legacy work profile config + ├── work.state.json ← Legacy work profile state + ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.json ← ID profile config + ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.state.json ← ID profile state */ // ProfileManager manages profiles for Android @@ -99,6 +99,7 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) { var profiles []*Profile for _, p := range internalProfiles { profiles = append(profiles, &Profile{ + ID: p.ID.String(), Name: p.Name, IsActive: p.IsActive, }) @@ -108,55 +109,65 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) { } // GetActiveProfile returns the currently active profile name -func (pm *ProfileManager) GetActiveProfile() (string, error) { +func (pm *ProfileManager) GetActiveProfile() (*Profile, error) { // Use ServiceManager to stay consistent with ListProfiles // ServiceManager uses active_profile.json activeState, err := pm.serviceMgr.GetActiveProfileState() if err != nil { - return "", fmt.Errorf("failed to get active profile: %w", err) + return nil, fmt.Errorf("failed to get active profile: %w", err) } - return activeState.Name, nil + + // ActiveProfileState only stores the ID (and username), not the display + // name. Resolve the ID to the full profile so callers get the real Name. + prof, err := pm.serviceMgr.ResolveProfile(activeState.ID.String(), androidUsername) + if err != nil { + return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err) + } + return &Profile{ID: prof.ID.String(), Name: prof.Name, IsActive: true}, nil } // SwitchProfile switches to a different profile -func (pm *ProfileManager) SwitchProfile(profileName string) error { +func (pm *ProfileManager) SwitchProfile(id string) error { // Use ServiceManager to stay consistent with ListProfiles // ServiceManager uses active_profile.json err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: profileName, + ID: profilemanager.ID(id), Username: androidUsername, }) if err != nil { return fmt.Errorf("failed to switch profile: %w", err) } - log.Infof("switched to profile: %s", profileName) + log.Infof("switched to profile: %s", id) return nil } // AddProfile creates a new profile func (pm *ProfileManager) AddProfile(profileName string) error { // Use ServiceManager (creates profile in profiles/ directory) - if err := pm.serviceMgr.AddProfile(profileName, androidUsername); err != nil { + profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername) + if err != nil { return fmt.Errorf("failed to add profile: %w", err) } - log.Infof("created new profile: %s", profileName) + log.Infof("created new profile: %s", profile.ID) return nil } // LogoutProfile logs out from a profile (clears authentication) -func (pm *ProfileManager) LogoutProfile(profileName string) error { - profileName = sanitizeProfileName(profileName) - - configPath, err := pm.getProfileConfigPath(profileName) +func (pm *ProfileManager) LogoutProfile(id string) error { + configPath, err := pm.getProfileConfigPath(id) if err != nil { return err } + if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) { + return fmt.Errorf("id '%s' is not valid", id) + } + // Check if profile exists if _, err := os.Stat(configPath); os.IsNotExist(err) { - return fmt.Errorf("profile '%s' does not exist", profileName) + return fmt.Errorf("profile '%s' does not exist", id) } // Read current config using internal profilemanager @@ -174,53 +185,57 @@ func (pm *ProfileManager) LogoutProfile(profileName string) error { return fmt.Errorf("failed to save config: %w", err) } - log.Infof("logged out from profile: %s", profileName) + log.Infof("logged out from profile: %s", id) return nil } // RemoveProfile deletes a profile -func (pm *ProfileManager) RemoveProfile(profileName string) error { +func (pm *ProfileManager) RemoveProfile(id string) error { // Use ServiceManager (removes profile from profiles/ directory) - if err := pm.serviceMgr.RemoveProfile(profileName, androidUsername); err != nil { + if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), androidUsername); err != nil { return fmt.Errorf("failed to remove profile: %w", err) } - log.Infof("removed profile: %s", profileName) + log.Infof("removed profile: %s", id) return nil } // getProfileConfigPath returns the config file path for a profile // This is needed for Android-specific path handling (netbird.cfg for default profile) -func (pm *ProfileManager) getProfileConfigPath(profileName string) (string, error) { - if profileName == "" || profileName == profilemanager.DefaultProfileName { +func (pm *ProfileManager) getProfileConfigPath(id string) (string, error) { + if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) { + return "", fmt.Errorf("id %q is not valid", id) + } + + if id == profilemanager.DefaultProfileName { // Android uses netbird.cfg for default profile instead of default.json // Default profile is stored in root configDir, not in profiles/ return filepath.Join(pm.configDir, defaultConfigFilename), nil } - // Non-default profiles are stored in profiles subdirectory - // This matches the Java Preferences.java expectation - profileName = sanitizeProfileName(profileName) profilesDir := filepath.Join(pm.configDir, profilesSubdir) - return filepath.Join(profilesDir, profileName+".json"), nil + return filepath.Join(profilesDir, id+".json"), nil } -// GetConfigPath returns the config file path for a given profile +// GetConfigPath returns the config file path for a given profile id // Java should call this instead of constructing paths with Preferences.configFile() -func (pm *ProfileManager) GetConfigPath(profileName string) (string, error) { - return pm.getProfileConfigPath(profileName) +func (pm *ProfileManager) GetConfigPath(id string) (string, error) { + return pm.getProfileConfigPath(id) } // GetStateFilePath returns the state file path for a given profile // Java should call this instead of constructing paths with Preferences.stateFile() -func (pm *ProfileManager) GetStateFilePath(profileName string) (string, error) { - if profileName == "" || profileName == profilemanager.DefaultProfileName { +func (pm *ProfileManager) GetStateFilePath(id string) (string, error) { + if id == "" || id == profilemanager.DefaultProfileName { return filepath.Join(pm.configDir, "state.json"), nil } - profileName = sanitizeProfileName(profileName) + if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) { + return "", fmt.Errorf("id %q is not valid", id) + } + profilesDir := filepath.Join(pm.configDir, profilesSubdir) - return filepath.Join(profilesDir, profileName+".state.json"), nil + return filepath.Join(profilesDir, id+".state.json"), nil } // GetActiveConfigPath returns the config file path for the currently active profile @@ -230,7 +245,7 @@ func (pm *ProfileManager) GetActiveConfigPath() (string, error) { if err != nil { return "", fmt.Errorf("failed to get active profile: %w", err) } - return pm.GetConfigPath(activeProfile) + return pm.GetConfigPath(activeProfile.ID) } // GetActiveStateFilePath returns the state file path for the currently active profile @@ -240,18 +255,5 @@ func (pm *ProfileManager) GetActiveStateFilePath() (string, error) { if err != nil { return "", fmt.Errorf("failed to get active profile: %w", err) } - return pm.GetStateFilePath(activeProfile) -} - -// sanitizeProfileName removes invalid characters from profile name -func sanitizeProfileName(name string) string { - // Keep only alphanumeric, underscore, and hyphen - var result strings.Builder - for _, r := range name { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || - (r >= '0' && r <= '9') || r == '_' || r == '-' { - result.WriteRune(r) - } - } - return result.String() + return pm.GetStateFilePath(activeProfile.ID) } diff --git a/client/cmd/login.go b/client/cmd/login.go index bd37e30f1..2f7677901 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -96,17 +96,19 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str dnsLabelsReq = dnsLabelsValidated.ToSafeStringList() } + handle := activeProf.ID.String() + loginRequest := proto.LoginRequest{ SetupKey: providedSetupKey, ManagementUrl: managementURL, IsUnixDesktopClient: isUnixRunningDesktop(), Hostname: hostName, DnsLabels: dnsLabelsReq, - ProfileName: &activeProf.Name, + ProfileName: &handle, Username: &username, } - profileState, err := pm.GetProfileState(activeProf.Name) + profileState, err := pm.GetProfileState(activeProf.ID) if err != nil { log.Debugf("failed to get profile state for login hint: %v", err) } else if profileState.Email != "" { @@ -170,14 +172,13 @@ func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, pr return activeProf, nil } -func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, profileName string, username string) error { - err := switchProfile(context.Background(), profileName, username) +func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, handle string, username string) error { + resolvedID, err := switchProfile(ctx, handle, username) if err != nil { return fmt.Errorf("switch profile on daemon: %v", err) } - err = pm.SwitchProfile(profileName) - if err != nil { + if err := pm.SwitchProfile(resolvedID); err != nil { return fmt.Errorf("switch profile: %v", err) } @@ -205,11 +206,15 @@ func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManage return nil } -func switchProfile(ctx context.Context, profileName string, username string) error { +// switchProfile asks the daemon to switch to the profile identified by +// handle (a name, ID, or unique ID prefix). Returns the resolved profile +// ID so the caller can update the local active-profile state without +// re-resolving the handle. +func switchProfile(ctx context.Context, handle string, username string) (profilemanager.ID, error) { conn, err := DialClientGRPCServer(ctx, daemonAddr) if err != nil { //nolint - return fmt.Errorf("failed to connect to daemon error: %v\n"+ + return "", fmt.Errorf("failed to connect to daemon error: %v\n"+ "If the daemon is not running please run: "+ "\nnetbird service install \nnetbird service start\n", err) } @@ -217,15 +222,15 @@ func switchProfile(ctx context.Context, profileName string, username string) err client := proto.NewDaemonServiceClient(conn) - _, err = client.SwitchProfile(ctx, &proto.SwitchProfileRequest{ - ProfileName: &profileName, + resp, err := client.SwitchProfile(ctx, &proto.SwitchProfileRequest{ + ProfileName: &handle, Username: &username, }) if err != nil { - return fmt.Errorf("switch profile failed: %v", err) + return "", fmt.Errorf("switch profile failed: %v", err) } - return nil + return profilemanager.ID(resp.Id), nil } func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, activeProf *profilemanager.Profile) error { @@ -249,7 +254,7 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, return fmt.Errorf("read config file %s: %v", configFilePath, err) } - err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.Name) + err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.ID) if err != nil { return fmt.Errorf("foreground login failed: %v", err) } @@ -277,7 +282,7 @@ func handleSSOLogin(ctx context.Context, cmd *cobra.Command, loginResp *proto.Lo return nil } -func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, setupKey, profileName string) error { +func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, setupKey string, profileID profilemanager.ID) error { authClient, err := auth.NewAuth(ctx, config.PrivateKey, config.ManagementURL, config) if err != nil { return fmt.Errorf("failed to create auth client: %v", err) @@ -291,7 +296,7 @@ func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profileman jwtToken := "" if setupKey == "" && needsLogin { - tokenInfo, err := foregroundGetTokenInfo(ctx, cmd, config, profileName) + tokenInfo, err := foregroundGetTokenInfo(ctx, cmd, config, profileID) if err != nil { return fmt.Errorf("interactive sso login failed: %v", err) } @@ -306,10 +311,10 @@ func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profileman return nil } -func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, profileName string) (*auth.TokenInfo, error) { +func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, profileID profilemanager.ID) (*auth.TokenInfo, error) { hint := "" pm := profilemanager.NewProfileManager() - profileState, err := pm.GetProfileState(profileName) + profileState, err := pm.GetProfileState(profileID) if err != nil { log.Debugf("failed to get profile state for login hint: %v", err) } else if profileState.Email != "" { diff --git a/client/cmd/login_test.go b/client/cmd/login_test.go index 47522e189..0aa1856b1 100644 --- a/client/cmd/login_test.go +++ b/client/cmd/login_test.go @@ -27,7 +27,7 @@ func TestLogin(t *testing.T) { profilemanager.ActiveProfileStatePath = tempDir + "/active_profile.json" sm := profilemanager.ServiceManager{} err = sm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: "default", + ID: "default", Username: currUser.Username, }) if err != nil { diff --git a/client/cmd/profile.go b/client/cmd/profile.go index d6e81760f..4de2d754e 100644 --- a/client/cmd/profile.go +++ b/client/cmd/profile.go @@ -2,11 +2,16 @@ package cmd import ( "context" + "errors" "fmt" "os/user" + "strings" + "text/tabwriter" "time" "github.com/spf13/cobra" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/profilemanager" @@ -14,6 +19,8 @@ import ( "github.com/netbirdio/netbird/util" ) +var profileListShowID bool + var profileCmd = &cobra.Command{ Use: "profile", Short: "Manage NetBird client profiles", @@ -31,27 +38,40 @@ var profileListCmd = &cobra.Command{ var profileAddCmd = &cobra.Command{ Use: "add ", Short: "Add a new profile", - Long: `Add a new profile to the NetBird client. The profile name must be unique.`, + Long: `Add a new profile. Profile name is free-form, a unique ID is generated for the on-disk config file.`, Args: cobra.ExactArgs(1), RunE: addProfileFunc, } +var profileRenameCmd = &cobra.Command{ + Use: "rename ", + Short: "Renames an existing profile", + Long: `Renames an existing profile (by a name, ID, or unique ID prefix). Profile name is free-form.`, + Args: cobra.ExactArgs(2), + RunE: renameProfileFunc, +} + var profileRemoveCmd = &cobra.Command{ - Use: "remove ", - Short: "Remove a profile", - Long: `Remove a profile from the NetBird client. The profile must not be inactive.`, - Args: cobra.ExactArgs(1), - RunE: removeProfileFunc, + Use: "remove ", + Short: "Remove a profile", + Long: `Remove a profile by name, ID, or unique ID prefix.`, + Aliases: []string{"rm"}, + Args: cobra.ExactArgs(1), + RunE: removeProfileFunc, } var profileSelectCmd = &cobra.Command{ - Use: "select ", + Use: "select ", Short: "Select a profile", - Long: `Make the specified profile active. This will switch the client to use the selected profile's configuration.`, + Long: `Make the specified profile active. Accepts a name, ID, or unique ID prefix.`, Args: cobra.ExactArgs(1), RunE: selectProfileFunc, } +func init() { + profileListCmd.Flags().BoolVar(&profileListShowID, "show-id", false, "show the profile ID column") +} + func setupCmd(cmd *cobra.Command) error { SetFlagsFromEnvVars(rootCmd) SetFlagsFromEnvVars(cmd) @@ -65,6 +85,7 @@ func setupCmd(cmd *cobra.Command) error { return nil } + func listProfilesFunc(cmd *cobra.Command, _ []string) error { if err := setupCmd(cmd); err != nil { return err @@ -83,25 +104,33 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error { daemonClient := proto.NewDaemonServiceClient(conn) - profiles, err := daemonClient.ListProfiles(cmd.Context(), &proto.ListProfilesRequest{ + resp, err := daemonClient.ListProfiles(cmd.Context(), &proto.ListProfilesRequest{ Username: currUser.Username, }) if err != nil { return err } - // list profiles, add a tick if the profile is active - cmd.Println("Found", len(profiles.Profiles), "profiles:") - for _, profile := range profiles.Profiles { - // use a cross to indicate the passive profiles - activeMarker := "✗" - if profile.IsActive { - activeMarker = "✓" - } - cmd.Println(activeMarker, profile.Name) + tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + if profileListShowID { + fmt.Fprintln(tw, "ID\tNAME\tACTIVE") + } else { + fmt.Fprintln(tw, "NAME\tACTIVE") } - - return nil + for _, profile := range resp.Profiles { + marker := "" + if profile.IsActive { + marker = "✓" + } + name := profilemanager.StripCtrlChars(profile.Name) + id := profilemanager.ID(profile.Id) + if profileListShowID { + fmt.Fprintf(tw, "%s\t%s\t%s\n", id.ShortID(), name, marker) + } else { + fmt.Fprintf(tw, "%s\t%s\n", name, marker) + } + } + return tw.Flush() } func addProfileFunc(cmd *cobra.Command, args []string) error { @@ -121,21 +150,82 @@ func addProfileFunc(cmd *cobra.Command, args []string) error { } daemonClient := proto.NewDaemonServiceClient(conn) - profileName := args[0] - _, err = daemonClient.AddProfile(cmd.Context(), &proto.AddProfileRequest{ + resp, err := daemonClient.AddProfile(cmd.Context(), &proto.AddProfileRequest{ ProfileName: profileName, Username: currUser.Username, }) if err != nil { + return fmt.Errorf("add profile request: %w", err) + } + + dupCount, _ := countProfilesWithName(cmd.Context(), daemonClient, currUser.Username, profileName) + if dupCount > 1 { + cmd.Printf("Warning: %d other profile(s) already use the name %q.\n", dupCount-1, profileName) + cmd.Println("Use `netbird profile list --show-id` to disambiguate later.") + } + + id := profilemanager.ID(resp.Id) + cmd.Printf("Profile added: %s %s\n", id.ShortID(), profilemanager.StripCtrlChars(profileName)) + return nil + +} + +func renameProfileFunc(cmd *cobra.Command, args []string) error { + if err := setupCmd(cmd); err != nil { return err } - cmd.Println("Profile added successfully:", profileName) + conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr) + if err != nil { + return fmt.Errorf("connect to service CLI interface: %w", err) + } + defer conn.Close() + + currUser, err := user.Current() + if err != nil { + return fmt.Errorf("get current user: %w", err) + } + + daemonClient := proto.NewDaemonServiceClient(conn) + handle := args[0] + newProfilename := args[1] + + resp, err := daemonClient.RenameProfile(cmd.Context(), &proto.RenameProfileRequest{ + Handle: handle, + Username: currUser.Username, + NewProfileName: newProfilename, + }) + if err != nil { + return wrapAmbiguityError(err, handle) + } + + dupCount, _ := countProfilesWithName(cmd.Context(), daemonClient, currUser.Username, newProfilename) + if dupCount > 1 { + cmd.Printf("Warning: %d other profile(s) already use the name %q.\n", dupCount-1, newProfilename) + cmd.Println("Use `netbird profile list --show-id` to disambiguate later.") + } + + cmd.Printf("Profile renamed from %s to %s\n", profilemanager.StripCtrlChars(resp.OldProfileName), profilemanager.StripCtrlChars(newProfilename)) + return nil } +func countProfilesWithName(ctx context.Context, c proto.DaemonServiceClient, username, name string) (int, error) { + resp, err := c.ListProfiles(ctx, &proto.ListProfilesRequest{Username: username}) + if err != nil { + return 0, err + } + n := 0 + for _, p := range resp.Profiles { + if p.Name == name { + n++ + } + } + return n, nil +} + func removeProfileFunc(cmd *cobra.Command, args []string) error { if err := setupCmd(cmd); err != nil { return err @@ -153,18 +243,17 @@ func removeProfileFunc(cmd *cobra.Command, args []string) error { } daemonClient := proto.NewDaemonServiceClient(conn) + handle := args[0] - profileName := args[0] - - _, err = daemonClient.RemoveProfile(cmd.Context(), &proto.RemoveProfileRequest{ - ProfileName: profileName, + resp, err := daemonClient.RemoveProfile(cmd.Context(), &proto.RemoveProfileRequest{ + ProfileName: handle, Username: currUser.Username, }) if err != nil { - return err + return wrapAmbiguityError(err, handle) } - cmd.Println("Profile removed successfully:", profileName) + cmd.Printf("Profile removed: %s\n", resp.Id) return nil } @@ -174,7 +263,7 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error { } profileManager := profilemanager.NewProfileManager() - profileName := args[0] + handle := args[0] currUser, err := user.Current() if err != nil { @@ -191,32 +280,15 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error { daemonClient := proto.NewDaemonServiceClient(conn) - profiles, err := daemonClient.ListProfiles(ctx, &proto.ListProfilesRequest{ - Username: currUser.Username, + switchResp, err := daemonClient.SwitchProfile(ctx, &proto.SwitchProfileRequest{ + ProfileName: &handle, + Username: &currUser.Username, }) if err != nil { - return fmt.Errorf("list profiles: %w", err) + return wrapAmbiguityError(err, handle) } - var profileExists bool - - for _, profile := range profiles.Profiles { - if profile.Name == profileName { - profileExists = true - break - } - } - - if !profileExists { - return fmt.Errorf("profile %s does not exist", profileName) - } - - if err := switchProfile(cmd.Context(), profileName, currUser.Username); err != nil { - return err - } - - err = profileManager.SwitchProfile(profileName) - if err != nil { + if err := profileManager.SwitchProfile(profilemanager.ID(switchResp.Id)); err != nil { return err } @@ -231,6 +303,30 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error { } } - cmd.Println("Profile switched successfully to:", profileName) + id := profilemanager.ID(switchResp.Id) + cmd.Printf("Profile switched to: %s\n", id.ShortID()) return nil } + +// wrapAmbiguityError turns the daemon's gRPC InvalidArgument errors +// (which carry the resolver's message verbatim) into CLI-friendly text +// that points the user at --show-id. +func wrapAmbiguityError(err error, handle string) error { + if err == nil { + return nil + } + st, ok := gstatus.FromError(err) + if !ok { + return err + } + switch st.Code() { + case codes.InvalidArgument: + msg := st.Message() + if strings.Contains(msg, "ambiguous") { + return errors.New(msg + "\nRun `netbird profile list --show-id` to see IDs, then select by ID prefix:\n netbird profile select|remove ") + } + case codes.NotFound: + return fmt.Errorf("profile %q not found", handle) + } + return err +} diff --git a/client/cmd/root.go b/client/cmd/root.go index b1d960bec..f3fde2f1c 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -190,6 +190,7 @@ func init() { // profile commands profileCmd.AddCommand(profileListCmd) profileCmd.AddCommand(profileAddCmd) + profileCmd.AddCommand(profileRenameCmd) profileCmd.AddCommand(profileRemoveCmd) profileCmd.AddCommand(profileSelectCmd) diff --git a/client/cmd/up.go b/client/cmd/up.go index cabd0aacf..2761cf74a 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -128,13 +128,12 @@ func upFunc(cmd *cobra.Command, args []string) error { var profileSwitched bool // switch profile if provided if profileName != "" { - err = switchProfile(cmd.Context(), profileName, username.Username) + resolvedID, err := switchProfile(cmd.Context(), profileName, username.Username) if err != nil { return fmt.Errorf("switch profile: %v", err) } - err = pm.SwitchProfile(profileName) - if err != nil { + if err := pm.SwitchProfile(resolvedID); err != nil { return fmt.Errorf("switch profile: %v", err) } @@ -190,7 +189,7 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr _, _ = profilemanager.UpdateOldManagementURL(ctx, config, configFilePath) - err = foregroundLogin(ctx, cmd, config, providedSetupKey, activeProf.Name) + err = foregroundLogin(ctx, cmd, config, providedSetupKey, activeProf.ID) if err != nil { return fmt.Errorf("foreground login failed: %v", err) } @@ -261,10 +260,10 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager } // set the new config - req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.Name, username.Username) + req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.ID.String(), username.Username) if _, err := client.SetConfig(ctx, req); err != nil { if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unavailable { - log.Warnf("setConfig method is not available in the daemon") + log.Warnf("setConfig method is not available in the daemon: %s", st.Message()) } else { return fmt.Errorf("call service setConfig method: %v", err) } @@ -289,10 +288,11 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ return fmt.Errorf("setup login request: %v", err) } - loginRequest.ProfileName = &activeProf.Name + profileID := activeProf.ID.String() + loginRequest.ProfileName = &profileID loginRequest.Username = &username - profileState, err := pm.GetProfileState(activeProf.Name) + profileState, err := pm.GetProfileState(activeProf.ID) if err != nil { log.Debugf("failed to get profile state for login hint: %v", err) } else if profileState.Email != "" { @@ -329,7 +329,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ } if _, err := client.Up(ctx, &proto.UpRequest{ - ProfileName: &activeProf.Name, + ProfileName: &profileID, Username: &username, }); err != nil { return fmt.Errorf("call service up method: %v", err) diff --git a/client/cmd/up_daemon_test.go b/client/cmd/up_daemon_test.go index 682a45365..ea4cdf162 100644 --- a/client/cmd/up_daemon_test.go +++ b/client/cmd/up_daemon_test.go @@ -29,14 +29,14 @@ func TestUpDaemon(t *testing.T) { } sm := profilemanager.ServiceManager{} - err = sm.AddProfile("test1", currUser.Username) + created, err := sm.AddProfile("test1", currUser.Username) if err != nil { t.Fatalf("failed to add profile: %v", err) return } err = sm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: "test1", + ID: created.ID, Username: currUser.Username, }) if err != nil { diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go index 76df588a5..ca7785d35 100644 --- a/client/internal/debug/debug_test.go +++ b/client/internal/debug/debug_test.go @@ -843,6 +843,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) { "PreSharedKey": "sensitive: WireGuard pre-shared key", "SSHKey": "sensitive: SSH private key", "ClientCertKeyPair": "non-config: parsed cert pair, not serialized", + "Name": "non-config: profile name is not needed for debug purposes", "policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields", } diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index b0c7fd470..a77f0ff32 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -108,6 +108,10 @@ type ConfigInput struct { // Config Configuration type type Config struct { + // Name is the human-readable profile name shown in CLI/UI listings. + // It is independent of the profile's on-disk filename (which is the ID). + Name string + // Wireguard private key of local peer PrivateKey string PreSharedKey string @@ -270,6 +274,16 @@ func createNewConfig(input ConfigInput) (*Config, error) { } func (config *Config) apply(input ConfigInput) (updated bool, err error) { + if config.Name != "" { + sanitized, err := sanitizeDisplayName(config.Name) + if err != nil { + return false, fmt.Errorf("invalid profile name: %w", err) + } + if sanitized != config.Name { + config.Name = sanitized + updated = true + } + } if config.ManagementURL == nil { log.Infof("using default Management URL %s", DefaultManagementURL) config.ManagementURL, err = parseURL("Management URL", DefaultManagementURL) diff --git a/client/internal/profilemanager/id.go b/client/internal/profilemanager/id.go new file mode 100644 index 000000000..3b82c8779 --- /dev/null +++ b/client/internal/profilemanager/id.go @@ -0,0 +1,118 @@ +package profilemanager + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "path/filepath" + "strings" + "unicode" + "unicode/utf8" +) + +const ( + // profileIDByteLen is the number of random bytes generated for a new + // profile ID. The resulting hex string is twice this length. + profileIDByteLen = 16 + + // shortIDLen is the number of leading characters of an ID we render in + // list output. Profiles per device are few, so 8 chars is collision-safe + // in practice and easy to type as a prefix. + shortIDLen = 8 + + // maxProfileNameLen caps the human-readable profile name to keep table + // output legible and prevent denial-of-service via huge JSON fields. + maxProfileNameLen = 128 + + // maxProfileIDLen bounds the on-disk filename we'll accept. New + // IDs are 32 hex chars, legacy stems are sanitized profile names. The + // cap is generous enough to cover both without permitting absurdly + // long filenames. + maxProfileIDLen = 64 +) + +type ID string + +// generateProfileID returns a new random hex ID for a profile file. +func generateProfileID() (ID, error) { + buf := make([]byte, profileIDByteLen) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("read random bytes: %w", err) + } + return ID(hex.EncodeToString(buf)), nil +} + +// IsValidProfileFilenameStem reports whether id is safe to use as the stem +// of a profile JSON filename. +func IsValidProfileFilenameStem(id ID) bool { + s := id.String() + if s == "" || len(s) > maxProfileIDLen { + return false + } + if s == defaultProfileName { + return true + } + if strings.ContainsAny(s, `/\`) || strings.Contains(s, "..") { + return false + } + // filepath.Base catches any leftover separators on platforms with + // exotic path conventions. + if filepath.Base(s) != s { + return false + } + for _, r := range s { + if !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-') { + return false + } + } + return true +} + +// sanitizeDisplayName normalizes a user-supplied profile display name for +// storage. It strips ASCII control characters, rejects invalid UTF-8, and +// caps the length. Emojis, spaces, punctuation, and non-ASCII letters are +// preserved. Returns an error if nothing usable remains. +func sanitizeDisplayName(name string) (string, error) { + if !utf8.ValidString(name) { + return "", fmt.Errorf("name is not valid UTF-8") + } + name = StripCtrlChars(name) + name = strings.TrimSpace(name) + if name == "" { + return "", fmt.Errorf("name is empty after sanitization") + } + if utf8.RuneCountInString(name) > maxProfileNameLen { + return "", fmt.Errorf("name exceeds %d characters", maxProfileNameLen) + } + return name, nil +} + +// StripCtrlChars control characters from a name before printing it. +func StripCtrlChars(name string) string { + var b strings.Builder + b.Grow(len(name)) + for _, r := range name { + // Skip C0 controls and DEL, plus C1 controls (0x80–0x9F). + if r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F) { + continue + } + b.WriteRune(r) + } + return b.String() +} + +// ShortID truncates an ID for display. +func (id ID) ShortID() string { + if id == DefaultProfileName { + return DefaultProfileName + } + runes := []rune(id) + if len(runes) <= shortIDLen { + return id.String() + } + return string(runes[:shortIDLen]) +} + +func (id ID) String() string { + return string(id) +} diff --git a/client/internal/profilemanager/profilemanager.go b/client/internal/profilemanager/profilemanager.go index c87f521cb..e25d493d5 100644 --- a/client/internal/profilemanager/profilemanager.go +++ b/client/internal/profilemanager/profilemanager.go @@ -19,19 +19,41 @@ const ( ) type Profile struct { - Name string + // ID is the on-disk filename stem (without .json). For new profiles + // it is a 32-char hex string; legacy profiles created before the + // ID-keyed layout keep their original name as their ID. The reserved + // value "default" identifies the special default profile. + ID ID + // Name is the human-readable display name. Falls back to ID when the + // underlying JSON has no "name" field set. + Name string + // Path is the absolute path to the profile JSON. Populated by the + // loader so callers do not have to reconstruct it from ID + dir. + Path string IsActive bool } func (p *Profile) FilePath() (string, error) { - if p.Name == "" { - return "", fmt.Errorf("active profile name is empty") + if p.Path != "" { + return p.Path, nil } - if p.Name == defaultProfileName { + id := p.ID + if id == "" { + id = ID(p.Name) + } + if id == "" { + return "", fmt.Errorf("profile ID is empty") + } + + if id == defaultProfileName { return DefaultConfigPath, nil } + if !IsValidProfileFilenameStem(id) { + return "", fmt.Errorf("invalid profile ID: %q", id) + } + username, err := user.Current() if err != nil { return "", fmt.Errorf("failed to get current user: %w", err) @@ -42,10 +64,13 @@ func (p *Profile) FilePath() (string, error) { return "", fmt.Errorf("failed to get config directory for user %s: %w", username.Username, err) } - return filepath.Join(configDir, p.Name+".json"), nil + return filepath.Join(configDir, id.String()+".json"), nil } func (p *Profile) IsDefault() bool { + if p.ID != "" { + return p.ID == defaultProfileName + } return p.Name == defaultProfileName } @@ -57,18 +82,24 @@ func NewProfileManager() *ProfileManager { return &ProfileManager{} } +// GetActiveProfile returns the active profile as recorded in the local +// user state file. Only ID is populated. func (pm *ProfileManager) GetActiveProfile() (*Profile, error) { pm.mu.Lock() defer pm.mu.Unlock() - prof := pm.getActiveProfileState() - return &Profile{Name: prof}, nil + id := pm.getActiveProfileState() + return &Profile{ID: id}, nil } -func (pm *ProfileManager) SwitchProfile(profileName string) error { - profileName = sanitizeProfileName(profileName) +// SwitchProfile records the given profile ID as active in the local user +// state file. +func (pm *ProfileManager) SwitchProfile(id ID) error { + if id != defaultProfileName && !IsValidProfileFilenameStem(id) { + return fmt.Errorf("invalid profile ID: %q", id) + } - if err := pm.setActiveProfileState(profileName); err != nil { + if err := pm.setActiveProfileState(id); err != nil { return fmt.Errorf("failed to switch profile: %w", err) } return nil @@ -85,7 +116,7 @@ func sanitizeProfileName(name string) string { }, name) } -func (pm *ProfileManager) getActiveProfileState() string { +func (pm *ProfileManager) getActiveProfileState() ID { configDir, err := getConfigDir() if err != nil { @@ -113,10 +144,10 @@ func (pm *ProfileManager) getActiveProfileState() string { return defaultProfileName } - return profileName + return ID(profileName) } -func (pm *ProfileManager) setActiveProfileState(profileName string) error { +func (pm *ProfileManager) setActiveProfileState(id ID) error { configDir, err := getConfigDir() if err != nil { @@ -125,7 +156,7 @@ func (pm *ProfileManager) setActiveProfileState(profileName string) error { statePath := filepath.Join(configDir, activeProfileStateFilename) - err = os.WriteFile(statePath, []byte(profileName), 0600) + err = os.WriteFile(statePath, []byte(id), 0600) if err != nil { return fmt.Errorf("failed to write active profile state: %w", err) } @@ -142,7 +173,7 @@ func GetLoginHint() string { return "" } - profileState, err := pm.GetProfileState(activeProf.Name) + profileState, err := pm.GetProfileState(activeProf.ID) if err != nil { log.Debugf("failed to get profile state for login hint: %v", err) return "" diff --git a/client/internal/profilemanager/profilemanager_test.go b/client/internal/profilemanager/profilemanager_test.go index 79a7ae650..882a71d0a 100644 --- a/client/internal/profilemanager/profilemanager_test.go +++ b/client/internal/profilemanager/profilemanager_test.go @@ -50,14 +50,14 @@ func TestServiceManager_CreateAndGetDefaultProfile(t *testing.T) { state, err := sm.GetActiveProfileState() assert.NoError(t, err) - assert.Equal(t, state.Name, defaultProfileName) // No active profile state yet + assert.Equal(t, defaultProfileName, state.ID.String()) // No active profile state yet err = sm.SetActiveProfileStateToDefault() assert.NoError(t, err) active, err := sm.GetActiveProfileState() assert.NoError(t, err) - assert.Equal(t, "default", active.Name) + assert.Equal(t, "default", active.ID.String()) }) }) } @@ -92,14 +92,14 @@ func TestServiceManager_SetActiveProfileState(t *testing.T) { currUser, err := user.Current() assert.NoError(t, err) sm := &ServiceManager{} - state := &ActiveProfileState{Name: "foo", Username: currUser.Username} + state := &ActiveProfileState{ID: "foo", Username: currUser.Username} err = sm.SetActiveProfileState(state) assert.NoError(t, err) // Should error on nil or incomplete state err = sm.SetActiveProfileState(nil) assert.Error(t, err) - err = sm.SetActiveProfileState(&ActiveProfileState{Name: "", Username: ""}) + err = sm.SetActiveProfileState(&ActiveProfileState{ID: "", Username: ""}) assert.Error(t, err) }) }) diff --git a/client/internal/profilemanager/service.go b/client/internal/profilemanager/service.go index ef3eb1114..5ddd11b04 100644 --- a/client/internal/profilemanager/service.go +++ b/client/internal/profilemanager/service.go @@ -2,6 +2,7 @@ package profilemanager import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -23,12 +24,43 @@ var ( DefaultConfigPathDir = "" DefaultConfigPath = "" ActiveProfileStatePath = "" -) -var ( ErrorOldDefaultConfigNotFound = errors.New("old default config not found") ) +// ErrAmbiguousHandle is returned when a profile handle (ID prefix or name) +// matches more than one profile. Callers can render Candidates to help the +// user disambiguate. +type ErrAmbiguousHandle struct { + Handle string + Candidates []Profile + Kind AmbiguityKind +} + +// AmbiguityKind describes which matcher produced the ambiguity, so callers +// can tailor the error message. +type AmbiguityKind int + +const ( + AmbiguityKindIDPrefix AmbiguityKind = iota + AmbiguityKindName +) + +// profileMeta is the minimal slice of a profile JSON we need, so we avoid +// reading all fields +type profileMeta struct { + Name string +} + +func (e *ErrAmbiguousHandle) Error() string { + switch e.Kind { + case AmbiguityKindIDPrefix: + return fmt.Sprintf("ID prefix %q is ambiguous (matches %d profiles)", e.Handle, len(e.Candidates)) + default: + return fmt.Sprintf("name %q is ambiguous (%d profiles share this name)", e.Handle, len(e.Candidates)) + } +} + func init() { DefaultConfigPathDir = "/var/lib/netbird/" @@ -54,25 +86,34 @@ func init() { } type ActiveProfileState struct { - Name string `json:"name"` + // ID is the on-disk filename stem of the active profile. The JSON tag stays + // as "name" for backwards compatibility with active state files written + // before the ID-based config files. Legacy values were profile names, which + // were also the legacy filename stems, so they still resolve to the correct + // file on disk. + ID ID `json:"name"` Username string `json:"username"` } func (a *ActiveProfileState) FilePath() (string, error) { - if a.Name == "" { - return "", fmt.Errorf("active profile name is empty") + if a.ID == "" { + return "", fmt.Errorf("active profile ID is empty") } - if a.Name == defaultProfileName { + if a.ID == defaultProfileName { return DefaultConfigPath, nil } + if !IsValidProfileFilenameStem(a.ID) { + return "", fmt.Errorf("invalid profile ID: %q", a.ID) + } + configDir, err := getConfigDirForUser(a.Username) if err != nil { return "", fmt.Errorf("failed to get config directory for user %s: %w", a.Username, err) } - return filepath.Join(configDir, a.Name+".json"), nil + return filepath.Join(configDir, a.ID.String()+".json"), nil } type ServiceManager struct { @@ -178,7 +219,7 @@ func (s *ServiceManager) GetActiveProfileState() (*ActiveProfileState, error) { return nil, fmt.Errorf("failed to set active profile to default: %w", err) } return &ActiveProfileState{ - Name: "default", + ID: defaultProfileName, Username: "", }, nil } else { @@ -186,12 +227,12 @@ func (s *ServiceManager) GetActiveProfileState() (*ActiveProfileState, error) { } } - if activeProfile.Name == "" { + if activeProfile.ID == "" { if err := s.SetActiveProfileStateToDefault(); err != nil { return nil, fmt.Errorf("failed to set active profile to default: %w", err) } return &ActiveProfileState{ - Name: "default", + ID: defaultProfileName, Username: "", }, nil } @@ -216,25 +257,29 @@ func (s *ServiceManager) setDefaultActiveState() error { } func (s *ServiceManager) SetActiveProfileState(a *ActiveProfileState) error { - if a == nil || a.Name == "" { + if a == nil || a.ID == "" { return errors.New("invalid active profile state") } - if a.Name != defaultProfileName && a.Username == "" { - return fmt.Errorf("username must be set for non-default profiles, got: %s", a.Name) + if a.ID != defaultProfileName && a.Username == "" { + return fmt.Errorf("username must be set for non-default profiles, got: %s", a.ID) + } + + if a.ID != defaultProfileName && !IsValidProfileFilenameStem(a.ID) { + return fmt.Errorf("invalid profile ID: %q", a.ID) } if err := util.WriteJsonWithRestrictedPermission(context.Background(), ActiveProfileStatePath, a); err != nil { return fmt.Errorf("failed to write active profile state: %w", err) } - log.Infof("active profile set to %s for %s", a.Name, a.Username) + log.Infof("active profile set to %s for %s", a.ID, a.Username) return nil } func (s *ServiceManager) SetActiveProfileStateToDefault() error { return s.SetActiveProfileState(&ActiveProfileState{ - Name: "default", + ID: defaultProfileName, Username: "", }) } @@ -243,57 +288,117 @@ func (s *ServiceManager) DefaultProfilePath() string { return DefaultConfigPath } -func (s *ServiceManager) AddProfile(profileName, username string) error { +// AddProfile creates a new profile with a generated ID. The user-supplied +// displayName is stored inside the JSON's name field, the on-disk filename +// uses the generated ID. +// +// The returned Profile carries the freshly-generated ID so callers can +// show it to the user (and so the gRPC AddProfileResponse can include +// it). +func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, error) { configDir, err := s.getConfigDir(username) if err != nil { - return fmt.Errorf("failed to get config directory: %w", err) + return nil, fmt.Errorf("failed to get config directory: %w", err) } - profileName = sanitizeProfileName(profileName) - - if profileName == defaultProfileName { - return fmt.Errorf("cannot create profile with reserved name: %s", defaultProfileName) - } - - profPath := filepath.Join(configDir, profileName+".json") - profileExists, err := fileExists(profPath) + displayName, err = sanitizeDisplayName(displayName) if err != nil { - return fmt.Errorf("failed to check if profile exists: %w", err) - } - if profileExists { - return ErrProfileAlreadyExists + return nil, fmt.Errorf("invalid profile name: %w", err) } + id, err := generateProfileID() + if err != nil { + return nil, fmt.Errorf("generate profile id: %w", err) + } + + profPath := filepath.Join(configDir, id.String()+".json") cfg, err := createNewConfig(ConfigInput{ConfigPath: profPath}) if err != nil { - return fmt.Errorf("failed to create new config: %w", err) + return nil, fmt.Errorf("failed to create new config: %w", err) + } + cfg.Name = displayName + + if err := util.WriteJson(context.Background(), profPath, cfg); err != nil { + return nil, fmt.Errorf("failed to write profile config: %w", err) } - err = util.WriteJson(context.Background(), profPath, cfg) + return &Profile{ + ID: id, + Name: displayName, + Path: profPath, + }, nil +} + +func (s *ServiceManager) RenameProfile(id ID, username string, newName string) error { + displayName, err := sanitizeDisplayName(newName) if err != nil { - return fmt.Errorf("failed to write profile config: %w", err) + return fmt.Errorf("invalid profile name: %w", err) } + if !IsValidProfileFilenameStem(id) { + return fmt.Errorf("invalid profile ID: %q", id) + } + + profiles, err := s.loadAllProfiles(username) + if err != nil { + return fmt.Errorf("load profiles: %w", err) + } + + var target *Profile + for i := range profiles { + if profiles[i].ID == id { + target = &profiles[i] + break + } + } + if target == nil { + return ErrProfileNotFound + } + + data, err := os.ReadFile(target.Path) + if err != nil { + return err + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return err + } + cfg.Name = displayName + + if err := util.WriteJson(context.Background(), target.Path, cfg); err != nil { + return fmt.Errorf("failed to write profile name: %w", err) + } return nil } -func (s *ServiceManager) RemoveProfile(profileName, username string) error { - configDir, err := s.getConfigDir(username) - if err != nil { - return fmt.Errorf("failed to get config directory: %w", err) +// RemoveProfile deletes the profile identified by id. Callers must have +// already resolved any user-supplied handle to a concrete ID via +// ResolveProfile. +func (s *ServiceManager) RemoveProfile(id ID, username string) error { + if id == defaultProfileName { + defaultName := readProfileName(DefaultConfigPath) + if defaultName == "" { + defaultName = defaultProfileName + } + return fmt.Errorf("cannot remove default profile with name: %s", defaultName) + } + if !IsValidProfileFilenameStem(id) { + return fmt.Errorf("invalid profile ID: %q", id) } - profileName = sanitizeProfileName(profileName) - - if profileName == defaultProfileName { - return fmt.Errorf("cannot remove profile with reserved name: %s", defaultProfileName) - } - profPath := filepath.Join(configDir, profileName+".json") - profileExists, err := fileExists(profPath) + profiles, err := s.loadAllProfiles(username) if err != nil { - return fmt.Errorf("failed to check if profile exists: %w", err) + return fmt.Errorf("load profiles: %w", err) } - if !profileExists { + + var target *Profile + for i := range profiles { + if profiles[i].ID == id { + target = &profiles[i] + break + } + } + if target == nil { return ErrProfileNotFound } @@ -301,57 +406,26 @@ func (s *ServiceManager) RemoveProfile(profileName, username string) error { if err != nil && !errors.Is(err, ErrNoActiveProfile) { return fmt.Errorf("failed to get active profile: %w", err) } - - if activeProf != nil && activeProf.Name == profileName { - return fmt.Errorf("cannot remove active profile: %s", profileName) + if activeProf != nil && activeProf.ID == id { + return fmt.Errorf("cannot remove active profile: %s", id) } - err = util.RemoveJson(profPath) - if err != nil { + if err := util.RemoveJson(target.Path); err != nil { return fmt.Errorf("failed to remove profile config: %w", err) } + + stateFile := filepath.Join(filepath.Dir(target.Path), id.String()+".state.json") + if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) { + log.Warnf("failed to remove profile state file %s: %v", stateFile, err) + } + return nil } +// ListProfiles returns every profile for the given user, including the +// default profile, with IsActive flags set. func (s *ServiceManager) ListProfiles(username string) ([]Profile, error) { - configDir, err := s.getConfigDir(username) - if err != nil { - return nil, fmt.Errorf("failed to get config directory: %w", err) - } - - files, err := util.ListFiles(configDir, "*.json") - if err != nil { - return nil, fmt.Errorf("failed to list profile files: %w", err) - } - - var filtered []string - for _, file := range files { - if strings.HasSuffix(file, "state.json") { - continue // skip state files - } - filtered = append(filtered, file) - } - sort.Strings(filtered) - - var activeProfName string - activeProf, err := s.GetActiveProfileState() - if err == nil { - activeProfName = activeProf.Name - } - - var profiles []Profile - // add default profile always - profiles = append(profiles, Profile{Name: defaultProfileName, IsActive: activeProfName == "" || activeProfName == defaultProfileName}) - for _, file := range filtered { - profileName := strings.TrimSuffix(filepath.Base(file), ".json") - var isActive bool - if activeProfName != "" && activeProfName == profileName { - isActive = true - } - profiles = append(profiles, Profile{Name: profileName, IsActive: isActive}) - } - - return profiles, nil + return s.loadAllProfiles(username) } // GetStatePath returns the path to the state file based on the operating system @@ -369,7 +443,12 @@ func (s *ServiceManager) GetStatePath() string { return defaultStatePath } - if activeProf.Name == defaultProfileName { + if activeProf.ID == defaultProfileName { + return defaultStatePath + } + + if !IsValidProfileFilenameStem(activeProf.ID) { + log.Warnf("invalid active profile ID %q, using default state path", activeProf.ID) return defaultStatePath } @@ -379,7 +458,7 @@ func (s *ServiceManager) GetStatePath() string { return defaultStatePath } - return filepath.Join(configDir, activeProf.Name+".state.json") + return filepath.Join(configDir, activeProf.ID.String()+".state.json") } // getConfigDir returns the profiles directory, using profilesDir if set, otherwise getConfigDirForUser @@ -390,3 +469,169 @@ func (s *ServiceManager) getConfigDir(username string) (string, error) { return getConfigDirForUser(username) } + +// loadAllProfiles returns every profile visible to the daemon for the +// given user, including the default profile. The returned slice is sorted +// by ID for a stable display order. +// +// Each Profile is fully populated: ID is the filename stem, Name comes +// from the JSON's "name" field (falling back to the filename stem when absent) +// and Path is built from a basename read off disk. +func (s *ServiceManager) loadAllProfiles(username string) ([]Profile, error) { + activeID, activeIsDefault := s.activeProfileID() + defaultName := readProfileName(DefaultConfigPath) + if defaultName == "" { + defaultName = defaultProfileName + } + + profiles := []Profile{{ + ID: defaultProfileName, + Name: defaultName, + Path: DefaultConfigPath, + IsActive: activeIsDefault, + }} + + configDir, err := s.getConfigDir(username) + if err != nil { + return nil, fmt.Errorf("get config directory: %w", err) + } + + entries, err := os.ReadDir(configDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return profiles, nil + } + return nil, fmt.Errorf("read profile directory: %w", err) + } + + var fileProfiles []Profile + for _, entry := range entries { + if entry.IsDir() { + continue + } + base := entry.Name() + if !strings.HasSuffix(base, ".json") { + continue + } + if strings.HasSuffix(base, ".state.json") { + continue + } + stem := ID(strings.TrimSuffix(base, ".json")) + if stem == defaultProfileName { + // default lives at the top-level config dir, not under / + continue + } + if !IsValidProfileFilenameStem(ID(stem)) { + continue + } + path := filepath.Join(configDir, base) + name := readProfileName(path) + if name == "" { + name = stem.String() + } + fileProfiles = append(fileProfiles, Profile{ + ID: stem, + Name: name, + Path: path, + IsActive: stem == ID(activeID), + }) + } + + sort.Slice(fileProfiles, func(i, j int) bool { + if fileProfiles[i].Name != fileProfiles[j].Name { + return fileProfiles[i].Name < fileProfiles[j].Name + } + // Sort tie-break on ID so duplicate names always render in the same order. + return fileProfiles[i].ID < fileProfiles[j].ID + }) + profiles = append(profiles, fileProfiles...) + return profiles, nil +} + +// readProfileName parses just the "name" field from the profile Json. +func readProfileName(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + var meta profileMeta + if err := json.Unmarshal(data, &meta); err != nil { + return "" + } + return meta.Name +} + +// activeProfileID returns the currently-active profile's ID. The second +// return value is true when the active profile is the default one. +func (s *ServiceManager) activeProfileID() (ID, bool) { + state, err := s.GetActiveProfileState() + if err != nil || state == nil { + return defaultProfileName, true + } + if state.ID == "" || state.ID == defaultProfileName { + return defaultProfileName, true + } + return state.ID, false +} + +// ResolveProfile turns a user-supplied handle into a Profile. Resolution +// precedence is: exact ID match, then unique exact name, then unique ID +// prefix. Ambiguous matches return *ErrAmbiguousHandle so callers can +// surface the candidates. +func (s *ServiceManager) ResolveProfile(handle, username string) (*Profile, error) { + if handle == "" { + return nil, fmt.Errorf("profile handle is empty") + } + + profiles, err := s.loadAllProfiles(username) + if err != nil { + return nil, err + } + + for i := range profiles { + if profiles[i].ID == ID(handle) { + return &profiles[i], nil + } + } + + var nameMatches []Profile + for i := range profiles { + if profiles[i].Name == handle { + nameMatches = append(nameMatches, profiles[i]) + } + } + if len(nameMatches) == 1 { + return &nameMatches[0], nil + } + if len(nameMatches) > 1 { + return nil, &ErrAmbiguousHandle{ + Handle: handle, + Candidates: nameMatches, + Kind: AmbiguityKindName, + } + } + + // ID prefix match. Skip the default profile so `select d` does not + // accidentally pick it via prefix. + var prefixMatches []Profile + for i := range profiles { + if profiles[i].ID == defaultProfileName { + continue + } + if strings.HasPrefix(profiles[i].ID.String(), handle) { + prefixMatches = append(prefixMatches, profiles[i]) + } + } + if len(prefixMatches) == 1 { + return &prefixMatches[0], nil + } + if len(prefixMatches) > 1 { + return nil, &ErrAmbiguousHandle{ + Handle: handle, + Candidates: prefixMatches, + Kind: AmbiguityKindIDPrefix, + } + } + + return nil, ErrProfileNotFound +} diff --git a/client/internal/profilemanager/service_test.go b/client/internal/profilemanager/service_test.go new file mode 100644 index 000000000..5e051b15d --- /dev/null +++ b/client/internal/profilemanager/service_test.go @@ -0,0 +1,230 @@ +package profilemanager + +import ( + "context" + "errors" + "os" + "os/user" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/util" +) + +// withTestSM wires up patched globals + a clean config dir and returns a +// fully initialized ServiceManager plus the username we are scoped to. +func withTestSM(t *testing.T, fn func(sm *ServiceManager, username string)) { + t.Helper() + withTempConfigDir(t, func(configDir string) { + withPatchedGlobals(t, configDir, func() { + u, err := user.Current() + require.NoError(t, err) + sm := &ServiceManager{} + require.NoError(t, sm.CreateDefaultProfile()) + fn(sm, u.Username) + }) + }) +} + +func TestServiceProfile_ExactID(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + got, err := sm.ResolveProfile(created.ID.String(), username) + require.NoError(t, err) + assert.Equal(t, created.ID, got.ID) + assert.Equal(t, "work", got.Name) + }) +} + +func TestServiceProfile_IDPrefix(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + prefix := created.ID[:4] + got, err := sm.ResolveProfile(prefix.String(), username) + require.NoError(t, err) + assert.Equal(t, created.ID, got.ID) + }) +} + +func TestServiceProfile_AmbiguousPrefix(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + // Plant two profiles whose IDs share a known prefix by writing + // the files directly, since generated IDs are random. + configDir, err := sm.getConfigDir(username) + require.NoError(t, err) + for _, id := range []string{"abcd1111aaaa", "abcd2222bbbb"} { + path := filepath.Join(configDir, id+".json") + require.NoError(t, util.WriteJson(context.Background(), path, &Config{Name: id})) + } + + _, err = sm.ResolveProfile("abcd", username) + var amb *ErrAmbiguousHandle + require.ErrorAs(t, err, &amb) + assert.Equal(t, AmbiguityKindIDPrefix, amb.Kind) + assert.Len(t, amb.Candidates, 2) + }) +} + +func TestServiceProfile_ExactNameUnique(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + _, err := sm.AddProfile("work", username) + require.NoError(t, err) + + got, err := sm.ResolveProfile("work", username) + require.NoError(t, err) + assert.Equal(t, "work", got.Name) + }) +} + +func TestServiceProfile_AmbiguousName(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + _, err := sm.AddProfile("work", username) + require.NoError(t, err) + _, err = sm.AddProfile("work", username) + require.NoError(t, err) + + _, err = sm.ResolveProfile("work", username) + var amb *ErrAmbiguousHandle + require.ErrorAs(t, err, &amb) + assert.Equal(t, AmbiguityKindName, amb.Kind) + assert.Len(t, amb.Candidates, 2) + }) +} + +func TestServiceProfile_NotFound(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + _, err := sm.ResolveProfile("nope", username) + assert.ErrorIs(t, err, ErrProfileNotFound) + }) +} + +func TestServiceProfile_DefaultByExactID(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + got, err := sm.ResolveProfile(defaultProfileName, username) + require.NoError(t, err) + assert.Equal(t, defaultProfileName, got.ID.String()) + }) +} + +func TestServiceProfile_LegacyFilenameCoexists(t *testing.T) { + // Legacy profiles stored as .json with no "name" JSON field + // should still be discoverable by name and removable by name. + withTestSM(t, func(sm *ServiceManager, username string) { + configDir, err := sm.getConfigDir(username) + require.NoError(t, err) + path := filepath.Join(configDir, "legacy.json") + require.NoError(t, util.WriteJson(context.Background(), path, &Config{})) + + got, err := sm.ResolveProfile("legacy", username) + require.NoError(t, err) + assert.Equal(t, "legacy", got.ID.String()) + // Name falls back to the filename stem when JSON omits it. + assert.Equal(t, "legacy", got.Name) + }) +} + +func TestAddProfile_AllowsDuplicateWithFlag(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + first, err := sm.AddProfile("work", username) + require.NoError(t, err) + + second, err := sm.AddProfile("work", username) + require.NoError(t, err) + assert.NotEqual(t, first.ID, second.ID) + assert.Equal(t, "work", second.Name) + }) +} + +func TestAddProfile_RejectsInvalidNames(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + cases := []string{ + "", // empty + "\x00\x01", // only control chars (becomes empty) + strings.Repeat("a", maxProfileNameLen+1), // too long + } + for _, name := range cases { + _, err := sm.AddProfile(name, username) + assert.Error(t, err, "expected error for %q", name) + } + }) +} + +func TestRemoveProfile_RejectsInvalidID(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + err := sm.RemoveProfile("../escape", username) + assert.Error(t, err) + }) +} + +func TestSanitizeDisplayName(t *testing.T) { + cases := []struct { + in string + want string + wantErr bool + }{ + {"work", "work", false}, + {"My Work Account", "My Work Account", false}, + {"emoji 🚀 ok", "emoji 🚀 ok", false}, + {"漢字テスト", "漢字テスト", false}, + {"with\x00null", "withnull", false}, + {"\x01\x02\x03", "", true}, + {"", "", true}, + } + for _, tc := range cases { + got, err := sanitizeDisplayName(tc.in) + if tc.wantErr { + assert.Error(t, err, "case %q", tc.in) + continue + } + assert.NoError(t, err, "case %q", tc.in) + assert.Equal(t, tc.want, got, "case %q", tc.in) + } +} + +func TestIsValidProfileFilenameStem(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"default", true}, + {"abc123def456", true}, + {"legacy-name", true}, + {"legacy_name", true}, + {"", false}, + {"..", false}, + {"../etc", false}, + {"foo/bar", false}, + {`foo\bar`, false}, + {"with space", false}, + {"with.dot", false}, + {strings.Repeat("a", maxProfileIDLen+1), false}, + } + for _, tc := range cases { + got := IsValidProfileFilenameStem(ID(tc.in)) + assert.Equal(t, tc.want, got, "case %q", tc.in) + } +} + +func TestRemoveProfile_DeletesStateFile(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + configDir, err := sm.getConfigDir(username) + require.NoError(t, err) + statePath := filepath.Join(configDir, created.ID.String()+".state.json") + require.NoError(t, os.WriteFile(statePath, []byte(`{"email":"a@b"}`), 0600)) + + require.NoError(t, sm.RemoveProfile(created.ID, username)) + _, err = os.Stat(statePath) + assert.True(t, errors.Is(err, os.ErrNotExist), "state file should be removed") + }) +} diff --git a/client/internal/profilemanager/state.go b/client/internal/profilemanager/state.go index f09391ede..1bf3318af 100644 --- a/client/internal/profilemanager/state.go +++ b/client/internal/profilemanager/state.go @@ -13,13 +13,20 @@ type ProfileState struct { Email string `json:"email"` } -func (pm *ProfileManager) GetProfileState(profileName string) (*ProfileState, error) { +// GetProfileState reads the per-profile state file keyed by profile ID. +// The state file lives in the user's config directory. Legacy state files +// keyed by the old profile name remain readable. +func (pm *ProfileManager) GetProfileState(id ID) (*ProfileState, error) { configDir, err := getConfigDir() if err != nil { return nil, fmt.Errorf("get config directory: %w", err) } - stateFile := filepath.Join(configDir, profileName+".state.json") + if id != defaultProfileName && !IsValidProfileFilenameStem(id) { + return nil, fmt.Errorf("invalid profile ID: %q", id) + } + + stateFile := filepath.Join(configDir, id.String()+".state.json") stateFileExists, err := fileExists(stateFile) if err != nil { return nil, fmt.Errorf("failed to check if profile state file exists: %w", err) @@ -51,7 +58,12 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error { return fmt.Errorf("get active profile: %w", err) } - stateFile := filepath.Join(configDir, activeProf.Name+".state.json") + id := activeProf.ID + if id != defaultProfileName && !IsValidProfileFilenameStem(id) { + return fmt.Errorf("invalid active profile ID: %q", id) + } + + stateFile := filepath.Join(configDir, id.String()+".state.json") err = util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state) if err != nil { return fmt.Errorf("write profile state: %w", err) diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 6b5a37658..488b0186c 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -3954,9 +3954,11 @@ func (x *GetEventsResponse) GetEvents() []*SystemEvent { } type SwitchProfileRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"` - Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + // profileName is treated as a handle: exact ID, unique ID prefix, or + // unique display name. The daemon resolves it server-side. + ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"` + Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4006,7 +4008,11 @@ func (x *SwitchProfileRequest) GetUsername() string { } type SwitchProfileResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState `protogen:"open.v1"` + // id is the resolved on-disk ID of the profile that became active. + // Lets CLI clients update their local active-profile state without + // duplicating the resolution logic. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4041,6 +4047,13 @@ func (*SwitchProfileResponse) Descriptor() ([]byte, []int) { return file_daemon_proto_rawDescGZIP(), []int{55} } +func (x *SwitchProfileResponse) GetId() string { + if x != nil { + return x.Id + } + return "" +} + type SetConfigRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` @@ -4397,9 +4410,11 @@ func (*SetConfigResponse) Descriptor() ([]byte, []int) { } type AddProfileRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` - ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` + // profileName carries the human-readable display name for the new + // profile. The on-disk filename is a separately-generated ID. + ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4449,7 +4464,10 @@ func (x *AddProfileRequest) GetProfileName() string { } type AddProfileResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState `protogen:"open.v1"` + // id is the generated on-disk ID of the new profile. CLI clients + // display a truncated form, UI clients can ignore it. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4484,17 +4502,133 @@ func (*AddProfileResponse) Descriptor() ([]byte, []int) { return file_daemon_proto_rawDescGZIP(), []int{59} } +func (x *AddProfileResponse) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type RenameProfileRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` + // handle: an exact ID, a unique ID prefix, or a unique display name. + Handle string `protobuf:"bytes,2,opt,name=handle,proto3" json:"handle,omitempty"` + // newProfileName is the new human-readable display name for the profile. + NewProfileName string `protobuf:"bytes,3,opt,name=newProfileName,proto3" json:"newProfileName,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenameProfileRequest) Reset() { + *x = RenameProfileRequest{} + mi := &file_daemon_proto_msgTypes[60] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenameProfileRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenameProfileRequest) ProtoMessage() {} + +func (x *RenameProfileRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[60] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RenameProfileRequest.ProtoReflect.Descriptor instead. +func (*RenameProfileRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{60} +} + +func (x *RenameProfileRequest) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *RenameProfileRequest) GetHandle() string { + if x != nil { + return x.Handle + } + return "" +} + +func (x *RenameProfileRequest) GetNewProfileName() string { + if x != nil { + return x.NewProfileName + } + return "" +} + +type RenameProfileResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // confirm the old profile name after resolving handle. + OldProfileName string `protobuf:"bytes,1,opt,name=oldProfileName,proto3" json:"oldProfileName,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenameProfileResponse) Reset() { + *x = RenameProfileResponse{} + mi := &file_daemon_proto_msgTypes[61] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenameProfileResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenameProfileResponse) ProtoMessage() {} + +func (x *RenameProfileResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[61] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RenameProfileResponse.ProtoReflect.Descriptor instead. +func (*RenameProfileResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{61} +} + +func (x *RenameProfileResponse) GetOldProfileName() string { + if x != nil { + return x.OldProfileName + } + return "" +} + type RemoveProfileRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` - ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` + // profileName is treated as a handle: an exact ID, a unique ID + // prefix, or a unique display name. Resolution happens server-side. + ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RemoveProfileRequest) Reset() { *x = RemoveProfileRequest{} - mi := &file_daemon_proto_msgTypes[60] + mi := &file_daemon_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4506,7 +4640,7 @@ func (x *RemoveProfileRequest) String() string { func (*RemoveProfileRequest) ProtoMessage() {} func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[60] + mi := &file_daemon_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4519,7 +4653,7 @@ func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveProfileRequest.ProtoReflect.Descriptor instead. func (*RemoveProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{60} + return file_daemon_proto_rawDescGZIP(), []int{62} } func (x *RemoveProfileRequest) GetUsername() string { @@ -4537,14 +4671,17 @@ func (x *RemoveProfileRequest) GetProfileName() string { } type RemoveProfileResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState `protogen:"open.v1"` + // id is the full resolved ID of the removed profile, so callers can + // confirm exactly which profile a name/prefix handle resolved to. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RemoveProfileResponse) Reset() { *x = RemoveProfileResponse{} - mi := &file_daemon_proto_msgTypes[61] + mi := &file_daemon_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4556,7 +4693,7 @@ func (x *RemoveProfileResponse) String() string { func (*RemoveProfileResponse) ProtoMessage() {} func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[61] + mi := &file_daemon_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4569,7 +4706,14 @@ func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveProfileResponse.ProtoReflect.Descriptor instead. func (*RemoveProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{61} + return file_daemon_proto_rawDescGZIP(), []int{63} +} + +func (x *RemoveProfileResponse) GetId() string { + if x != nil { + return x.Id + } + return "" } type ListProfilesRequest struct { @@ -4581,7 +4725,7 @@ type ListProfilesRequest struct { func (x *ListProfilesRequest) Reset() { *x = ListProfilesRequest{} - mi := &file_daemon_proto_msgTypes[62] + mi := &file_daemon_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4593,7 +4737,7 @@ func (x *ListProfilesRequest) String() string { func (*ListProfilesRequest) ProtoMessage() {} func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[62] + mi := &file_daemon_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4606,7 +4750,7 @@ func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProfilesRequest.ProtoReflect.Descriptor instead. func (*ListProfilesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{62} + return file_daemon_proto_rawDescGZIP(), []int{64} } func (x *ListProfilesRequest) GetUsername() string { @@ -4625,7 +4769,7 @@ type ListProfilesResponse struct { func (x *ListProfilesResponse) Reset() { *x = ListProfilesResponse{} - mi := &file_daemon_proto_msgTypes[63] + mi := &file_daemon_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4637,7 +4781,7 @@ func (x *ListProfilesResponse) String() string { func (*ListProfilesResponse) ProtoMessage() {} func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[63] + mi := &file_daemon_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4650,7 +4794,7 @@ func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProfilesResponse.ProtoReflect.Descriptor instead. func (*ListProfilesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{63} + return file_daemon_proto_rawDescGZIP(), []int{65} } func (x *ListProfilesResponse) GetProfiles() []*Profile { @@ -4664,13 +4808,14 @@ type Profile struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` IsActive bool `protobuf:"varint,2,opt,name=is_active,json=isActive,proto3" json:"is_active,omitempty"` + Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Profile) Reset() { *x = Profile{} - mi := &file_daemon_proto_msgTypes[64] + mi := &file_daemon_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4682,7 +4827,7 @@ func (x *Profile) String() string { func (*Profile) ProtoMessage() {} func (x *Profile) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[64] + mi := &file_daemon_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4695,7 +4840,7 @@ func (x *Profile) ProtoReflect() protoreflect.Message { // Deprecated: Use Profile.ProtoReflect.Descriptor instead. func (*Profile) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{64} + return file_daemon_proto_rawDescGZIP(), []int{66} } func (x *Profile) GetName() string { @@ -4712,6 +4857,13 @@ func (x *Profile) GetIsActive() bool { return false } +func (x *Profile) GetId() string { + if x != nil { + return x.Id + } + return "" +} + type GetActiveProfileRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -4720,7 +4872,7 @@ type GetActiveProfileRequest struct { func (x *GetActiveProfileRequest) Reset() { *x = GetActiveProfileRequest{} - mi := &file_daemon_proto_msgTypes[65] + mi := &file_daemon_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4732,7 +4884,7 @@ func (x *GetActiveProfileRequest) String() string { func (*GetActiveProfileRequest) ProtoMessage() {} func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[65] + mi := &file_daemon_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4745,20 +4897,21 @@ func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActiveProfileRequest.ProtoReflect.Descriptor instead. func (*GetActiveProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{65} + return file_daemon_proto_rawDescGZIP(), []int{67} } type GetActiveProfileResponse struct { state protoimpl.MessageState `protogen:"open.v1"` ProfileName string `protobuf:"bytes,1,opt,name=profileName,proto3" json:"profileName,omitempty"` Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"` + Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetActiveProfileResponse) Reset() { *x = GetActiveProfileResponse{} - mi := &file_daemon_proto_msgTypes[66] + mi := &file_daemon_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4770,7 +4923,7 @@ func (x *GetActiveProfileResponse) String() string { func (*GetActiveProfileResponse) ProtoMessage() {} func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[66] + mi := &file_daemon_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4783,7 +4936,7 @@ func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActiveProfileResponse.ProtoReflect.Descriptor instead. func (*GetActiveProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{66} + return file_daemon_proto_rawDescGZIP(), []int{68} } func (x *GetActiveProfileResponse) GetProfileName() string { @@ -4800,6 +4953,13 @@ func (x *GetActiveProfileResponse) GetUsername() string { return "" } +func (x *GetActiveProfileResponse) GetId() string { + if x != nil { + return x.Id + } + return "" +} + type LogoutRequest struct { state protoimpl.MessageState `protogen:"open.v1"` ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"` @@ -4810,7 +4970,7 @@ type LogoutRequest struct { func (x *LogoutRequest) Reset() { *x = LogoutRequest{} - mi := &file_daemon_proto_msgTypes[67] + mi := &file_daemon_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4822,7 +4982,7 @@ func (x *LogoutRequest) String() string { func (*LogoutRequest) ProtoMessage() {} func (x *LogoutRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[67] + mi := &file_daemon_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4835,7 +4995,7 @@ func (x *LogoutRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead. func (*LogoutRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{67} + return file_daemon_proto_rawDescGZIP(), []int{69} } func (x *LogoutRequest) GetProfileName() string { @@ -4860,7 +5020,7 @@ type LogoutResponse struct { func (x *LogoutResponse) Reset() { *x = LogoutResponse{} - mi := &file_daemon_proto_msgTypes[68] + mi := &file_daemon_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4872,7 +5032,7 @@ func (x *LogoutResponse) String() string { func (*LogoutResponse) ProtoMessage() {} func (x *LogoutResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[68] + mi := &file_daemon_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4885,7 +5045,7 @@ func (x *LogoutResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead. func (*LogoutResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{68} + return file_daemon_proto_rawDescGZIP(), []int{70} } type GetFeaturesRequest struct { @@ -4896,7 +5056,7 @@ type GetFeaturesRequest struct { func (x *GetFeaturesRequest) Reset() { *x = GetFeaturesRequest{} - mi := &file_daemon_proto_msgTypes[69] + mi := &file_daemon_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4908,7 +5068,7 @@ func (x *GetFeaturesRequest) String() string { func (*GetFeaturesRequest) ProtoMessage() {} func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[69] + mi := &file_daemon_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4921,7 +5081,7 @@ func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFeaturesRequest.ProtoReflect.Descriptor instead. func (*GetFeaturesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{69} + return file_daemon_proto_rawDescGZIP(), []int{71} } type GetFeaturesResponse struct { @@ -4935,7 +5095,7 @@ type GetFeaturesResponse struct { func (x *GetFeaturesResponse) Reset() { *x = GetFeaturesResponse{} - mi := &file_daemon_proto_msgTypes[70] + mi := &file_daemon_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4947,7 +5107,7 @@ func (x *GetFeaturesResponse) String() string { func (*GetFeaturesResponse) ProtoMessage() {} func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[70] + mi := &file_daemon_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4960,7 +5120,7 @@ func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFeaturesResponse.ProtoReflect.Descriptor instead. func (*GetFeaturesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{70} + return file_daemon_proto_rawDescGZIP(), []int{72} } func (x *GetFeaturesResponse) GetDisableProfiles() bool { @@ -4998,7 +5158,7 @@ type MDMManagedFieldsViolation struct { func (x *MDMManagedFieldsViolation) Reset() { *x = MDMManagedFieldsViolation{} - mi := &file_daemon_proto_msgTypes[71] + mi := &file_daemon_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5010,7 +5170,7 @@ func (x *MDMManagedFieldsViolation) String() string { func (*MDMManagedFieldsViolation) ProtoMessage() {} func (x *MDMManagedFieldsViolation) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[71] + mi := &file_daemon_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5023,7 +5183,7 @@ func (x *MDMManagedFieldsViolation) ProtoReflect() protoreflect.Message { // Deprecated: Use MDMManagedFieldsViolation.ProtoReflect.Descriptor instead. func (*MDMManagedFieldsViolation) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{71} + return file_daemon_proto_rawDescGZIP(), []int{73} } func (x *MDMManagedFieldsViolation) GetFields() []string { @@ -5041,7 +5201,7 @@ type TriggerUpdateRequest struct { func (x *TriggerUpdateRequest) Reset() { *x = TriggerUpdateRequest{} - mi := &file_daemon_proto_msgTypes[72] + mi := &file_daemon_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5053,7 +5213,7 @@ func (x *TriggerUpdateRequest) String() string { func (*TriggerUpdateRequest) ProtoMessage() {} func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[72] + mi := &file_daemon_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5066,7 +5226,7 @@ func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TriggerUpdateRequest.ProtoReflect.Descriptor instead. func (*TriggerUpdateRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{72} + return file_daemon_proto_rawDescGZIP(), []int{74} } type TriggerUpdateResponse struct { @@ -5079,7 +5239,7 @@ type TriggerUpdateResponse struct { func (x *TriggerUpdateResponse) Reset() { *x = TriggerUpdateResponse{} - mi := &file_daemon_proto_msgTypes[73] + mi := &file_daemon_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5091,7 +5251,7 @@ func (x *TriggerUpdateResponse) String() string { func (*TriggerUpdateResponse) ProtoMessage() {} func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[73] + mi := &file_daemon_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5104,7 +5264,7 @@ func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TriggerUpdateResponse.ProtoReflect.Descriptor instead. func (*TriggerUpdateResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{73} + return file_daemon_proto_rawDescGZIP(), []int{75} } func (x *TriggerUpdateResponse) GetSuccess() bool { @@ -5132,7 +5292,7 @@ type GetPeerSSHHostKeyRequest struct { func (x *GetPeerSSHHostKeyRequest) Reset() { *x = GetPeerSSHHostKeyRequest{} - mi := &file_daemon_proto_msgTypes[74] + mi := &file_daemon_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5144,7 +5304,7 @@ func (x *GetPeerSSHHostKeyRequest) String() string { func (*GetPeerSSHHostKeyRequest) ProtoMessage() {} func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[74] + mi := &file_daemon_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5157,7 +5317,7 @@ func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPeerSSHHostKeyRequest.ProtoReflect.Descriptor instead. func (*GetPeerSSHHostKeyRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{74} + return file_daemon_proto_rawDescGZIP(), []int{76} } func (x *GetPeerSSHHostKeyRequest) GetPeerAddress() string { @@ -5184,7 +5344,7 @@ type GetPeerSSHHostKeyResponse struct { func (x *GetPeerSSHHostKeyResponse) Reset() { *x = GetPeerSSHHostKeyResponse{} - mi := &file_daemon_proto_msgTypes[75] + mi := &file_daemon_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5196,7 +5356,7 @@ func (x *GetPeerSSHHostKeyResponse) String() string { func (*GetPeerSSHHostKeyResponse) ProtoMessage() {} func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[75] + mi := &file_daemon_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5209,7 +5369,7 @@ func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPeerSSHHostKeyResponse.ProtoReflect.Descriptor instead. func (*GetPeerSSHHostKeyResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{75} + return file_daemon_proto_rawDescGZIP(), []int{77} } func (x *GetPeerSSHHostKeyResponse) GetSshHostKey() []byte { @@ -5251,7 +5411,7 @@ type RequestJWTAuthRequest struct { func (x *RequestJWTAuthRequest) Reset() { *x = RequestJWTAuthRequest{} - mi := &file_daemon_proto_msgTypes[76] + mi := &file_daemon_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5263,7 +5423,7 @@ func (x *RequestJWTAuthRequest) String() string { func (*RequestJWTAuthRequest) ProtoMessage() {} func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[76] + mi := &file_daemon_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5276,7 +5436,7 @@ func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestJWTAuthRequest.ProtoReflect.Descriptor instead. func (*RequestJWTAuthRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{76} + return file_daemon_proto_rawDescGZIP(), []int{78} } func (x *RequestJWTAuthRequest) GetHint() string { @@ -5309,7 +5469,7 @@ type RequestJWTAuthResponse struct { func (x *RequestJWTAuthResponse) Reset() { *x = RequestJWTAuthResponse{} - mi := &file_daemon_proto_msgTypes[77] + mi := &file_daemon_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5321,7 +5481,7 @@ func (x *RequestJWTAuthResponse) String() string { func (*RequestJWTAuthResponse) ProtoMessage() {} func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[77] + mi := &file_daemon_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5334,7 +5494,7 @@ func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestJWTAuthResponse.ProtoReflect.Descriptor instead. func (*RequestJWTAuthResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{77} + return file_daemon_proto_rawDescGZIP(), []int{79} } func (x *RequestJWTAuthResponse) GetVerificationURI() string { @@ -5399,7 +5559,7 @@ type WaitJWTTokenRequest struct { func (x *WaitJWTTokenRequest) Reset() { *x = WaitJWTTokenRequest{} - mi := &file_daemon_proto_msgTypes[78] + mi := &file_daemon_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5411,7 +5571,7 @@ func (x *WaitJWTTokenRequest) String() string { func (*WaitJWTTokenRequest) ProtoMessage() {} func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[78] + mi := &file_daemon_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5424,7 +5584,7 @@ func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitJWTTokenRequest.ProtoReflect.Descriptor instead. func (*WaitJWTTokenRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{78} + return file_daemon_proto_rawDescGZIP(), []int{80} } func (x *WaitJWTTokenRequest) GetDeviceCode() string { @@ -5456,7 +5616,7 @@ type WaitJWTTokenResponse struct { func (x *WaitJWTTokenResponse) Reset() { *x = WaitJWTTokenResponse{} - mi := &file_daemon_proto_msgTypes[79] + mi := &file_daemon_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5468,7 +5628,7 @@ func (x *WaitJWTTokenResponse) String() string { func (*WaitJWTTokenResponse) ProtoMessage() {} func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[79] + mi := &file_daemon_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5481,7 +5641,7 @@ func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitJWTTokenResponse.ProtoReflect.Descriptor instead. func (*WaitJWTTokenResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{79} + return file_daemon_proto_rawDescGZIP(), []int{81} } func (x *WaitJWTTokenResponse) GetToken() string { @@ -5514,7 +5674,7 @@ type StartCPUProfileRequest struct { func (x *StartCPUProfileRequest) Reset() { *x = StartCPUProfileRequest{} - mi := &file_daemon_proto_msgTypes[80] + mi := &file_daemon_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5526,7 +5686,7 @@ func (x *StartCPUProfileRequest) String() string { func (*StartCPUProfileRequest) ProtoMessage() {} func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[80] + mi := &file_daemon_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5539,7 +5699,7 @@ func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCPUProfileRequest.ProtoReflect.Descriptor instead. func (*StartCPUProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{80} + return file_daemon_proto_rawDescGZIP(), []int{82} } // StartCPUProfileResponse confirms CPU profiling has started @@ -5551,7 +5711,7 @@ type StartCPUProfileResponse struct { func (x *StartCPUProfileResponse) Reset() { *x = StartCPUProfileResponse{} - mi := &file_daemon_proto_msgTypes[81] + mi := &file_daemon_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5563,7 +5723,7 @@ func (x *StartCPUProfileResponse) String() string { func (*StartCPUProfileResponse) ProtoMessage() {} func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[81] + mi := &file_daemon_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5576,7 +5736,7 @@ func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCPUProfileResponse.ProtoReflect.Descriptor instead. func (*StartCPUProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{81} + return file_daemon_proto_rawDescGZIP(), []int{83} } // StopCPUProfileRequest for stopping CPU profiling @@ -5588,7 +5748,7 @@ type StopCPUProfileRequest struct { func (x *StopCPUProfileRequest) Reset() { *x = StopCPUProfileRequest{} - mi := &file_daemon_proto_msgTypes[82] + mi := &file_daemon_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5600,7 +5760,7 @@ func (x *StopCPUProfileRequest) String() string { func (*StopCPUProfileRequest) ProtoMessage() {} func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[82] + mi := &file_daemon_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5613,7 +5773,7 @@ func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopCPUProfileRequest.ProtoReflect.Descriptor instead. func (*StopCPUProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{82} + return file_daemon_proto_rawDescGZIP(), []int{84} } // StopCPUProfileResponse confirms CPU profiling has stopped @@ -5625,7 +5785,7 @@ type StopCPUProfileResponse struct { func (x *StopCPUProfileResponse) Reset() { *x = StopCPUProfileResponse{} - mi := &file_daemon_proto_msgTypes[83] + mi := &file_daemon_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5637,7 +5797,7 @@ func (x *StopCPUProfileResponse) String() string { func (*StopCPUProfileResponse) ProtoMessage() {} func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[83] + mi := &file_daemon_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5650,7 +5810,7 @@ func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopCPUProfileResponse.ProtoReflect.Descriptor instead. func (*StopCPUProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{83} + return file_daemon_proto_rawDescGZIP(), []int{85} } type InstallerResultRequest struct { @@ -5661,7 +5821,7 @@ type InstallerResultRequest struct { func (x *InstallerResultRequest) Reset() { *x = InstallerResultRequest{} - mi := &file_daemon_proto_msgTypes[84] + mi := &file_daemon_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5673,7 +5833,7 @@ func (x *InstallerResultRequest) String() string { func (*InstallerResultRequest) ProtoMessage() {} func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[84] + mi := &file_daemon_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5686,7 +5846,7 @@ func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallerResultRequest.ProtoReflect.Descriptor instead. func (*InstallerResultRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{84} + return file_daemon_proto_rawDescGZIP(), []int{86} } type InstallerResultResponse struct { @@ -5699,7 +5859,7 @@ type InstallerResultResponse struct { func (x *InstallerResultResponse) Reset() { *x = InstallerResultResponse{} - mi := &file_daemon_proto_msgTypes[85] + mi := &file_daemon_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5711,7 +5871,7 @@ func (x *InstallerResultResponse) String() string { func (*InstallerResultResponse) ProtoMessage() {} func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[85] + mi := &file_daemon_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5724,7 +5884,7 @@ func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallerResultResponse.ProtoReflect.Descriptor instead. func (*InstallerResultResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{85} + return file_daemon_proto_rawDescGZIP(), []int{87} } func (x *InstallerResultResponse) GetSuccess() bool { @@ -5757,7 +5917,7 @@ type ExposeServiceRequest struct { func (x *ExposeServiceRequest) Reset() { *x = ExposeServiceRequest{} - mi := &file_daemon_proto_msgTypes[86] + mi := &file_daemon_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5769,7 +5929,7 @@ func (x *ExposeServiceRequest) String() string { func (*ExposeServiceRequest) ProtoMessage() {} func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[86] + mi := &file_daemon_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5782,7 +5942,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead. func (*ExposeServiceRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{86} + return file_daemon_proto_rawDescGZIP(), []int{88} } func (x *ExposeServiceRequest) GetPort() uint32 { @@ -5853,7 +6013,7 @@ type ExposeServiceEvent struct { func (x *ExposeServiceEvent) Reset() { *x = ExposeServiceEvent{} - mi := &file_daemon_proto_msgTypes[87] + mi := &file_daemon_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5865,7 +6025,7 @@ func (x *ExposeServiceEvent) String() string { func (*ExposeServiceEvent) ProtoMessage() {} func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[87] + mi := &file_daemon_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5878,7 +6038,7 @@ func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceEvent.ProtoReflect.Descriptor instead. func (*ExposeServiceEvent) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{87} + return file_daemon_proto_rawDescGZIP(), []int{89} } func (x *ExposeServiceEvent) GetEvent() isExposeServiceEvent_Event { @@ -5919,7 +6079,7 @@ type ExposeServiceReady struct { func (x *ExposeServiceReady) Reset() { *x = ExposeServiceReady{} - mi := &file_daemon_proto_msgTypes[88] + mi := &file_daemon_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5931,7 +6091,7 @@ func (x *ExposeServiceReady) String() string { func (*ExposeServiceReady) ProtoMessage() {} func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[88] + mi := &file_daemon_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5944,7 +6104,7 @@ func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceReady.ProtoReflect.Descriptor instead. func (*ExposeServiceReady) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{88} + return file_daemon_proto_rawDescGZIP(), []int{90} } func (x *ExposeServiceReady) GetServiceName() string { @@ -5989,7 +6149,7 @@ type StartCaptureRequest struct { func (x *StartCaptureRequest) Reset() { *x = StartCaptureRequest{} - mi := &file_daemon_proto_msgTypes[89] + mi := &file_daemon_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6001,7 +6161,7 @@ func (x *StartCaptureRequest) String() string { func (*StartCaptureRequest) ProtoMessage() {} func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[89] + mi := &file_daemon_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6014,7 +6174,7 @@ func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCaptureRequest.ProtoReflect.Descriptor instead. func (*StartCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{89} + return file_daemon_proto_rawDescGZIP(), []int{91} } func (x *StartCaptureRequest) GetTextOutput() bool { @@ -6068,7 +6228,7 @@ type CapturePacket struct { func (x *CapturePacket) Reset() { *x = CapturePacket{} - mi := &file_daemon_proto_msgTypes[90] + mi := &file_daemon_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6080,7 +6240,7 @@ func (x *CapturePacket) String() string { func (*CapturePacket) ProtoMessage() {} func (x *CapturePacket) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[90] + mi := &file_daemon_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6093,7 +6253,7 @@ func (x *CapturePacket) ProtoReflect() protoreflect.Message { // Deprecated: Use CapturePacket.ProtoReflect.Descriptor instead. func (*CapturePacket) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{90} + return file_daemon_proto_rawDescGZIP(), []int{92} } func (x *CapturePacket) GetData() []byte { @@ -6114,7 +6274,7 @@ type StartBundleCaptureRequest struct { func (x *StartBundleCaptureRequest) Reset() { *x = StartBundleCaptureRequest{} - mi := &file_daemon_proto_msgTypes[91] + mi := &file_daemon_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6126,7 +6286,7 @@ func (x *StartBundleCaptureRequest) String() string { func (*StartBundleCaptureRequest) ProtoMessage() {} func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[91] + mi := &file_daemon_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6139,7 +6299,7 @@ func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartBundleCaptureRequest.ProtoReflect.Descriptor instead. func (*StartBundleCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{91} + return file_daemon_proto_rawDescGZIP(), []int{93} } func (x *StartBundleCaptureRequest) GetTimeout() *durationpb.Duration { @@ -6157,7 +6317,7 @@ type StartBundleCaptureResponse struct { func (x *StartBundleCaptureResponse) Reset() { *x = StartBundleCaptureResponse{} - mi := &file_daemon_proto_msgTypes[92] + mi := &file_daemon_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6169,7 +6329,7 @@ func (x *StartBundleCaptureResponse) String() string { func (*StartBundleCaptureResponse) ProtoMessage() {} func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[92] + mi := &file_daemon_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6182,7 +6342,7 @@ func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartBundleCaptureResponse.ProtoReflect.Descriptor instead. func (*StartBundleCaptureResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{92} + return file_daemon_proto_rawDescGZIP(), []int{94} } type StopBundleCaptureRequest struct { @@ -6193,7 +6353,7 @@ type StopBundleCaptureRequest struct { func (x *StopBundleCaptureRequest) Reset() { *x = StopBundleCaptureRequest{} - mi := &file_daemon_proto_msgTypes[93] + mi := &file_daemon_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6205,7 +6365,7 @@ func (x *StopBundleCaptureRequest) String() string { func (*StopBundleCaptureRequest) ProtoMessage() {} func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[93] + mi := &file_daemon_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6218,7 +6378,7 @@ func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopBundleCaptureRequest.ProtoReflect.Descriptor instead. func (*StopBundleCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{93} + return file_daemon_proto_rawDescGZIP(), []int{95} } type StopBundleCaptureResponse struct { @@ -6229,7 +6389,7 @@ type StopBundleCaptureResponse struct { func (x *StopBundleCaptureResponse) Reset() { *x = StopBundleCaptureResponse{} - mi := &file_daemon_proto_msgTypes[94] + mi := &file_daemon_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6241,7 +6401,7 @@ func (x *StopBundleCaptureResponse) String() string { func (*StopBundleCaptureResponse) ProtoMessage() {} func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[94] + mi := &file_daemon_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6254,7 +6414,7 @@ func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopBundleCaptureResponse.ProtoReflect.Descriptor instead. func (*StopBundleCaptureResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{94} + return file_daemon_proto_rawDescGZIP(), []int{96} } type PortInfo_Range struct { @@ -6267,7 +6427,7 @@ type PortInfo_Range struct { func (x *PortInfo_Range) Reset() { *x = PortInfo_Range{} - mi := &file_daemon_proto_msgTypes[96] + mi := &file_daemon_proto_msgTypes[98] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6279,7 +6439,7 @@ func (x *PortInfo_Range) String() string { func (*PortInfo_Range) ProtoMessage() {} func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[96] + mi := &file_daemon_proto_msgTypes[98] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6672,8 +6832,9 @@ const file_daemon_proto_rawDesc = "" + "\vprofileName\x18\x01 \x01(\tH\x00R\vprofileName\x88\x01\x01\x12\x1f\n" + "\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" + "\f_profileNameB\v\n" + - "\t_username\"\x17\n" + - "\x15SwitchProfileResponse\"\x98\x11\n" + + "\t_username\"'\n" + + "\x15SwitchProfileResponse\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"\x98\x11\n" + "\x10SetConfigRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + "\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" + @@ -6742,23 +6903,33 @@ const file_daemon_proto_rawDesc = "" + "\x11SetConfigResponse\"Q\n" + "\x11AddProfileRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + - "\vprofileName\x18\x02 \x01(\tR\vprofileName\"\x14\n" + - "\x12AddProfileResponse\"T\n" + + "\vprofileName\x18\x02 \x01(\tR\vprofileName\"$\n" + + "\x12AddProfileResponse\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"r\n" + + "\x14RenameProfileRequest\x12\x1a\n" + + "\busername\x18\x01 \x01(\tR\busername\x12\x16\n" + + "\x06handle\x18\x02 \x01(\tR\x06handle\x12&\n" + + "\x0enewProfileName\x18\x03 \x01(\tR\x0enewProfileName\"?\n" + + "\x15RenameProfileResponse\x12&\n" + + "\x0eoldProfileName\x18\x01 \x01(\tR\x0eoldProfileName\"T\n" + "\x14RemoveProfileRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + - "\vprofileName\x18\x02 \x01(\tR\vprofileName\"\x17\n" + - "\x15RemoveProfileResponse\"1\n" + + "\vprofileName\x18\x02 \x01(\tR\vprofileName\"'\n" + + "\x15RemoveProfileResponse\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"1\n" + "\x13ListProfilesRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\"C\n" + "\x14ListProfilesResponse\x12+\n" + - "\bprofiles\x18\x01 \x03(\v2\x0f.daemon.ProfileR\bprofiles\":\n" + + "\bprofiles\x18\x01 \x03(\v2\x0f.daemon.ProfileR\bprofiles\"J\n" + "\aProfile\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1b\n" + - "\tis_active\x18\x02 \x01(\bR\bisActive\"\x19\n" + - "\x17GetActiveProfileRequest\"X\n" + + "\tis_active\x18\x02 \x01(\bR\bisActive\x12\x0e\n" + + "\x02id\x18\x03 \x01(\tR\x02id\"\x19\n" + + "\x17GetActiveProfileRequest\"h\n" + "\x18GetActiveProfileResponse\x12 \n" + "\vprofileName\x18\x01 \x01(\tR\vprofileName\x12\x1a\n" + - "\busername\x18\x02 \x01(\tR\busername\"t\n" + + "\busername\x18\x02 \x01(\tR\busername\x12\x0e\n" + + "\x02id\x18\x03 \x01(\tR\x02id\"t\n" + "\rLogoutRequest\x12%\n" + "\vprofileName\x18\x01 \x01(\tH\x00R\vprofileName\x88\x01\x01\x12\x1f\n" + "\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" + @@ -6869,7 +7040,7 @@ const file_daemon_proto_rawDesc = "" + "\n" + "EXPOSE_UDP\x10\x03\x12\x0e\n" + "\n" + - "EXPOSE_TLS\x10\x042\xaf\x17\n" + + "EXPOSE_TLS\x10\x042\xff\x17\n" + "\rDaemonService\x126\n" + "\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" + "\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" + @@ -6900,6 +7071,7 @@ const file_daemon_proto_rawDesc = "" + "\tSetConfig\x12\x18.daemon.SetConfigRequest\x1a\x19.daemon.SetConfigResponse\"\x00\x12E\n" + "\n" + "AddProfile\x12\x19.daemon.AddProfileRequest\x1a\x1a.daemon.AddProfileResponse\"\x00\x12N\n" + + "\rRenameProfile\x12\x1c.daemon.RenameProfileRequest\x1a\x1d.daemon.RenameProfileResponse\"\x00\x12N\n" + "\rRemoveProfile\x12\x1c.daemon.RemoveProfileRequest\x1a\x1d.daemon.RemoveProfileResponse\"\x00\x12K\n" + "\fListProfiles\x12\x1b.daemon.ListProfilesRequest\x1a\x1c.daemon.ListProfilesResponse\"\x00\x12W\n" + "\x10GetActiveProfile\x12\x1f.daemon.GetActiveProfileRequest\x1a .daemon.GetActiveProfileResponse\"\x00\x129\n" + @@ -6927,7 +7099,7 @@ func file_daemon_proto_rawDescGZIP() []byte { } var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4) -var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 98) +var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 100) var file_daemon_proto_goTypes = []any{ (LogLevel)(0), // 0: daemon.LogLevel (ExposeProtocol)(0), // 1: daemon.ExposeProtocol @@ -6993,53 +7165,55 @@ var file_daemon_proto_goTypes = []any{ (*SetConfigResponse)(nil), // 61: daemon.SetConfigResponse (*AddProfileRequest)(nil), // 62: daemon.AddProfileRequest (*AddProfileResponse)(nil), // 63: daemon.AddProfileResponse - (*RemoveProfileRequest)(nil), // 64: daemon.RemoveProfileRequest - (*RemoveProfileResponse)(nil), // 65: daemon.RemoveProfileResponse - (*ListProfilesRequest)(nil), // 66: daemon.ListProfilesRequest - (*ListProfilesResponse)(nil), // 67: daemon.ListProfilesResponse - (*Profile)(nil), // 68: daemon.Profile - (*GetActiveProfileRequest)(nil), // 69: daemon.GetActiveProfileRequest - (*GetActiveProfileResponse)(nil), // 70: daemon.GetActiveProfileResponse - (*LogoutRequest)(nil), // 71: daemon.LogoutRequest - (*LogoutResponse)(nil), // 72: daemon.LogoutResponse - (*GetFeaturesRequest)(nil), // 73: daemon.GetFeaturesRequest - (*GetFeaturesResponse)(nil), // 74: daemon.GetFeaturesResponse - (*MDMManagedFieldsViolation)(nil), // 75: daemon.MDMManagedFieldsViolation - (*TriggerUpdateRequest)(nil), // 76: daemon.TriggerUpdateRequest - (*TriggerUpdateResponse)(nil), // 77: daemon.TriggerUpdateResponse - (*GetPeerSSHHostKeyRequest)(nil), // 78: daemon.GetPeerSSHHostKeyRequest - (*GetPeerSSHHostKeyResponse)(nil), // 79: daemon.GetPeerSSHHostKeyResponse - (*RequestJWTAuthRequest)(nil), // 80: daemon.RequestJWTAuthRequest - (*RequestJWTAuthResponse)(nil), // 81: daemon.RequestJWTAuthResponse - (*WaitJWTTokenRequest)(nil), // 82: daemon.WaitJWTTokenRequest - (*WaitJWTTokenResponse)(nil), // 83: daemon.WaitJWTTokenResponse - (*StartCPUProfileRequest)(nil), // 84: daemon.StartCPUProfileRequest - (*StartCPUProfileResponse)(nil), // 85: daemon.StartCPUProfileResponse - (*StopCPUProfileRequest)(nil), // 86: daemon.StopCPUProfileRequest - (*StopCPUProfileResponse)(nil), // 87: daemon.StopCPUProfileResponse - (*InstallerResultRequest)(nil), // 88: daemon.InstallerResultRequest - (*InstallerResultResponse)(nil), // 89: daemon.InstallerResultResponse - (*ExposeServiceRequest)(nil), // 90: daemon.ExposeServiceRequest - (*ExposeServiceEvent)(nil), // 91: daemon.ExposeServiceEvent - (*ExposeServiceReady)(nil), // 92: daemon.ExposeServiceReady - (*StartCaptureRequest)(nil), // 93: daemon.StartCaptureRequest - (*CapturePacket)(nil), // 94: daemon.CapturePacket - (*StartBundleCaptureRequest)(nil), // 95: daemon.StartBundleCaptureRequest - (*StartBundleCaptureResponse)(nil), // 96: daemon.StartBundleCaptureResponse - (*StopBundleCaptureRequest)(nil), // 97: daemon.StopBundleCaptureRequest - (*StopBundleCaptureResponse)(nil), // 98: daemon.StopBundleCaptureResponse - nil, // 99: daemon.Network.ResolvedIPsEntry - (*PortInfo_Range)(nil), // 100: daemon.PortInfo.Range - nil, // 101: daemon.SystemEvent.MetadataEntry - (*durationpb.Duration)(nil), // 102: google.protobuf.Duration - (*timestamppb.Timestamp)(nil), // 103: google.protobuf.Timestamp + (*RenameProfileRequest)(nil), // 64: daemon.RenameProfileRequest + (*RenameProfileResponse)(nil), // 65: daemon.RenameProfileResponse + (*RemoveProfileRequest)(nil), // 66: daemon.RemoveProfileRequest + (*RemoveProfileResponse)(nil), // 67: daemon.RemoveProfileResponse + (*ListProfilesRequest)(nil), // 68: daemon.ListProfilesRequest + (*ListProfilesResponse)(nil), // 69: daemon.ListProfilesResponse + (*Profile)(nil), // 70: daemon.Profile + (*GetActiveProfileRequest)(nil), // 71: daemon.GetActiveProfileRequest + (*GetActiveProfileResponse)(nil), // 72: daemon.GetActiveProfileResponse + (*LogoutRequest)(nil), // 73: daemon.LogoutRequest + (*LogoutResponse)(nil), // 74: daemon.LogoutResponse + (*GetFeaturesRequest)(nil), // 75: daemon.GetFeaturesRequest + (*GetFeaturesResponse)(nil), // 76: daemon.GetFeaturesResponse + (*MDMManagedFieldsViolation)(nil), // 77: daemon.MDMManagedFieldsViolation + (*TriggerUpdateRequest)(nil), // 78: daemon.TriggerUpdateRequest + (*TriggerUpdateResponse)(nil), // 79: daemon.TriggerUpdateResponse + (*GetPeerSSHHostKeyRequest)(nil), // 80: daemon.GetPeerSSHHostKeyRequest + (*GetPeerSSHHostKeyResponse)(nil), // 81: daemon.GetPeerSSHHostKeyResponse + (*RequestJWTAuthRequest)(nil), // 82: daemon.RequestJWTAuthRequest + (*RequestJWTAuthResponse)(nil), // 83: daemon.RequestJWTAuthResponse + (*WaitJWTTokenRequest)(nil), // 84: daemon.WaitJWTTokenRequest + (*WaitJWTTokenResponse)(nil), // 85: daemon.WaitJWTTokenResponse + (*StartCPUProfileRequest)(nil), // 86: daemon.StartCPUProfileRequest + (*StartCPUProfileResponse)(nil), // 87: daemon.StartCPUProfileResponse + (*StopCPUProfileRequest)(nil), // 88: daemon.StopCPUProfileRequest + (*StopCPUProfileResponse)(nil), // 89: daemon.StopCPUProfileResponse + (*InstallerResultRequest)(nil), // 90: daemon.InstallerResultRequest + (*InstallerResultResponse)(nil), // 91: daemon.InstallerResultResponse + (*ExposeServiceRequest)(nil), // 92: daemon.ExposeServiceRequest + (*ExposeServiceEvent)(nil), // 93: daemon.ExposeServiceEvent + (*ExposeServiceReady)(nil), // 94: daemon.ExposeServiceReady + (*StartCaptureRequest)(nil), // 95: daemon.StartCaptureRequest + (*CapturePacket)(nil), // 96: daemon.CapturePacket + (*StartBundleCaptureRequest)(nil), // 97: daemon.StartBundleCaptureRequest + (*StartBundleCaptureResponse)(nil), // 98: daemon.StartBundleCaptureResponse + (*StopBundleCaptureRequest)(nil), // 99: daemon.StopBundleCaptureRequest + (*StopBundleCaptureResponse)(nil), // 100: daemon.StopBundleCaptureResponse + nil, // 101: daemon.Network.ResolvedIPsEntry + (*PortInfo_Range)(nil), // 102: daemon.PortInfo.Range + nil, // 103: daemon.SystemEvent.MetadataEntry + (*durationpb.Duration)(nil), // 104: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 105: google.protobuf.Timestamp } var file_daemon_proto_depIdxs = []int32{ - 102, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 104, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration 25, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus - 103, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp - 103, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp - 102, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration + 105, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp + 105, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp + 104, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration 23, // 5: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo 20, // 6: daemon.FullStatus.managementState:type_name -> daemon.ManagementState 19, // 7: daemon.FullStatus.signalState:type_name -> daemon.SignalState @@ -7050,8 +7224,8 @@ var file_daemon_proto_depIdxs = []int32{ 55, // 12: daemon.FullStatus.events:type_name -> daemon.SystemEvent 24, // 13: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState 31, // 14: daemon.ListNetworksResponse.routes:type_name -> daemon.Network - 99, // 15: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry - 100, // 16: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range + 101, // 15: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry + 102, // 16: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range 32, // 17: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo 32, // 18: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo 33, // 19: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule @@ -7062,15 +7236,15 @@ var file_daemon_proto_depIdxs = []int32{ 52, // 24: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage 2, // 25: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity 3, // 26: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category - 103, // 27: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp - 101, // 28: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry + 105, // 27: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp + 103, // 28: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry 55, // 29: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent - 102, // 30: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration - 68, // 31: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile + 104, // 30: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 70, // 31: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile 1, // 32: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol - 92, // 33: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady - 102, // 34: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration - 102, // 35: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration + 94, // 33: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady + 104, // 34: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration + 104, // 35: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration 30, // 36: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList 5, // 37: daemon.DaemonService.Login:input_type -> daemon.LoginRequest 7, // 38: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest @@ -7090,68 +7264,70 @@ var file_daemon_proto_depIdxs = []int32{ 46, // 52: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest 48, // 53: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest 51, // 54: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest - 93, // 55: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest - 95, // 56: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest - 97, // 57: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest + 95, // 55: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest + 97, // 56: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest + 99, // 57: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest 54, // 58: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest 56, // 59: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest 58, // 60: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest 60, // 61: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest 62, // 62: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest - 64, // 63: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest - 66, // 64: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest - 69, // 65: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest - 71, // 66: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest - 73, // 67: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest - 76, // 68: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest - 78, // 69: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest - 80, // 70: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest - 82, // 71: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest - 84, // 72: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest - 86, // 73: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest - 88, // 74: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest - 90, // 75: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest - 6, // 76: daemon.DaemonService.Login:output_type -> daemon.LoginResponse - 8, // 77: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse - 10, // 78: daemon.DaemonService.Up:output_type -> daemon.UpResponse - 12, // 79: daemon.DaemonService.Status:output_type -> daemon.StatusResponse - 14, // 80: daemon.DaemonService.Down:output_type -> daemon.DownResponse - 16, // 81: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse - 27, // 82: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse - 29, // 83: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse - 29, // 84: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse - 34, // 85: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse - 36, // 86: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse - 38, // 87: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse - 40, // 88: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse - 43, // 89: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse - 45, // 90: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse - 47, // 91: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse - 49, // 92: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse - 53, // 93: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse - 94, // 94: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket - 96, // 95: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse - 98, // 96: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse - 55, // 97: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent - 57, // 98: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse - 59, // 99: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse - 61, // 100: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse - 63, // 101: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse - 65, // 102: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse - 67, // 103: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse - 70, // 104: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse - 72, // 105: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse - 74, // 106: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse - 77, // 107: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse - 79, // 108: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse - 81, // 109: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse - 83, // 110: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse - 85, // 111: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse - 87, // 112: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse - 89, // 113: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse - 91, // 114: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent - 76, // [76:115] is the sub-list for method output_type - 37, // [37:76] is the sub-list for method input_type + 64, // 63: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest + 66, // 64: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest + 68, // 65: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest + 71, // 66: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest + 73, // 67: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest + 75, // 68: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest + 78, // 69: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest + 80, // 70: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest + 82, // 71: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest + 84, // 72: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest + 86, // 73: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest + 88, // 74: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest + 90, // 75: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest + 92, // 76: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest + 6, // 77: daemon.DaemonService.Login:output_type -> daemon.LoginResponse + 8, // 78: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse + 10, // 79: daemon.DaemonService.Up:output_type -> daemon.UpResponse + 12, // 80: daemon.DaemonService.Status:output_type -> daemon.StatusResponse + 14, // 81: daemon.DaemonService.Down:output_type -> daemon.DownResponse + 16, // 82: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse + 27, // 83: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse + 29, // 84: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse + 29, // 85: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse + 34, // 86: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse + 36, // 87: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse + 38, // 88: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse + 40, // 89: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse + 43, // 90: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse + 45, // 91: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse + 47, // 92: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse + 49, // 93: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse + 53, // 94: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse + 96, // 95: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket + 98, // 96: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse + 100, // 97: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse + 55, // 98: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent + 57, // 99: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse + 59, // 100: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse + 61, // 101: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse + 63, // 102: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse + 65, // 103: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse + 67, // 104: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse + 69, // 105: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse + 72, // 106: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse + 74, // 107: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse + 76, // 108: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse + 79, // 109: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse + 81, // 110: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse + 83, // 111: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse + 85, // 112: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse + 87, // 113: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse + 89, // 114: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse + 91, // 115: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse + 93, // 116: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent + 77, // [77:117] is the sub-list for method output_type + 37, // [37:77] is the sub-list for method input_type 37, // [37:37] is the sub-list for extension type_name 37, // [37:37] is the sub-list for extension extendee 0, // [0:37] is the sub-list for field type_name @@ -7173,9 +7349,9 @@ func file_daemon_proto_init() { file_daemon_proto_msgTypes[48].OneofWrappers = []any{} file_daemon_proto_msgTypes[54].OneofWrappers = []any{} file_daemon_proto_msgTypes[56].OneofWrappers = []any{} - file_daemon_proto_msgTypes[67].OneofWrappers = []any{} - file_daemon_proto_msgTypes[76].OneofWrappers = []any{} - file_daemon_proto_msgTypes[87].OneofWrappers = []any{ + file_daemon_proto_msgTypes[69].OneofWrappers = []any{} + file_daemon_proto_msgTypes[78].OneofWrappers = []any{} + file_daemon_proto_msgTypes[89].OneofWrappers = []any{ (*ExposeServiceEvent_Ready)(nil), } type x struct{} @@ -7184,7 +7360,7 @@ func file_daemon_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)), NumEnums: 4, - NumMessages: 98, + NumMessages: 100, NumExtensions: 0, NumServices: 1, }, diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index ea668f629..c1e3fe513 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -85,6 +85,8 @@ service DaemonService { rpc AddProfile(AddProfileRequest) returns (AddProfileResponse) {} + rpc RenameProfile(RenameProfileRequest) returns (RenameProfileResponse) {} + rpc RemoveProfile(RemoveProfileRequest) returns (RemoveProfileResponse) {} rpc ListProfiles(ListProfilesRequest) returns (ListProfilesResponse) {} @@ -625,11 +627,18 @@ message GetEventsResponse { } message SwitchProfileRequest { + // profileName is treated as a handle: exact ID, unique ID prefix, or + // unique display name. The daemon resolves it server-side. optional string profileName = 1; optional string username = 2; } -message SwitchProfileResponse {} +message SwitchProfileResponse { + // id is the resolved on-disk ID of the profile that became active. + // Lets CLI clients update their local active-profile state without + // duplicating the resolution logic. + string id = 1; +} message SetConfigRequest { string username = 1; @@ -696,17 +705,42 @@ message SetConfigResponse{} message AddProfileRequest { string username = 1; + // profileName carries the human-readable display name for the new + // profile. The on-disk filename is a separately-generated ID. string profileName = 2; } -message AddProfileResponse {} +message AddProfileResponse { + // id is the generated on-disk ID of the new profile. CLI clients + // display a truncated form, UI clients can ignore it. + string id = 1; +} + +message RenameProfileRequest { + string username = 1; + // handle: an exact ID, a unique ID prefix, or a unique display name. + string handle = 2; + // newProfileName is the new human-readable display name for the profile. + string newProfileName = 3; +} + +message RenameProfileResponse { + // confirm the old profile name after resolving handle. + string oldProfileName = 1; +} message RemoveProfileRequest { string username = 1; + // profileName is treated as a handle: an exact ID, a unique ID + // prefix, or a unique display name. Resolution happens server-side. string profileName = 2; } -message RemoveProfileResponse {} +message RemoveProfileResponse { + // id is the full resolved ID of the removed profile, so callers can + // confirm exactly which profile a name/prefix handle resolved to. + string id = 1; +} message ListProfilesRequest { string username = 1; @@ -719,6 +753,7 @@ message ListProfilesResponse { message Profile { string name = 1; bool is_active = 2; + string id = 3; } message GetActiveProfileRequest {} @@ -726,6 +761,7 @@ message GetActiveProfileRequest {} message GetActiveProfileResponse { string profileName = 1; string username = 2; + string id = 3; } message LogoutRequest { diff --git a/client/proto/daemon_grpc.pb.go b/client/proto/daemon_grpc.pb.go index 66a8efcc3..5f585aafc 100644 --- a/client/proto/daemon_grpc.pb.go +++ b/client/proto/daemon_grpc.pb.go @@ -45,6 +45,7 @@ const ( DaemonService_SwitchProfile_FullMethodName = "/daemon.DaemonService/SwitchProfile" DaemonService_SetConfig_FullMethodName = "/daemon.DaemonService/SetConfig" DaemonService_AddProfile_FullMethodName = "/daemon.DaemonService/AddProfile" + DaemonService_RenameProfile_FullMethodName = "/daemon.DaemonService/RenameProfile" DaemonService_RemoveProfile_FullMethodName = "/daemon.DaemonService/RemoveProfile" DaemonService_ListProfiles_FullMethodName = "/daemon.DaemonService/ListProfiles" DaemonService_GetActiveProfile_FullMethodName = "/daemon.DaemonService/GetActiveProfile" @@ -112,6 +113,7 @@ type DaemonServiceClient interface { SwitchProfile(ctx context.Context, in *SwitchProfileRequest, opts ...grpc.CallOption) (*SwitchProfileResponse, error) SetConfig(ctx context.Context, in *SetConfigRequest, opts ...grpc.CallOption) (*SetConfigResponse, error) AddProfile(ctx context.Context, in *AddProfileRequest, opts ...grpc.CallOption) (*AddProfileResponse, error) + RenameProfile(ctx context.Context, in *RenameProfileRequest, opts ...grpc.CallOption) (*RenameProfileResponse, error) RemoveProfile(ctx context.Context, in *RemoveProfileRequest, opts ...grpc.CallOption) (*RemoveProfileResponse, error) ListProfiles(ctx context.Context, in *ListProfilesRequest, opts ...grpc.CallOption) (*ListProfilesResponse, error) GetActiveProfile(ctx context.Context, in *GetActiveProfileRequest, opts ...grpc.CallOption) (*GetActiveProfileResponse, error) @@ -422,6 +424,16 @@ func (c *daemonServiceClient) AddProfile(ctx context.Context, in *AddProfileRequ return out, nil } +func (c *daemonServiceClient) RenameProfile(ctx context.Context, in *RenameProfileRequest, opts ...grpc.CallOption) (*RenameProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RenameProfileResponse) + err := c.cc.Invoke(ctx, DaemonService_RenameProfile_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *daemonServiceClient) RemoveProfile(ctx context.Context, in *RemoveProfileRequest, opts ...grpc.CallOption) (*RemoveProfileResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(RemoveProfileResponse) @@ -613,6 +625,7 @@ type DaemonServiceServer interface { SwitchProfile(context.Context, *SwitchProfileRequest) (*SwitchProfileResponse, error) SetConfig(context.Context, *SetConfigRequest) (*SetConfigResponse, error) AddProfile(context.Context, *AddProfileRequest) (*AddProfileResponse, error) + RenameProfile(context.Context, *RenameProfileRequest) (*RenameProfileResponse, error) RemoveProfile(context.Context, *RemoveProfileRequest) (*RemoveProfileResponse, error) ListProfiles(context.Context, *ListProfilesRequest) (*ListProfilesResponse, error) GetActiveProfile(context.Context, *GetActiveProfileRequest) (*GetActiveProfileResponse, error) @@ -723,6 +736,9 @@ func (UnimplementedDaemonServiceServer) SetConfig(context.Context, *SetConfigReq func (UnimplementedDaemonServiceServer) AddProfile(context.Context, *AddProfileRequest) (*AddProfileResponse, error) { return nil, status.Error(codes.Unimplemented, "method AddProfile not implemented") } +func (UnimplementedDaemonServiceServer) RenameProfile(context.Context, *RenameProfileRequest) (*RenameProfileResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RenameProfile not implemented") +} func (UnimplementedDaemonServiceServer) RemoveProfile(context.Context, *RemoveProfileRequest) (*RemoveProfileResponse, error) { return nil, status.Error(codes.Unimplemented, "method RemoveProfile not implemented") } @@ -1237,6 +1253,24 @@ func _DaemonService_AddProfile_Handler(srv interface{}, ctx context.Context, dec return interceptor(ctx, in, info, handler) } +func _DaemonService_RenameProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RenameProfileRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).RenameProfile(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_RenameProfile_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).RenameProfile(ctx, req.(*RenameProfileRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _DaemonService_RemoveProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(RemoveProfileRequest) if err := dec(in); err != nil { @@ -1567,6 +1601,10 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ MethodName: "AddProfile", Handler: _DaemonService_AddProfile_Handler, }, + { + MethodName: "RenameProfile", + Handler: _DaemonService_RenameProfile_Handler, + }, { MethodName: "RemoveProfile", Handler: _DaemonService_RemoveProfile_Handler, diff --git a/client/server/login_overrides_test.go b/client/server/login_overrides_test.go index c45557c59..5a2298764 100644 --- a/client/server/login_overrides_test.go +++ b/client/server/login_overrides_test.go @@ -79,7 +79,7 @@ func TestPersistLoginOverrides(t *testing.T) { _, err := profilemanager.UpdateOrCreateConfig(seed) require.NoError(t, err, "seed config") - activeProf := &profilemanager.ActiveProfileState{Name: "default"} + activeProf := &profilemanager.ActiveProfileState{ID: "default"} err = persistLoginOverrides(activeProf, tt.newMgmtURL, tt.newPSK) require.NoError(t, err, "persistLoginOverrides") diff --git a/client/server/server.go b/client/server/server.go index 32daf7718..a4d53a823 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -78,7 +78,7 @@ type Server struct { // changed by connectWithRetryRuns goroutine exit — for that // (goroutine-still-alive) check, see connectionGoroutineRunning() which // derives from clientGiveUpChan close state. Protected by s.mutex. - clientRunning bool + clientRunning bool clientRunningChan chan struct{} clientGiveUpChan chan struct{} // closed when connectWithRetryRuns goroutine exits @@ -375,7 +375,7 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques return nil, err } - config, err := setConfigInputFromRequest(msg) + config, err := s.setConfigInputFromRequest(msg) if err != nil { return nil, err } @@ -398,17 +398,17 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques // field is its own optional case. Returns the resolved ConfigInput // and a non-nil error only when the active profile file path cannot // be determined. -func setConfigInputFromRequest(msg *proto.SetConfigRequest) (profilemanager.ConfigInput, error) { +func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profilemanager.ConfigInput, error) { var config profilemanager.ConfigInput - profState := profilemanager.ActiveProfileState{ - Name: msg.ProfileName, - Username: msg.Username, - } - profPath, err := profState.FilePath() + resolved, err := s.resolveProfileHandle(msg.ProfileName, msg.Username) if err != nil { - log.Errorf("failed to get active profile file path: %v", err) - return config, fmt.Errorf("failed to get active profile file path: %w", err) + log.Errorf("failed to resolve profile %q: %v", msg.ProfileName, err) + return config, err + } + profPath := resolved.Path + if profPath == "" { + profPath = profilemanager.DefaultConfigPath } config.ConfigPath = profPath @@ -535,30 +535,9 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro } if msg.ProfileName != nil { - if *msg.ProfileName != "default" && (msg.Username == nil || *msg.Username == "") { - log.Errorf("profile name is set to %s, but username is not provided", *msg.ProfileName) - return nil, fmt.Errorf("profile name is set to %s, but username is not provided", *msg.ProfileName) - } - - var username string - if *msg.ProfileName != "default" { - username = *msg.Username - } - - if *msg.ProfileName != activeProf.Name && username != activeProf.Username { - if s.checkProfilesDisabled() { - log.Errorf("profiles are disabled, you cannot use this feature without profiles enabled") - return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled) - } - - log.Infof("switching to profile %s for user '%s'", *msg.ProfileName, username) - if err := s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: *msg.ProfileName, - Username: username, - }); err != nil { - log.Errorf("failed to set active profile state: %v", err) - return nil, fmt.Errorf("failed to set active profile state: %w", err) - } + if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { + log.Errorf("failed to switch profile: %v", err) + return nil, err } } @@ -568,7 +547,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro return nil, fmt.Errorf("failed to get active profile state: %w", err) } - log.Infof("active profile: %s for %s", activeProf.Name, activeProf.Username) + log.Infof("active profile: %s for %s", activeProf.ID, activeProf.Username) s.mutex.Lock() @@ -806,10 +785,10 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR } if msg != nil && msg.ProfileName != nil { - if err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { + if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { s.mutex.Unlock() log.Errorf("failed to switch profile: %v", err) - return nil, fmt.Errorf("failed to switch profile: %w", err) + return nil, err } } @@ -820,7 +799,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR return nil, fmt.Errorf("failed to get active profile state: %w", err) } - log.Infof("active profile: %s for %s", activeProf.Name, activeProf.Username) + log.Infof("active profile: %s for %s", activeProf.ID, activeProf.Username) config, _, err := s.getConfig(activeProf) if err != nil { @@ -864,34 +843,60 @@ func (s *Server) waitForUp(callerCtx context.Context) (*proto.UpResponse, error) } } -func (s *Server) switchProfileIfNeeded(profileName string, userName *string, activeProf *profilemanager.ActiveProfileState) error { - if profileName != "default" && (userName == nil || *userName == "") { - log.Errorf("profile name is set to %s, but username is not provided", profileName) - return fmt.Errorf("profile name is set to %s, but username is not provided", profileName) +// resolveProfileHandle resolves a wire-level profile handle (display +// name, ID, or unique ID prefix) to a concrete profile. Returns gRPC +// status errors so handlers can return them directly. +func (s *Server) resolveProfileHandle(handle, username string) (*profilemanager.Profile, error) { + p, err := s.profileManager.ResolveProfile(handle, username) + if err == nil { + return p, nil + } + var amb *profilemanager.ErrAmbiguousHandle + if errors.As(err, &amb) { + return nil, gstatus.Errorf(codes.InvalidArgument, "%v", amb) + } + if errors.Is(err, profilemanager.ErrProfileNotFound) { + return nil, gstatus.Errorf(codes.NotFound, "profile %q not found", handle) + } + return nil, fmt.Errorf("resolve profile: %w", err) +} + +// switchProfileIfNeeded resolves the user-supplied handle, updates the +// active profile state if it differs from the current one, and returns +// the resolved profile so callers can include its ID in RPC responses. +func (s *Server) switchProfileIfNeeded(handle string, userName *string, activeProf *profilemanager.ActiveProfileState) (*profilemanager.Profile, error) { + if handle != profilemanager.DefaultProfileName && (userName == nil || *userName == "") { + log.Errorf("profile name is set to %s, but username is not provided", handle) + return nil, fmt.Errorf("profile name is set to %s, but username is not provided", handle) } var username string - if profileName != "default" { + if handle != profilemanager.DefaultProfileName { username = *userName } - if profileName != activeProf.Name || username != activeProf.Username { + resolved, err := s.resolveProfileHandle(handle, username) + if err != nil { + return nil, err + } + + if resolved.ID != activeProf.ID || username != activeProf.Username { if s.checkProfilesDisabled() { log.Errorf("profiles are disabled, you cannot use this feature without profiles enabled") - return gstatus.Errorf(codes.Unavailable, errProfilesDisabled) + return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled) } - log.Infof("switching to profile %s for user %s", profileName, username) + log.Infof("switching to profile %s (%s) for user %s", resolved.Name, resolved.ID, username) if err := s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: profileName, + ID: resolved.ID, Username: username, }); err != nil { log.Errorf("failed to set active profile state: %v", err) - return fmt.Errorf("failed to set active profile state: %w", err) + return nil, fmt.Errorf("failed to set active profile state: %w", err) } } - return nil + return resolved, nil } // SwitchProfile switches the active profile in the daemon. @@ -906,9 +911,9 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi } if msg != nil && msg.ProfileName != nil { - if err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { + if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { log.Errorf("failed to switch profile: %v", err) - return nil, fmt.Errorf("failed to switch profile: %w", err) + return nil, err } } activeProf, err = s.profileManager.GetActiveProfileState() @@ -924,7 +929,7 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi s.config = config - return &proto.SwitchProfileResponse{}, nil + return &proto.SwitchProfileResponse{Id: activeProf.ID.String()}, nil } // Down engine work in the daemon. @@ -1014,22 +1019,27 @@ func (s *Server) Logout(ctx context.Context, msg *proto.LogoutRequest) (*proto.L } func (s *Server) handleProfileLogout(ctx context.Context, msg *proto.LogoutRequest) (*proto.LogoutResponse, error) { - if err := s.validateProfileOperation(*msg.ProfileName, true); err != nil { - return nil, err - } - if msg.Username == nil || *msg.Username == "" { return nil, gstatus.Errorf(codes.InvalidArgument, "username must be provided when profile name is specified") } username := *msg.Username - if err := s.logoutFromProfile(ctx, *msg.ProfileName, username); err != nil { - log.Errorf("failed to logout from profile %s: %v", *msg.ProfileName, err) + resolved, err := s.resolveProfileHandle(*msg.ProfileName, username) + if err != nil { + return nil, err + } + + if err := s.validateProfileOperation(resolved.ID, true); err != nil { + return nil, err + } + + if err := s.logoutFromProfile(ctx, resolved); err != nil { + log.Errorf("failed to logout from profile %s: %v", resolved.ID, err) return nil, gstatus.Errorf(codes.Internal, "logout: %v", err) } activeProf, _ := s.profileManager.GetActiveProfileState() - if activeProf != nil && activeProf.Name == *msg.ProfileName { + if activeProf != nil && activeProf.ID == resolved.ID { if err := s.cleanupConnection(); err != nil && !errors.Is(err, ErrServiceNotUp) { log.Errorf("failed to cleanup connection: %v", err) } @@ -1091,30 +1101,30 @@ func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*prof return config, configExisted, nil } -func (s *Server) canRemoveProfile(profileName string) error { - if profileName == profilemanager.DefaultProfileName { +func (s *Server) canRemoveProfile(id profilemanager.ID) error { + if id == profilemanager.DefaultProfileName { return fmt.Errorf("remove profile with reserved name: %s", profilemanager.DefaultProfileName) } activeProf, err := s.profileManager.GetActiveProfileState() - if err == nil && activeProf.Name == profileName { - return fmt.Errorf("remove active profile: %s", profileName) + if err == nil && activeProf.ID == id { + return fmt.Errorf("remove active profile: %s", id) } return nil } -func (s *Server) validateProfileOperation(profileName string, allowActiveProfile bool) error { +func (s *Server) validateProfileOperation(id profilemanager.ID, allowActiveProfile bool) error { if s.checkProfilesDisabled() { return gstatus.Errorf(codes.Unavailable, errProfilesDisabled) } - if profileName == "" { + if id == "" { return gstatus.Errorf(codes.InvalidArgument, "profile name must be provided") } if !allowActiveProfile { - if err := s.canRemoveProfile(profileName); err != nil { + if err := s.canRemoveProfile(id); err != nil { return gstatus.Errorf(codes.InvalidArgument, "%v", err) } } @@ -1122,25 +1132,20 @@ func (s *Server) validateProfileOperation(profileName string, allowActiveProfile return nil } -// logoutFromProfile logs out from a specific profile by loading its config and sending logout request -func (s *Server) logoutFromProfile(ctx context.Context, profileName, username string) error { +func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager.Profile) error { activeProf, err := s.profileManager.GetActiveProfileState() - if err == nil && activeProf.Name == profileName && s.connectClient != nil { + if err == nil && activeProf.ID == profile.ID && s.connectClient != nil { return s.sendLogoutRequest(ctx) } - profileState := &profilemanager.ActiveProfileState{ - Name: profileName, - Username: username, - } - profilePath, err := profileState.FilePath() - if err != nil { - return fmt.Errorf("get profile path: %w", err) + cfgPath := profile.Path + if cfgPath == "" { + cfgPath = profilemanager.DefaultConfigPath } - config, err := profilemanager.GetConfig(profilePath) + config, err := profilemanager.GetConfig(cfgPath) if err != nil { - return fmt.Errorf("profile '%s' not found", profileName) + return fmt.Errorf("profile '%s' not found", profile.ID) } return s.sendLogoutRequestWithConfig(ctx, config) @@ -1558,15 +1563,14 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p return nil, ctx.Err() } - prof := profilemanager.ActiveProfileState{ - Name: req.ProfileName, - Username: req.Username, - } - - cfgPath, err := prof.FilePath() + resolved, err := s.resolveProfileHandle(req.ProfileName, req.Username) if err != nil { - log.Errorf("failed to get active profile file path: %v", err) - return nil, fmt.Errorf("failed to get active profile file path: %w", err) + log.Errorf("failed to resolve profile %q: %v", req.ProfileName, err) + return nil, err + } + cfgPath := resolved.Path + if cfgPath == "" { + cfgPath = profilemanager.DefaultConfigPath } cfg, err := profilemanager.GetConfig(cfgPath) @@ -1671,12 +1675,39 @@ func (s *Server) AddProfile(ctx context.Context, msg *proto.AddProfileRequest) ( return nil, gstatus.Errorf(codes.InvalidArgument, "profile name and username must be provided") } - if err := s.profileManager.AddProfile(msg.ProfileName, msg.Username); err != nil { + created, err := s.profileManager.AddProfile(msg.ProfileName, msg.Username) + if err != nil { log.Errorf("failed to create profile: %v", err) return nil, fmt.Errorf("failed to create profile: %w", err) } - return &proto.AddProfileResponse{}, nil + return &proto.AddProfileResponse{Id: created.ID.String()}, nil +} + +func (s *Server) RenameProfile(ctx context.Context, msg *proto.RenameProfileRequest) (*proto.RenameProfileResponse, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + if s.checkProfilesDisabled() { + return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled) + } + + if msg.Handle == "" || msg.Username == "" || msg.NewProfileName == "" { + return nil, gstatus.Errorf(codes.InvalidArgument, "profile name, username and new profile name must be provided") + } + + resolved, err := s.resolveProfileHandle(msg.Handle, msg.Username) + if err != nil { + return nil, err + } + + err = s.profileManager.RenameProfile(resolved.ID, msg.Username, msg.NewProfileName) + if err != nil { + log.Errorf("failed to rename profile: %v", err) + return nil, fmt.Errorf("failed to rename profile: %w", err) + } + + return &proto.RenameProfileResponse{OldProfileName: resolved.Name}, nil } // RemoveProfile removes a profile from the daemon. @@ -1684,20 +1715,29 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ s.mutex.Lock() defer s.mutex.Unlock() - if err := s.validateProfileOperation(msg.ProfileName, false); err != nil { + if s.checkProfilesDisabled() { + return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled) + } + + if msg.ProfileName == "" { + return nil, gstatus.Errorf(codes.InvalidArgument, "profile name must be provided") + } + + resolved, err := s.resolveProfileHandle(msg.ProfileName, msg.Username) + if err != nil { return nil, err } - if err := s.logoutFromProfile(ctx, msg.ProfileName, msg.Username); err != nil { - log.Warnf("failed to logout from profile %s before removal: %v", msg.ProfileName, err) + if err := s.logoutFromProfile(ctx, resolved); err != nil { + log.Warnf("failed to logout from profile %s before removal: %v", resolved.ID, err) } - if err := s.profileManager.RemoveProfile(msg.ProfileName, msg.Username); err != nil { + if err := s.profileManager.RemoveProfile(resolved.ID, msg.Username); err != nil { log.Errorf("failed to remove profile: %v", err) return nil, fmt.Errorf("failed to remove profile: %w", err) } - return &proto.RemoveProfileResponse{}, nil + return &proto.RemoveProfileResponse{Id: resolved.ID.String()}, nil } // ListProfiles lists all profiles in the daemon. @@ -1720,6 +1760,7 @@ func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesReques } for i, profile := range profiles { response.Profiles[i] = &proto.Profile{ + Id: profile.ID.String(), Name: profile.Name, IsActive: profile.IsActive, } @@ -1728,7 +1769,9 @@ func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesReques return response, nil } -// GetActiveProfile returns the active profile in the daemon. +// GetActiveProfile returns the active profile in the daemon. The ProfileName +// field carries the display name for backwards compatibility with UI clients, +// new callers should prefer Id. func (s *Server) GetActiveProfile(ctx context.Context, msg *proto.GetActiveProfileRequest) (*proto.GetActiveProfileResponse, error) { s.mutex.Lock() defer s.mutex.Unlock() @@ -1739,9 +1782,23 @@ func (s *Server) GetActiveProfile(ctx context.Context, msg *proto.GetActiveProfi return nil, fmt.Errorf("failed to get active profile state: %w", err) } + // Fallback to legacy name == ID + displayName := activeProfile.ID.String() + if activeProfile.ID != profilemanager.DefaultProfileName { + if profiles, lerr := s.profileManager.ListProfiles(activeProfile.Username); lerr == nil { + for _, p := range profiles { + if p.ID == activeProfile.ID { + displayName = p.Name + break + } + } + } + } + return &proto.GetActiveProfileResponse{ - ProfileName: activeProfile.Name, + ProfileName: displayName, Username: activeProfile.Username, + Id: activeProfile.ID.String(), }, nil } diff --git a/client/server/server_test.go b/client/server/server_test.go index 66e0fcc4c..fa9599818 100644 --- a/client/server/server_test.go +++ b/client/server/server_test.go @@ -97,7 +97,7 @@ func TestConnectWithRetryRuns(t *testing.T) { pm := profilemanager.ServiceManager{} err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: "test-profile", + ID: "test-profile", Username: currUser.Username, }) if err != nil { @@ -158,7 +158,7 @@ func TestServer_Up(t *testing.T) { pm := profilemanager.ServiceManager{} err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: profName, + ID: profilemanager.ID(profName), Username: currUser.Username, }) if err != nil { @@ -228,7 +228,7 @@ func TestServer_SubcribeEvents(t *testing.T) { pm := profilemanager.ServiceManager{} err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: "default", + ID: "default", Username: currUser.Username, }) if err != nil { diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go index 53232c70d..9818f9fdf 100644 --- a/client/server/setconfig_mdm_test.go +++ b/client/server/setconfig_mdm_test.go @@ -62,7 +62,7 @@ func setupServerWithProfile(t *testing.T) (s *Server, ctx context.Context, profN pm := profilemanager.ServiceManager{} require.NoError(t, pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: profName, + ID: profilemanager.ID(profName), Username: currUser.Username, })) @@ -107,9 +107,9 @@ func TestSetConfig_MDMReject_SingleField(t *testing.T) { func TestSetConfig_MDMReject_MultipleFields(t *testing.T) { withMDMPolicy(t, mdm.NewPolicy(map[string]any{ - mdm.KeyManagementURL: "https://mdm.example.com:443", - mdm.KeyBlockInbound: true, - mdm.KeyRosenpassEnabled: true, + mdm.KeyManagementURL: "https://mdm.example.com:443", + mdm.KeyBlockInbound: true, + mdm.KeyRosenpassEnabled: true, })) s, ctx, profName, username, _ := setupServerWithProfile(t) diff --git a/client/server/setconfig_test.go b/client/server/setconfig_test.go index 553d4ad71..7c85d16ce 100644 --- a/client/server/setconfig_test.go +++ b/client/server/setconfig_test.go @@ -47,7 +47,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { pm := profilemanager.ServiceManager{} err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: profName, + ID: profilemanager.ID(profName), Username: currUser.Username, }) require.NoError(t, err) @@ -96,7 +96,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { DisableNotifications: &disableNotifications, LazyConnectionEnabled: &lazyConnectionEnabled, BlockInbound: &blockInbound, - DisableIpv6: &disableIPv6, + DisableIpv6: &disableIPv6, NatExternalIPs: []string{"1.2.3.4", "5.6.7.8"}, CleanNATExternalIPs: false, CustomDNSAddress: []byte("1.1.1.1:53"), @@ -112,7 +112,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { require.NoError(t, err) profState := profilemanager.ActiveProfileState{ - Name: profName, + ID: profilemanager.ID(profName), Username: currUser.Username, } cfgPath, err := profState.FilePath() diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go index 5814ad9b4..d2f38cfd7 100644 --- a/client/ui/client_ui.go +++ b/client/ui/client_ui.go @@ -645,7 +645,7 @@ func (s *serviceClient) buildSetConfigRequest(iMngURL string, port, mtu int64) ( } req := &proto.SetConfigRequest{ - ProfileName: activeProf.Name, + ProfileName: activeProf.ID.String(), Username: currUser.Username, } @@ -818,13 +818,15 @@ func (s *serviceClient) login(ctx context.Context, openURL bool) (*proto.LoginRe return nil, fmt.Errorf("get current user: %w", err) } + handle := activeProf.ID.String() + loginReq := &proto.LoginRequest{ IsUnixDesktopClient: runtime.GOOS == "linux" || runtime.GOOS == "freebsd", - ProfileName: &activeProf.Name, + ProfileName: &handle, Username: &currUser.Username, } - profileState, err := s.profileManager.GetProfileState(activeProf.Name) + profileState, err := s.profileManager.GetProfileState(activeProf.ID) if err != nil { log.Debugf("failed to get profile state for login hint: %v", err) } else if profileState.Email != "" { @@ -1367,7 +1369,7 @@ func (s *serviceClient) getSrvConfig() { } srvCfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{ - ProfileName: activeProf.Name, + ProfileName: activeProf.ID.String(), Username: currUser.Username, }) if err != nil { @@ -1613,7 +1615,7 @@ func (s *serviceClient) loadSettings() { } cfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{ - ProfileName: activeProf.Name, + ProfileName: activeProf.ID.String(), Username: currUser.Username, }) if err != nil { @@ -1813,7 +1815,7 @@ func (s *serviceClient) updateConfig() error { } req := proto.SetConfigRequest{ - ProfileName: activeProf.Name, + ProfileName: activeProf.ID.String(), Username: currUser.Username, DisableAutoConnect: &disableAutoStart, ServerSSHAllowed: &sshAllowed, diff --git a/client/ui/profile.go b/client/ui/profile.go index d3db17855..83b0ec18b 100644 --- a/client/ui/profile.go +++ b/client/ui/profile.go @@ -66,7 +66,7 @@ func (s *serviceClient) showProfilesUI() { } else { indicator.SetText("") } - nameLabel.SetText(profile.Name) + nameLabel.SetText(formatProfileLabel(profile, profiles)) // Configure Select/Active button selectBtn.SetText(func() string { @@ -88,7 +88,7 @@ func (s *serviceClient) showProfilesUI() { return } // switch - err = s.switchProfile(profile.Name) + err = s.switchProfile(profile.ID) if err != nil { log.Errorf("failed to switch profile: %v", err) dialog.ShowError(errors.New("failed to select profile"), s.wProfiles) @@ -130,7 +130,7 @@ func (s *serviceClient) showProfilesUI() { logoutBtn.Show() logoutBtn.SetText("Deregister") logoutBtn.OnTapped = func() { - s.handleProfileLogout(profile.Name, refresh) + s.handleProfileLogout(profile, refresh) } // Remove profile @@ -144,7 +144,7 @@ func (s *serviceClient) showProfilesUI() { return } - err = s.removeProfile(profile.Name) + err = s.removeProfile(profile.ID) if err != nil { log.Errorf("failed to remove profile: %v", err) dialog.ShowError(fmt.Errorf("failed to remove profile"), s.wProfiles) @@ -250,7 +250,7 @@ func (s *serviceClient) addProfile(profileName string) error { return nil } -func (s *serviceClient) switchProfile(profileName string) error { +func (s *serviceClient) switchProfile(handle string) error { conn, err := s.getSrvClient(defaultFailTimeout) if err != nil { return fmt.Errorf(getClientFMT, err) @@ -261,15 +261,15 @@ func (s *serviceClient) switchProfile(profileName string) error { return fmt.Errorf("get current user: %w", err) } - if _, err := conn.SwitchProfile(s.ctx, &proto.SwitchProfileRequest{ - ProfileName: &profileName, + resp, err := conn.SwitchProfile(s.ctx, &proto.SwitchProfileRequest{ + ProfileName: &handle, Username: &currUser.Username, - }); err != nil { + }) + if err != nil { return fmt.Errorf("switch profile failed: %w", err) } - err = s.profileManager.SwitchProfile(profileName) - if err != nil { + if err := s.profileManager.SwitchProfile(profilemanager.ID(resp.Id)); err != nil { return fmt.Errorf("switch profile: %w", err) } @@ -299,10 +299,27 @@ func (s *serviceClient) removeProfile(profileName string) error { } type Profile struct { + ID string Name string IsActive bool } +// formatProfileLabel returns the display label for a profile. Profiles can +// share the same Name, so when more than one profile in profiles carries this +// Name, a short form of the ID is appended to disambiguate the entries. +func formatProfileLabel(profile Profile, profiles []Profile) string { + count := 0 + for _, p := range profiles { + if p.Name == profile.Name { + count++ + } + } + if count <= 1 { + return profile.Name + } + return fmt.Sprintf("%s (%s)", profile.Name, profilemanager.ID(profile.ID).ShortID()) +} + func (s *serviceClient) getProfiles() ([]Profile, error) { conn, err := s.getSrvClient(defaultFailTimeout) if err != nil { @@ -324,6 +341,7 @@ func (s *serviceClient) getProfiles() ([]Profile, error) { for _, profile := range profilesResp.Profiles { profiles = append(profiles, Profile{ + ID: profile.Id, Name: profile.Name, IsActive: profile.IsActive, }) @@ -332,10 +350,10 @@ func (s *serviceClient) getProfiles() ([]Profile, error) { return profiles, nil } -func (s *serviceClient) handleProfileLogout(profileName string, refreshCallback func()) { +func (s *serviceClient) handleProfileLogout(profile Profile, refreshCallback func()) { dialog.ShowConfirm( "Deregister", - fmt.Sprintf("Are you sure you want to deregister from '%s'?", profileName), + fmt.Sprintf("Are you sure you want to deregister from '%s'?", profile.Name), func(confirm bool) { if !confirm { return @@ -356,8 +374,10 @@ func (s *serviceClient) handleProfileLogout(profileName string, refreshCallback } username := currUser.Username + // ProfileName is treated as a handle; send the ID so the + // daemon resolves to exactly this profile. _, err = conn.Logout(s.ctx, &proto.LogoutRequest{ - ProfileName: &profileName, + ProfileName: &profile.ID, Username: &username, }) if err != nil { @@ -368,7 +388,7 @@ func (s *serviceClient) handleProfileLogout(profileName string, refreshCallback dialog.ShowInformation( "Deregistered", - fmt.Sprintf("Successfully deregistered from '%s'", profileName), + fmt.Sprintf("Successfully deregistered from '%s'", profile.Name), s.wProfiles, ) @@ -461,6 +481,7 @@ func (p *profileMenu) getProfiles() ([]Profile, error) { for _, profile := range profilesResp.Profiles { profiles = append(profiles, Profile{ + ID: profile.Id, Name: profile.Name, IsActive: profile.IsActive, }) @@ -501,7 +522,7 @@ func (p *profileMenu) refresh() { } if activeProf.ProfileName == "default" || activeProf.Username == currUser.Username { - activeProfState, err := p.profileManager.GetProfileState(activeProf.ProfileName) + activeProfState, err := p.profileManager.GetProfileState(profilemanager.ID(activeProf.Id)) if err != nil { log.Warnf("failed to get active profile state: %v", err) p.emailMenuItem.Hide() @@ -512,7 +533,7 @@ func (p *profileMenu) refresh() { } for _, profile := range profiles { - item := p.profileMenuItem.AddSubMenuItem(profile.Name, "") + item := p.profileMenuItem.AddSubMenuItem(formatProfileLabel(profile, profiles), "") if profile.IsActive { item.Check() } @@ -541,8 +562,8 @@ func (p *profileMenu) refresh() { return } - _, err = conn.SwitchProfile(ctx, &proto.SwitchProfileRequest{ - ProfileName: &profile.Name, + switchResp, err := conn.SwitchProfile(ctx, &proto.SwitchProfileRequest{ + ProfileName: &profile.ID, Username: &currUser.Username, }) if err != nil { @@ -552,7 +573,7 @@ func (p *profileMenu) refresh() { return } - err = p.profileManager.SwitchProfile(profile.Name) + err = p.profileManager.SwitchProfile(profilemanager.ID(switchResp.Id)) if err != nil { log.Errorf("failed to switch profile '%s': %v", profile.Name, err) return @@ -727,7 +748,10 @@ func (p *profileMenu) updateMenu() { } sort.Slice(profiles, func(i, j int) bool { - return profiles[i].Name < profiles[j].Name + if profiles[i].Name != profiles[j].Name { + return profiles[i].Name < profiles[j].Name + } + return profiles[i].ID < profiles[j].ID }) p.mu.Lock() From d3710d4bb2cfd7dc17aa0c004304a8bb96f27f39 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:00:19 +0900 Subject: [PATCH 09/20] [signal] Serialize concurrent sends to a peer signal stream (#6463) --- signal/peer/peer.go | 11 +++++ signal/server/concurrent_send_test.go | 67 +++++++++++++++++++++++++++ signal/server/signal.go | 2 +- 3 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 signal/server/concurrent_send_test.go diff --git a/signal/peer/peer.go b/signal/peer/peer.go index c9dd60fc0..c04654b8b 100644 --- a/signal/peer/peer.go +++ b/signal/peer/peer.go @@ -26,6 +26,10 @@ type Peer struct { // a gRpc connection stream to the Peer Stream proto.SignalExchange_ConnectStreamServer + // sendMu serializes writes to Stream. gRPC forbids concurrent SendMsg on + // the same ServerStream, and a peer can be the target of many senders at + // once. + sendMu sync.Mutex // registration time RegisteredAt time.Time @@ -33,6 +37,13 @@ type Peer struct { Cancel context.CancelFunc } +// Send writes a message to the peer's stream, serializing concurrent senders. +func (p *Peer) Send(msg *proto.EncryptedMessage) error { + p.sendMu.Lock() + defer p.sendMu.Unlock() + return p.Stream.Send(msg) +} + // NewPeer creates a new instance of a connected Peer func NewPeer(id string, stream proto.SignalExchange_ConnectStreamServer, cancel context.CancelFunc) *Peer { return &Peer{ diff --git a/signal/server/concurrent_send_test.go b/signal/server/concurrent_send_test.go new file mode 100644 index 000000000..b3830482d --- /dev/null +++ b/signal/server/concurrent_send_test.go @@ -0,0 +1,67 @@ +package server + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + + "github.com/netbirdio/netbird/shared/signal/proto" + "github.com/netbirdio/netbird/signal/peer" +) + +// concurrencyCheckStream records the maximum number of Send calls in flight at +// once. gRPC forbids concurrent SendMsg on the same ServerStream, so a correct +// server must never have more than one in flight per peer. +type concurrencyCheckStream struct { + proto.SignalExchange_ConnectStreamServer + ctx context.Context + inflight atomic.Int32 + maxSeen atomic.Int32 +} + +func (s *concurrencyCheckStream) Send(*proto.EncryptedMessage) error { + n := s.inflight.Add(1) + for { + old := s.maxSeen.Load() + if n <= old || s.maxSeen.CompareAndSwap(old, n) { + break + } + } + // Widen the window so overlapping callers are reliably observed. + time.Sleep(time.Millisecond) + s.inflight.Add(-1) + return nil +} + +func (s *concurrencyCheckStream) Context() context.Context { return s.ctx } + +// TestForwardMessageToPeerSerializesSend verifies that concurrent forwards to the +// same peer never call Stream.Send concurrently, which would violate the gRPC +// ServerStream contract. +func TestForwardMessageToPeerSerializesSend(t *testing.T) { + s, err := NewServer(context.Background(), otel.Meter("")) + require.NoError(t, err) + + const peerID = "peerX" + stream := &concurrencyCheckStream{ctx: context.Background()} + _, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + require.NoError(t, s.registry.Register(peer.NewPeer(peerID, stream, cancel))) + + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + s.forwardMessageToPeer(context.Background(), &proto.EncryptedMessage{Key: "sender", RemoteKey: peerID}) + }() + } + wg.Wait() + + require.Equal(t, int32(1), stream.maxSeen.Load(), "Stream.Send must never run concurrently on the same peer stream") +} diff --git a/signal/server/signal.go b/signal/server/signal.go index c46df56d2..7edbb4d34 100644 --- a/signal/server/signal.go +++ b/signal/server/signal.go @@ -179,7 +179,7 @@ func (s *Server) forwardMessageToPeer(ctx context.Context, msg *proto.EncryptedM sendResultChan := make(chan error, 1) go func() { select { - case sendResultChan <- dstPeer.Stream.Send(msg): + case sendResultChan <- dstPeer.Send(msg): return case <-dstPeer.Stream.Context().Done(): return From 60a95446565fe02611a1202e30d48449c8a017c6 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:22:42 +0200 Subject: [PATCH 10/20] [management] pass meta update for browser clients (#6465) --- management/server/peer.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/management/server/peer.go b/management/server/peer.go index 9d78f597b..58ea53d8c 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -1124,7 +1124,7 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer } var peer *nbpeer.Peer - var shouldStorePeer bool + var shouldStorePeer, shouldUpdatePeers bool var peerGroupIDs []string settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) @@ -1151,6 +1151,7 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer if changed { shouldStorePeer = true + shouldUpdatePeers = true } } @@ -1174,13 +1175,16 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer } } + // This is needed to keep in memory for the peer config. Otherwise browser client will end in a retry loop + peer.UpdateMetaIfNew(login.Meta) + return nil }) if err != nil { return nil, nil, nil, false, err } - isRequiresApproval, isStatusChanged, err := am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra) + isRequiresApproval, _, err := am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra) if err != nil { return nil, nil, nil, false, err } @@ -1190,7 +1194,7 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer return nil, nil, nil, false, err } - if isStatusChanged || shouldStorePeer { + if shouldUpdatePeers { changedPeerIDs := []string{peer.ID} affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs) if err = am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil { From 8c031ea6f0798de65a414290a3f3cf5297745ecb Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:12:59 +0200 Subject: [PATCH 11/20] [management] remove db calls in nested loops (#6470) --- management/server/peer.go | 73 ++++++++++++++++----------------------- 1 file changed, 29 insertions(+), 44 deletions(-) diff --git a/management/server/peer.go b/management/server/peer.go index 58ea53d8c..bd6b2b6c5 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -1026,7 +1026,12 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy return err } - postureChecks, err = getPeerPostureChecks(ctx, transaction, accountID, peer.ID) + policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return err + } + + postureChecks, err = getPeerPostureChecks(ctx, transaction, accountID, peerGroupIDs, policies) if err != nil { return err } @@ -1290,12 +1295,22 @@ func getPeerLoginInfo(ctx context.Context, transaction store.Store, accountID st return network, nil, false, nil } - postureChecks, err := getPeerPostureChecks(ctx, transaction, accountID, peer.ID) + policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) if err != nil { return nil, nil, false, err } - enableSSH, err := isPeerSSHEnabled(ctx, transaction, accountID, peer) + peerGroupIDs, err := transaction.GetPeerGroupIDs(ctx, store.LockingStrengthNone, accountID, peer.ID) + if err != nil { + return nil, nil, false, err + } + + postureChecks, err := getPeerPostureChecks(ctx, transaction, accountID, peerGroupIDs, policies) + if err != nil { + return nil, nil, false, err + } + + enableSSH, err := isPeerSSHEnabled(ctx, peer, policies, peerGroupIDs) if err != nil { return nil, nil, false, err } @@ -1303,32 +1318,16 @@ func getPeerLoginInfo(ctx context.Context, transaction store.Store, accountID st return network, postureChecks, enableSSH, nil } -func isPeerSSHEnabled(ctx context.Context, transaction store.Store, accountID string, peer *nbpeer.Peer) (bool, error) { - policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - if err != nil { - return false, err +func isPeerSSHEnabled(ctx context.Context, peer *nbpeer.Peer, policies []*types.Policy, peerGroupIDs []string) (bool, error) { + groupIDsMap := make(map[string]struct{}, len(peerGroupIDs)) + for _, peerID := range peerGroupIDs { + groupIDsMap[peerID] = struct{}{} } - - peerGroups, err := transaction.GetPeerGroups(ctx, store.LockingStrengthNone, accountID, peer.ID) - if err != nil { - return false, err - } - - peerGroupIDs := make(map[string]struct{}, len(peerGroups)) - for _, g := range peerGroups { - peerGroupIDs[g.ID] = struct{}{} - } - - return types.PeerSSHEnabledFromPolicies(policies, peer.ID, peerGroupIDs, peer.SSHEnabled), nil + return types.PeerSSHEnabledFromPolicies(policies, peer.ID, groupIDsMap, peer.SSHEnabled), nil } // getPeerPostureChecks returns the posture checks for the peer. -func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountID, peerID string) ([]*posture.Checks, error) { - policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - if err != nil { - return nil, err - } - +func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountID string, peerGroupIDs []string, policies []*types.Policy) ([]*posture.Checks, error) { if len(policies) == 0 { return nil, nil } @@ -1340,11 +1339,7 @@ func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountI continue } - postureChecksIDs, err := processPeerPostureChecks(ctx, transaction, policy, accountID, peerID) - if err != nil { - return nil, err - } - + postureChecksIDs := processPeerPostureChecks(policy, peerGroupIDs) peerPostureChecksIDs = append(peerPostureChecksIDs, postureChecksIDs...) } @@ -1357,29 +1352,19 @@ func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountI } // processPeerPostureChecks checks if the peer is in the source group of the policy and returns the posture checks. -func processPeerPostureChecks(ctx context.Context, transaction store.Store, policy *types.Policy, accountID, peerID string) ([]string, error) { +func processPeerPostureChecks(policy *types.Policy, peerGroupIDs []string) []string { for _, rule := range policy.Rules { if !rule.Enabled { continue } - sourceGroups, err := transaction.GetGroupsByIDs(ctx, store.LockingStrengthNone, accountID, rule.Sources) - if err != nil { - return nil, err - } - for _, sourceGroup := range rule.Sources { - group, ok := sourceGroups[sourceGroup] - if !ok { - return nil, fmt.Errorf("failed to check peer in policy source group") - } - - if slices.Contains(group.Peers, peerID) { - return policy.SourcePostureChecks, nil + if slices.Contains(peerGroupIDs, sourceGroup) { + return policy.SourcePostureChecks } } } - return nil, nil + return nil } // checkIFPeerNeedsLoginWithoutLock checks if the peer needs login without acquiring the account lock. The check validate if the peer was not added via SSO From 679c7182a4a112aa86466df596aafd06d1578fae Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Thu, 18 Jun 2026 22:34:24 +0200 Subject: [PATCH 12/20] [misc] Remove version prefix `v` docker tags (#6471) --- .goreleaser.yaml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 5031ef446..c068f51d1 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -247,7 +247,7 @@ dockers_v2: - netbirdio/netbird - ghcr.io/netbirdio/netbird tags: - - "v{{ .Version }}" + - "{{ .Version }}" - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" dockerfile: client/Dockerfile extra_files: @@ -295,7 +295,7 @@ dockers_v2: - netbirdio/relay - ghcr.io/netbirdio/relay tags: - - "v{{ .Version }}" + - "{{ .Version }}" - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" dockerfile: relay/Dockerfile platforms: @@ -317,7 +317,7 @@ dockers_v2: - netbirdio/signal - ghcr.io/netbirdio/signal tags: - - "v{{ .Version }}" + - "{{ .Version }}" - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" dockerfile: signal/Dockerfile platforms: @@ -339,7 +339,7 @@ dockers_v2: - netbirdio/management - ghcr.io/netbirdio/management tags: - - "v{{ .Version }}" + - "{{ .Version }}" - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" dockerfile: management/Dockerfile platforms: @@ -361,7 +361,7 @@ dockers_v2: - netbirdio/upload - ghcr.io/netbirdio/upload tags: - - "v{{ .Version }}" + - "{{ .Version }}" - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" dockerfile: upload-server/Dockerfile platforms: @@ -383,7 +383,7 @@ dockers_v2: - netbirdio/netbird-server - ghcr.io/netbirdio/netbird-server tags: - - "v{{ .Version }}" + - "{{ .Version }}" - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" dockerfile: combined/Dockerfile platforms: @@ -405,7 +405,7 @@ dockers_v2: - netbirdio/reverse-proxy - ghcr.io/netbirdio/reverse-proxy tags: - - "v{{ .Version }}" + - "{{ .Version }}" - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" dockerfile: proxy/Dockerfile platforms: From fb87f751a5f2da6d463a333e6ccb6e470269e12c Mon Sep 17 00:00:00 2001 From: Brad Ison Date: Fri, 19 Jun 2026 11:39:21 +0200 Subject: [PATCH 13/20] [management] Fetch complete user data in ValidateTunnelPeer (#6457) * [management] Fetch complete user data in ValidateTunnelPeer Previously the `ValidateTunnelPeer` method used by the ProxyService would fetch user information from the database if the connected peer was associated with a user ID, but it would not consult the IdP data for cached info from JWT claims like email. This caused the value of the injected `X-Netbird-User` header to always display the peer ID and never the user email associated with the peer as expected. This change adds an optional IdP manager to the ProxyService and fetches the complete user data from it if present. * [management] Refactor ValidateTunnelPeer principal info gathering This refactors the gathering of info on proxy tunnel peer principals into its own method to keep the complexity down and make Sonar happy. --- .../service/manager/manager_test.go | 6 +- management/internals/server/boot.go | 2 +- management/internals/shared/grpc/proxy.go | 64 ++++-- .../shared/grpc/proxy_group_access_test.go | 208 ++++++++++++++++++ .../shared/grpc/validate_session_test.go | 2 +- management/server/account_test.go | 2 +- .../proxy/auth_callback_integration_test.go | 1 + .../testing/testing_tools/channel/channel.go | 4 +- proxy/management_byop_integration_test.go | 1 + proxy/management_integration_test.go | 1 + 10 files changed, 266 insertions(+), 25 deletions(-) diff --git a/management/internals/modules/reverseproxy/service/manager/manager_test.go b/management/internals/modules/reverseproxy/service/manager/manager_test.go index ace105b31..29a117921 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/service/manager/manager_test.go @@ -434,7 +434,7 @@ func TestDeletePeerService_SourcePeerValidation(t *testing.T) { t.Helper() tokenStore := nbgrpc.NewOneTimeTokenStore(context.Background(), testCacheStore(t)) pkceStore := nbgrpc.NewPKCEVerifierStore(context.Background(), testCacheStore(t)) - srv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil) + srv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil) return srv } @@ -723,7 +723,7 @@ func setupIntegrationTest(t *testing.T) (*Manager, store.Store) { tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, testCacheStore(t)) pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, testCacheStore(t)) - proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil) + proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil) proxyController, err := proxymanager.NewGRPCController(proxySrv, noop.NewMeterProvider().Meter("")) require.NoError(t, err) @@ -1147,7 +1147,7 @@ func TestDeleteService_DeletesTargets(t *testing.T) { tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, testCacheStore(t)) pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, testCacheStore(t)) - proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil) + proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil) proxyController, err := proxymanager.NewGRPCController(proxySrv, noop.NewMeterProvider().Meter("")) require.NoError(t, err) diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index 46e475143..ae82b60fe 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -219,7 +219,7 @@ func (s *BaseServer) GRPCServer() *grpc.Server { func (s *BaseServer) ReverseProxyGRPCServer() *nbgrpc.ProxyServiceServer { return Create(s, func() *nbgrpc.ProxyServiceServer { - proxyService := nbgrpc.NewProxyServiceServer(s.AccessLogsManager(), s.ProxyTokenStore(), s.PKCEVerifierStore(), s.proxyOIDCConfig(), s.PeersManager(), s.UsersManager(), s.ProxyManager(), s.Store()) + proxyService := nbgrpc.NewProxyServiceServer(s.AccessLogsManager(), s.ProxyTokenStore(), s.PKCEVerifierStore(), s.proxyOIDCConfig(), s.PeersManager(), s.UsersManager(), s.IdpManager(), s.ProxyManager(), s.Store()) s.AfterInit(func(s *BaseServer) { proxyService.SetServiceManager(s.ServiceManager()) proxyService.SetProxyController(s.ServiceProxyController()) diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go index 0feb807f6..76663f898 100644 --- a/management/internals/shared/grpc/proxy.go +++ b/management/internals/shared/grpc/proxy.go @@ -33,6 +33,8 @@ import ( "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey" + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/management/server/users" proxyauth "github.com/netbirdio/netbird/proxy/auth" @@ -82,6 +84,9 @@ type ProxyServiceServer struct { // Manager for users usersManager users.Manager + // Manager for IdP-enriched user data (may be nil when no IdP is configured) + idpManager idp.Manager + // Store for one-time authentication tokens tokenStore *OneTimeTokenStore @@ -157,7 +162,7 @@ func enforceAccountScope(ctx context.Context, requestAccountID string) error { } // NewProxyServiceServer creates a new proxy service server. -func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeTokenStore, pkceStore *PKCEVerifierStore, oidcConfig ProxyOIDCConfig, peersManager peers.Manager, usersManager users.Manager, proxyMgr proxy.Manager, tokenChecker ProxyTokenChecker) *ProxyServiceServer { +func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeTokenStore, pkceStore *PKCEVerifierStore, oidcConfig ProxyOIDCConfig, peersManager peers.Manager, usersManager users.Manager, idpManager idp.Manager, proxyMgr proxy.Manager, tokenChecker ProxyTokenChecker) *ProxyServiceServer { ctx, cancel := context.WithCancel(context.Background()) s := &ProxyServiceServer{ accessLogManager: accessLogMgr, @@ -166,6 +171,7 @@ func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeT pkceVerifierStore: pkceStore, peersManager: peersManager, usersManager: usersManager, + idpManager: idpManager, proxyManager: proxyMgr, tokenChecker: tokenChecker, snapshotBatchSize: snapshotBatchSizeFromEnv(), @@ -1702,22 +1708,7 @@ func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto. } groupIDs, groupNames := pairGroupIDsAndNames(peerGroups) - - // Resolve the principal: when the peer is linked to a user, the human - // is the principal so multiple peers owned by the same user share a - // single identity. Unlinked peers (machine agents) are their own - // principal keyed on peer.ID. displayIdentity is what upstream gateways - // tag spend with — user.Email when linked, peer.Name when not. - principalID := peer.ID - displayIdentity := peer.Name - if peer.UserID != "" { - if user, uerr := s.usersManager.GetUser(ctx, peer.UserID); uerr == nil && user != nil { - principalID = user.Id - if user.Email != "" { - displayIdentity = user.Email - } - } - } + principalID, displayIdentity := s.getTunnelPeerInfo(ctx, domain, service, peer) if err := checkPeerGroupAccess(service, groupIDs); err != nil { log.WithFields(log.Fields{"domain": domain, "peer_id": peer.ID, "error": err.Error()}).Debug("ValidateTunnelPeer: access denied") @@ -1754,6 +1745,45 @@ func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto. }, nil } +// getTunnelPeerInfo returns the principal ID and display name for a peer, e.g. a +// user or peer ID, and peer name or user email. +func (s *ProxyServiceServer) getTunnelPeerInfo(ctx context.Context, domain string, service *rpservice.Service, peer *peer.Peer) (string, string) { + // Resolve the principal: when the peer is linked to a user, the human is the + // principal so multiple peers owned by the same user share a single + // identity. Unlinked peers (machine agents) are their own principal keyed on + // peer.ID. displayIdentity is what upstream gateways tag spend with — + // user.Email when linked, peer.Name when not. + + // If the peer isn't associated with a user, return the peer info directly. + if peer.UserID == "" { + return peer.ID, peer.Name + } + + // Otherwise, if the peer is linked to a user, the user is the principal and + // if an IdP is available, we gather details on the user from it. + principalID := peer.UserID + displayIdentity := peer.Name + // Stored column first (cheap, but often empty for OIDC-provisioned users). + if user, uerr := s.usersManager.GetUser(ctx, peer.UserID); uerr == nil && user != nil { + principalID = user.Id + if user.Email != "" { + displayIdentity = user.Email + } + } + // IdP enrichment wins when available — the stored email column is a + // best-effort cache and is frequently empty for OIDC users. Enrichment + // failures must never fail the RPC; we simply keep the stored/peer identity. + if s.idpManager != nil { + if ud, uerr := s.idpManager.GetUserDataByID(ctx, peer.UserID, idp.AppMetadata{WTAccountID: service.AccountID}); uerr == nil && ud != nil && ud.Email != "" { + displayIdentity = ud.Email + } else if uerr != nil { + log.WithFields(log.Fields{"domain": domain, "user_id": peer.UserID, "error": uerr.Error()}).Debug("ValidateTunnelPeer: IdP user enrichment failed; using stored/peer identity") + } + } + + return principalID, displayIdentity +} + // checkPeerGroupAccess gates ValidateTunnelPeer by the service's required // groups. Private services authorise against AccessGroups (empty list fails // closed — Validate() rejects that at save time but the RPC is the security diff --git a/management/internals/shared/grpc/proxy_group_access_test.go b/management/internals/shared/grpc/proxy_group_access_test.go index 76da7ddbc..532cb7cc3 100644 --- a/management/internals/shared/grpc/proxy_group_access_test.go +++ b/management/internals/shared/grpc/proxy_group_access_test.go @@ -3,14 +3,19 @@ package grpc import ( "context" "errors" + "net" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/netbirdio/netbird/management/internals/modules/peers" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/proto" ) type mockReverseProxyManager struct { @@ -137,6 +142,52 @@ func (m *mockUsersManager) GetUserWithGroups(ctx context.Context, userID string) return user, nil, nil } +// mockTunnelPeersManager implements only the two peers.Manager methods that +// ValidateTunnelPeer calls; the embedded interface satisfies the rest (and +// panics if any unexpected method is invoked). +type mockTunnelPeersManager struct { + peers.Manager + peer *peer.Peer + peerErr error + groups []*types.Group + groupsErr error +} + +func (m *mockTunnelPeersManager) GetPeerByTunnelIP(_ context.Context, _ string, _ net.IP) (*peer.Peer, error) { + return m.peer, m.peerErr +} + +func (m *mockTunnelPeersManager) GetPeerWithGroups(_ context.Context, _, _ string) (*peer.Peer, []*types.Group, error) { + return m.peer, m.groups, m.groupsErr +} + +// mockTunnelIdpManager implements only GetUserDataByID; the embedded interface +// satisfies the rest of idp.Manager. hasData==false returns (nil, nil) to model +// an IdP that knows nothing about the user. +type mockTunnelIdpManager struct { + idp.Manager + email string + hasData bool + err error + gotCalls int + gotMeta []idp.AppMetadata +} + +func (m *mockTunnelIdpManager) GetUserDataByID(_ context.Context, userID string, meta idp.AppMetadata) (*idp.UserData, error) { + m.gotCalls++ + m.gotMeta = append(m.gotMeta, meta) + if m.err != nil { + return nil, m.err + } + if !m.hasData { + // This might not be a thing any of the actual IDP implementations do, + // i.e. return a nil value with no error, but it seems valuable to test + // that behavior here. + return nil, nil //nolint:nilnil + } + return &idp.UserData{ID: userID, Email: m.email}, nil +} + func TestValidateUserGroupAccess(t *testing.T) { tests := []struct { name string @@ -354,6 +405,163 @@ func TestValidateUserGroupAccess(t *testing.T) { } } +// TestValidateTunnelPeerUserEmailEnrichment verifies the UserEmail/UserId +// resolution in ValidateTunnelPeer, including the IdP-enrichment fallback order +// (IdP email -> stored User.Email -> peer.Name). +func TestValidateTunnelPeerUserEmailEnrichment(t *testing.T) { + const ( + domain = "app.example.com" + accountID = "account1" + peerID = "peer1" + peerName = "peer-display-name" + userID = "user1" + ) + + storedUser := map[string]*types.User{userID: {Id: userID, AccountID: accountID, Email: "stored@example.com"}} + storedUserNoEmail := map[string]*types.User{userID: {Id: userID, AccountID: accountID, Email: ""}} + + tests := []struct { + name string + peerUserID string + storedUsers map[string]*types.User + storedErr error + noIdP bool + idpEmail string + idpHasData bool + idpErr error + expectEmail string + expectUserID string + expectIdPHit bool + }{ + { + name: "idp email wins over stored email", + peerUserID: userID, + storedUsers: storedUser, + idpEmail: "idp@example.com", + idpHasData: true, + expectEmail: "idp@example.com", + expectUserID: userID, + expectIdPHit: true, + }, + { + name: "stored email when idp returns empty email", + peerUserID: userID, + storedUsers: storedUser, + idpEmail: "", + idpHasData: true, + expectEmail: "stored@example.com", + expectUserID: userID, + expectIdPHit: true, + }, + { + name: "stored email when idp has no data", + peerUserID: userID, + storedUsers: storedUser, + idpHasData: false, + expectEmail: "stored@example.com", + expectUserID: userID, + expectIdPHit: true, + }, + { + name: "stored email when idp errors", + peerUserID: userID, + storedUsers: storedUser, + idpErr: errors.New("idp unreachable"), + expectEmail: "stored@example.com", + expectUserID: userID, + expectIdPHit: true, + }, + { + name: "stored email when no idp manager", + peerUserID: userID, + storedUsers: storedUser, + noIdP: true, + expectEmail: "stored@example.com", + expectUserID: userID, + }, + { + name: "idp email when stored email is empty", + peerUserID: userID, + storedUsers: storedUserNoEmail, + idpEmail: "idp@example.com", + idpHasData: true, + expectEmail: "idp@example.com", + expectUserID: userID, + expectIdPHit: true, + }, + { + name: "idp email when stored user missing keeps peer.UserID as principal", + peerUserID: userID, + storedUsers: map[string]*types.User{}, + idpEmail: "idp@example.com", + idpHasData: true, + expectEmail: "idp@example.com", + expectUserID: userID, + expectIdPHit: true, + }, + { + name: "unlinked peer uses peer name and never consults idp", + peerUserID: "", + storedUsers: storedUser, + idpEmail: "idp@example.com", + idpHasData: true, + expectEmail: peerName, + expectUserID: peerID, + expectIdPHit: false, + }, + { + name: "linked peer with empty stored email and no idp falls back to peer name", + peerUserID: userID, + storedUsers: storedUserNoEmail, + noIdP: true, + expectEmail: peerName, + expectUserID: userID, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := &service.Service{Domain: domain, AccountID: accountID} + server := &ProxyServiceServer{ + serviceManager: &mockReverseProxyManager{ + proxiesByAccount: map[string][]*service.Service{accountID: {svc}}, + }, + peersManager: &mockTunnelPeersManager{ + peer: &peer.Peer{ID: peerID, Name: peerName, UserID: tt.peerUserID}, + }, + usersManager: &mockUsersManager{users: tt.storedUsers, err: tt.storedErr}, + } + + var idpMock *mockTunnelIdpManager + if !tt.noIdP { + idpMock = &mockTunnelIdpManager{email: tt.idpEmail, hasData: tt.idpHasData, err: tt.idpErr} + server.idpManager = idpMock + } + + resp, err := server.ValidateTunnelPeer(context.Background(), &proto.ValidateTunnelPeerRequest{ + Domain: domain, + TunnelIp: "100.64.0.1", + }) + + require.NoError(t, err) + require.NotNil(t, resp) + assert.True(t, resp.GetValid(), "expected access granted") + assert.Equal(t, tt.expectEmail, resp.GetUserEmail()) + assert.Equal(t, tt.expectUserID, resp.GetUserId()) + + if idpMock != nil { + if tt.expectIdPHit { + assert.Equal(t, 1, idpMock.gotCalls, "expected IdP to be consulted") + require.Len(t, idpMock.gotMeta, 1) + assert.Equal(t, accountID, idpMock.gotMeta[0].WTAccountID) + } else { + assert.Equal(t, 0, idpMock.gotCalls, "expected IdP to not be consulted") + } + } + }) + } +} + func TestGetAccountProxyByDomain(t *testing.T) { tests := []struct { name string diff --git a/management/internals/shared/grpc/validate_session_test.go b/management/internals/shared/grpc/validate_session_test.go index 27d9a65e7..d649102a1 100644 --- a/management/internals/shared/grpc/validate_session_test.go +++ b/management/internals/shared/grpc/validate_session_test.go @@ -42,7 +42,7 @@ func setupValidateSessionTest(t *testing.T) *validateSessionTestSetup { tokenStore := NewOneTimeTokenStore(ctx, testCacheStore(t)) pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t)) - proxyService := NewProxyServiceServer(nil, tokenStore, pkceStore, ProxyOIDCConfig{}, nil, usersManager, proxyManager, nil) + proxyService := NewProxyServiceServer(nil, tokenStore, pkceStore, ProxyOIDCConfig{}, nil, usersManager, nil, proxyManager, nil) proxyService.SetServiceManager(serviceManager) createTestProxies(t, ctx, testStore) diff --git a/management/server/account_test.go b/management/server/account_test.go index 256b71f18..2e26ac222 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -3215,7 +3215,7 @@ func createManager(t testing.TB) (*DefaultAccountManager, *update_channel.PeersU return nil, nil, err } - proxyGrpcServer := nbgrpc.NewProxyServiceServer(nil, nil, nil, nbgrpc.ProxyOIDCConfig{}, peersManager, nil, proxyManager, nil) + proxyGrpcServer := nbgrpc.NewProxyServiceServer(nil, nil, nil, nbgrpc.ProxyOIDCConfig{}, peersManager, nil, nil, proxyManager, nil) proxyController, err := proxymanager.NewGRPCController(proxyGrpcServer, noop.Meter{}) if err != nil { return nil, nil, err diff --git a/management/server/http/handlers/proxy/auth_callback_integration_test.go b/management/server/http/handlers/proxy/auth_callback_integration_test.go index f08d5daf1..a24857066 100644 --- a/management/server/http/handlers/proxy/auth_callback_integration_test.go +++ b/management/server/http/handlers/proxy/auth_callback_integration_test.go @@ -217,6 +217,7 @@ func setupAuthCallbackTest(t *testing.T) *testSetup { usersManager, nil, nil, + nil, ) proxyService.SetServiceManager(&testServiceManager{store: testStore}) diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go index 8da9c7ad4..61584a615 100644 --- a/management/server/http/testing/testing_tools/channel/channel.go +++ b/management/server/http/testing/testing_tools/channel/channel.go @@ -110,7 +110,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee if err != nil { t.Fatalf("Failed to create proxy manager: %v", err) } - proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, proxyMgr, nil) + proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil) domainManager := manager.NewManager(store, proxyMgr, permissionsManager, am) serviceProxyController, err := proxymanager.NewGRPCController(proxyServiceServer, noopMeter) if err != nil { @@ -240,7 +240,7 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin if err != nil { t.Fatalf("Failed to create proxy manager: %v", err) } - proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, proxyMgr, nil) + proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil) domainManager := manager.NewManager(store, proxyMgr, permissionsManager, am) serviceProxyController, err := proxymanager.NewGRPCController(proxyServiceServer, noopMeter) if err != nil { diff --git a/proxy/management_byop_integration_test.go b/proxy/management_byop_integration_test.go index c0fbe682a..d075e47ec 100644 --- a/proxy/management_byop_integration_test.go +++ b/proxy/management_byop_integration_test.go @@ -125,6 +125,7 @@ func setupBYOPIntegrationTest(t *testing.T) *byopTestSetup { oidcConfig, nil, usersManager, + nil, realProxyManager, nil, ) diff --git a/proxy/management_integration_test.go b/proxy/management_integration_test.go index bf5067b85..cb82813b0 100644 --- a/proxy/management_integration_test.go +++ b/proxy/management_integration_test.go @@ -140,6 +140,7 @@ func setupIntegrationTest(t *testing.T) *integrationTestSetup { oidcConfig, nil, usersManager, + nil, proxyManager, nil, ) From 35b465fa4a1667b99877d78c554bb1d1231aa616 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:43:01 +0200 Subject: [PATCH 14/20] [management] reduce sync and login transaction (#6472) --- management/server/peer.go | 42 ++++++++++++++------------------------- 1 file changed, 15 insertions(+), 27 deletions(-) diff --git a/management/server/peer.go b/management/server/peer.go index bd6b2b6c5..83236d961 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -982,8 +982,6 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy var peer *nbpeer.Peer var updated, versionChanged, ipv6CapabilityChanged bool var err error - var postureChecks []*posture.Checks - var peerGroupIDs []string settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) if err != nil { @@ -1011,11 +1009,6 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy return status.NewPeerLoginExpiredError() } - peerGroupIDs, err = getPeerGroupIDs(ctx, transaction, accountID, peer.ID) - if err != nil { - return err - } - oldHasIPv6Cap := peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay) updated, versionChanged = peer.UpdateMetaIfNew(sync.Meta) ipv6CapabilityChanged = oldHasIPv6Cap != peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay) @@ -1025,16 +1018,6 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy if err = transaction.SavePeer(ctx, accountID, peer); err != nil { return err } - - policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - if err != nil { - return err - } - - postureChecks, err = getPeerPostureChecks(ctx, transaction, accountID, peerGroupIDs, policies) - if err != nil { - return err - } } return nil }) @@ -1042,6 +1025,11 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy return nil, nil, nil, 0, err } + peerGroupIDs, err := getPeerGroupIDs(ctx, am.Store, accountID, peer.ID) + if err != nil { + return nil, nil, nil, 0, err + } + peerNotValid, isStatusChanged, err := am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra) if err != nil { return nil, nil, nil, 0, err @@ -1052,9 +1040,9 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy return nil, nil, nil, 0, err } - if isStatusChanged || sync.UpdateAccountPeers || ipv6CapabilityChanged || (updated && (len(postureChecks) > 0 || versionChanged)) { + if isStatusChanged || sync.UpdateAccountPeers || ipv6CapabilityChanged || (updated && (len(resPostureChecks) > 0 || versionChanged)) { changedPeerIDs := []string{peer.ID} - affectedPeerIDs := am.syncPeerAffectedPeers(ctx, accountID, peer.ID, nmap, peerNotValid, updated, len(postureChecks) > 0) + affectedPeerIDs := am.syncPeerAffectedPeers(ctx, accountID, peer.ID, nmap, peerNotValid, updated, len(resPostureChecks) > 0) if err = am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil { return nil, nil, nil, 0, fmt.Errorf("notify network map controller of peer update: %w", err) } @@ -1160,11 +1148,6 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer } } - peerGroupIDs, err = getPeerGroupIDs(ctx, transaction, accountID, peer.ID) - if err != nil { - return err - } - if peer.SSHKey != login.SSHKey { peer.SSHKey = login.SSHKey shouldStorePeer = true @@ -1180,15 +1163,20 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer } } - // This is needed to keep in memory for the peer config. Otherwise browser client will end in a retry loop - peer.UpdateMetaIfNew(login.Meta) - return nil }) if err != nil { return nil, nil, nil, false, err } + // This is needed to keep in memory for the peer config. Otherwise browser client will end in a retry loop + peer.UpdateMetaIfNew(login.Meta) + + peerGroupIDs, err = getPeerGroupIDs(ctx, am.Store, accountID, peer.ID) + if err != nil { + return nil, nil, nil, false, err + } + isRequiresApproval, _, err := am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra) if err != nil { return nil, nil, nil, false, err From 85116872706057cd8063e4ec22deb19da32799f1 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:30:52 +0200 Subject: [PATCH 15/20] [management] log peer meta diff (#6468) --- management/server/peer.go | 4 +- management/server/peer/peer.go | 167 +++++++++++++------ management/server/peer/peer_metadiff_test.go | 113 +++++++++++++ 3 files changed, 233 insertions(+), 51 deletions(-) create mode 100644 management/server/peer/peer_metadiff_test.go diff --git a/management/server/peer.go b/management/server/peer.go index 83236d961..c54c1dc7b 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -1010,7 +1010,7 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy } oldHasIPv6Cap := peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay) - updated, versionChanged = peer.UpdateMetaIfNew(sync.Meta) + updated, versionChanged = peer.UpdateMetaIfNew(ctx, sync.Meta) ipv6CapabilityChanged = oldHasIPv6Cap != peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay) if updated { am.metrics.AccountManagerMetrics().CountPeerMetUpdate() @@ -1170,7 +1170,7 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer } // This is needed to keep in memory for the peer config. Otherwise browser client will end in a retry loop - peer.UpdateMetaIfNew(login.Meta) + peer.UpdateMetaIfNew(ctx, login.Meta) peerGroupIDs, err = getPeerGroupIDs(ctx, am.Store, accountID, peer.ID) if err != nil { diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go index e5475c07d..591ac074e 100644 --- a/management/server/peer/peer.go +++ b/management/server/peer/peer.go @@ -1,12 +1,16 @@ package peer import ( + "context" + "fmt" "net" "net/netip" "slices" - "sort" + "strings" "time" + log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/shared/management/http/api" ) @@ -162,49 +166,7 @@ type PeerSystemMeta struct { //nolint:revive } func (p PeerSystemMeta) isEqual(other PeerSystemMeta) bool { - sort.Slice(p.NetworkAddresses, func(i, j int) bool { - return p.NetworkAddresses[i].Mac < p.NetworkAddresses[j].Mac - }) - sort.Slice(other.NetworkAddresses, func(i, j int) bool { - return other.NetworkAddresses[i].Mac < other.NetworkAddresses[j].Mac - }) - equalNetworkAddresses := slices.EqualFunc(p.NetworkAddresses, other.NetworkAddresses, func(addr NetworkAddress, oAddr NetworkAddress) bool { - return addr.Mac == oAddr.Mac && addr.NetIP == oAddr.NetIP - }) - if !equalNetworkAddresses { - return false - } - - sort.Slice(p.Files, func(i, j int) bool { - return p.Files[i].Path < p.Files[j].Path - }) - sort.Slice(other.Files, func(i, j int) bool { - return other.Files[i].Path < other.Files[j].Path - }) - equalFiles := slices.EqualFunc(p.Files, other.Files, func(file File, oFile File) bool { - return file.Path == oFile.Path && file.Exist == oFile.Exist && file.ProcessIsRunning == oFile.ProcessIsRunning - }) - if !equalFiles { - return false - } - - return p.Hostname == other.Hostname && - p.GoOS == other.GoOS && - p.Kernel == other.Kernel && - p.KernelVersion == other.KernelVersion && - p.Core == other.Core && - p.Platform == other.Platform && - p.OS == other.OS && - p.OSVersion == other.OSVersion && - p.WtVersion == other.WtVersion && - p.UIVersion == other.UIVersion && - p.SystemSerialNumber == other.SystemSerialNumber && - p.SystemProductName == other.SystemProductName && - p.SystemManufacturer == other.SystemManufacturer && - p.Environment.Cloud == other.Environment.Cloud && - p.Environment.Platform == other.Environment.Platform && - p.Flags.isEqual(other.Flags) && - capabilitiesEqual(p.Capabilities, other.Capabilities) + return len(metaDiff(p, other)) == 0 } func (p PeerSystemMeta) isEmpty() bool { @@ -296,7 +258,7 @@ func (p *Peer) Copy() *Peer { // UpdateMetaIfNew updates peer's system metadata if new information is provided // returns true if meta was updated, false otherwise -func (p *Peer) UpdateMetaIfNew(meta PeerSystemMeta) (updated, versionChanged bool) { +func (p *Peer) UpdateMetaIfNew(ctx context.Context, meta PeerSystemMeta) (updated, versionChanged bool) { if meta.isEmpty() { return updated, versionChanged } @@ -308,14 +270,121 @@ func (p *Peer) UpdateMetaIfNew(meta PeerSystemMeta) (updated, versionChanged boo meta.UIVersion = p.Meta.UIVersion } - if p.Meta.isEqual(meta) { - return updated, versionChanged + oldVersion := p.Meta.WtVersion + + diff := metaDiff(p.Meta, meta) + if len(diff) != 0 { + p.Meta = meta + updated = true } - p.Meta = meta - updated = true + + versionInfo := "" + if versionChanged { + versionInfo = fmt.Sprintf("version changed: %s -> %s, ", oldVersion, meta.WtVersion) + } + + if len(diff) > 0 || versionChanged { + log.WithContext(ctx). + Debugf("peer meta updated, %s%d field(s) changed: %s", versionInfo, len(diff), strings.Join(diff, ", ")) + } + return updated, versionChanged } +// metaDiff returns a human-readable list of the fields that differ between the +// old and new meta, each formatted as `field: -> `. It is the single +// source of truth for meta comparison: isEqual reports equality as an empty +// diff, so the log line can never disagree with the change decision. Slices are +// cloned before sorting, so callers' meta is not mutated. +func metaDiff(oldMeta, newMeta PeerSystemMeta) []string { + var diff []string + add := func(field string, oldVal, newVal any) { + diff = append(diff, fmt.Sprintf("%s: %v -> %v", field, oldVal, newVal)) + } + + if oldMeta.Hostname != newMeta.Hostname { + add("hostname", oldMeta.Hostname, newMeta.Hostname) + } + if oldMeta.GoOS != newMeta.GoOS { + add("goos", oldMeta.GoOS, newMeta.GoOS) + } + if oldMeta.Kernel != newMeta.Kernel { + add("kernel", oldMeta.Kernel, newMeta.Kernel) + } + if oldMeta.KernelVersion != newMeta.KernelVersion { + add("kernel_version", oldMeta.KernelVersion, newMeta.KernelVersion) + } + if oldMeta.Core != newMeta.Core { + add("core", oldMeta.Core, newMeta.Core) + } + if oldMeta.Platform != newMeta.Platform { + add("platform", oldMeta.Platform, newMeta.Platform) + } + if oldMeta.OS != newMeta.OS { + add("os", oldMeta.OS, newMeta.OS) + } + if oldMeta.OSVersion != newMeta.OSVersion { + add("os_version", oldMeta.OSVersion, newMeta.OSVersion) + } + if oldMeta.WtVersion != newMeta.WtVersion { + add("wt_version", oldMeta.WtVersion, newMeta.WtVersion) + } + if oldMeta.UIVersion != newMeta.UIVersion { + add("ui_version", oldMeta.UIVersion, newMeta.UIVersion) + } + if oldMeta.SystemSerialNumber != newMeta.SystemSerialNumber { + add("system_serial_number", oldMeta.SystemSerialNumber, newMeta.SystemSerialNumber) + } + if oldMeta.SystemProductName != newMeta.SystemProductName { + add("system_product_name", oldMeta.SystemProductName, newMeta.SystemProductName) + } + if oldMeta.SystemManufacturer != newMeta.SystemManufacturer { + add("system_manufacturer", oldMeta.SystemManufacturer, newMeta.SystemManufacturer) + } + if oldMeta.Environment.Cloud != newMeta.Environment.Cloud { + add("environment_cloud", oldMeta.Environment.Cloud, newMeta.Environment.Cloud) + } + if oldMeta.Environment.Platform != newMeta.Environment.Platform { + add("environment_platform", oldMeta.Environment.Platform, newMeta.Environment.Platform) + } + if !oldMeta.Flags.isEqual(newMeta.Flags) { + add("flags", fmt.Sprintf("%+v", oldMeta.Flags), fmt.Sprintf("%+v", newMeta.Flags)) + } + if !capabilitiesEqual(oldMeta.Capabilities, newMeta.Capabilities) { + add("capabilities", oldMeta.Capabilities, newMeta.Capabilities) + } + + if !sameMultiset(oldMeta.NetworkAddresses, newMeta.NetworkAddresses) { + add("network_addresses", fmt.Sprintf("%v", oldMeta.NetworkAddresses), fmt.Sprintf("%v", newMeta.NetworkAddresses)) + } + + if !sameMultiset(oldMeta.Files, newMeta.Files) { + add("files", fmt.Sprintf("%v", oldMeta.Files), fmt.Sprintf("%v", newMeta.Files)) + } + + return diff +} + +// sameMultiset reports whether two slices contain the same elements with the +// same multiplicity, ignoring order. The element type is the comparison key, so +// every field participates in equality. +func sameMultiset[T comparable](a, b []T) bool { + if len(a) != len(b) { + return false + } + counts := make(map[T]int, len(a)) + for _, v := range a { + counts[v]++ + } + for _, v := range b { + counts[v]-- + if counts[v] == 0 { + delete(counts, v) + } + } + return len(counts) == 0 +} + // GetLastLogin returns the last login time of the peer. func (p *Peer) GetLastLogin() time.Time { if p.LastLogin != nil { diff --git a/management/server/peer/peer_metadiff_test.go b/management/server/peer/peer_metadiff_test.go new file mode 100644 index 000000000..1256cdb02 --- /dev/null +++ b/management/server/peer/peer_metadiff_test.go @@ -0,0 +1,113 @@ +package peer + +import ( + "net/netip" + "reflect" + "testing" + + "github.com/stretchr/testify/require" +) + +// metaDiffExtraEntries accounts for PeerSystemMeta fields that metaDiff does not +// map 1:1 to a single diff entry. Today the only such field is Environment, which +// is exploded into two checks (Cloud, Platform) and therefore yields one extra +// entry beyond its single struct field. If you teach metaDiff to explode another +// field into N entries, bump this by N-1; if you collapse a field, lower it. +const metaDiffExtraEntries = 1 + +// TestMetaDiff_CoversAllFields fully populates a PeerSystemMeta with non-zero +// values and diffs it against the zero value, then asserts metaDiff emits exactly +// one entry per exported field (plus metaDiffExtraEntries for fields it explodes). +// +// The expected count is derived from the struct via reflection, so adding a field +// to PeerSystemMeta raises the expectation automatically — but the actual diff +// only grows if metaDiff was taught to compare the new field. A mismatch means +// someone changed the struct without updating metaDiff (or this test's +// extra-entry accounting), which is exactly what we want to catch. +func TestMetaDiff_CoversAllFields(t *testing.T) { + var full PeerSystemMeta + exported := populateAll(t, reflect.ValueOf(&full).Elem()) + require.NotZero(t, exported, "expected PeerSystemMeta to expose fields") + + diff := metaDiff(PeerSystemMeta{}, full) + + require.Len(t, diff, exported+metaDiffExtraEntries, + "metaDiff entry count no longer matches PeerSystemMeta's fields: a field was "+ + "likely added or removed without updating metaDiff (or metaDiffExtraEntries). "+ + "diff was: %v", diff) + + require.False(t, full.isEqual(PeerSystemMeta{}), + "isEqual must report a fully-populated meta as different from the zero value") +} + +// TestFlags_isEqualChecksEveryField guards the one field that the count-based +// TestMetaDiff_CoversAllFields cannot: metaDiff collapses all of Flags into a +// single "flags" diff entry, so a new Flags field that Flags.isEqual forgets to +// compare would not change the diff count. This flips each Flags field on its own +// and asserts Flags.isEqual notices, so adding a Flags field without comparing it +// fails here. +func TestFlags_isEqualChecksEveryField(t *testing.T) { + typ := reflect.TypeOf(Flags{}) + for i := 0; i < typ.NumField(); i++ { + f := typ.Field(i) + require.Equal(t, reflect.Bool, f.Type.Kind(), + "Flags.%s is not a bool; extend this test to set it non-zero", f.Name) + + var a, b Flags + reflect.ValueOf(&b).Elem().Field(i).SetBool(true) + require.False(t, a.isEqual(b), "Flags.isEqual ignores field %s", f.Name) + } +} + +// populateAll sets every exported field of the struct to a deterministic non-zero +// value, recursing into nested structs and the element type of struct slices so +// that each leaf differs from zero. It returns the number of exported fields on +// the top-level struct. netip.Prefix is treated as an opaque leaf (it has no +// settable exported fields and is comparable with ==). +func populateAll(t *testing.T, v reflect.Value) int { + t.Helper() + + typ := v.Type() + exported := 0 + for i := 0; i < typ.NumField(); i++ { + f := typ.Field(i) + if f.PkgPath != "" { // unexported + continue + } + exported++ + setNonZero(t, v.Field(i)) + } + return exported +} + +// setNonZero assigns a deterministic non-zero value to a field based on its kind, +// recursing into nested structs and populating one element of slice fields. +func setNonZero(t *testing.T, field reflect.Value) { + t.Helper() + + if field.Type() == reflect.TypeOf(netip.Prefix{}) { + field.Set(reflect.ValueOf(netip.MustParsePrefix("10.0.0.0/24"))) + return + } + + switch field.Kind() { + case reflect.String: + field.SetString("non-zero") + case reflect.Bool: + field.SetBool(true) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + field.SetInt(7) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + field.SetUint(7) + case reflect.Float32, reflect.Float64: + field.SetFloat(7) + case reflect.Struct: + populateAll(t, field) + case reflect.Slice: + s := reflect.MakeSlice(field.Type(), 1, 1) + setNonZero(t, s.Index(0)) + field.Set(s) + default: + t.Fatalf("unhandled field kind %s; extend setNonZero", field.Kind()) + } +} From 54192a94b7bf3a59e0b86b98845014b5f4a9fb3a Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Fri, 19 Jun 2026 14:10:43 +0200 Subject: [PATCH 16/20] [misc] handle release candidates when fetching tags in FreeBSD port scripts (#6480) * [misc] Exclude release candidates when fetching tags in FreeBSD port scripts --- release_files/freebsd-port-diff.sh | 3 ++- release_files/freebsd-port-issue-body.sh | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/release_files/freebsd-port-diff.sh b/release_files/freebsd-port-diff.sh index b030b9164..6ffa141be 100755 --- a/release_files/freebsd-port-diff.sh +++ b/release_files/freebsd-port-diff.sh @@ -21,7 +21,8 @@ AWK_FIRST_FIELD='{print $1}' fetch_all_tags() { curl -sL "https://github.com/${GITHUB_REPO}/tags" 2>/dev/null | \ - grep -oE '/releases/tag/v[0-9]+\.[0-9]+\.[0-9]+' | \ + grep -oE '/releases/tag/v[0-9]+\.[0-9]+\.[0-9]+([^"]+)?' | \ + grep -iv 'rc' | \ sed 's/.*\/v//' | \ sort -u -V return 0 diff --git a/release_files/freebsd-port-issue-body.sh b/release_files/freebsd-port-issue-body.sh index b7ad0f5b1..1c23dbbbe 100755 --- a/release_files/freebsd-port-issue-body.sh +++ b/release_files/freebsd-port-issue-body.sh @@ -32,7 +32,8 @@ fetch_current_ports_version() { fetch_all_tags() { # Fetch tags from GitHub tags page (no rate limiting, no auth needed) curl -sL "https://github.com/${GITHUB_REPO}/tags" 2>/dev/null | \ - grep -oE '/releases/tag/v[0-9]+\.[0-9]+\.[0-9]+' | \ + grep -oE '/releases/tag/v[0-9]+\.[0-9]+\.[0-9]+([^"]+)?' | \ + grep -iv 'rc' | \ sed 's/.*\/v//' | \ sort -u -V return 0 From 883a1a8961ff181bd61cbd23dd603c541190df17 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:23:51 +0200 Subject: [PATCH 17/20] [client] Fix profile regressions in `up --profile` and `status` (#6479) * Restores behavior to create profile if not there on Up * Allows to restore nerbird status showing of the profile name * [client] Reduce upFunc cognitive complexity Extract the profile switch/auto-create logic from upFunc into a dedicated switchOrCreateProfile helper. The inlined NotFound-retry branch pushed upFunc over SonarCloud's cognitive complexity threshold (S3776). No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) * [client] Make up --profile auto-create idempotent under concurrent runs Don't fail switchOrCreateProfile on a createProfile error: a concurrent run may create the profile between the NotFound check and our create call. Retry the switch regardless and only surface the create error if the switch also fails. Addresses CodeRabbit race-condition feedback. Co-Authored-By: Claude Opus 4.8 (1M context) * Share createProfile with addProfileFunc * But allow conn reusage * moves switchOrCreateProfile to where it's used --------- Co-authored-by: Claude Opus 4.8 (1M context) --- client/cmd/login.go | 2 +- client/cmd/profile.go | 34 ++++++++++++++++++--------- client/cmd/status.go | 29 ++++++++++++++++++----- client/cmd/up.go | 54 +++++++++++++++++++++++++++++++++++++------ 4 files changed, 94 insertions(+), 25 deletions(-) diff --git a/client/cmd/login.go b/client/cmd/login.go index 2f7677901..a7ee960b1 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -227,7 +227,7 @@ func switchProfile(ctx context.Context, handle string, username string) (profile Username: &username, }) if err != nil { - return "", fmt.Errorf("switch profile failed: %v", err) + return "", fmt.Errorf("switch profile failed: %w", err) } return profilemanager.ID(resp.Id), nil diff --git a/client/cmd/profile.go b/client/cmd/profile.go index 4de2d754e..268034e70 100644 --- a/client/cmd/profile.go +++ b/client/cmd/profile.go @@ -138,26 +138,23 @@ func addProfileFunc(cmd *cobra.Command, args []string) error { return err } + currUser, err := user.Current() + if err != nil { + return fmt.Errorf("get current user: %w", err) + } + conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr) if err != nil { return fmt.Errorf("connect to service CLI interface: %w", err) } defer conn.Close() - currUser, err := user.Current() - if err != nil { - return fmt.Errorf("get current user: %w", err) - } - daemonClient := proto.NewDaemonServiceClient(conn) profileName := args[0] - resp, err := daemonClient.AddProfile(cmd.Context(), &proto.AddProfileRequest{ - ProfileName: profileName, - Username: currUser.Username, - }) + id, err := addProfileOnDaemon(cmd.Context(), daemonClient, profileName, currUser.Username) if err != nil { - return fmt.Errorf("add profile request: %w", err) + return err } dupCount, _ := countProfilesWithName(cmd.Context(), daemonClient, currUser.Username, profileName) @@ -166,7 +163,6 @@ func addProfileFunc(cmd *cobra.Command, args []string) error { cmd.Println("Use `netbird profile list --show-id` to disambiguate later.") } - id := profilemanager.ID(resp.Id) cmd.Printf("Profile added: %s %s\n", id.ShortID(), profilemanager.StripCtrlChars(profileName)) return nil @@ -330,3 +326,19 @@ func wrapAmbiguityError(err error, handle string) error { } return err } + +// addProfileOnDaemon issues the AddProfile RPC on an existing daemon client +// and returns the new profile's ID. It is the single entry point for profile +// creation, shared by `netbird profile add` and the `netbird up --profile +// ` auto-create path. +func addProfileOnDaemon(ctx context.Context, client proto.DaemonServiceClient, profileName, username string) (profilemanager.ID, error) { + resp, err := client.AddProfile(ctx, &proto.AddProfileRequest{ + ProfileName: profileName, + Username: username, + }) + if err != nil { + return "", fmt.Errorf("add profile failed: %w", err) + } + + return profilemanager.ID(resp.Id), nil +} diff --git a/client/cmd/status.go b/client/cmd/status.go index 103b3044a..5a7559cf1 100644 --- a/client/cmd/status.go +++ b/client/cmd/status.go @@ -11,7 +11,6 @@ import ( "google.golang.org/grpc/status" "github.com/netbirdio/netbird/client/internal" - "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/proto" nbstatus "github.com/netbirdio/netbird/client/status" "github.com/netbirdio/netbird/util" @@ -111,11 +110,10 @@ func statusFunc(cmd *cobra.Command, args []string) error { return nil } - pm := profilemanager.NewProfileManager() - var profName string - if activeProf, err := pm.GetActiveProfile(); err == nil { - profName = activeProf.Name - } + // Resolve the active profile's display name via the daemon, which runs + // as root and can read the per-user profile files. The local profile + // manager only knows the active profile ID, not its display name. + profName := getActiveProfileName(ctx) var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{ Anonymize: anonymizeFlag, @@ -167,6 +165,25 @@ func getStatus(ctx context.Context, fullPeerStatus bool, shouldRunProbes bool) ( return resp, nil } +// getActiveProfileName asks the daemon for the active profile's display +// name. The daemon runs as root and can read the per-user profile files to +// resolve the ID to its human-readable name. Returns an empty string on any +// error so status output degrades gracefully. +func getActiveProfileName(ctx context.Context) string { + conn, err := DialClientGRPCServer(ctx, daemonAddr) + if err != nil { + return "" + } + defer conn.Close() + + resp, err := proto.NewDaemonServiceClient(conn).GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}) + if err != nil { + return "" + } + + return resp.GetProfileName() +} + func parseFilters() error { switch strings.ToLower(statusFilter) { case "", "idle", "connecting", "connected": diff --git a/client/cmd/up.go b/client/cmd/up.go index 2761cf74a..0506bc65b 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -128,15 +128,9 @@ func upFunc(cmd *cobra.Command, args []string) error { var profileSwitched bool // switch profile if provided if profileName != "" { - resolvedID, err := switchProfile(cmd.Context(), profileName, username.Username) - if err != nil { + if err := switchOrCreateProfile(cmd.Context(), pm, profileName, username.Username); err != nil { return fmt.Errorf("switch profile: %v", err) } - - if err := pm.SwitchProfile(resolvedID); err != nil { - return fmt.Errorf("switch profile: %v", err) - } - profileSwitched = true } @@ -151,6 +145,52 @@ func upFunc(cmd *cobra.Command, args []string) error { return runInDaemonMode(ctx, cmd, pm, activeProf, profileSwitched) } +// switchOrCreateProfile switches the active profile to the one identified by +// handle, creating it first when it does not exist yet. This restores the +// pre-0.73 behaviour where `netbird up --profile ` auto-creates a +// missing profile instead of failing. +func switchOrCreateProfile(ctx context.Context, pm *profilemanager.ProfileManager, handle, username string) error { + resolvedID, err := switchProfile(ctx, handle, username) + if err != nil { + st, ok := gstatus.FromError(err) + if !ok || st.Code() != codes.NotFound { + return err + } + // Don't fail immediately on a create error: a concurrent run may + // have created the profile between the NotFound above and this + // call, in which case the retried switch still succeeds. Only + // surface the create error if the switch also fails. + _, createErr := createProfile(ctx, handle, username) + if resolvedID, err = switchProfile(ctx, handle, username); err != nil { + if createErr != nil { + return fmt.Errorf("create profile: %w", createErr) + } + return err + } + } + + if err := pm.SwitchProfile(resolvedID); err != nil { + return err + } + return nil +} + +// createProfile dials the daemon and creates a new profile with the given +// display name, returning its generated ID. Use addProfileOnDaemon directly +// when a daemon client is already available to reuse the connection. +func createProfile(ctx context.Context, profileName, username string) (profilemanager.ID, error) { + conn, err := DialClientGRPCServer(ctx, daemonAddr) + if err != nil { + //nolint + return "", fmt.Errorf("failed to connect to daemon error: %v\n"+ + "If the daemon is not running please run: "+ + "\nnetbird service install \nnetbird service start\n", err) + } + defer conn.Close() + + return addProfileOnDaemon(ctx, proto.NewDaemonServiceClient(conn), profileName, username) +} + func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *profilemanager.Profile) error { // override the default profile filepath if provided if configPath != "" { From 15a0504fb1bf8046db96da8970437b057fc59a2d Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:32:49 +0900 Subject: [PATCH 18/20] [client] Treat answering upstreams as reachable and widen DNS health grace window (#6453) --- client/internal/dns/server.go | 33 +++++++++++-- client/internal/dns/server_test.go | 26 ++++++++++ client/internal/dns/upstream.go | 9 ++-- client/internal/dns/upstream_test.go | 72 ++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 7 deletions(-) diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go index dcd4cb9d0..77446b330 100644 --- a/client/internal/dns/server.go +++ b/client/internal/dns/server.go @@ -6,6 +6,7 @@ import ( "fmt" "net/netip" "net/url" + "os" "slices" "strings" "sync" @@ -38,11 +39,15 @@ const ( // defaultWarningDelayBase is the starting grace window before a // "Nameserver group unreachable" event fires for a group that's // never been healthy and only has overlay upstreams with no - // Connected peer. Per-server and overridable; see warningDelayFor. - defaultWarningDelayBase = 30 * time.Second + // Connected peer. Per-server and overridable via envWarningDelay; + // see warningDelay. + defaultWarningDelayBase = 60 * time.Second // warningDelayBonusCap caps the route-count bonus added to the - // base grace window. See warningDelayFor. + // base grace window. See warningDelay. warningDelayBonusCap = 30 * time.Second + // envWarningDelay overrides defaultWarningDelayBase with a Go duration + // string (e.g. "90s", "2m"). Invalid or non-positive values are ignored. + envWarningDelay = "NB_DNS_HEALTH_WARNING_DELAY" ) // errNoUsableNameservers signals that a merged-domain group has no usable @@ -298,7 +303,7 @@ func newDefaultServer( hostManager: &noopHostConfigurator{}, mgmtCacheResolver: mgmtCacheResolver, currentConfigHash: ^uint64(0), // Initialize to max uint64 to ensure first config is always applied - warningDelayBase: defaultWarningDelayBase, + warningDelayBase: warningDelayBaseFromEnv(), healthRefresh: make(chan struct{}, 1), } // Wire the local resolver against the peer status recorder so it can @@ -1154,6 +1159,26 @@ func (s *DefaultServer) projectUnhealthy(p *nsGroupProj, servers []netip.AddrPor return false } +// warningDelayBaseFromEnv returns the base grace window, honoring +// envWarningDelay when it holds a valid positive Go duration. Invalid or +// non-positive values fall back to defaultWarningDelayBase. +func warningDelayBaseFromEnv() time.Duration { + val := os.Getenv(envWarningDelay) + if val == "" { + return defaultWarningDelayBase + } + d, err := time.ParseDuration(val) + if err != nil { + log.Warnf("invalid %s value %q, using default %v: %v", envWarningDelay, val, defaultWarningDelayBase, err) + return defaultWarningDelayBase + } + if d <= 0 { + log.Warnf("%s must be positive, got %v, using default %v", envWarningDelay, d, defaultWarningDelayBase) + return defaultWarningDelayBase + } + return d +} + // warningDelay returns the grace window for the given selected-route // count. Scales gently: +1s per 100 routes, capped by // warningDelayBonusCap. Parallel handshakes mean handshake time grows diff --git a/client/internal/dns/server_test.go b/client/internal/dns/server_test.go index 722c2abd7..53d864115 100644 --- a/client/internal/dns/server_test.go +++ b/client/internal/dns/server_test.go @@ -2484,6 +2484,32 @@ func TestProjection_StopClearsHealthState(t *testing.T) { // rule 3: startup failures while the peer is handshaking, then the peer // comes up and a query succeeds before the grace window elapses. No // warning should ever have fired, and no recovery either. +func TestWarningDelayBaseFromEnv(t *testing.T) { + tests := []struct { + name string + set bool + val string + want time.Duration + }{ + {name: "unset uses default", set: false, want: defaultWarningDelayBase}, + {name: "valid override", set: true, val: "90s", want: 90 * time.Second}, + {name: "valid minutes", set: true, val: "2m", want: 2 * time.Minute}, + {name: "invalid falls back", set: true, val: "notaduration", want: defaultWarningDelayBase}, + {name: "zero falls back", set: true, val: "0s", want: defaultWarningDelayBase}, + {name: "negative falls back", set: true, val: "-30s", want: defaultWarningDelayBase}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(envWarningDelay, tc.val) + if !tc.set { + os.Unsetenv(envWarningDelay) + } + assert.Equal(t, tc.want, warningDelayBaseFromEnv(), "grace window base") + }) + } +} + func TestProjection_OverlayRecoversDuringGrace(t *testing.T) { fx := newProjTestFixture(t) fx.server.warningDelayBase = 200 * time.Millisecond diff --git a/client/internal/dns/upstream.go b/client/internal/dns/upstream.go index a4f713d68..9c0d00212 100644 --- a/client/internal/dns/upstream.go +++ b/client/internal/dns/upstream.go @@ -443,21 +443,25 @@ func (u *upstreamResolverBase) queryUpstream(parentCtx context.Context, r *dns.M return raceResult{}, &upstreamFailure{upstream: upstream, reason: "no response"} } + // A valid response means the upstream is reachable, whatever the Rcode. + u.markUpstreamOk(upstream) + proto := "" if upstreamProto != nil { proto = upstreamProto.protocol } if rm.Rcode == dns.RcodeServerFailure || rm.Rcode == dns.RcodeRefused { + // SERVFAIL and REFUSED are per-question outcomes (DNSSEC-bogus names, + // refused zones, transient recursion errors), not reachability + // problems: fail over for a better answer but keep the upstream healthy. if code, ok := nonRetryableEDE(rm); ok { if !hadEdns { stripOPT(rm) } - u.markUpstreamOk(upstream) return raceResult{msg: rm, upstream: upstream, protocol: proto, ede: edeName(code)}, nil } reason := dns.RcodeToString[rm.Rcode] - u.markUpstreamFail(upstream, reason) return raceResult{}, &upstreamFailure{upstream: upstream, reason: reason} } @@ -465,7 +469,6 @@ func (u *upstreamResolverBase) queryUpstream(parentCtx context.Context, r *dns.M stripOPT(rm) } - u.markUpstreamOk(upstream) return raceResult{msg: rm, upstream: upstream, protocol: proto}, nil } diff --git a/client/internal/dns/upstream_test.go b/client/internal/dns/upstream_test.go index 8b3c589f1..afd2053cc 100644 --- a/client/internal/dns/upstream_test.go +++ b/client/internal/dns/upstream_test.go @@ -517,6 +517,78 @@ func TestUpstreamResolver_HealthTracking(t *testing.T) { assert.NotContains(t, health, bad, "sibling upstream should not be queried when primary answers") } +// TestUpstreamResolver_HealthTracking_ResponseMeansReachable verifies that an +// upstream which answers with SERVFAIL or REFUSED is recorded as healthy: +// those are per-question outcomes from a reachable server and must not mark +// the upstream unhealthy. Only transport failures (timeouts) do. +func TestUpstreamResolver_HealthTracking_ResponseMeansReachable(t *testing.T) { + a := netip.MustParseAddrPort("192.0.2.10:53") + b := netip.MustParseAddrPort("192.0.2.11:53") + timeoutErr := &net.OpError{Op: "read", Err: fmt.Errorf("i/o timeout")} + + tests := []struct { + name string + respA mockUpstreamResponse + respB mockUpstreamResponse + wantHealthy bool + }{ + { + name: "both SERVFAIL are reachable", + respA: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeServerFailure, "")}, + respB: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeServerFailure, "")}, + wantHealthy: true, + }, + { + name: "both REFUSED are reachable", + respA: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeRefused, "")}, + respB: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeRefused, "")}, + wantHealthy: true, + }, + { + name: "timeout marks unhealthy", + respA: mockUpstreamResponse{err: timeoutErr}, + respB: mockUpstreamResponse{err: timeoutErr}, + wantHealthy: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mockClient := &mockUpstreamResolverPerServer{ + responses: map[string]mockUpstreamResponse{ + a.String(): tc.respA, + b.String(): tc.respB, + }, + rtt: time.Millisecond, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + resolver := &upstreamResolverBase{ + ctx: ctx, + upstreamClient: mockClient, + upstreamTimeout: UpstreamTimeout, + } + resolver.addRace([]netip.AddrPort{a, b}) + + responseWriter := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { return nil }} + resolver.ServeDNS(responseWriter, new(dns.Msg).SetQuestion("example.com.", dns.TypeA)) + + health := resolver.UpstreamHealth() + require.Contains(t, health, a, "primary upstream should have a health record") + if tc.wantHealthy { + assert.False(t, health[a].LastOk.IsZero(), "responding upstream should have LastOk set") + assert.True(t, health[a].LastFail.IsZero(), "responding upstream should not be marked failed") + assert.Empty(t, health[a].LastErr, "responding upstream should have no error") + } else { + assert.False(t, health[a].LastFail.IsZero(), "timed-out upstream should be marked failed") + assert.NotEmpty(t, health[a].LastErr, "timed-out upstream should record an error") + } + }) + } +} + func TestFormatFailures(t *testing.T) { testCases := []struct { name string From 58c79f587878511c478c21638c42376f9c5c401f Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:33:09 +0900 Subject: [PATCH 19/20] [client] Fix DNS custom zone teardown: handler leak and external CNAME resolution (#6445) --- client/internal/dns/server.go | 25 +-- client/internal/dns/server_test.go | 261 +++++++++++++++++++---------- 2 files changed, 184 insertions(+), 102 deletions(-) diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go index 77446b330..7556c66cc 100644 --- a/client/internal/dns/server.go +++ b/client/internal/dns/server.go @@ -140,7 +140,7 @@ type DefaultServer struct { disableSys bool mux sync.Mutex service service - dnsMuxMap registeredHandlerMap + dnsMuxHandlers []handlerWrapper localResolver *local.Resolver wgInterface WGIface hostManager hostManager @@ -204,8 +204,6 @@ type handlerWrapper struct { priority int } -type registeredHandlerMap map[types.HandlerID]handlerWrapper - // DefaultServerConfig holds configuration parameters for NewDefaultServer type DefaultServerConfig struct { WgInterface WGIface @@ -294,7 +292,6 @@ func newDefaultServer( service: dnsService, handlerChain: handlerChain, extraDomains: make(map[domain.Domain]int), - dnsMuxMap: make(registeredHandlerMap), localResolver: local.NewResolver(), wgInterface: wgInterface, statusRecorder: statusRecorder, @@ -333,7 +330,7 @@ func (s *DefaultServer) SetRouteSources(selected, active func() route.HAMap) { type routeSettable interface { setSelectedRoutes(func() route.HAMap) } - for _, entry := range s.dnsMuxMap { + for _, entry := range s.dnsMuxHandlers { if h, ok := entry.handler.(routeSettable); ok { h.setSelectedRoutes(selected) } @@ -983,19 +980,23 @@ func (s *DefaultServer) usableNameServers(nameServers []nbdns.NameServer) []neti func (s *DefaultServer) updateMux(muxUpdates []handlerWrapper) { // this will introduce a short period of time when the server is not able to handle DNS requests - for _, existing := range s.dnsMuxMap { + for _, existing := range s.dnsMuxHandlers { s.deregisterHandler([]string{existing.domain}, existing.priority) - existing.handler.Stop() + // The local resolver is a persistent singleton shared by every custom + // zone and reused across config updates. Its chain registrations are + // per-config and must be deregistered, but Stop() cancels its lookup + // context (breaking external CNAME-target resolution) and clears its + // records, so it must not be torn down here. + if existing.handler != s.localResolver { + existing.handler.Stop() + } } - muxUpdateMap := make(registeredHandlerMap) - for _, update := range muxUpdates { s.registerHandler([]string{update.domain}, update.handler, update.priority) - muxUpdateMap[update.handler.ID()] = update } - s.dnsMuxMap = muxUpdateMap + s.dnsMuxHandlers = muxUpdates } // updateNSGroupStates records the new group set and pokes the refresher. @@ -1229,7 +1230,7 @@ func (s *DefaultServer) groupHasImmediateUpstream(servers []netip.AddrPort, snap // in more than one handler. func (s *DefaultServer) collectUpstreamHealth() map[netip.AddrPort]UpstreamHealth { merged := make(map[netip.AddrPort]UpstreamHealth) - for _, entry := range s.dnsMuxMap { + for _, entry := range s.dnsMuxHandlers { reporter, ok := entry.handler.(upstreamHealthReporter) if !ok { continue diff --git a/client/internal/dns/server_test.go b/client/internal/dns/server_test.go index 53d864115..4ef790412 100644 --- a/client/internal/dns/server_test.go +++ b/client/internal/dns/server_test.go @@ -104,19 +104,6 @@ func init() { formatter.SetTextFormatter(log.StandardLogger()) } -func generateDummyHandler(d string, servers []nbdns.NameServer) *upstreamResolverBase { - var srvs []netip.AddrPort - for _, srv := range servers { - srvs = append(srvs, srv.AddrPort()) - } - u := &upstreamResolverBase{ - domain: domain.Domain(d), - cancel: func() {}, - } - u.addRace(srvs) - return u -} - func TestUpdateDNSServer(t *testing.T) { nameServers := []nbdns.NameServer{ @@ -132,22 +119,20 @@ func TestUpdateDNSServer(t *testing.T) { }, } - dummyHandler := local.NewResolver() - testCases := []struct { name string - initUpstreamMap registeredHandlerMap + initUpstreamMap []handlerWrapper initLocalZones []nbdns.CustomZone initSerial uint64 inputSerial uint64 inputUpdate nbdns.Config shouldFail bool - expectedUpstreamMap registeredHandlerMap + expectedUpstreamMap []handlerWrapper expectedLocalQs []dns.Question }{ { name: "Initial Config Should Succeed", - initUpstreamMap: make(registeredHandlerMap), + initUpstreamMap: nil, initSerial: 0, inputSerial: 1, inputUpdate: nbdns.Config{ @@ -169,20 +154,17 @@ func TestUpdateDNSServer(t *testing.T) { }, }, }, - expectedUpstreamMap: registeredHandlerMap{ - generateDummyHandler("netbird.io", nameServers).ID(): handlerWrapper{ + expectedUpstreamMap: []handlerWrapper{ + { domain: "netbird.io", - handler: dummyHandler, priority: PriorityUpstream, }, - dummyHandler.ID(): handlerWrapper{ + { domain: "netbird.cloud", - handler: dummyHandler, priority: PriorityLocal, }, - generateDummyHandler(".", nameServers).ID(): handlerWrapper{ + { domain: nbdns.RootZone, - handler: dummyHandler, priority: PriorityDefault, }, }, @@ -191,10 +173,10 @@ func TestUpdateDNSServer(t *testing.T) { { name: "New Config Should Succeed", initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}, - initUpstreamMap: registeredHandlerMap{ - generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{ + initUpstreamMap: []handlerWrapper{ + { domain: "netbird.cloud", - handler: dummyHandler, + handler: &mockHandler{}, priority: PriorityUpstream, }, }, @@ -215,15 +197,13 @@ func TestUpdateDNSServer(t *testing.T) { }, }, }, - expectedUpstreamMap: registeredHandlerMap{ - generateDummyHandler("netbird.io", nameServers).ID(): handlerWrapper{ + expectedUpstreamMap: []handlerWrapper{ + { domain: "netbird.io", - handler: dummyHandler, priority: PriorityUpstream, }, - "local-resolver": handlerWrapper{ + { domain: "netbird.cloud", - handler: dummyHandler, priority: PriorityLocal, }, }, @@ -232,7 +212,7 @@ func TestUpdateDNSServer(t *testing.T) { { name: "Smaller Config Serial Should Be Skipped", initLocalZones: []nbdns.CustomZone{}, - initUpstreamMap: make(registeredHandlerMap), + initUpstreamMap: nil, initSerial: 2, inputSerial: 1, shouldFail: true, @@ -240,7 +220,7 @@ func TestUpdateDNSServer(t *testing.T) { { name: "Empty NS Group Domain Or Not Primary Element Should Fail", initLocalZones: []nbdns.CustomZone{}, - initUpstreamMap: make(registeredHandlerMap), + initUpstreamMap: nil, initSerial: 0, inputSerial: 1, inputUpdate: nbdns.Config{ @@ -262,7 +242,7 @@ func TestUpdateDNSServer(t *testing.T) { { name: "Invalid NS Group Nameservers list Should Fail", initLocalZones: []nbdns.CustomZone{}, - initUpstreamMap: make(registeredHandlerMap), + initUpstreamMap: nil, initSerial: 0, inputSerial: 1, inputUpdate: nbdns.Config{ @@ -284,7 +264,7 @@ func TestUpdateDNSServer(t *testing.T) { { name: "Invalid Custom Zone Records list Should Skip", initLocalZones: []nbdns.CustomZone{}, - initUpstreamMap: make(registeredHandlerMap), + initUpstreamMap: nil, initSerial: 0, inputSerial: 1, inputUpdate: nbdns.Config{ @@ -301,42 +281,41 @@ func TestUpdateDNSServer(t *testing.T) { }, }, }, - expectedUpstreamMap: registeredHandlerMap{generateDummyHandler(".", nameServers).ID(): handlerWrapper{ + expectedUpstreamMap: []handlerWrapper{{ domain: ".", - handler: dummyHandler, priority: PriorityDefault, }}, }, { name: "Empty Config Should Succeed and Clean Maps", initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}, - initUpstreamMap: registeredHandlerMap{ - generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{ + initUpstreamMap: []handlerWrapper{ + { domain: zoneRecords[0].Name, - handler: dummyHandler, + handler: &mockHandler{}, priority: PriorityUpstream, }, }, initSerial: 0, inputSerial: 1, inputUpdate: nbdns.Config{ServiceEnable: true}, - expectedUpstreamMap: make(registeredHandlerMap), + expectedUpstreamMap: nil, expectedLocalQs: []dns.Question{}, }, { name: "Disabled Service Should clean map", initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}, - initUpstreamMap: registeredHandlerMap{ - generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{ + initUpstreamMap: []handlerWrapper{ + { domain: zoneRecords[0].Name, - handler: dummyHandler, + handler: &mockHandler{}, priority: PriorityUpstream, }, }, initSerial: 0, inputSerial: 1, inputUpdate: nbdns.Config{ServiceEnable: false}, - expectedUpstreamMap: make(registeredHandlerMap), + expectedUpstreamMap: nil, expectedLocalQs: []dns.Question{}, }, } @@ -393,7 +372,7 @@ func TestUpdateDNSServer(t *testing.T) { } }() - dnsServer.dnsMuxMap = testCase.initUpstreamMap + dnsServer.dnsMuxHandlers = testCase.initUpstreamMap dnsServer.localResolver.Update(testCase.initLocalZones) dnsServer.updateSerial = testCase.initSerial @@ -405,14 +384,20 @@ func TestUpdateDNSServer(t *testing.T) { t.Fatalf("update dns server should not fail, got error: %v", err) } - if len(dnsServer.dnsMuxMap) != len(testCase.expectedUpstreamMap) { - t.Fatalf("update upstream failed, map size is different than expected, want %d, got %d", len(testCase.expectedUpstreamMap), len(dnsServer.dnsMuxMap)) + if len(dnsServer.dnsMuxHandlers) != len(testCase.expectedUpstreamMap) { + t.Fatalf("update upstream failed, map size is different than expected, want %d, got %d", len(testCase.expectedUpstreamMap), len(dnsServer.dnsMuxHandlers)) } - for key := range testCase.expectedUpstreamMap { - _, found := dnsServer.dnsMuxMap[key] + for _, expected := range testCase.expectedUpstreamMap { + found := false + for _, got := range dnsServer.dnsMuxHandlers { + if got.domain == expected.domain && got.priority == expected.priority { + found = true + break + } + } if !found { - t.Fatalf("update upstream failed, key %s was not found in the dnsMuxMap: %#v", key, dnsServer.dnsMuxMap) + t.Fatalf("update upstream failed, handler for domain=%s priority=%d not found in dnsMuxHandlers: %#v", expected.domain, expected.priority, dnsServer.dnsMuxHandlers) } } @@ -512,8 +497,8 @@ func TestDNSFakeResolverHandleUpdates(t *testing.T) { } }() - dnsServer.dnsMuxMap = registeredHandlerMap{ - "id1": handlerWrapper{ + dnsServer.dnsMuxHandlers = []handlerWrapper{ + { domain: zoneRecords[0].Name, handler: &local.Resolver{}, priority: PriorityUpstream, @@ -1029,15 +1014,15 @@ func (m *mockService) RegisterMux(string, dns.Handler) {} func (m *mockService) DeregisterMux(string) {} func TestDefaultServer_UpdateMux(t *testing.T) { - baseMatchHandlers := registeredHandlerMap{ - "upstream-group1": { + baseMatchHandlers := []handlerWrapper{ + { domain: "example.com", handler: &mockHandler{ Id: "upstream-group1", }, priority: PriorityUpstream, }, - "upstream-group2": { + { domain: "example.com", handler: &mockHandler{ Id: "upstream-group2", @@ -1046,15 +1031,15 @@ func TestDefaultServer_UpdateMux(t *testing.T) { }, } - baseRootHandlers := registeredHandlerMap{ - "upstream-root1": { + baseRootHandlers := []handlerWrapper{ + { domain: ".", handler: &mockHandler{ Id: "upstream-root1", }, priority: PriorityDefault, }, - "upstream-root2": { + { domain: ".", handler: &mockHandler{ Id: "upstream-root2", @@ -1063,22 +1048,22 @@ func TestDefaultServer_UpdateMux(t *testing.T) { }, } - baseMixedHandlers := registeredHandlerMap{ - "upstream-group1": { + baseMixedHandlers := []handlerWrapper{ + { domain: "example.com", handler: &mockHandler{ Id: "upstream-group1", }, priority: PriorityUpstream, }, - "upstream-group2": { + { domain: "example.com", handler: &mockHandler{ Id: "upstream-group2", }, priority: PriorityUpstream - 1, }, - "upstream-other": { + { domain: "other.com", handler: &mockHandler{ Id: "upstream-other", @@ -1089,7 +1074,7 @@ func TestDefaultServer_UpdateMux(t *testing.T) { tests := []struct { name string - initialHandlers registeredHandlerMap + initialHandlers []handlerWrapper updates []handlerWrapper expectedHandlers map[string]string // map[HandlerID]domain description string @@ -1373,32 +1358,38 @@ func TestDefaultServer_UpdateMux(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { server := &DefaultServer{ - dnsMuxMap: tt.initialHandlers, - handlerChain: NewHandlerChain(), - service: &mockService{}, + dnsMuxHandlers: tt.initialHandlers, + handlerChain: NewHandlerChain(), + service: &mockService{}, } // Perform the update server.updateMux(tt.updates) // Verify the results - assert.Equal(t, len(tt.expectedHandlers), len(server.dnsMuxMap), + assert.Equal(t, len(tt.expectedHandlers), len(server.dnsMuxHandlers), "Number of handlers after update doesn't match expected") // Check each expected handler for id, expectedDomain := range tt.expectedHandlers { - handler, exists := server.dnsMuxMap[types.HandlerID(id)] - assert.True(t, exists, "Expected handler %s not found", id) - if exists { - assert.Equal(t, expectedDomain, handler.domain, + var found *handlerWrapper + for i := range server.dnsMuxHandlers { + if server.dnsMuxHandlers[i].handler.ID() == types.HandlerID(id) { + found = &server.dnsMuxHandlers[i] + break + } + } + assert.NotNil(t, found, "Expected handler %s not found", id) + if found != nil { + assert.Equal(t, expectedDomain, found.domain, "Domain mismatch for handler %s", id) } } // Verify no unexpected handlers exist - for HandlerID := range server.dnsMuxMap { - _, expected := tt.expectedHandlers[string(HandlerID)] - assert.True(t, expected, "Unexpected handler found: %s", HandlerID) + for _, entry := range server.dnsMuxHandlers { + _, expected := tt.expectedHandlers[string(entry.handler.ID())] + assert.True(t, expected, "Unexpected handler found: %s", entry.handler.ID()) } // Verify the handlerChain state and order @@ -1413,7 +1404,7 @@ func TestDefaultServer_UpdateMux(t *testing.T) { // Verify handler exists in mux foundInMux := false - for _, muxEntry := range server.dnsMuxMap { + for _, muxEntry := range server.dnsMuxHandlers { if chainEntry.Handler == muxEntry.handler && chainEntry.Priority == muxEntry.priority && chainEntry.Pattern == dns.Fqdn(muxEntry.domain) { @@ -1422,12 +1413,108 @@ func TestDefaultServer_UpdateMux(t *testing.T) { } } assert.True(t, foundInMux, - "Handler in chain not found in dnsMuxMap") + "Handler in chain not found in dnsMuxHandlers") } }) } } +// chainHasPattern reports whether the handler chain holds an entry registered +// for the given fqdn pattern at the given priority. +func chainHasPattern(s *DefaultServer, pattern string, priority int) bool { + for _, h := range s.handlerChain.handlers { + if h.OrigPattern == pattern && h.Priority == priority { + return true + } + } + return false +} + +// TestDefaultServer_UpdateMux_SharedHandlerZoneRemoval verifies that updateMux +// tracks each (handler, domain) registration independently when one handler +// serves multiple zones. Every custom zone is served by the same handler +// instance (the local resolver, whose ID is the constant "local-resolver"), so +// removing one zone must deregister exactly that zone's chain entry and leave +// the others in place. Tracking registrations by handler ID alone collapses all +// zones onto one entry, leaving removed zones in the chain to answer +// authoritatively with no records. +func TestDefaultServer_UpdateMux_SharedHandlerZoneRemoval(t *testing.T) { + // One handler serves every custom zone, mirroring s.localResolver. + shared := &mockHandler{Id: "local-resolver"} + + server := &DefaultServer{ + handlerChain: NewHandlerChain(), + service: &mockService{}, + } + + // Two custom zones under the same handler. The surviving zone is registered + // last, mirroring the management emission order. + server.updateMux([]handlerWrapper{ + {domain: "userzone.test", handler: shared, priority: PriorityLocal}, + {domain: "peerzone.test", handler: shared, priority: PriorityLocal}, + }) + + require.True(t, chainHasPattern(server, "userzone.test.", PriorityLocal), + "userzone.test should be registered after the first update") + require.True(t, chainHasPattern(server, "peerzone.test.", PriorityLocal), + "peerzone.test should be registered after the first update") + + // Remove one zone, keep the other. + server.updateMux([]handlerWrapper{ + {domain: "peerzone.test", handler: shared, priority: PriorityLocal}, + }) + + assert.True(t, chainHasPattern(server, "peerzone.test.", PriorityLocal), + "peerzone.test should remain after removing userzone.test") + assert.False(t, chainHasPattern(server, "userzone.test.", PriorityLocal), + "userzone.test handler must be deregistered, not leaked in the chain") +} + +// TestDefaultServer_UpdateMux_PreservesLocalResolver verifies that updateMux +// does not tear down the shared local resolver during reconfiguration. The +// resolver is a process-lifetime singleton reused across config updates; +// Stop() cancels its lookup context (breaking external CNAME-target +// resolution) and clears its records. updateMux must deregister its chain +// entries without stopping it. Records surviving a teardown update is the +// observable proxy: Stop() would have cleared them. +func TestDefaultServer_UpdateMux_PreservesLocalResolver(t *testing.T) { + resolver := local.NewResolver() + require.NoError(t, resolver.RegisterRecord(nbdns.SimpleRecord{ + Name: "peer.netbird.cloud.", + Type: int(dns.TypeA), + Class: nbdns.DefaultClass, + TTL: 300, + RData: "10.0.0.1", + })) + + server := &DefaultServer{ + handlerChain: NewHandlerChain(), + service: &mockService{}, + localResolver: resolver, + } + + server.updateMux([]handlerWrapper{ + {domain: "netbird.cloud", handler: resolver, priority: PriorityLocal}, + }) + + // Remove the zone. The resolver must survive so its records and lookup + // context stay intact for the next registration. + server.updateMux(nil) + + var response *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{ + WriteMsgFunc: func(m *dns.Msg) error { + response = m + return nil + }, + }, &dns.Msg{Question: []dns.Question{{Name: "peer.netbird.cloud.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}}) + + require.NotNil(t, response, "local resolver should answer after teardown") + assert.Equal(t, dns.RcodeSuccess, response.Rcode, + "local resolver records must survive teardown; updateMux must not Stop() the shared resolver") + assert.NotEmpty(t, response.Answer, "answer should contain the surviving record") +} + func TestExtraDomains(t *testing.T) { tests := []struct { name string @@ -2049,7 +2136,6 @@ func TestBuildUpstreamHandler_MergesGroupsPerDomain(t *testing.T) { localResolver: local.NewResolver(), handlerChain: NewHandlerChain(), hostManager: &noopHostConfigurator{}, - dnsMuxMap: make(registeredHandlerMap), } groups := []*nbdns.NameServerGroup{ @@ -2207,7 +2293,7 @@ func TestEvaluateNSGroupHealth(t *testing.T) { } } -// healthStubHandler is a minimal dnsMuxMap entry that exposes a fixed +// healthStubHandler is a minimal dnsMuxHandlers entry that exposes a fixed // UpstreamHealth snapshot, letting tests drive recomputeNSGroupStates // without spinning up real handlers. type healthStubHandler struct { @@ -2283,12 +2369,11 @@ func newProjTestFixture(t *testing.T) *projTestFixture { ctx: context.Background(), wgInterface: &mocWGIface{}, statusRecorder: recorder, - dnsMuxMap: make(registeredHandlerMap), selectedRoutes: func() route.HAMap { return fx.selected }, activeRoutes: func() route.HAMap { return fx.active }, warningDelayBase: defaultWarningDelayBase, } - fx.server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: fx.stub, priority: PriorityUpstream} + fx.server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: fx.stub, priority: PriorityUpstream}} fx.server.mux.Lock() fx.server.updateNSGroupStates([]*nbdns.NameServerGroup{fx.group}) @@ -2395,7 +2480,6 @@ func TestProjection_OverlayAddrNoRouteDelaysWarning(t *testing.T) { ctx: context.Background(), wgInterface: &mocWGIface{}, statusRecorder: recorder, - dnsMuxMap: make(registeredHandlerMap), selectedRoutes: func() route.HAMap { return nil }, activeRoutes: func() route.HAMap { return nil }, warningDelayBase: 50 * time.Millisecond, @@ -2407,7 +2491,7 @@ func TestProjection_OverlayAddrNoRouteDelaysWarning(t *testing.T) { stub := &healthStubHandler{health: map[netip.AddrPort]UpstreamHealth{ overlayPeer: {LastFail: time.Now(), LastErr: "timeout"}, }} - server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: stub, priority: PriorityUpstream} + server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: stub, priority: PriorityUpstream}} server.mux.Lock() server.updateNSGroupStates([]*nbdns.NameServerGroup{group}) @@ -2444,7 +2528,6 @@ func TestProjection_StopClearsHealthState(t *testing.T) { service: NewServiceViaMemory(wgIface), hostManager: &noopHostConfigurator{}, extraDomains: map[domain.Domain]int{}, - dnsMuxMap: make(registeredHandlerMap), statusRecorder: peer.NewRecorder("mgm"), selectedRoutes: func() route.HAMap { return nil }, activeRoutes: func() route.HAMap { return nil }, @@ -2459,7 +2542,7 @@ func TestProjection_StopClearsHealthState(t *testing.T) { NameServers: []nbdns.NameServer{{IP: srv.Addr(), NSType: nbdns.UDPNameServerType, Port: int(srv.Port())}}, } stub := &healthStubHandler{health: map[netip.AddrPort]UpstreamHealth{srv: {LastOk: time.Now()}}} - server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: stub, priority: PriorityUpstream} + server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: stub, priority: PriorityUpstream}} server.mux.Lock() server.updateNSGroupStates([]*nbdns.NameServerGroup{group}) @@ -2621,7 +2704,6 @@ func TestProjection_MixedGroupEmitsImmediately(t *testing.T) { server := &DefaultServer{ ctx: context.Background(), statusRecorder: recorder, - dnsMuxMap: make(registeredHandlerMap), selectedRoutes: func() route.HAMap { return overlayMap }, activeRoutes: func() route.HAMap { return nil }, warningDelayBase: time.Hour, @@ -2639,7 +2721,7 @@ func TestProjection_MixedGroupEmitsImmediately(t *testing.T) { overlay: {LastFail: time.Now(), LastErr: "timeout"}, }, } - server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: stub, priority: PriorityUpstream} + server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: stub, priority: PriorityUpstream}} server.mux.Lock() server.updateNSGroupStates([]*nbdns.NameServerGroup{group}) @@ -2666,7 +2748,6 @@ func TestDNSLoopPrevention(t *testing.T) { localResolver: local.NewResolver(), handlerChain: NewHandlerChain(), hostManager: &noopHostConfigurator{}, - dnsMuxMap: make(registeredHandlerMap), } tests := []struct { From c9e99659eadaccaa36d4c79395f2902087957419 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 09:43:33 +0200 Subject: [PATCH 20/20] [misc] Bump the actions group across 1 directory with 9 updates (#6451) Bumps the actions group with 9 updates in the / directory: | Package | From | To | | --- | --- | --- | | [actions/checkout](https://github.com/actions/checkout) | `6.0.2` | `7.0.0` | | [actions/setup-go](https://github.com/actions/setup-go) | `6.3.0` | `6.4.0` | | [codecov/codecov-action](https://github.com/codecov/codecov-action) | `6.0.1` | `7.0.0` | | [vmactions/freebsd-vm](https://github.com/vmactions/freebsd-vm) | `1.4.5` | `1.4.8` | | [actions/setup-java](https://github.com/actions/setup-java) | `5.2.0` | `5.3.0` | | [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) | `4.0.0` | `4.1.0` | | [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `4.0.0` | `4.1.0` | | [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) | `7.2.0` | `7.2.2` | | [actions/download-artifact](https://github.com/actions/download-artifact) | `8.0.0` | `8.0.1` | Updates `actions/checkout` from 6.0.2 to 7.0.0 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0) Updates `actions/setup-go` from 6.3.0 to 6.4.0 - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/4b73464bb391d4059bd26b0524d20df3927bd417...4a3601121dd01d1626a1e23e37211e3254c1c06c) Updates `codecov/codecov-action` from 6.0.1 to 7.0.0 - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/e79a6962e0d4c0c17b229090214935d2e33f8354...fb8b3582c8e4def4969c97caa2f19720cb33a72f) Updates `vmactions/freebsd-vm` from 1.4.5 to 1.4.8 - [Release notes](https://github.com/vmactions/freebsd-vm/releases) - [Commits](https://github.com/vmactions/freebsd-vm/compare/d1e65811565151536c0c894fff74f06351ed26e6...b84ab5559b5a1bb4b8ee2737d2506a16e1737636) Updates `actions/setup-java` from 5.2.0 to 5.3.0 - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/be666c2fcd27ec809703dec50e508c2fdc7f6654...ad2b38190b15e4d6bdf0c97fb4fca8412226d287) Updates `docker/setup-qemu-action` from 4.0.0 to 4.1.0 - [Release notes](https://github.com/docker/setup-qemu-action/releases) - [Commits](https://github.com/docker/setup-qemu-action/compare/ce360397dd3f832beb865e1373c09c0e9f86d70a...06116385d9baf250c9f4dcb4858b16962ea869c3) Updates `docker/setup-buildx-action` from 4.0.0 to 4.1.0 - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](https://github.com/docker/setup-buildx-action/compare/4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd...d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5) Updates `goreleaser/goreleaser-action` from 7.2.0 to 7.2.2 - [Release notes](https://github.com/goreleaser/goreleaser-action/releases) - [Commits](https://github.com/goreleaser/goreleaser-action/compare/4c6ab561adb47e50c45ef534e2155934e91c40c1...5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89) Updates `actions/download-artifact` from 8.0.0 to 8.0.1 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: actions/download-artifact dependency-version: 8.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: actions/setup-go dependency-version: 6.4.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: actions/setup-java dependency-version: 5.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: codecov/codecov-action dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: docker/setup-buildx-action dependency-version: 4.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: docker/setup-qemu-action dependency-version: 4.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: goreleaser/goreleaser-action dependency-version: 7.2.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: vmactions/freebsd-vm dependency-version: 1.4.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../workflows/check-license-dependencies.yml | 6 +-- .github/workflows/git-town.yml | 2 +- .github/workflows/golang-test-darwin.yml | 6 +-- .github/workflows/golang-test-freebsd.yml | 4 +- .github/workflows/golang-test-linux.yml | 52 +++++++++---------- .github/workflows/golang-test-windows.yml | 4 +- .github/workflows/golangci-lint.yml | 6 +-- .github/workflows/install-script-test.yml | 2 +- .github/workflows/mobile-build-validation.yml | 10 ++-- .github/workflows/release.yml | 32 ++++++------ .../workflows/test-infrastructure-files.yml | 6 +-- .github/workflows/wasm-build-validation.yml | 8 +-- 12 files changed, 69 insertions(+), 69 deletions(-) diff --git a/.github/workflows/check-license-dependencies.yml b/.github/workflows/check-license-dependencies.yml index 8acd645e2..50510368b 100644 --- a/.github/workflows/check-license-dependencies.yml +++ b/.github/workflows/check-license-dependencies.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -59,12 +59,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: true diff --git a/.github/workflows/git-town.yml b/.github/workflows/git-town.yml index 3f145020f..160c2ea38 100644 --- a/.github/workflows/git-town.yml +++ b/.github/workflows/git-town.yml @@ -15,7 +15,7 @@ jobs: pull-requests: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - uses: git-town/action@3d8b878379abb1ee393fb49865a28b4a6c2cd3b0 # v1.2.1 diff --git a/.github/workflows/golang-test-darwin.yml b/.github/workflows/golang-test-darwin.yml index ad84840a2..7ecec0e92 100644 --- a/.github/workflows/golang-test-darwin.yml +++ b/.github/workflows/golang-test-darwin.yml @@ -16,12 +16,12 @@ jobs: runs-on: macos-latest steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -48,7 +48,7 @@ jobs: run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined) - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} slug: netbirdio/netbird diff --git a/.github/workflows/golang-test-freebsd.yml b/.github/workflows/golang-test-freebsd.yml index 9a81d3e4c..4243613b1 100644 --- a/.github/workflows/golang-test-freebsd.yml +++ b/.github/workflows/golang-test-freebsd.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -28,7 +28,7 @@ jobs: id: test env: GO_VERSION: ${{ steps.goversion.outputs.version }} - uses: vmactions/freebsd-vm@d1e65811565151536c0c894fff74f06351ed26e6 # v1.4.5 + uses: vmactions/freebsd-vm@b84ab5559b5a1bb4b8ee2737d2506a16e1737636 # v1.4.8 with: usesh: true copyback: false diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index c17f83222..cd34d1696 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -18,7 +18,7 @@ jobs: management: ${{ steps.filter.outputs.management }} steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -30,7 +30,7 @@ jobs: - 'management/**' - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -119,12 +119,12 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -162,7 +162,7 @@ jobs: - name: Upload coverage reports to Codecov if: matrix.arch == 'amd64' - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} slug: netbirdio/netbird @@ -175,12 +175,12 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -246,12 +246,12 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -290,7 +290,7 @@ jobs: - name: Upload coverage reports to Codecov if: matrix.arch == 'amd64' - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} slug: netbirdio/netbird @@ -306,12 +306,12 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -347,7 +347,7 @@ jobs: - name: Upload coverage reports to Codecov if: matrix.arch == 'amd64' - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} slug: netbirdio/netbird @@ -363,12 +363,12 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -407,7 +407,7 @@ jobs: - name: Upload coverage reports to Codecov if: matrix.arch == 'amd64' - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} slug: netbirdio/netbird @@ -424,12 +424,12 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -484,7 +484,7 @@ jobs: - name: Upload coverage reports to Codecov if: matrix.arch == 'amd64' - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} slug: netbirdio/netbird @@ -529,12 +529,12 @@ jobs: prom/prometheus - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -623,12 +623,12 @@ jobs: prom/prometheus - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -692,12 +692,12 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -734,7 +734,7 @@ jobs: - name: Upload coverage reports to Codecov if: matrix.arch == 'amd64' - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} slug: netbirdio/netbird diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml index 8712cc879..a6064d574 100644 --- a/.github/workflows/golang-test-windows.yml +++ b/.github/workflows/golang-test-windows.yml @@ -18,12 +18,12 @@ jobs: runs-on: windows-latest steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 id: go with: go-version-file: "go.mod" diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 8f6d1ddb0..66882ac05 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: codespell @@ -40,7 +40,7 @@ jobs: timeout-minutes: 15 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Check for duplicate constants @@ -48,7 +48,7 @@ jobs: run: | ! awk '/const \(/,/)/{print $0}' management/server/activity/codes.go | grep -o '= [0-9]*' | sort | uniq -d | grep . - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false diff --git a/.github/workflows/install-script-test.yml b/.github/workflows/install-script-test.yml index aec9f6300..1514caedc 100644 --- a/.github/workflows/install-script-test.yml +++ b/.github/workflows/install-script-test.yml @@ -22,7 +22,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/mobile-build-validation.yml b/.github/workflows/mobile-build-validation.yml index 8e0538104..778462a21 100644 --- a/.github/workflows/mobile-build-validation.yml +++ b/.github/workflows/mobile-build-validation.yml @@ -16,11 +16,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" - name: Setup Android SDK @@ -28,7 +28,7 @@ jobs: with: cmdline-tools-version: 8512546 - name: Setup Java - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 with: java-version: "11" distribution: "adopt" @@ -54,11 +54,11 @@ jobs: runs-on: macos-latest steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" - name: install gomobile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bd3514d27..4e533687b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -64,7 +64,7 @@ jobs: if: steps.check_diff.outputs.diff_exists == 'true' env: GO_VERSION: ${{ steps.goversion.outputs.version }} - uses: vmactions/freebsd-vm@d1e65811565151536c0c894fff74f06351ed26e6 # v1.4.5 + uses: vmactions/freebsd-vm@b84ab5559b5a1bb4b8ee2737d2506a16e1737636 # v1.4.8 with: usesh: true copyback: false @@ -135,7 +135,7 @@ jobs: ghcr_images: ${{ steps.tag_and_push_images.outputs.images_markdown }} steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 # It is required for GoReleaser to work properly persist-credentials: false @@ -166,7 +166,7 @@ jobs: fi - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -186,9 +186,9 @@ jobs: - name: check git status run: git --no-pager diff --exit-code - name: Set up QEMU - uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a #v4.0.0 + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 #v4.1.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd #v4.0.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0 - name: Login to Docker hub if: github.event_name != 'pull_request' uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 @@ -221,7 +221,7 @@ jobs: run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso - name: Run GoReleaser id: goreleaser - uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0 + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 with: version: ${{ env.GORELEASER_VER }} args: release --clean ${{ env.flags }} @@ -347,7 +347,7 @@ jobs: release_ui_artifact_url: ${{ steps.upload_release_ui.outputs.artifact-url }} steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 # It is required for GoReleaser to work properly persist-credentials: false @@ -374,7 +374,7 @@ jobs: fi - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -420,7 +420,7 @@ jobs: run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/ui/manifest.xml -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_arm64.syso - name: Run GoReleaser - uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0 + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 with: version: ${{ env.GORELEASER_VER }} args: release --config .goreleaser_ui.yaml --clean ${{ env.flags }} @@ -464,12 +464,12 @@ jobs: - if: ${{ !startsWith(github.ref, 'refs/tags/v') }} run: echo "flags=--snapshot" >> $GITHUB_ENV - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 # It is required for GoReleaser to work properly persist-credentials: false - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -488,7 +488,7 @@ jobs: run: git --no-pager diff --exit-code - name: Run GoReleaser id: goreleaser - uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0 + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 with: version: ${{ env.GORELEASER_VER }} args: release --config .goreleaser_ui_darwin.yaml --clean ${{ env.flags }} @@ -522,7 +522,7 @@ jobs: downloadPath: '${{ github.workspace }}\temp' steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -534,13 +534,13 @@ jobs: run: echo "C:\Program Files\7-Zip" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - name: Download release artifacts - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.1 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release path: release - name: Download UI release artifacts - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.1 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release-ui path: release-ui diff --git a/.github/workflows/test-infrastructure-files.yml b/.github/workflows/test-infrastructure-files.yml index 9ad1f2f67..258091d8e 100644 --- a/.github/workflows/test-infrastructure-files.yml +++ b/.github/workflows/test-infrastructure-files.yml @@ -68,12 +68,12 @@ jobs: run: sudo apt-get install -y curl - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" @@ -256,7 +256,7 @@ jobs: run: sudo apt-get install -y jq - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/wasm-build-validation.yml b/.github/workflows/wasm-build-validation.yml index 318a127dd..a5ae59720 100644 --- a/.github/workflows/wasm-build-validation.yml +++ b/.github/workflows/wasm-build-validation.yml @@ -19,11 +19,11 @@ jobs: GOARCH: wasm steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" - name: Install dependencies @@ -44,11 +44,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" - name: Build Wasm client