diff --git a/.github/workflows/agent-network-e2e.yml b/.github/workflows/agent-network-e2e.yml index d1981d4bc..0f6147fe8 100644 --- a/.github/workflows/agent-network-e2e.yml +++ b/.github/workflows/agent-network-e2e.yml @@ -56,6 +56,9 @@ jobs: # token (and URL, for gateways) is unset, so partial coverage is fine. OPENAI_TOKEN: ${{ secrets.E2E_OPENAI_TOKEN }} ANTHROPIC_TOKEN: ${{ secrets.E2E_ANTHROPIC_TOKEN }} + # Moonshot AI platform key (platform.kimi.ai); drives both Kimi wire + # shapes (OpenAI /v1 and Anthropic /anthropic) through kimi_api. + KIMI_TOKEN: ${{ secrets.E2E_KIMI_TOKEN }} VERCEL_URL: ${{ secrets.E2E_VERCEL_URL }} VERCEL_TOKEN: ${{ secrets.E2E_VERCEL_TOKEN }} OPENROUTER_URL: ${{ secrets.E2E_OPENROUTER_URL }} diff --git a/client/iface/iface_test.go b/client/iface/iface_test.go index 8ff2bbb54..89c8cd16e 100644 --- a/client/iface/iface_test.go +++ b/client/iface/iface_test.go @@ -464,6 +464,8 @@ func Test_RemovePeer(t *testing.T) { } func Test_ConnectPeers(t *testing.T) { + t.Setenv("NB_DISABLE_EBPF_WG_PROXY", "true") + peer1ifaceName := fmt.Sprintf("utun%d", WgIntNumber+400) peer1wgIP := netip.MustParsePrefix("10.99.99.17/30") peer1Key, _ := wgtypes.GeneratePrivateKey() @@ -505,12 +507,8 @@ func Test_ConnectPeers(t *testing.T) { t.Fatal(err) } - localIP, err := getLocalIP() - if err != nil { - t.Fatal(err) - } - - peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer1wgPort)) + localIP1 := "127.0.0.1" + peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP1, peer1wgPort)) if err != nil { t.Fatal(err) } @@ -546,7 +544,8 @@ func Test_ConnectPeers(t *testing.T) { t.Fatal(err) } - peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer2wgPort)) + localIP2 := "127.0.0.1" + peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP2, peer2wgPort)) if err != nil { t.Fatal(err) } @@ -569,17 +568,17 @@ func Test_ConnectPeers(t *testing.T) { if err != nil { t.Fatal(err) } - // todo: investigate why in some tests execution we need 30s + // The peers use userspace WireGuard (stdnet transport). A tight busy-loop + // here starves the wireguard-go goroutines that process the handshake, so + // poll on a ticker instead and yield the CPU between checks. WireGuard also + // only retries a lost handshake initiation every REKEY_TIMEOUT (5s), which + // is why the overall wait can occasionally stretch to tens of seconds. timeout := 30 * time.Second timeoutChannel := time.After(timeout) + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() for { - select { - case <-timeoutChannel: - t.Fatalf("waiting for peer handshake timeout after %s", timeout.String()) - default: - } - peer, gpErr := getPeer(peer1ifaceName, peer2Key.PublicKey().String()) if gpErr != nil { t.Fatal(gpErr) @@ -588,6 +587,12 @@ func Test_ConnectPeers(t *testing.T) { t.Log("peers successfully handshake") break } + + select { + case <-timeoutChannel: + t.Fatalf("waiting for peer handshake timeout after %s", timeout.String()) + case <-ticker.C: + } } } @@ -615,28 +620,3 @@ func getPeer(ifaceName, peerPubKey string) (wgtypes.Peer, error) { } return wgtypes.Peer{}, fmt.Errorf("peer not found") } - -func getLocalIP() (string, error) { - // Get all interfaces - addrs, err := net.InterfaceAddrs() - if err != nil { - return "", err - } - - for _, addr := range addrs { - ipNet, ok := addr.(*net.IPNet) - if !ok { - continue - } - if ipNet.IP.IsLoopback() { - continue - } - - if ipNet.IP.To4() == nil { - continue - } - return ipNet.IP.String(), nil - } - - return "", fmt.Errorf("no local IP found") -} diff --git a/client/internal/conn_mgr.go b/client/internal/conn_mgr.go index 754ce37a3..7a591f60c 100644 --- a/client/internal/conn_mgr.go +++ b/client/internal/conn_mgr.go @@ -49,11 +49,21 @@ type ConnMgr struct { // engine.syncMsgMux; all other reads stay under engine.syncMsgMux only. lazyConnMgrMu sync.RWMutex + // reconcileRoutedIPs re-applies a peer's routed allowed IPs after its lazy wake endpoint is + // (re)armed (Mode A at arm time). Injected by the engine; nil disables the reconcile. + reconcileRoutedIPs func(peerKey string) error + wg sync.WaitGroup lazyCtx context.Context lazyCtxCancel context.CancelFunc } +// SetRoutedIPsReconciler injects the callback used to re-apply a peer's routed allowed IPs when +// its lazy wake endpoint is (re)armed. Must be called before the lazy manager starts. +func (e *ConnMgr) SetRoutedIPsReconciler(fn func(peerKey string) error) { + e.reconcileRoutedIPs = fn +} + func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerStore *peerstore.Store, iface lazyconn.WGIface) *ConnMgr { e := &ConnMgr{ peerStore: peerStore, @@ -291,6 +301,7 @@ func (e *ConnMgr) Close() { func (e *ConnMgr) initLazyManager(engineCtx context.Context) { cfg := manager.Config{ InactivityThreshold: inactivityThresholdEnv(), + ReconcileAllowedIPs: e.reconcileRoutedIPs, } e.lazyConnMgrMu.Lock() diff --git a/client/internal/ebpf/ebpf/bpf_bpfeb.o b/client/internal/ebpf/ebpf/bpf_bpfeb.o index 6e9cda44a..7433ad740 100644 Binary files a/client/internal/ebpf/ebpf/bpf_bpfeb.o and b/client/internal/ebpf/ebpf/bpf_bpfeb.o differ diff --git a/client/internal/ebpf/ebpf/bpf_bpfel.o b/client/internal/ebpf/ebpf/bpf_bpfel.o index 6338f4774..779f43a00 100644 Binary files a/client/internal/ebpf/ebpf/bpf_bpfel.o and b/client/internal/ebpf/ebpf/bpf_bpfel.o differ diff --git a/client/internal/ebpf/ebpf/src/dns_fwd.c b/client/internal/ebpf/ebpf/src/dns_fwd.c index 5f3fbcc32..9f8de2001 100644 --- a/client/internal/ebpf/ebpf/src/dns_fwd.c +++ b/client/internal/ebpf/ebpf/src/dns_fwd.c @@ -52,11 +52,14 @@ int xdp_dns_fwd(struct iphdr *ip, struct udphdr *udp) { if (udp->dest == GENERAL_DNS_PORT && ip->daddr == dns_ip) { udp->dest = dns_port; + // Clear the now-stale checksum; zero means "not computed" for IPv4. + udp->check = 0; return XDP_PASS; } if (udp->source == dns_port && ip->saddr == dns_ip) { udp->source = GENERAL_DNS_PORT; + udp->check = 0; return XDP_PASS; } diff --git a/client/internal/ebpf/ebpf/src/wg_proxy.c b/client/internal/ebpf/ebpf/src/wg_proxy.c index 88fea65cf..5e7474928 100644 --- a/client/internal/ebpf/ebpf/src/wg_proxy.c +++ b/client/internal/ebpf/ebpf/src/wg_proxy.c @@ -50,5 +50,11 @@ int xdp_wg_proxy(struct iphdr *ip, struct udphdr *udp) { __be16 new_dst_port = htons(proxy_port); udp->dest = new_dst_port; udp->source = new_src_port; + + // The ports are covered by the UDP checksum. This is an IPv4 loopback hop + // and the payload is already integrity-protected, so clear the checksum (a + // zero UDP checksum means "not computed" for IPv4) rather than leave a + // stale value the kernel would drop as UDP_CSUM. + udp->check = 0; return XDP_PASS; } diff --git a/client/internal/engine.go b/client/internal/engine.go index e1b03e878..617892e43 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -663,6 +663,12 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) iceCfg := e.createICEConfig() e.connMgr = NewConnMgr(e.config, e.statusRecorder, e.peerStore, wgIface) + e.connMgr.SetRoutedIPsReconciler(func(peerKey string) error { + if e.routeManager == nil { + return nil + } + return e.routeManager.ReconcilePeerAllowedIPs(peerKey) + }) e.connMgr.Start(e.ctx) // Wire DNS-time lazy-connection warm-up now that the connection manager diff --git a/client/internal/lazyconn/manager/manager.go b/client/internal/lazyconn/manager/manager.go index 3868e37e8..b7424bb2f 100644 --- a/client/internal/lazyconn/manager/manager.go +++ b/client/internal/lazyconn/manager/manager.go @@ -29,6 +29,11 @@ type managedPeer struct { type Config struct { InactivityThreshold *time.Duration + // ReconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is + // armed. The activity listener creates the wake peer with the overlay /32 only; without the + // routed prefixes WireGuard would not steer subnet-bound traffic to the wake endpoint, so an + // idle routing peer could never be woken by that traffic. Optional; nil disables the reconcile. + ReconcileAllowedIPs func(peerKey string) error } // Manager manages lazy connections @@ -56,6 +61,9 @@ type Manager struct { peerToHAGroups map[string][]route.HAUniqueID // peer ID -> HA groups they belong to haGroupToPeers map[route.HAUniqueID][]string // HA group -> peer IDs in the group routesMu sync.RWMutex + + // reconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is armed. + reconcileAllowedIPs func(peerKey string) error } // NewManager creates a new lazy connection manager @@ -73,6 +81,7 @@ func NewManager(config Config, engineCtx context.Context, peerStore *peerstore.S activityManager: activity.NewManager(wgIface), peerToHAGroups: make(map[string][]route.HAUniqueID), haGroupToPeers: make(map[route.HAUniqueID][]string), + reconcileAllowedIPs: config.ReconcileAllowedIPs, } if wgIface.IsUserspaceBind() { @@ -201,7 +210,7 @@ func (m *Manager) AddPeer(peerCfg lazyconn.PeerConfig) (bool, error) { return false, nil } - if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil { + if err := m.armActivityListener(peerCfg); err != nil { return false, err } @@ -288,7 +297,7 @@ func (m *Manager) DeactivatePeer(peerID peerid.ConnID) { m.inactivityManager.RemovePeer(mp.peerCfg.PublicKey) - if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil { + if err := m.armActivityListener(*mp.peerCfg); err != nil { mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err) return } @@ -465,6 +474,31 @@ func (m *Manager) close() { } // shouldDeferIdleForHA checks if peer should stay connected due to HA group requirements +// armRoutedAllowedIPs re-applies the peer's routed allowed IPs onto its freshly armed wake +// endpoint. The activity listener creates the wake peer with the overlay /32 only, so without +// this the routed prefixes would be missing and traffic to a routed subnet could not wake the +// idle routing peer. It is a no-op when no reconciler is configured. +// armActivityListener (re)arms the peer's wake endpoint via the activity manager and then +// re-applies its routed allowed IPs, so traffic to a routed subnet can wake an idle routing +// peer. The routed prefixes must be re-applied after the wake endpoint exists because the +// listener creates it with the overlay /32 only. +func (m *Manager) armActivityListener(peerCfg lazyconn.PeerConfig) error { + if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil { + return err + } + m.armRoutedAllowedIPs(&peerCfg) + return nil +} + +func (m *Manager) armRoutedAllowedIPs(peerCfg *lazyconn.PeerConfig) { + if m.reconcileAllowedIPs == nil { + return + } + if err := m.reconcileAllowedIPs(peerCfg.PublicKey); err != nil { + peerCfg.Log.Errorf("failed to reconcile routed allowed IPs on wake endpoint: %v", err) + } +} + func (m *Manager) shouldDeferIdleForHA(inactivePeers map[string]struct{}, peerID string) bool { m.routesMu.RLock() defer m.routesMu.RUnlock() @@ -577,7 +611,7 @@ func (m *Manager) onPeerInactivityTimedOut(peerIDs map[string]struct{}) { mp.peerCfg.Log.Infof("start activity monitor") - if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil { + if err := m.armActivityListener(*mp.peerCfg); err != nil { mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err) continue } diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 66b24cc5a..ef69b81a4 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -61,6 +61,7 @@ type Manager interface { InitialRouteRange() []string SetFirewall(firewall.Manager) error SetDNSForwarderPort(port uint16) + ReconcilePeerAllowedIPs(peerKey string) error Stop(stateManager *statemanager.Manager) } @@ -232,6 +233,30 @@ func (m *DefaultManager) setupRefCounters(useNoop bool) { ) } +// ReconcilePeerAllowedIPs re-applies every routed allowed IP currently tracked for the peer +// onto the WireGuard device. The allowed-IP refcounter only calls its AddFunc (which pushes to +// the device) on a prefix's 0->1 transition, so a peer whose device entry was rebuilt without a +// matching refcounter change — e.g. a lazy connection cycling through idle->wake, which recreates +// the WireGuard peer with the overlay /32 only — ends up missing routed prefixes the refcounter +// still considers installed, and nothing retries. Calling this when the peer's WireGuard entry is +// (re)created restores convergence. It is add-only and idempotent: AddAllowedIP is update-only, so +// prefixes are re-added to an existing peer and an absent peer is left untouched. +func (m *DefaultManager) ReconcilePeerAllowedIPs(peerKey string) error { + if m.allowedIPsRefCounter == nil { + return nil + } + + return m.allowedIPsRefCounter.ReapplyMatching( + func(out string) bool { return out == peerKey }, + func(prefix netip.Prefix) error { + if err := m.wgInterface.AddAllowedIP(peerKey, prefix); err != nil { + return fmt.Errorf("add allowed IP %s for peer %s: %w", prefix, peerKey, err) + } + return nil + }, + ) +} + // Init sets up the routing func (m *DefaultManager) Init() error { m.routeSelector = m.initSelector() diff --git a/client/internal/routemanager/mock.go b/client/internal/routemanager/mock.go index 937314995..c1620b24c 100644 --- a/client/internal/routemanager/mock.go +++ b/client/internal/routemanager/mock.go @@ -112,6 +112,11 @@ func (m *MockManager) SetFirewall(firewall.Manager) error { func (m *MockManager) SetDNSForwarderPort(port uint16) { } +// ReconcilePeerAllowedIPs mock implementation of ReconcilePeerAllowedIPs from Manager interface +func (m *MockManager) ReconcilePeerAllowedIPs(peerKey string) error { + return nil +} + // Stop mock implementation of Stop from Manager interface func (m *MockManager) Stop(stateManager *statemanager.Manager) { if m.StopFunc != nil { diff --git a/client/internal/routemanager/reconcile_test.go b/client/internal/routemanager/reconcile_test.go new file mode 100644 index 000000000..2a8a4dc10 --- /dev/null +++ b/client/internal/routemanager/reconcile_test.go @@ -0,0 +1,90 @@ +//go:build !windows + +package routemanager + +import ( + "net" + "net/netip" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/tun/netstack" + + "github.com/netbirdio/netbird/client/iface/device" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/routemanager/refcounter" +) + +// reconcileWGMock is a minimal iface.WGIface that only records AddAllowedIP calls; every other +// method is an inert stub because ReconcilePeerAllowedIPs exercises none of them. +type reconcileWGMock struct { + mu sync.Mutex + adds map[string][]netip.Prefix +} + +func (m *reconcileWGMock) AddAllowedIP(peerKey string, allowedIP netip.Prefix) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.adds == nil { + m.adds = map[string][]netip.Prefix{} + } + m.adds[peerKey] = append(m.adds[peerKey], allowedIP) + return nil +} + +func (m *reconcileWGMock) added(peerKey string) []netip.Prefix { + m.mu.Lock() + defer m.mu.Unlock() + return m.adds[peerKey] +} + +func (m *reconcileWGMock) RemoveAllowedIP(string, netip.Prefix) error { return nil } +func (m *reconcileWGMock) Name() string { return "utun-test" } +func (m *reconcileWGMock) Address() wgaddr.Address { return wgaddr.Address{} } +func (m *reconcileWGMock) ToInterface() *net.Interface { return nil } +func (m *reconcileWGMock) IsUserspaceBind() bool { return false } +func (m *reconcileWGMock) GetFilter() device.PacketFilter { return nil } +func (m *reconcileWGMock) GetDevice() *device.FilteredDevice { return nil } +func (m *reconcileWGMock) GetNet() *netstack.Net { return nil } + +// TestReconcilePeerAllowedIPs verifies the declarative reconcile re-applies every routed prefix +// tracked for the peer (self-heal, independent of refcount level) and stays scoped to that peer. +func TestReconcilePeerAllowedIPs(t *testing.T) { + wg := &reconcileWGMock{} + m := &DefaultManager{wgInterface: wg} + m.allowedIPsRefCounter = refcounter.New[netip.Prefix, string, string]( + func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil }, + func(netip.Prefix, string) error { return nil }, + ) + + peerA1 := netip.MustParsePrefix("10.0.0.0/24") + peerA2 := netip.MustParsePrefix("10.1.0.0/24") + peerB1 := netip.MustParsePrefix("10.2.0.0/24") + + for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} { + _, err := m.allowedIPsRefCounter.Increment(prefix, peer) + require.NoError(t, err) + } + // Extra reference: reconcile must still re-apply the prefix even though its refcount never + // hit 0 again (the exact case the plain incremental path skips). + _, err := m.allowedIPsRefCounter.Increment(peerA1, "peerA") + require.NoError(t, err) + + require.NoError(t, m.ReconcilePeerAllowedIPs("peerA")) + + assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, wg.added("peerA"), + "reconcile must re-apply all routed prefixes of the peer") + assert.Empty(t, wg.added("peerB"), "reconcile must not touch another peer's prefixes") +} + +// TestReconcilePeerAllowedIPsNoCounter verifies reconcile is a safe no-op before the refcounter is +// set up. +func TestReconcilePeerAllowedIPsNoCounter(t *testing.T) { + wg := &reconcileWGMock{} + m := &DefaultManager{wgInterface: wg} + + require.NoError(t, m.ReconcilePeerAllowedIPs("peerA")) + assert.Empty(t, wg.added("peerA")) +} diff --git a/client/internal/routemanager/refcounter/refcounter.go b/client/internal/routemanager/refcounter/refcounter.go index 27a724f50..917120275 100644 --- a/client/internal/routemanager/refcounter/refcounter.go +++ b/client/internal/routemanager/refcounter/refcounter.go @@ -94,6 +94,26 @@ func (rm *Counter[Key, I, O]) Get(key Key) (Ref[O], bool) { return ref, ok } +// ReapplyMatching calls apply for every key whose stored Out satisfies pred, holding the +// counter lock for the whole pass. Running apply under the lock keeps it atomic with respect +// to Increment/Decrement: a prefix dropped to zero is removed from the map (and had its +// RemoveFunc called) before this pass observes it, so a stale key can never be re-applied. +// pred and apply are invoked under the lock, so they must not call back into the counter. +func (rm *Counter[Key, I, O]) ReapplyMatching(pred func(out O) bool, apply func(key Key) error) error { + rm.mu.Lock() + defer rm.mu.Unlock() + + var merr *multierror.Error + for key, ref := range rm.refCountMap { + if pred(ref.Out) { + if err := apply(key); err != nil { + merr = multierror.Append(merr, err) + } + } + } + return nberrors.FormatErrorOrNil(merr) +} + // Increment increments the reference count for the given key. // If this is the first reference to the key, the AddFunc is called. func (rm *Counter[Key, I, O]) Increment(key Key, in I) (Ref[O], error) { diff --git a/client/internal/routemanager/refcounter/refcounter_test.go b/client/internal/routemanager/refcounter/refcounter_test.go new file mode 100644 index 000000000..79a99c388 --- /dev/null +++ b/client/internal/routemanager/refcounter/refcounter_test.go @@ -0,0 +1,47 @@ +package refcounter + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestReapplyMatching verifies ReapplyMatching invokes apply for exactly the keys whose stored +// Out satisfies the predicate (no duplicates for multiply-referenced keys) — the primitive +// ReconcilePeerAllowedIPs relies on to re-apply a single peer's routed prefixes. +func TestReapplyMatching(t *testing.T) { + rc := New[netip.Prefix, string, string]( + func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil }, + func(netip.Prefix, string) error { return nil }, + ) + + peerA1 := netip.MustParsePrefix("10.0.0.0/24") + peerA2 := netip.MustParsePrefix("10.1.0.0/24") + peerB1 := netip.MustParsePrefix("10.2.0.0/24") + + for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} { + _, err := rc.Increment(prefix, peer) + require.NoError(t, err) + } + // a second reference must not make the key applied twice + _, err := rc.Increment(peerA1, "peerA") + require.NoError(t, err) + + var applied []netip.Prefix + err = rc.ReapplyMatching( + func(out string) bool { return out == "peerA" }, + func(key netip.Prefix) error { applied = append(applied, key); return nil }, + ) + require.NoError(t, err) + assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, applied) + + var none []netip.Prefix + err = rc.ReapplyMatching( + func(out string) bool { return out == "missing" }, + func(key netip.Prefix) error { none = append(none, key); return nil }, + ) + require.NoError(t, err) + assert.Empty(t, none) +} diff --git a/e2e/agentnetwork/chat_test.go b/e2e/agentnetwork/chat_test.go index a928d1265..90c3766ec 100644 --- a/e2e/agentnetwork/chat_test.go +++ b/e2e/agentnetwork/chat_test.go @@ -20,14 +20,15 @@ import ( // covers whatever credentials are present (source ~/.llm-keys locally / set the // Actions secrets in CI). type providerCase struct { - name string - catalogID string - upstream string - apiKey string - model string // body model (chat/messages) or path model@version (vertex) - kind string // harness.WireChat, harness.WireMessages, or harness.WireVertex - project string // vertex only: GCP project for the rawPredict path - region string // vertex only: GCP region for the rawPredict path + name string + catalogID string + upstream string + apiKey string + model string // body model (chat/messages) or path model@version (vertex) + kind string // harness.WireChat, harness.WireMessages, or harness.WireVertex + project string // vertex only: GCP project for the rawPredict path + region string // vertex only: GCP region for the rawPredict path + pathPrefix string // base-URL path prefix the agent carries (e.g. "/anthropic" for Kimi) } // availableProviders builds the matrix from the provider env vars that are set. @@ -39,6 +40,24 @@ func availableProviders() []providerCase { if k := os.Getenv("ANTHROPIC_TOKEN"); k != "" { ps = append(ps, providerCase{name: "anthropic", catalogID: "anthropic_api", upstream: "https://api.anthropic.com", apiKey: k, model: "claude-haiku-4-5", kind: harness.WireMessages}) } + if k := os.Getenv("KIMI_TOKEN"); k != "" { + // Kimi (Moonshot AI) serves two body shapes from the same key: OpenAI + // Chat Completions on the bare host (/v1/...) and the Anthropic + // Messages API under the /anthropic path prefix (the endpoint + // Moonshot's Claude Code guide uses). The provider keeps the bare + // default upstream and the AGENT carries the /anthropic prefix in + // its base URL — exactly the documented Claude Code / Kimi CLI + // setup (ANTHROPIC_BASE_URL=https:///anthropic) — so one + // provider serves both shapes and the prefix rides through to + // Moonshot. Run the Anthropic shape, the flagship Claude Code path; + // the OpenAI wire shape is covered live by the other chat-shaped + // matrix providers, and Kimi-over-chat passed with kimi-k3 before + // the single-model constraint surfaced (run #73 on the kimi feature + // branch). The platform serves this account exactly ONE model — + // kimi-k3 (kimi-k2-thinking and even kimi-latest return + // resource_not_found_error on both surfaces). + ps = append(ps, providerCase{name: "kimi", catalogID: "kimi_api", upstream: "https://api.moonshot.ai", apiKey: k, model: "kimi-k3", kind: harness.WireMessages, pathPrefix: "/anthropic"}) + } if k, u := os.Getenv("VERCEL_TOKEN"), os.Getenv("VERCEL_URL"); k != "" && u != "" { ps = append(ps, providerCase{name: "vercel", catalogID: "vercel_ai_gateway", upstream: u, apiKey: k, model: "openai/gpt-4o-mini", kind: harness.WireChat}) } @@ -84,12 +103,18 @@ func availableProviders() []providerCase { } } - // Bedrock: path-routed, bearer auth. Model is a cross-region inference - // profile id (distinct string from the first-party Anthropic case). + // Bedrock: path-routed, bearer auth. Model is the FULL cross-region + // inference-profile id exactly as AWS issues it — region-family prefix + // plus the date/version suffix. A bare or wrong-region id makes Bedrock + // reject the request with "The provided model identifier is invalid" + // before any inference runs. The proxy normalizes this id to the catalog + // key (anthropic.claude-haiku-4-5) for routing/pricing/allowlists. + // Defaults pair eu-central-1 with the eu.* profile; AWS_REGION overrides + // the region and the prefix follows its family. if k := os.Getenv("AWS_BEARER_TOKEN_BEDROCK"); k != "" { region := os.Getenv("AWS_REGION") if region == "" { - region = "us-east-1" + region = "eu-central-1" } // A valid Bedrock inference-profile id (region prefix + date + version), // overridable per account. `global.` profiles can be invoked from any @@ -246,7 +271,7 @@ func TestProvidersMatrix(t *testing.T) { case harness.WireBedrock: c, b, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, pc.model, "Reply with exactly: pong", sessionID) default: - c, b, cerr = cl.Chat(ctx, settings.Endpoint, proxyIP, pc.kind, pc.model, "Reply with exactly: pong", sessionID) + c, b, cerr = cl.ChatPrefixed(ctx, settings.Endpoint, proxyIP, pc.pathPrefix, pc.kind, pc.model, "Reply with exactly: pong", sessionID) } if cerr == nil { code, body = c, b diff --git a/e2e/agentnetwork/guardrail_test.go b/e2e/agentnetwork/guardrail_test.go index 1e4b222f0..6a2487a88 100644 --- a/e2e/agentnetwork/guardrail_test.go +++ b/e2e/agentnetwork/guardrail_test.go @@ -52,7 +52,9 @@ func catalogModel(pc providerCase) string { func disallowedModel(pc providerCase) string { switch pc.kind { case harness.WireBedrock: - return "us.anthropic.claude-opus-4-8" + // Same profile prefix as the allowed model so only the model name + // differs; the guardrail must deny it before it reaches AWS. + return strings.SplitN(pc.model, ".", 2)[0] + ".anthropic.claude-opus-4-8" case harness.WireVertex: return "claude-opus-4-8@20250101" default: @@ -72,7 +74,7 @@ func sendModel(ctx context.Context, t *testing.T, cl *harness.Client, endpoint, case harness.WireVertex: code, _, err = cl.Vertex(ctx, endpoint, proxyIP, pc.project, pc.region, model, "Reply with exactly: pong", "") default: - code, _, err = cl.Chat(ctx, endpoint, proxyIP, pc.kind, model, "Reply with exactly: pong", "") + code, _, err = cl.ChatPrefixed(ctx, endpoint, proxyIP, pc.pathPrefix, pc.kind, model, "Reply with exactly: pong", "") } require.NoError(t, err, "request must reach the proxy for %s", pc.name) return code diff --git a/e2e/harness/client.go b/e2e/harness/client.go index 4c9983e4a..2ffcf653d 100644 --- a/e2e/harness/client.go +++ b/e2e/harness/client.go @@ -239,6 +239,17 @@ const ( // the wire shape: WireChat (OpenAI) or WireMessages (Anthropic). A non-empty // sessionID is sent as the universal x-session-id header the proxy records. func (cl *Client) Chat(ctx context.Context, endpoint, proxyIP, kind, model, prompt, sessionID string) (int, string, error) { + return cl.ChatPrefixed(ctx, endpoint, proxyIP, "", kind, model, prompt, sessionID) +} + +// ChatPrefixed is Chat with a base-URL path prefix prepended to the wire +// path, mirroring agents whose base URL carries a shape-selecting prefix that +// rides through to the upstream — e.g. Claude Code against a Kimi provider +// sets ANTHROPIC_BASE_URL=https:///anthropic so the proxy forwards +// /anthropic/v1/messages to Moonshot's Anthropic surface while the provider's +// upstream URL stays the bare https://api.moonshot.ai. Empty prefix is plain +// Chat. +func (cl *Client) ChatPrefixed(ctx context.Context, endpoint, proxyIP, pathPrefix, kind, model, prompt, sessionID string) (int, string, error) { var path, body string var headers []string switch kind { @@ -250,7 +261,7 @@ func (cl *Client) Chat(ctx context.Context, endpoint, proxyIP, kind, model, prom path = "/v1/chat/completions" body = fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":%q}]}`, model, prompt) } - return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(headers, sessionID)) + return cl.post(ctx, endpoint, proxyIP, pathPrefix+path, body, withSessionID(headers, sessionID)) } // Vertex issues an Anthropic-on-Vertex rawPredict POST over the tunnel. Unlike diff --git a/infrastructure_files/getting-started-enterprise.sh b/infrastructure_files/getting-started-enterprise.sh index 135440180..31f3f1c13 100755 --- a/infrastructure_files/getting-started-enterprise.sh +++ b/infrastructure_files/getting-started-enterprise.sh @@ -234,9 +234,6 @@ init_environment() { NETBIRD_LICENSE_KEY=$(read_secret "Enter license key (input hidden)") - GHCR_USERNAME="netbirdExtAccess1" - GHCR_TOKEN=$(read_secret "Enter GHCR token (input hidden)") - POSTGRES_USER="netbird" POSTGRES_DB="netbird" POSTGRES_PASSWORD=$(rand_secret) @@ -263,10 +260,6 @@ init_environment() { install -m 600 /dev/null config.yaml render_config_yaml >> config.yaml - echo "Logging in to ghcr.io ..." - printf '%s' "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin - unset GHCR_TOKEN - echo "" echo "Pulling images ..." $DOCKER_COMPOSE_COMMAND pull diff --git a/infrastructure_files/migrate-to-enterprise.sh b/infrastructure_files/migrate-to-enterprise.sh index 8e8a41114..d4c59699b 100755 --- a/infrastructure_files/migrate-to-enterprise.sh +++ b/infrastructure_files/migrate-to-enterprise.sh @@ -490,8 +490,6 @@ init_migration() { echo "" echo "Step 1: Image swap (community → Enterprise). License key required." NB_LICENSE_KEY=$(read_secret " License key") - GHCR_USERNAME="netbirdExtAccess1" - GHCR_TOKEN=$(read_secret " GHCR token (input hidden)") # Step 2 — optional echo "" @@ -588,11 +586,6 @@ apply_changes() { fi } >> "$ENV_FILE" - echo "" - echo "Logging in to ghcr.io ..." - printf '%s' "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin - unset GHCR_TOKEN - echo "" echo "Pulling enterprise images ..." $DOCKER_COMPOSE_COMMAND pull diff --git a/management/internals/modules/agentnetwork/catalog/catalog.go b/management/internals/modules/agentnetwork/catalog/catalog.go index f82cffae6..f82e94bf3 100644 --- a/management/internals/modules/agentnetwork/catalog/catalog.go +++ b/management/internals/modules/agentnetwork/catalog/catalog.go @@ -420,6 +420,47 @@ var providers = []Provider{ {ID: "mistral-embed", Label: "Mistral Embed", InputPer1k: 0.0001, OutputPer1k: 0, ContextWindow: 8192}, }, }, + { + ID: "kimi_api", + Kind: KindProvider, + Name: "Kimi (Moonshot AI) API", + Description: "Kimi K3 / K2 models via the Moonshot AI platform", + DefaultHost: "api.moonshot.ai", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#1A1A2E", + // ParserID empty on purpose: Moonshot serves two body shapes on + // the same host and key, and the proxy's URL sniffer dispatches + // both (same pattern as Bifrost). /v1/chat/completions matches + // OpenAIParser; the Anthropic-compatible endpoint the official + // Claude Code guide uses (/anthropic/v1/messages) contains + // "/v1/messages" and matches AnthropicParser. Pinning "openai" + // here would misparse the Claude Code path — the primary way + // teams consume Kimi for coding today. Both endpoints accept the + // same Moonshot key via Authorization: Bearer (Claude Code's + // ANTHROPIC_AUTH_TOKEN rides that header too). + // + // api.moonshot.ai is the international platform; mainland-China + // accounts live on api.moonshot.cn with separate billing — + // operators there override the host on the provider record. The + // kimi.com subscription coding endpoint (api.kimi.com/coding, + // model id "k3") is account-bound seat licensing rather than a + // meterable platform key, so it's deliberately not the default. + ParserID: "", + // Pricing per Moonshot's platform rates at K3 launch (July 2026): + // $3/$15 per MTok with $0.30 cached input, flat across the 1M-token + // window. kimi-k3 is the ONLY model the platform serves newer + // accounts — K2-era ids (kimi-k2-thinking) and even the kimi-latest + // alias return resource_not_found_error, verified live 2026-07-21 — + // so it's the only catalog entry. Grandfathered accounts with K2 + // access can still type those ids on the provider's model rows. + // The consumer app's "K3 Swarm Max" mode is not an API SKU, so it + // doesn't appear here. + Models: []Model{ + {ID: "kimi-k3", Label: "Kimi K3", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000}, + }, + }, { ID: "litellm_proxy", Kind: KindGateway, diff --git a/management/internals/shared/grpc/components_encoder.go b/management/internals/shared/grpc/components_encoder.go index d7b787464..7e43cf478 100644 --- a/management/internals/shared/grpc/components_encoder.go +++ b/management/internals/shared/grpc/components_encoder.go @@ -5,9 +5,6 @@ import ( "strconv" nbdns "github.com/netbirdio/netbird/dns" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" - routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/types" nbroute "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/networkmap" @@ -166,7 +163,7 @@ func (e *componentEncoder) indexAllPeers() { } } -func (e *componentEncoder) appendPeer(p *nbpeer.Peer) uint32 { +func (e *componentEncoder) appendPeer(p *types.ComponentPeer) uint32 { if idx, ok := e.peerOrder[p.ID]; ok { return idx } @@ -180,7 +177,7 @@ func (e *componentEncoder) appendPeer(p *nbpeer.Peer) uint32 { // (c.RouterPeers may contain peers not in c.Peers when validation rules drop // them) and returns their wire indexes for the RouterPeerIndexes field. Must // run before any encoder that resolves peer ids via e.peerOrder. -func (e *componentEncoder) indexRouterPeers(routers map[string]*nbpeer.Peer) []uint32 { +func (e *componentEncoder) indexRouterPeers(routers map[string]*types.ComponentPeer) []uint32 { if len(routers) == 0 { return nil } @@ -514,7 +511,7 @@ func encodeCustomZones(zones []nbdns.CustomZone) []*proto.CustomZone { return out } -func (e *componentEncoder) encodeNetworkResources(resources []*resourceTypes.NetworkResource) []*proto.NetworkResourceRaw { +func (e *componentEncoder) encodeNetworkResources(resources []*types.ComponentResource) []*proto.NetworkResourceRaw { if len(resources) == 0 { return nil } @@ -543,7 +540,7 @@ func (e *componentEncoder) encodeNetworkResources(resources []*resourceTypes.Net return out } -func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*routerTypes.NetworkRouter) map[string]*proto.NetworkRouterList { +func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*types.ComponentRouter) map[string]*proto.NetworkRouterList { if len(routersMap) == 0 { return nil } @@ -692,20 +689,20 @@ func toAccountNetwork(n *types.Network) *proto.AccountNetwork { return out } -func toPeerCompact(p *nbpeer.Peer) *proto.PeerCompact { +func toPeerCompact(p *types.ComponentPeer) *proto.PeerCompact { pc := &proto.PeerCompact{ WgPubKey: decodeWgKey(p.Key), SshPubKey: []byte(p.SSHKey), DnsLabel: p.DNSLabel, - AgentVersion: p.Meta.WtVersion, - AddedWithSsoLogin: p.UserID != "", + AgentVersion: p.AgentVersion, + AddedWithSsoLogin: p.AddedWithSSOLogin, LoginExpirationEnabled: p.LoginExpirationEnabled, SshEnabled: p.SSHEnabled, - SupportsIpv6: p.SupportsIPv6(), - SupportsSourcePrefixes: p.SupportsSourcePrefixes(), - ServerSshAllowed: p.Meta.Flags.ServerSSHAllowed, + SupportsIpv6: p.SupportsIPv6, + SupportsSourcePrefixes: p.SupportsSourcePrefixes, + ServerSshAllowed: p.ServerSSHAllowed, } - if p.LastLogin != nil { + if !p.LastLogin.IsZero() { pc.LastLoginUnixNano = p.LastLogin.UnixNano() } switch { diff --git a/management/internals/shared/grpc/components_encoder_test.go b/management/internals/shared/grpc/components_encoder_test.go index d82bba362..100ab0948 100644 --- a/management/internals/shared/grpc/components_encoder_test.go +++ b/management/internals/shared/grpc/components_encoder_test.go @@ -15,9 +15,6 @@ import ( goproto "google.golang.org/protobuf/proto" nbdns "github.com/netbirdio/netbird/dns" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" - routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/types" nbroute "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/proto" @@ -155,29 +152,28 @@ func envelopesEquivalent(a, b *proto.NetworkMapEnvelope) bool { } func newTestComponents() *types.NetworkMapComponents { - peerA := &nbpeer.Peer{ - ID: "peer-a", - Key: testWgKeyA, - IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), - DNSLabel: "peera", - SSHKey: "ssh-a", - Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + peerA := &types.ComponentPeer{ + ID: "peer-a", + Key: testWgKeyA, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), + DNSLabel: "peera", + SSHKey: "ssh-a", + AgentVersion: "0.40.0", } - peerB := &nbpeer.Peer{ - ID: "peer-b", - Key: testWgKeyB, - IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), - IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}), - DNSLabel: "peerb", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.25.0"}, + peerB := &types.ComponentPeer{ + ID: "peer-b", + Key: testWgKeyB, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), + IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}), + DNSLabel: "peerb", + AgentVersion: "0.25.0", } - peerC := &nbpeer.Peer{ - ID: "peer-c", - Key: testWgKeyC, - IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}), - DNSLabel: "peerc", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + peerC := &types.ComponentPeer{ + ID: "peer-c", + Key: testWgKeyC, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}), + DNSLabel: "peerc", + AgentVersion: "0.40.0", } return &types.NetworkMapComponents{ @@ -191,12 +187,12 @@ func newTestComponents() *types.NetworkMapComponents { PeerLoginExpirationEnabled: true, PeerLoginExpiration: 2 * time.Hour, }, - Peers: map[string]*nbpeer.Peer{ + Peers: map[string]*types.ComponentPeer{ "peer-a": peerA, "peer-b": peerB, "peer-c": peerC, }, - Groups: map[string]*types.Group{ + Groups: map[string]*types.ComponentGroup{ "group-src": {ID: "group-src", PublicID: "1", Name: "Src", Peers: []string{"peer-a"}}, "group-dst": {ID: "group-dst", PublicID: "2", Name: "Dst", Peers: []string{"peer-b", "peer-c"}}, }, @@ -215,7 +211,7 @@ func newTestComponents() *types.NetworkMapComponents { }}, }, }, - RouterPeers: map[string]*nbpeer.Peer{"peer-c": peerC}, + RouterPeers: map[string]*types.ComponentPeer{"peer-c": peerC}, } } @@ -381,12 +377,12 @@ func TestEncodeNetworkMapEnvelope_MalformedWgKey(t *testing.T) { func TestEncodeNetworkMapEnvelope_IPv6OnlyPeer(t *testing.T) { c := newTestComponents() - v6Only := &nbpeer.Peer{ - ID: "peer-v6", - Key: testWgKeyA, - IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9}), - DNSLabel: "peerv6", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + v6Only := &types.ComponentPeer{ + ID: "peer-v6", + Key: testWgKeyA, + IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9}), + DNSLabel: "peerv6", + AgentVersion: "0.40.0", } c.Peers["peer-v6"] = v6Only @@ -405,11 +401,11 @@ func TestEncodeNetworkMapEnvelope_IPv6OnlyPeer(t *testing.T) { func TestEncodeNetworkMapEnvelope_PeerWithoutIP(t *testing.T) { c := newTestComponents() - c.Peers["peer-noip"] = &nbpeer.Peer{ - ID: "peer-noip", - Key: testWgKeyA, - DNSLabel: "peernoip", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + c.Peers["peer-noip"] = &types.ComponentPeer{ + ID: "peer-noip", + Key: testWgKeyA, + DNSLabel: "peernoip", + AgentVersion: "0.40.0", } full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() @@ -444,9 +440,9 @@ func TestEncodeNetworkMapEnvelope_EmptyInput(t *testing.T) { func TestEncodeNetworkMapEnvelope_PeerLoginExpirationFields(t *testing.T) { c := newTestComponents() now := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) - c.Peers["peer-a"].UserID = "user-1" + c.Peers["peer-a"].AddedWithSSOLogin = true c.Peers["peer-a"].LoginExpirationEnabled = true - c.Peers["peer-a"].LastLogin = &now + c.Peers["peer-a"].LastLogin = now full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() @@ -557,7 +553,7 @@ func TestEncodeNetworkMapEnvelope_ResourceOnlyPolicyShippedAndIndexed(t *testing } // Resource must appear in components.NetworkResources with a seq id — // encoder uses that to translate the xid map key to uint32. - c.NetworkResources = []*resourceTypes.NetworkResource{ + c.NetworkResources = []*types.ComponentResource{ {ID: "resource-x", PublicID: "77", Name: "res-x", Enabled: true}, } @@ -625,11 +621,11 @@ func TestEncodeNetworkMapEnvelope_PostureFailedPeers(t *testing.T) { func TestEncodeNetworkMapEnvelope_RoutersMap(t *testing.T) { c := newTestComponents() c.NetworkXIDToPublicID = map[string]string{"net-1": "5"} - c.RoutersMap = map[string]map[string]*routerTypes.NetworkRouter{ + c.RoutersMap = map[string]map[string]*types.ComponentRouter{ "net-1": { "peer-c": { - ID: "router-1", PublicID: "200", - Peer: "peer-c", Masquerade: true, Metric: 10, Enabled: true, + PublicID: "200", + Peer: "peer-c", Masquerade: true, Metric: 10, Enabled: true, }, }, } @@ -655,14 +651,14 @@ func TestEncodeNetworkMapEnvelope_RouterPeerNotInComponentsPeers(t *testing.T) { // peer_index reference must still resolve. c := newTestComponents() delete(c.Peers, "peer-c") - routerPeer := &nbpeer.Peer{ + routerPeer := &types.ComponentPeer{ ID: "peer-c", Key: testWgKeyC, IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}), - DNSLabel: "peerc", Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + DNSLabel: "peerc", AgentVersion: "0.40.0", } - c.RouterPeers = map[string]*nbpeer.Peer{"peer-c": routerPeer} + c.RouterPeers = map[string]*types.ComponentPeer{"peer-c": routerPeer} c.NetworkXIDToPublicID = map[string]string{"net-1": "5"} - c.RoutersMap = map[string]map[string]*routerTypes.NetworkRouter{ - "net-1": {"peer-c": {ID: "r-1", PublicID: "1", Peer: "peer-c", Enabled: true}}, + c.RoutersMap = map[string]map[string]*types.ComponentRouter{ + "net-1": {"peer-c": {PublicID: "1", Peer: "peer-c", Enabled: true}}, } full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() @@ -695,9 +691,9 @@ func TestToProxyPatch_EmptyInputReturnsNil(t *testing.T) { func TestToProxyPatch_PopulatesAllFields(t *testing.T) { nm := &types.NetworkMap{ - Peers: []*nbpeer.Peer{{ + Peers: []*types.ComponentPeer{{ ID: "ext-peer", Key: testWgKeyA, IP: netip.AddrFrom4([4]byte{100, 64, 0, 9}), - DNSLabel: "extpeer", Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + DNSLabel: "extpeer", AgentVersion: "0.40.0", }}, FirewallRules: []*types.FirewallRule{{ PeerIP: "100.64.0.9", Action: "accept", Direction: 0, Protocol: "tcp", @@ -780,6 +776,6 @@ func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) { func emptyNetworkMapComponents() *types.NetworkMapComponents { return types.EmptyNetworkMapComponents( &types.NetworkMapComponents{ - PeerID: "peer-id", Peers: map[string]*nbpeer.Peer{"peer-id": {}}}, + PeerID: "peer-id", Peers: map[string]*types.ComponentPeer{"peer-id": {}}}, ) } diff --git a/management/internals/shared/grpc/components_envelope_response_test.go b/management/internals/shared/grpc/components_envelope_response_test.go index bf35bb7b9..20f4e6824 100644 --- a/management/internals/shared/grpc/components_envelope_response_test.go +++ b/management/internals/shared/grpc/components_envelope_response_test.go @@ -19,10 +19,10 @@ func TestComputeSSHEnabledForPeer(t *testing.T) { mkComponents := func(rule *types.PolicyRule, sshEnabled bool) (*types.NetworkMapComponents, *nbpeer.Peer) { peer := &nbpeer.Peer{ID: targetPeerID, SSHEnabled: sshEnabled} - group := &types.Group{ID: targetGroupID, Name: "dst", Peers: []string{targetPeerID}} + group := &types.ComponentGroup{ID: targetGroupID, Name: "dst", Peers: []string{targetPeerID}} return &types.NetworkMapComponents{ - Peers: map[string]*nbpeer.Peer{targetPeerID: peer}, - Groups: map[string]*types.Group{targetGroupID: group}, + Peers: map[string]*types.ComponentPeer{targetPeerID: peer.ToComponent()}, + Groups: map[string]*types.ComponentGroup{targetGroupID: group}, Policies: []*types.Policy{{ ID: "p", Enabled: true, @@ -158,8 +158,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) { func TestComputeSSHEnabledForPeer_TargetMissingFromComponents(t *testing.T) { peer := &nbpeer.Peer{ID: "missing", SSHEnabled: true} c := &types.NetworkMapComponents{ - Peers: map[string]*nbpeer.Peer{}, // target peer NOT present - Groups: map[string]*types.Group{ + Peers: map[string]*types.ComponentPeer{}, // target peer NOT present + Groups: map[string]*types.ComponentGroup{ "g": {ID: "g", Peers: []string{"missing"}}, }, Policies: []*types.Policy{{ diff --git a/management/server/groups/manager.go b/management/server/groups/manager.go index c9a877d6f..6d19b1c35 100644 --- a/management/server/groups/manager.go +++ b/management/server/groups/manager.go @@ -6,6 +6,7 @@ import ( "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" @@ -30,6 +31,10 @@ type managerImpl struct { accountManager account.Manager } +func eventMetaResource(group *types.Group, resource *resourceTypes.NetworkResource) map[string]any { + return map[string]any{"name": group.Name, "id": group.ID, "resource_name": resource.Name, "resource_id": resource.ID, "resource_type": resource.Type} +} + type mockManager struct { } @@ -109,7 +114,7 @@ func (m *managerImpl) AddResourceToGroupInTransaction(ctx context.Context, trans } event := func() { - m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceAddedToGroup, group.EventMetaResource(networkResource)) + m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceAddedToGroup, eventMetaResource(group, networkResource)) } return event, nil @@ -133,7 +138,7 @@ func (m *managerImpl) RemoveResourceFromGroupInTransaction(ctx context.Context, } event := func() { - m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceRemovedFromGroup, group.EventMetaResource(networkResource)) + m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceRemovedFromGroup, eventMetaResource(group, networkResource)) } return event, nil diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go index 310f90653..03a37c3ec 100644 --- a/management/server/http/handlers/peers/peers_handler.go +++ b/management/server/http/handlers/peers/peers_handler.go @@ -446,7 +446,7 @@ func (h *Handler) GetAccessiblePeers(w http.ResponseWriter, r *http.Request) { netMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, dns.CustomZone{}, nil, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers()) - util.WriteJSONObject(ctx, w, toAccessiblePeers(netMap, dnsDomain)) + util.WriteJSONObject(ctx, w, toAccessiblePeers(netMap, account.Peers, dnsDomain)) } func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) { @@ -534,15 +534,20 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) util.WriteJSONObject(r.Context(), w, resp) } -func toAccessiblePeers(netMap *types.NetworkMap, dnsDomain string) []api.AccessiblePeer { +// toAccessiblePeers rehydrates the calculated map's component peers into the +// account's full peer objects, which carry the location/status/meta fields +// the API response needs. +func toAccessiblePeers(netMap *types.NetworkMap, accountPeers map[string]*nbpeer.Peer, dnsDomain string) []api.AccessiblePeer { accessiblePeers := make([]api.AccessiblePeer, 0, len(netMap.Peers)+len(netMap.OfflinePeers)) - for _, p := range netMap.Peers { - accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(p, dnsDomain)) - } - - for _, p := range netMap.OfflinePeers { - accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(p, dnsDomain)) + add := func(peers []*types.ComponentPeer) { + for _, p := range peers { + if peer := accountPeers[p.ID]; peer != nil { + accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(peer, dnsDomain)) + } + } } + add(netMap.Peers) + add(netMap.OfflinePeers) return accessiblePeers } diff --git a/management/server/networks/resources/types/resource.go b/management/server/networks/resources/types/resource.go index 4cf7f7ea3..643f9cdd6 100644 --- a/management/server/networks/resources/types/resource.go +++ b/management/server/networks/resources/types/resource.go @@ -14,6 +14,7 @@ import ( nbDomain "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/http/api" + sharedTypes "github.com/netbirdio/netbird/shared/management/types" ) type NetworkResourceType string @@ -64,6 +65,27 @@ func NewNetworkResource(accountID, networkID, name, description, address string, }, nil } +// ToComponent converts the resource to its self-contained components +// representation. Returns nil for a nil resource. +func (n *NetworkResource) ToComponent() *sharedTypes.ComponentResource { + if n == nil { + return nil + } + return &sharedTypes.ComponentResource{ + ID: n.ID, + PublicID: n.PublicID, + NetworkID: n.NetworkID, + AccountID: n.AccountID, + Name: n.Name, + Description: n.Description, + Type: sharedTypes.ComponentResourceType(n.Type), + Address: n.Address, + Domain: n.Domain, + Prefix: n.Prefix, + Enabled: n.Enabled, + } +} + func (n *NetworkResource) ToAPIResponse(groups []api.GroupMinimum) *api.NetworkResource { addr := n.Prefix.String() if n.Type == Domain { diff --git a/management/server/networks/routers/types/router.go b/management/server/networks/routers/types/router.go index 189d7f792..b8097cdbb 100644 --- a/management/server/networks/routers/types/router.go +++ b/management/server/networks/routers/types/router.go @@ -7,6 +7,7 @@ import ( "github.com/netbirdio/netbird/management/server/networks/types" "github.com/netbirdio/netbird/shared/management/http/api" + sharedTypes "github.com/netbirdio/netbird/shared/management/types" ) type NetworkRouter struct { @@ -21,6 +22,36 @@ type NetworkRouter struct { Enabled bool } +// ToComponent converts the router to its self-contained components +// representation. Returns nil for a nil router. +func (n *NetworkRouter) ToComponent() *sharedTypes.ComponentRouter { + if n == nil { + return nil + } + return &sharedTypes.ComponentRouter{ + NetworkID: n.NetworkID, + PublicID: n.PublicID, + Peer: n.Peer, + PeerGroups: n.PeerGroups, + Masquerade: n.Masquerade, + Metric: n.Metric, + Enabled: n.Enabled, + } +} + +// ToComponentMap converts a peer-keyed router map to its components +// representation. +func ToComponentMap(routers map[string]*NetworkRouter) map[string]*sharedTypes.ComponentRouter { + if routers == nil { + return nil + } + out := make(map[string]*sharedTypes.ComponentRouter, len(routers)) + for id, r := range routers { + out[id] = r.ToComponent() + } + return out +} + func NewNetworkRouter(accountID string, networkID string, peer string, peerGroups []string, masquerade bool, metric int, enabled bool) (*NetworkRouter, error) { r := &NetworkRouter{ ID: xid.New().String(), diff --git a/management/server/peer.go b/management/server/peer.go index 5f2f5d2a2..589cf9abf 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -405,7 +405,7 @@ func (am *DefaultAccountManager) CreatePeerJob(ctx context.Context, accountID, p return status.NewPeerNotPartOfAccountError() } - meetMinVer, err := posture.MeetsMinVersion(remoteJobsMinVer, p.Meta.WtVersion) + meetMinVer, err := version.MeetsMinVersion(remoteJobsMinVer, p.Meta.WtVersion) if !version.IsDevelopmentVersion(p.Meta.WtVersion) && (!meetMinVer || err != nil) { return status.Errorf(status.PreconditionFailed, "peer version %s does not meet the minimum required version %s for remote jobs", p.Meta.WtVersion, remoteJobsMinVer) } @@ -1588,7 +1588,7 @@ func affectedPeerIDsFromNetworkMap(nmap *types.NetworkMap, selfPeerID string) [] } seen := make(map[string]struct{}, len(nmap.Peers)+len(nmap.OfflinePeers)) ids := make([]string, 0, len(nmap.Peers)+len(nmap.OfflinePeers)) - add := func(peers []*nbpeer.Peer) { + add := func(peers []*types.ComponentPeer) { for _, p := range peers { if p == nil || p.ID == "" || p.ID == selfPeerID { continue diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go index 39022d095..7c4971285 100644 --- a/management/server/peer/peer.go +++ b/management/server/peer/peer.go @@ -13,6 +13,7 @@ import ( "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/shared/management/http/api" + sharedTypes "github.com/netbirdio/netbird/shared/management/types" ) // Peer capability constants mirror the proto enum values. @@ -205,6 +206,35 @@ func (p *Peer) AddedWithSSOLogin() bool { return p.UserID != "" } +// ToComponent converts the peer to its self-contained components +// representation, carrying exactly the subset of peer data that crosses the +// components wire format. Returns nil for a nil peer so callers can convert +// possibly-missing peers without guarding. +func (p *Peer) ToComponent() *sharedTypes.ComponentPeer { + if p == nil { + return nil + } + cp := &sharedTypes.ComponentPeer{ + ID: p.ID, + Key: p.Key, + IP: p.IP, + IPv6: p.IPv6, + DNSLabel: p.DNSLabel, + SSHKey: p.SSHKey, + SSHEnabled: p.SSHEnabled, + ServerSSHAllowed: p.Meta.Flags.ServerSSHAllowed, + AgentVersion: p.Meta.WtVersion, + SupportsSourcePrefixes: p.SupportsSourcePrefixes(), + SupportsIPv6: p.SupportsIPv6(), + LoginExpirationEnabled: p.LoginExpirationEnabled, + AddedWithSSOLogin: p.AddedWithSSOLogin(), + } + if p.LastLogin != nil { + cp.LastLogin = *p.LastLogin + } + return cp +} + // HasCapability reports whether the peer has the given capability. func (p *Peer) HasCapability(capability int32) bool { return slices.Contains(p.Meta.Capabilities, capability) diff --git a/management/server/peer_test.go b/management/server/peer_test.go index d471a1302..a7f8ba695 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -1092,14 +1092,14 @@ func TestToSyncResponse(t *testing.T) { } networkMap := &types.NetworkMap{ Network: &types.Network{Net: *ipnet, Serial: 1000}, - Peers: []*nbpeer.Peer{{ + Peers: []*types.ComponentPeer{{ IP: netip.MustParseAddr("192.168.1.2"), IPv6: netip.MustParseAddr("fd00::2"), Key: "peer2-key", DNSLabel: "peer2", SSHEnabled: true, SSHKey: "peer2-ssh-key"}}, - OfflinePeers: []*nbpeer.Peer{{ + OfflinePeers: []*types.ComponentPeer{{ IP: netip.MustParseAddr("192.168.1.3"), IPv6: netip.MustParseAddr("fd00::3"), Key: "peer3-key", diff --git a/management/server/posture/nb_version.go b/management/server/posture/nb_version.go index 6e4757021..3cace3b5f 100644 --- a/management/server/posture/nb_version.go +++ b/management/server/posture/nb_version.go @@ -3,11 +3,9 @@ package posture import ( "context" "fmt" - "strings" - - "github.com/hashicorp/go-version" nbpeer "github.com/netbirdio/netbird/management/server/peer" + nbversion "github.com/netbirdio/netbird/version" ) type NBVersionCheck struct { @@ -16,14 +14,8 @@ type NBVersionCheck struct { var _ Check = (*NBVersionCheck)(nil) -// sanitizeVersion removes anything after the pre-release tag (e.g., "-dev", "-alpha", etc.) -func sanitizeVersion(version string) string { - parts := strings.Split(version, "-") - return parts[0] -} - func (n *NBVersionCheck) Check(ctx context.Context, peer nbpeer.Peer) (bool, error) { - meetsMin, err := MeetsMinVersion(n.MinVersion, peer.Meta.WtVersion) + meetsMin, err := nbversion.MeetsMinVersion(n.MinVersion, peer.Meta.WtVersion) if err != nil { return false, err } @@ -48,21 +40,3 @@ func (n *NBVersionCheck) Validate() error { } return nil } - -// MeetsMinVersion checks if the peer's version meets or exceeds the minimum required version -func MeetsMinVersion(minVer, peerVer string) (bool, error) { - peerVer = sanitizeVersion(peerVer) - minVer = sanitizeVersion(minVer) - - peerNBVer, err := version.NewVersion(peerVer) - if err != nil { - return false, err - } - - constraints, err := version.NewConstraint(">= " + minVer) - if err != nil { - return false, err - } - - return constraints.Check(peerNBVer), nil -} diff --git a/management/server/posture/nb_version_test.go b/management/server/posture/nb_version_test.go index d3478afc2..1bf485453 100644 --- a/management/server/posture/nb_version_test.go +++ b/management/server/posture/nb_version_test.go @@ -139,68 +139,3 @@ func TestNBVersionCheck_Validate(t *testing.T) { }) } } - -func TestMeetsMinVersion(t *testing.T) { - tests := []struct { - name string - minVer string - peerVer string - want bool - wantErr bool - }{ - { - name: "Peer version greater than min version", - minVer: "0.26.0", - peerVer: "0.60.1", - want: true, - wantErr: false, - }, - { - name: "Peer version equals min version", - minVer: "1.0.0", - peerVer: "1.0.0", - want: true, - wantErr: false, - }, - { - name: "Peer version less than min version", - minVer: "1.0.0", - peerVer: "0.9.9", - want: false, - wantErr: false, - }, - { - name: "Peer version with pre-release tag greater than min version", - minVer: "1.0.0", - peerVer: "1.0.1-alpha", - want: true, - wantErr: false, - }, - { - name: "Invalid peer version format", - minVer: "1.0.0", - peerVer: "dev", - want: false, - wantErr: true, - }, - { - name: "Invalid min version format", - minVer: "invalid.version", - peerVer: "1.0.0", - want: false, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := MeetsMinVersion(tt.minVer, tt.peerVer) - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/management/server/types/account.go b/management/server/types/account.go index 05033ae15..588e63a09 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -283,8 +283,8 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon // it, adding a single private service would black-hole every // other name under the zone apex. zone = &nbdns.CustomZone{ - Domain: dns.Fqdn(serviceDomainZone), - Records: []nbdns.SimpleRecord{}, + Domain: dns.Fqdn(serviceDomainZone), + Records: []nbdns.SimpleRecord{}, NonAuthoritative: true, SearchDomainDisabled: true, } @@ -1082,6 +1082,7 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer peersExists := make(map[string]struct{}) rules := make([]*FirewallRule, 0) peers := make([]*nbpeer.Peer, 0) + targetComponent := targetPeer.ToComponent() return func(rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int) { for _, peer := range groupPeers { @@ -1117,10 +1118,10 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 { rules = append(rules, &fr) } else { - rules = append(rules, ExpandPortsAndRanges(fr, rule, targetPeer)...) + rules = append(rules, ExpandPortsAndRanges(fr, rule, targetComponent)...) } - rules = AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, FirewallRuleContext{ + rules = AppendIPv6FirewallRule(rules, rulesExists, peer.ToComponent(), targetComponent, rule, FirewallRuleContext{ Direction: direction, DirStr: strconv.Itoa(direction), ProtocolStr: string(protocol), @@ -1280,7 +1281,7 @@ func (a *Account) getRouteFirewallRules(ctx context.Context, peerID string, poli return fwRules } -func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}, validatedPeersMap map[string]struct{}) []*nbpeer.Peer { +func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}, validatedPeersMap map[string]struct{}) []*ComponentPeer { distPeersWithPolicy := make(map[string]struct{}) for _, id := range rule.Sources { group := a.Groups[id] @@ -1307,13 +1308,13 @@ func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID } } - distributionGroupPeers := make([]*nbpeer.Peer, 0, len(distPeersWithPolicy)) + distributionGroupPeers := make([]*ComponentPeer, 0, len(distPeersWithPolicy)) for pID := range distPeersWithPolicy { peer := a.Peers[pID] if peer == nil { continue } - distributionGroupPeers = append(distributionGroupPeers, peer) + distributionGroupPeers = append(distributionGroupPeers, peer.ToComponent()) } return distributionGroupPeers } diff --git a/management/server/types/account_components.go b/management/server/types/account_components.go index 0205a1f55..af27788d8 100644 --- a/management/server/types/account_components.go +++ b/management/server/types/account_components.go @@ -9,9 +9,7 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/internals/modules/zones" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/telemetry" "github.com/netbirdio/netbird/route" ) @@ -113,7 +111,7 @@ func (a *Account) GetPeerNetworkMapComponents( PeerID: peerID, Network: a.Network.Copy(), // must include the target peer as it's required on the client - Peers: map[string]*nbpeer.Peer{peerID: peer}, + Peers: map[string]*ComponentPeer{peerID: peer.ToComponent()}, }) } @@ -126,7 +124,7 @@ func (a *Account) GetPeerNetworkMapComponents( PeerID: peerID, Network: a.Network.Copy(), // must include the target peer as it's required on the client - Peers: map[string]*nbpeer.Peer{peerID: peer}, + Peers: map[string]*ComponentPeer{peerID: peer.ToComponent()}, }) } @@ -136,10 +134,10 @@ func (a *Account) GetPeerNetworkMapComponents( NameServerGroups: make([]*nbdns.NameServerGroup, 0), CustomZoneDomain: peersCustomZone.Domain, ResourcePoliciesMap: make(map[string][]*Policy), - RoutersMap: make(map[string]map[string]*routerTypes.NetworkRouter), - NetworkResources: make([]*resourceTypes.NetworkResource, 0), + RoutersMap: make(map[string]map[string]*ComponentRouter), + NetworkResources: make([]*ComponentResource, 0), PostureFailedPeers: make(map[string]map[string]struct{}, len(a.PostureChecks)), - RouterPeers: make(map[string]*nbpeer.Peer), + RouterPeers: make(map[string]*ComponentPeer), NetworkXIDToPublicID: make(map[string]string, len(a.Networks)), PostureCheckXIDToPublicID: make(map[string]string, len(a.PostureChecks)), } @@ -174,7 +172,7 @@ func (a *Account) GetPeerNetworkMapComponents( } components.Peers = relevantPeers - components.Groups = relevantGroups + components.Groups = GroupsToComponent(relevantGroups) components.Policies = relevantPolicies components.Routes = relevantRoutes components.AllDNSRecords = filterDNSRecordsByPeers(peersCustomZone.Records, relevantPeers, peer.SupportsIPv6() && peer.IPv6.IsValid()) @@ -223,7 +221,7 @@ func (a *Account) GetPeerNetworkMapComponents( } for _, pID := range a.getPostureValidPeersSaveFailed(peers, policy.SourcePostureChecks, validatedPeersMap, &components.PostureFailedPeers) { if _, exists := components.Peers[pID]; !exists { - components.Peers[pID] = a.GetPeer(pID) + components.Peers[pID] = a.GetPeer(pID).ToComponent() } } } else { @@ -256,14 +254,14 @@ func (a *Account) GetPeerNetworkMapComponents( for _, srcGroupID := range rule.Sources { if g := a.Groups[srcGroupID]; g != nil { if _, exists := components.Groups[srcGroupID]; !exists { - components.Groups[srcGroupID] = g + components.Groups[srcGroupID] = g.ToComponent() } } } for _, dstGroupID := range rule.Destinations { if g := a.Groups[dstGroupID]; g != nil { if _, exists := components.Groups[dstGroupID]; !exists { - components.Groups[dstGroupID] = g + components.Groups[dstGroupID] = g.ToComponent() } } } @@ -278,20 +276,22 @@ func (a *Account) GetPeerNetworkMapComponents( // network in the account — accounts with many tenants/networks // shipped tens of unrelated peers in `peers[]` and `routers_map`. if addSourcePeers { - components.RoutersMap[resource.NetworkID] = networkRoutingPeers + components.RoutersMap[resource.NetworkID] = routerTypes.ToComponentMap(networkRoutingPeers) for peerIDKey := range networkRoutingPeers { if p := a.Peers[peerIDKey]; p != nil { - if _, exists := components.RouterPeers[peerIDKey]; !exists { - components.RouterPeers[peerIDKey] = p + cp := components.RouterPeers[peerIDKey] + if cp == nil { + cp = p.ToComponent() + components.RouterPeers[peerIDKey] = cp } if _, exists := components.Peers[peerIDKey]; !exists { if _, validated := validatedPeersMap[peerIDKey]; validated { - components.Peers[peerIDKey] = p + components.Peers[peerIDKey] = cp } } } } - components.NetworkResources = append(components.NetworkResources, resource) + components.NetworkResources = append(components.NetworkResources, resource.ToComponent()) } } @@ -312,14 +312,14 @@ func (a *Account) getPeersGroupsPoliciesRoutes( peerSSHEnabled bool, validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}, -) (map[string]*nbpeer.Peer, map[string]*Group, []*Policy, []*route.Route, sshRequirements) { - relevantPeerIDs := make(map[string]*nbpeer.Peer, len(a.Peers)/4) +) (map[string]*ComponentPeer, map[string]*Group, []*Policy, []*route.Route, sshRequirements) { + relevantPeerIDs := make(map[string]*ComponentPeer, len(a.Peers)/4) relevantGroupIDs := make(map[string]*Group, len(a.Groups)/4) relevantPolicies := make([]*Policy, 0, len(a.Policies)) relevantRoutes := make([]*route.Route, 0, len(a.Routes)) sshReqs := sshRequirements{neededGroupIDs: make(map[string]struct{})} - relevantPeerIDs[peerID] = a.GetPeer(peerID) + relevantPeerIDs[peerID] = a.GetPeer(peerID).ToComponent() peerGroupSet := make(map[string]struct{}, 8) for groupID, group := range a.Groups { @@ -384,7 +384,7 @@ func (a *Account) getPeersGroupsPoliciesRoutes( if r.Peer != "" { if _, ok := validatedPeersMap[r.Peer]; ok { if p := a.GetPeer(r.Peer); p != nil { - relevantPeerIDs[r.Peer] = p + relevantPeerIDs[r.Peer] = p.ToComponent() } } } @@ -401,7 +401,7 @@ func (a *Account) getPeersGroupsPoliciesRoutes( continue } if p := a.GetPeer(pid); p != nil { - relevantPeerIDs[pid] = p + relevantPeerIDs[pid] = p.ToComponent() } } } @@ -458,7 +458,9 @@ func (a *Account) getPeersGroupsPoliciesRoutes( if peerInSources { policyRelevant = true for _, pid := range destinationPeers { - relevantPeerIDs[pid] = a.GetPeer(pid) + if _, exists := relevantPeerIDs[pid]; !exists { + relevantPeerIDs[pid] = a.GetPeer(pid).ToComponent() + } } for _, dstGroupID := range rule.Destinations { relevantGroupIDs[dstGroupID] = a.GetGroup(dstGroupID) @@ -468,7 +470,9 @@ func (a *Account) getPeersGroupsPoliciesRoutes( if peerInDestinations { policyRelevant = true for _, pid := range sourcePeers { - relevantPeerIDs[pid] = a.GetPeer(pid) + if _, exists := relevantPeerIDs[pid]; !exists { + relevantPeerIDs[pid] = a.GetPeer(pid).ToComponent() + } } for _, srcGroupID := range rule.Sources { relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID) @@ -624,7 +628,7 @@ func (a *Account) getPostureValidPeersSaveFailed(inputPeers []string, postureChe // that name them. Calculate() tolerates groups with empty Peers (the inner // loops simply iterate zero times), so retaining them is behaviourally a // no-op for the legacy path that consumes the same NetworkMapComponents. -func filterGroupPeers(groups *map[string]*Group, peers map[string]*nbpeer.Peer) { +func filterGroupPeers(groups *map[string]*ComponentGroup, peers map[string]*ComponentPeer) { for groupID, groupInfo := range *groups { filteredPeers := make([]string, 0, len(groupInfo.Peers)) for _, pid := range groupInfo.Peers { @@ -634,14 +638,14 @@ func filterGroupPeers(groups *map[string]*Group, peers map[string]*nbpeer.Peer) } if len(filteredPeers) != len(groupInfo.Peers) { - ng := groupInfo.Copy() + ng := *groupInfo ng.Peers = filteredPeers - (*groups)[groupID] = ng + (*groups)[groupID] = &ng } } } -func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{}, policies []*Policy, resourcePoliciesMap map[string][]*Policy, peers map[string]*nbpeer.Peer) { +func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{}, policies []*Policy, resourcePoliciesMap map[string][]*Policy, peers map[string]*ComponentPeer) { if len(*postureFailedPeers) == 0 { return } @@ -676,7 +680,7 @@ func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{} } } -func filterDNSRecordsByPeers(records []nbdns.SimpleRecord, peers map[string]*nbpeer.Peer, includeIPv6 bool) []nbdns.SimpleRecord { +func filterDNSRecordsByPeers(records []nbdns.SimpleRecord, peers map[string]*ComponentPeer, includeIPv6 bool) []nbdns.SimpleRecord { if len(records) == 0 || len(peers) == 0 { return nil } diff --git a/management/server/types/account_private_netmap_test.go b/management/server/types/account_private_netmap_test.go index dc097ce26..11b3d985a 100644 --- a/management/server/types/account_private_netmap_test.go +++ b/management/server/types/account_private_netmap_test.go @@ -9,7 +9,6 @@ import ( "github.com/stretchr/testify/require" nbdns "github.com/netbirdio/netbird/dns" - nbpeer "github.com/netbirdio/netbird/management/server/peer" ) func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) { @@ -49,7 +48,7 @@ func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) { }) } -func netmapPeerIDs(peers []*nbpeer.Peer) []string { +func netmapPeerIDs(peers []*ComponentPeer) []string { ids := make([]string, 0, len(peers)) for _, p := range peers { if p == nil { diff --git a/management/server/types/account_test.go b/management/server/types/account_test.go index e5b5708fa..67d9e1c6f 100644 --- a/management/server/types/account_test.go +++ b/management/server/types/account_test.go @@ -666,7 +666,7 @@ func Test_ExpandPortsAndRanges_SSHRuleExpansion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := ExpandPortsAndRanges(tt.base, tt.rule, tt.peer) + result := ExpandPortsAndRanges(tt.base, tt.rule, tt.peer.ToComponent()) var ports []string for _, fr := range result { diff --git a/management/server/types/aliases.go b/management/server/types/aliases.go index f5837a343..9324cfa1e 100644 --- a/management/server/types/aliases.go +++ b/management/server/types/aliases.go @@ -6,7 +6,6 @@ import ( "net" "net/netip" - nbpeer "github.com/netbirdio/netbird/management/server/peer" nbroute "github.com/netbirdio/netbird/route" sharedtypes "github.com/netbirdio/netbird/shared/management/types" ) @@ -18,9 +17,6 @@ type DNSSettings = sharedtypes.DNSSettings type FirewallRule = sharedtypes.FirewallRule -type Group = sharedtypes.Group -type GroupPeer = sharedtypes.GroupPeer - type Network = sharedtypes.Network type NetworkMap = sharedtypes.NetworkMap type ForwardingRule = sharedtypes.ForwardingRule @@ -42,6 +38,18 @@ type RouteFirewallRule = sharedtypes.RouteFirewallRule type NetworkMapComponents = sharedtypes.NetworkMapComponents +type ComponentPeer = sharedtypes.ComponentPeer +type ComponentGroup = sharedtypes.ComponentGroup +type ComponentRouter = sharedtypes.ComponentRouter +type ComponentResource = sharedtypes.ComponentResource +type ComponentResourceType = sharedtypes.ComponentResourceType + +const ( + ComponentResourceHost = sharedtypes.ComponentResourceHost + ComponentResourceSubnet = sharedtypes.ComponentResourceSubnet + ComponentResourceDomain = sharedtypes.ComponentResourceDomain +) + var EmptyNetworkMapComponents = sharedtypes.EmptyNetworkMapComponents type AccountSettingsInfo = sharedtypes.AccountSettingsInfo @@ -52,12 +60,7 @@ type NetworkMapComponentsCompact = sharedtypes.NetworkMapComponentsCompact type LookupMap = sharedtypes.LookupMap type FirewallRuleContext = sharedtypes.FirewallRuleContext -const ( - GroupIssuedAPI = sharedtypes.GroupIssuedAPI - GroupIssuedJWT = sharedtypes.GroupIssuedJWT - GroupIssuedIntegration = sharedtypes.GroupIssuedIntegration - GroupAllName = sharedtypes.GroupAllName -) +const GroupAllName = sharedtypes.GroupAllName // Function forwarders preserve types.X(...) call sites that previously // resolved to package-local funcs. Plain forwarders (not var aliases) keep @@ -67,11 +70,11 @@ func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool { return sharedtypes.PolicyRuleImpliesLegacySSH(rule) } -func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer) []*FirewallRule { +func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPeer) []*FirewallRule { return sharedtypes.ExpandPortsAndRanges(base, rule, peer) } -func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *nbpeer.Peer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule { +func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *ComponentPeer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule { return sharedtypes.AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, rc) } @@ -79,7 +82,7 @@ func CalculateNetworkMapFromComponents(ctx context.Context, components *NetworkM return sharedtypes.CalculateNetworkMapFromComponents(ctx, components) } -func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int, includeIPv6 bool) []*RouteFirewallRule { +func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*ComponentPeer, direction int, includeIPv6 bool) []*RouteFirewallRule { return sharedtypes.GenerateRouteFirewallRules(ctx, route, rule, groupPeers, direction, includeIPv6) } diff --git a/shared/management/types/group.go b/management/server/types/group.go similarity index 83% rename from shared/management/types/group.go rename to management/server/types/group.go index e6e285e62..a5e196997 100644 --- a/shared/management/types/group.go +++ b/management/server/types/group.go @@ -2,7 +2,6 @@ package types import ( "github.com/netbirdio/netbird/management/server/integration_reference" - "github.com/netbirdio/netbird/management/server/networks/resources/types" ) const ( @@ -68,10 +67,6 @@ func (g *Group) EventMeta() map[string]any { return map[string]any{"name": g.Name} } -func (g *Group) EventMetaResource(resource *types.NetworkResource) map[string]any { - return map[string]any{"name": g.Name, "id": g.ID, "resource_name": resource.Name, "resource_id": resource.ID, "resource_type": resource.Type} -} - func (g *Group) Copy() *Group { group := &Group{ ID: g.ID, @@ -95,14 +90,39 @@ func (g *Group) HasPeers() bool { return len(g.Peers) > 0 } -// GroupAllName is the reserved name of the default group that contains every peer in an account. -const GroupAllName = "All" - // IsGroupAll checks if the group is a default "All" group. func (g *Group) IsGroupAll() bool { return g.Name == GroupAllName } +// ToComponent converts the group to its self-contained components +// representation. The Peers slice is shared, not copied — components are +// treated as immutable snapshots. Returns nil for a nil group. +func (g *Group) ToComponent() *ComponentGroup { + if g == nil { + return nil + } + return &ComponentGroup{ + ID: g.ID, + PublicID: g.PublicID, + Name: g.Name, + Peers: g.Peers, + } +} + +// GroupsToComponent converts an id-keyed group map to its components +// representation, preserving nil entries. +func GroupsToComponent(groups map[string]*Group) map[string]*ComponentGroup { + if groups == nil { + return nil + } + out := make(map[string]*ComponentGroup, len(groups)) + for id, g := range groups { + out[id] = g.ToComponent() + } + return out +} + // AddPeer adds peerID to Peers if not present, returning true if added. func (g *Group) AddPeer(peerID string) bool { if peerID == "" { diff --git a/management/server/types/ipv6_endtoend_test.go b/management/server/types/ipv6_endtoend_test.go index ddd1f649f..d83603abe 100644 --- a/management/server/types/ipv6_endtoend_test.go +++ b/management/server/types/ipv6_endtoend_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" ) func TestNetworkMapComponents_IPv6EndToEnd(t *testing.T) { @@ -104,7 +105,7 @@ func TestNetworkMapComponents_RemotePeerWithoutCapability(t *testing.T) { require.NotNil(t, nm) t.Run("AllowedIPs include remote v6", func(t *testing.T) { - var dst *nbpeer.Peer + var dst *types.ComponentPeer for _, p := range nm.Peers { if p.ID == "peer-dst-1" { dst = p diff --git a/management/server/types/networkmap_components_test.go b/management/server/types/networkmap_components_test.go index 1a99b4511..3f2288f88 100644 --- a/management/server/types/networkmap_components_test.go +++ b/management/server/types/networkmap_components_test.go @@ -49,7 +49,7 @@ func allPeersValidated(account *types.Account, excludePeerIDs ...string) map[str return validated } -func peerIDs(peers []*nbpeer.Peer) []string { +func peerIDs(peers []*types.ComponentPeer) []string { ids := make([]string, len(peers)) for i, p := range peers { ids[i] = p.ID diff --git a/management/server/util/util.go b/management/server/util/util.go index 617484274..d85b55f02 100644 --- a/management/server/util/util.go +++ b/management/server/util/util.go @@ -19,34 +19,3 @@ func Difference(a, b []string) []string { func ToPtr[T any](value T) *T { return &value } - -type comparableObject[T any] interface { - Equal(other T) bool -} - -func MergeUnique[T comparableObject[T]](arr1, arr2 []T) []T { - var result []T - - for _, item := range arr1 { - if !contains(result, item) { - result = append(result, item) - } - } - - for _, item := range arr2 { - if !contains(result, item) { - result = append(result, item) - } - } - - return result -} - -func contains[T comparableObject[T]](slice []T, element T) bool { - for _, item := range slice { - if item.Equal(element) { - return true - } - } - return false -} diff --git a/proxy/internal/llm/pricing/defaults_pricing.yaml b/proxy/internal/llm/pricing/defaults_pricing.yaml index cd5c64fbf..3fba8fe3f 100644 --- a/proxy/internal/llm/pricing/defaults_pricing.yaml +++ b/proxy/internal/llm/pricing/defaults_pricing.yaml @@ -161,6 +161,16 @@ openai: input_per_1k: 0.0001 output_per_1k: 0 + # Kimi / Moonshot AI (kimi_api) — OpenAI-compatible /v1 endpoint. Moonshot + # reports cache hits OpenAI-style when present; cached input is 10% of + # input ($0.30 vs $3.00 per MTok). kimi-k3 is the only model the platform + # serves newer accounts (K2-era ids and kimi-latest 404), matching the + # management catalog. + kimi-k3: + input_per_1k: 0.003 + output_per_1k: 0.015 + cached_input_per_1k: 0.0003 + anthropic: # Claude 4.x family — cache reads ≈10% of input, cache writes ≈125% of input. # Pricing source: Anthropic's current published rates per million tokens, @@ -206,6 +216,20 @@ anthropic: cache_read_per_1k: 0.0001 cache_creation_per_1k: 0.00125 + # Kimi / Moonshot AI (kimi_api) via the Anthropic-compatible endpoint + # (/anthropic/v1/messages — the official Claude Code setup). Same rates + # as the OpenAI-shape entry above. "kimi-k3[1m]" is the model id some + # Claude Code guides set for the 1M-context alias; priced identically so + # cost metering doesn't silently skip those requests. + kimi-k3: + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + "kimi-k3[1m]": + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + bedrock: # AWS Bedrock model ids, normalised by the request parser (cross-region # inference-profile prefix + version/throughput suffix stripped), e.g. diff --git a/release_files/freebsd-port-diff.sh b/release_files/freebsd-port-diff.sh index 6ffa141be..77ea55520 100755 --- a/release_files/freebsd-port-diff.sh +++ b/release_files/freebsd-port-diff.sh @@ -3,8 +3,8 @@ # FreeBSD Port Diff Generator for NetBird # # This script generates the diff file required for submitting a FreeBSD port update. -# It works on macOS, Linux, and FreeBSD by fetching files from FreeBSD cgit and -# computing checksums from the Go module proxy. +# It works on macOS, Linux, and FreeBSD by fetching files from the FreeBSD ports +# GitHub mirror and computing checksums from the Go module proxy. # # Usage: ./freebsd-port-diff.sh [new_version] # Example: ./freebsd-port-diff.sh 0.60.7 @@ -14,7 +14,7 @@ set -e GITHUB_REPO="netbirdio/netbird" -PORTS_CGIT_BASE="https://cgit.freebsd.org/ports/plain/security/netbird" +PORTS_MIRROR_BASE="https://raw.githubusercontent.com/freebsd/freebsd-ports/main/security/netbird" GO_PROXY="https://proxy.golang.org/github.com/netbirdio/netbird/@v" OUTPUT_DIR="${OUTPUT_DIR:-.}" AWK_FIRST_FIELD='{print $1}' @@ -30,10 +30,17 @@ fetch_all_tags() { fetch_current_ports_version() { echo "Fetching current version from FreeBSD ports..." >&2 - curl -sL "${PORTS_CGIT_BASE}/Makefile" 2>/dev/null | \ + local makefile version + makefile=$(fetch_ports_file "Makefile") || return 1 + version=$(echo "$makefile" | \ grep -E "^DISTVERSION=" | \ sed 's/DISTVERSION=[[:space:]]*//' | \ - tr -d '\t ' + tr -d '\t ') + if [[ -z "$version" ]]; then + echo "Error: Could not extract DISTVERSION from ports Makefile" >&2 + return 1 + fi + echo "$version" return 0 } @@ -45,7 +52,16 @@ fetch_latest_github_release() { fetch_ports_file() { local filename="$1" - curl -sL "${PORTS_CGIT_BASE}/${filename}" 2>/dev/null + local content + if ! content=$(curl -fsL --proto '=https' --proto-redir '=https' --retry 3 "${PORTS_MIRROR_BASE}/${filename}" 2>/dev/null); then + echo "Error: Could not fetch ${filename} from ${PORTS_MIRROR_BASE}" >&2 + return 1 + fi + if [[ "$content" == \<* ]]; then + echo "Error: Received HTML instead of ${filename} from ${PORTS_MIRROR_BASE}" >&2 + return 1 + fi + printf '%s' "$content" return 0 } diff --git a/release_files/freebsd-port-issue-body.sh b/release_files/freebsd-port-issue-body.sh index 1c23dbbbe..1f0c8a567 100755 --- a/release_files/freebsd-port-issue-body.sh +++ b/release_files/freebsd-port-issue-body.sh @@ -9,18 +9,22 @@ # Example: ./freebsd-port-issue-body.sh 0.56.0 0.59.1 # # If no versions are provided, the script will: -# - Fetch OLD version from FreeBSD ports cgit (current version in ports tree) +# - Fetch OLD version from the FreeBSD ports GitHub mirror (current version in ports tree) # - Fetch NEW version from latest NetBird GitHub release tag set -e GITHUB_REPO="netbirdio/netbird" -PORTS_CGIT_URL="https://cgit.freebsd.org/ports/plain/security/netbird/Makefile" +PORTS_MAKEFILE_URL="https://raw.githubusercontent.com/freebsd/freebsd-ports/main/security/netbird/Makefile" fetch_current_ports_version() { echo "Fetching current version from FreeBSD ports..." >&2 local makefile_content - makefile_content=$(curl -sL "$PORTS_CGIT_URL" 2>/dev/null) + makefile_content=$(curl -fsL --proto '=https' --proto-redir '=https' --retry 3 "$PORTS_MAKEFILE_URL" 2>/dev/null) || makefile_content="" + if [[ "$makefile_content" == \<* ]]; then + echo "Error: Received HTML instead of Makefile from ${PORTS_MAKEFILE_URL}" >&2 + return 1 + fi if [[ -z "$makefile_content" ]]; then echo "Error: Could not fetch Makefile from FreeBSD ports" >&2 return 1 diff --git a/shared/management/client/grpc.go b/shared/management/client/grpc.go index 78d28e3a3..bd2d0da1f 100644 --- a/shared/management/client/grpc.go +++ b/shared/management/client/grpc.go @@ -187,16 +187,16 @@ func (c *GrpcClient) ready() bool { // Sync wraps the real client's Sync endpoint call and takes care of retries and encryption/decryption of messages // Blocking request. The result will be sent via msgHandler callback function func (c *GrpcClient) Sync(ctx context.Context, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error) error { - return c.withMgmtStream(ctx, func(ctx context.Context, serverPubKey wgtypes.Key) error { - return c.handleSyncStream(ctx, serverPubKey, sysInfo, msgHandler) + return c.withMgmtStream(ctx, func(ctx context.Context, serverPubKey wgtypes.Key, backOff backoff.BackOff) error { + return c.handleSyncStream(ctx, serverPubKey, sysInfo, msgHandler, backOff) }) } // Job wraps the real client's Job endpoint call and takes care of retries and encryption/decryption of messages // Blocking request. The result will be sent via msgHandler callback function func (c *GrpcClient) Job(ctx context.Context, msgHandler func(msg *proto.JobRequest) *proto.JobResponse) error { - return c.withMgmtStream(ctx, func(ctx context.Context, serverPubKey wgtypes.Key) error { - return c.handleJobStream(ctx, serverPubKey, msgHandler) + return c.withMgmtStream(ctx, func(ctx context.Context, serverPubKey wgtypes.Key, backOff backoff.BackOff) error { + return c.handleJobStream(ctx, serverPubKey, msgHandler, backOff) }) } @@ -204,7 +204,7 @@ func (c *GrpcClient) Job(ctx context.Context, msgHandler func(msg *proto.JobRequ // It takes care of retries, connection readiness, and fetching server public key. func (c *GrpcClient) withMgmtStream( ctx context.Context, - handler func(ctx context.Context, serverPubKey wgtypes.Key) error, + handler func(ctx context.Context, serverPubKey wgtypes.Key, backOff backoff.BackOff) error, ) error { backOff := defaultBackoff(ctx) operation := func() error { @@ -224,7 +224,7 @@ func (c *GrpcClient) withMgmtStream( return err } - return handler(ctx, *serverPubKey) + return handler(ctx, *serverPubKey, backOff) } err := backoff.Retry(operation, backOff) @@ -239,6 +239,7 @@ func (c *GrpcClient) handleJobStream( ctx context.Context, serverPubKey wgtypes.Key, msgHandler func(msg *proto.JobRequest) *proto.JobResponse, + backOff backoff.BackOff, ) error { ctx, cancelStream := context.WithCancel(ctx) defer cancelStream() @@ -256,6 +257,19 @@ func (c *GrpcClient) handleJobStream( log.Debug("job stream handshake sent successfully") + // The stream is up, so reset the backoff. This matters for two reasons, + // both caused by the backoff lib not resetting its state on a successful + // connection: + // 1. Without a reset, after a connect followed by an error the next retry + // starts from the accumulated (large) interval instead of retrying + // promptly, delaying reconnection. + // 2. Worse, once the accumulated elapsed time exceeds MaxElapsedTime, the + // next stream error makes NextBackOff() return Stop, so the retry loop + // exits immediately. That error is then mislabeled unrecoverable and + // bubbles up to trigger a full engine restart / data-plane teardown + // instead of a silent reconnection. + backOff.Reset() + // Main loop: receive, process, respond for { jobReq, err := c.receiveJobRequest(ctx, stream, serverPubKey) @@ -371,7 +385,7 @@ func (c *GrpcClient) sendJobResponse( return nil } -func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes.Key, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error) error { +func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes.Key, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error, backOff backoff.BackOff) error { ctx, cancelStream := context.WithCancel(ctx) defer cancelStream() @@ -390,6 +404,19 @@ func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes. c.notifyConnected() c.setSyncStreamConnected() + // The stream is up, so reset the backoff. This matters for two reasons, + // both caused by the backoff lib not resetting its state on a successful + // connection: + // 1. Without a reset, after a connect followed by an error the next retry + // starts from the accumulated (large) interval instead of retrying + // promptly, delaying reconnection. + // 2. Worse, once the accumulated elapsed time exceeds MaxElapsedTime, the + // next stream error makes NextBackOff() return Stop, so the retry loop + // exits immediately. That error is then mislabeled unrecoverable and + // bubbles up to trigger a full engine restart / data-plane teardown + // instead of a silent reconnection. + backOff.Reset() + // blocking until error err = c.receiveUpdatesEvents(stream, serverPubKey, msgHandler) if err != nil { diff --git a/shared/management/networkmap/decode.go b/shared/management/networkmap/decode.go index c66074b4f..d15117b6e 100644 --- a/shared/management/networkmap/decode.go +++ b/shared/management/networkmap/decode.go @@ -11,9 +11,6 @@ import ( log "github.com/sirupsen/logrus" nbdns "github.com/netbirdio/netbird/dns" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" - routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" nbroute "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/proto" @@ -38,17 +35,17 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, Network: decodeAccountNetwork(full.Network), AccountSettings: decodeAccountSettings(full.AccountSettings), CustomZoneDomain: full.CustomZoneDomain, - Peers: make(map[string]*nbpeer.Peer, len(full.Peers)), - Groups: make(map[string]*types.Group, len(full.Groups)), + Peers: make(map[string]*types.ComponentPeer, len(full.Peers)), + Groups: make(map[string]*types.ComponentGroup, len(full.Groups)), Policies: make([]*types.Policy, 0, len(full.Policies)), Routes: make([]*nbroute.Route, 0, len(full.Routes)), NameServerGroups: make([]*nbdns.NameServerGroup, 0, len(full.NameserverGroups)), AllDNSRecords: decodeSimpleRecords(full.AllDnsRecords), AccountZones: decodeCustomZones(full.AccountZones), ResourcePoliciesMap: make(map[string][]*types.Policy), - RoutersMap: make(map[string]map[string]*routerTypes.NetworkRouter), - NetworkResources: make([]*resourceTypes.NetworkResource, 0, len(full.NetworkResources)), - RouterPeers: make(map[string]*nbpeer.Peer), + RoutersMap: make(map[string]map[string]*types.ComponentRouter), + NetworkResources: make([]*types.ComponentResource, 0, len(full.NetworkResources)), + RouterPeers: make(map[string]*types.ComponentPeer), AllowedUserIDs: stringSliceToSet(full.AllowedUserIds), PostureFailedPeers: make(map[string]map[string]struct{}, len(full.PostureFailedPeers)), GroupIDToUserIDs: make(map[string][]string, len(full.GroupIdToUserIds)), @@ -101,7 +98,7 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, log.WithField("peer idx", idx).Error("unrecognized peer idx during decoding") } } - group := &types.Group{ + group := &types.ComponentGroup{ ID: groupID, PublicID: gc.Id, Peers: peerIDs, @@ -151,7 +148,7 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, // Phase 7: routers_map (outer key = network seq id, inner key = peer-id // reconstructed from peer_index). Synthesized network id is "net_". for networkID, list := range full.RoutersMap { - inner := make(map[string]*routerTypes.NetworkRouter, len(list.Entries)) + inner := make(map[string]*types.ComponentRouter, len(list.Entries)) for _, entry := range list.Entries { if !entry.PeerIndexSet { continue @@ -161,8 +158,7 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, continue } peerID := peerIDByIndex[entry.PeerIndex] - inner[peerID] = &routerTypes.NetworkRouter{ - ID: "", + inner[peerID] = &types.ComponentRouter{ NetworkID: networkID, PublicID: entry.Id, Peer: peerID, @@ -264,40 +260,22 @@ func decodeAccountSettings(as *proto.AccountSettingsCompact) *types.AccountSetti } } -func decodePeerCompact(pc *proto.PeerCompact, peerID string) *nbpeer.Peer { - var caps []int32 - if pc.SupportsSourcePrefixes { - caps = append(caps, nbpeer.PeerCapabilitySourcePrefixes) - } - if pc.SupportsIpv6 { - caps = append(caps, nbpeer.PeerCapabilityIPv6Overlay) - } - peer := &nbpeer.Peer{ +func decodePeerCompact(pc *proto.PeerCompact, peerID string) *types.ComponentPeer { + peer := &types.ComponentPeer{ ID: peerID, Key: peerID, SSHKey: string(pc.SshPubKey), SSHEnabled: pc.SshEnabled, DNSLabel: pc.DnsLabel, LoginExpirationEnabled: pc.LoginExpirationEnabled, - Meta: nbpeer.PeerSystemMeta{ - WtVersion: pc.AgentVersion, - Capabilities: caps, - Flags: nbpeer.Flags{ - ServerSSHAllowed: pc.ServerSshAllowed, - }, - }, - } - if pc.AddedWithSsoLogin { - // Set a non-empty UserID so (*Peer).AddedWithSSOLogin() returns true. - // The original UserID isn't on the wire; the value is intentionally - // visibly synthetic so any future consumer that mistakes UserID for a - // real account user xid won't silently match (or worse, write the - // sentinel into a downstream record). - peer.UserID = "" + AgentVersion: pc.AgentVersion, + SupportsSourcePrefixes: pc.SupportsSourcePrefixes, + SupportsIPv6: pc.SupportsIpv6, + ServerSSHAllowed: pc.ServerSshAllowed, + AddedWithSSOLogin: pc.AddedWithSsoLogin, } if pc.LastLoginUnixNano != 0 { - t := time.Unix(0, pc.LastLoginUnixNano) - peer.LastLogin = &t + peer.LastLogin = time.Unix(0, pc.LastLoginUnixNano) } switch len(pc.Ip) { case 4: @@ -424,14 +402,14 @@ func decodeNameServerGroupRaw(nsg *proto.NameServerGroupRaw) *nbdns.NameServerGr return out } -func decodeNetworkResource(nr *proto.NetworkResourceRaw) *resourceTypes.NetworkResource { - out := &resourceTypes.NetworkResource{ +func decodeNetworkResource(nr *proto.NetworkResourceRaw) *types.ComponentResource { + out := &types.ComponentResource{ ID: nr.Id, PublicID: nr.Id, NetworkID: nr.NetworkSeq, Name: nr.Name, Description: nr.Description, - Type: resourceTypes.NetworkResourceType(nr.Type), + Type: types.ComponentResourceType(nr.Type), Address: nr.Address, Domain: nr.DomainValue, Enabled: nr.Enabled, diff --git a/shared/management/networkmap/encode.go b/shared/management/networkmap/encode.go index e808480ea..ccde32faf 100644 --- a/shared/management/networkmap/encode.go +++ b/shared/management/networkmap/encode.go @@ -20,10 +20,9 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "net/netip" - nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/shared/management/types" nbroute "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/management/types" "github.com/netbirdio/netbird/shared/netiputil" "github.com/netbirdio/netbird/shared/sshauth" ) @@ -274,7 +273,7 @@ func ToProtocolDNSConfig(update nbdns.Config, cache DNSConfigCache, forwardPort // AppendRemotePeerConfig appends typed peers as proto.RemotePeerConfig // entries to dst and returns the result. -func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*nbpeer.Peer, dnsName string, includeIPv6 bool) []*proto.RemotePeerConfig { +func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*types.ComponentPeer, dnsName string, includeIPv6 bool) []*proto.RemotePeerConfig { for _, rPeer := range peers { allowedIPs := []string{rPeer.IP.String() + "/32"} if includeIPv6 && rPeer.IPv6.IsValid() { @@ -285,7 +284,7 @@ func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*nbpeer.Peer, AllowedIps: allowedIPs, SshConfig: &proto.SSHConfig{SshPubKey: []byte(rPeer.SSHKey)}, Fqdn: rPeer.FQDN(dnsName), - AgentVersion: rPeer.Meta.WtVersion, + AgentVersion: rPeer.AgentVersion, }) } return dst diff --git a/shared/management/networkmap/envelope.go b/shared/management/networkmap/envelope.go index 3f045a9eb..a928c5059 100644 --- a/shared/management/networkmap/envelope.go +++ b/shared/management/networkmap/envelope.go @@ -54,8 +54,8 @@ func EnvelopeToNetworkMap(ctx context.Context, env *proto.NetworkMapEnvelope, lo } components.PeerID = canonicalKey - includeIPv6 := localPeer.SupportsIPv6() && localPeer.IPv6.IsValid() - useSourcePrefixes := localPeer.SupportsSourcePrefixes() + includeIPv6 := localPeer.SupportsIPv6 && localPeer.IPv6.IsValid() + useSourcePrefixes := localPeer.SupportsSourcePrefixes typedNM := components.Calculate(ctx) diff --git a/shared/management/networkmap/envelope_test.go b/shared/management/networkmap/envelope_test.go index 11a5335be..a81478aff 100644 --- a/shared/management/networkmap/envelope_test.go +++ b/shared/management/networkmap/envelope_test.go @@ -13,7 +13,6 @@ import ( goproto "google.golang.org/protobuf/proto" mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/types" nbnetworkmap "github.com/netbirdio/netbird/shared/management/networkmap" "github.com/netbirdio/netbird/shared/management/proto" @@ -144,14 +143,14 @@ func TestDecodeEnvelope_MalformedWgKeyPeerSkipped(t *testing.T) { func TestEnvelopeRoundTrip_AllGroupShortCircuitParity(t *testing.T) { ctx := context.Background() - peers := map[string]*nbpeer.Peer{} + peers := map[string]*types.ComponentPeer{} for i, id := range []string{"peer-T", "peer-S", "peer-ALL", "peer-O"} { - peers[id] = &nbpeer.Peer{ - ID: id, - Key: randomWgKey(t), - IP: netip.AddrFrom4([4]byte{100, 64, 0, byte(i + 1)}), - DNSLabel: id, - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + peers[id] = &types.ComponentPeer{ + ID: id, + Key: randomWgKey(t), + IP: netip.AddrFrom4([4]byte{100, 64, 0, byte(i + 1)}), + DNSLabel: id, + AgentVersion: "0.40.0", } } @@ -165,7 +164,7 @@ func TestEnvelopeRoundTrip_AllGroupShortCircuitParity(t *testing.T) { AccountSettings: &types.AccountSettingsInfo{}, DNSSettings: &types.DNSSettings{}, Peers: peers, - Groups: map[string]*types.Group{ + Groups: map[string]*types.ComponentGroup{ "g-src": {ID: "g-src", PublicID: "1", Name: "staff", Peers: []string{"peer-T", "peer-S"}}, "g-all": {ID: "g-all", PublicID: "2", Name: "All", Peers: []string{"peer-ALL"}}, "g-two": {ID: "g-two", PublicID: "3", Name: "second", Peers: []string{"peer-T", "peer-O"}}, @@ -232,22 +231,22 @@ func buildSmokeComponents(t *testing.T) (*types.NetworkMapComponents, string) { peerAKey := randomWgKey(t) peerBKey := randomWgKey(t) - peerA := &nbpeer.Peer{ - ID: "peer-A", - Key: peerAKey, - IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), - DNSLabel: "peerA", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + peerA := &types.ComponentPeer{ + ID: "peer-A", + Key: peerAKey, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), + DNSLabel: "peerA", + AgentVersion: "0.40.0", } - peerB := &nbpeer.Peer{ - ID: "peer-B", - Key: peerBKey, - IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), - DNSLabel: "peerB", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + peerB := &types.ComponentPeer{ + ID: "peer-B", + Key: peerBKey, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), + DNSLabel: "peerB", + AgentVersion: "0.40.0", } - group := &types.Group{ + group := &types.ComponentGroup{ ID: "group-all", PublicID: "1", Name: "All", Peers: []string{"peer-A", "peer-B"}, } @@ -274,11 +273,11 @@ func buildSmokeComponents(t *testing.T) (*types.NetworkMapComponents, string) { }, AccountSettings: &types.AccountSettingsInfo{}, DNSSettings: &types.DNSSettings{}, - Peers: map[string]*nbpeer.Peer{ + Peers: map[string]*types.ComponentPeer{ "peer-A": peerA, "peer-B": peerB, }, - Groups: map[string]*types.Group{ + Groups: map[string]*types.ComponentGroup{ "group-all": group, }, Policies: []*types.Policy{policy}, diff --git a/shared/management/types/component_types.go b/shared/management/types/component_types.go new file mode 100644 index 000000000..a511097b1 --- /dev/null +++ b/shared/management/types/component_types.go @@ -0,0 +1,103 @@ +package types + +import ( + "net/netip" + "time" +) + +// ComponentPeer is the self-contained peer representation used by +// NetworkMapComponents and the calculated NetworkMap. It carries exactly the +// subset of peer data that crosses the components wire format, so the shared +// calculation layer stays independent of the management server's domain +// types. +type ComponentPeer struct { + ID string + Key string + IP netip.Addr + IPv6 netip.Addr + DNSLabel string + SSHKey string + SSHEnabled bool + ServerSSHAllowed bool + AgentVersion string + SupportsSourcePrefixes bool + SupportsIPv6 bool + LoginExpirationEnabled bool + AddedWithSSOLogin bool + LastLogin time.Time +} + +// FQDN returns the peer's FQDN combined of the peer's DNS label and the system's DNS domain. +func (p *ComponentPeer) FQDN(dnsDomain string) string { + if dnsDomain == "" { + return "" + } + return p.DNSLabel + "." + dnsDomain +} + +// LoginExpired indicates whether the peer's login has expired, mirroring the +// server-side peer semantics: only SSO-added peers with login expiration +// enabled can expire. +func (p *ComponentPeer) LoginExpired(expiresIn time.Duration) (bool, time.Duration) { + if !p.AddedWithSSOLogin || !p.LoginExpirationEnabled { + return false, 0 + } + timeLeft := time.Until(p.LastLogin.Add(expiresIn)) + return timeLeft <= 0, timeLeft +} + +// GroupAllName is the reserved name of the default group that contains every peer in an account. +const GroupAllName = "All" + +// ComponentGroup is the self-contained group representation used by +// NetworkMapComponents: just the membership view the network-map calculation +// needs, without the server's storage fields. +type ComponentGroup struct { + ID string + PublicID string + Name string + Peers []string +} + +// IsGroupAll checks if the group is a default "All" group. +func (g *ComponentGroup) IsGroupAll() bool { + return g.Name == GroupAllName +} + +// ComponentRouter is the self-contained network-router representation used by +// NetworkMapComponents. +type ComponentRouter struct { + NetworkID string + PublicID string + Peer string + PeerGroups []string + Masquerade bool + Metric int + Enabled bool +} + +// ComponentResourceType mirrors the network-resource type enum on the +// components wire format. +type ComponentResourceType string + +const ( + ComponentResourceHost ComponentResourceType = "host" + ComponentResourceSubnet ComponentResourceType = "subnet" + ComponentResourceDomain ComponentResourceType = "domain" +) + +// ComponentResource is the self-contained network-resource representation +// used by NetworkMapComponents. +type ComponentResource struct { + ID string + PublicID string + NetworkID string + AccountID string + Name string + Description string + Type ComponentResourceType + Address string + Domain string + Prefix netip.Prefix + Enabled bool +} diff --git a/shared/management/types/firewall_helpers.go b/shared/management/types/firewall_helpers.go index dd174abe4..6e43af33e 100644 --- a/shared/management/types/firewall_helpers.go +++ b/shared/management/types/firewall_helpers.go @@ -3,8 +3,6 @@ package types import ( "strconv" - nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/version" ) @@ -48,8 +46,8 @@ func portsIncludesSSH(ports []string) bool { } // ExpandPortsAndRanges expands Ports and PortRanges of a rule into individual firewall rules. -func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer) []*FirewallRule { - features := peerSupportedFirewallFeatures(peer.Meta.WtVersion) +func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPeer) []*FirewallRule { + features := peerSupportedFirewallFeatures(peer.AgentVersion) var expanded []*FirewallRule @@ -106,8 +104,8 @@ func isPortInRule(portString string, portInt uint16, rule *FirewallRule) bool { return rule.Port == portString || (rule.PortRange.Start <= portInt && portInt <= rule.PortRange.End) } -func shouldCheckRulesForNativeSSH(supportsNative bool, rule *PolicyRule, peer *nbpeer.Peer) bool { - return supportsNative && peer.SSHEnabled && peer.Meta.Flags.ServerSSHAllowed && rule.Protocol == PolicyRuleProtocolTCP +func shouldCheckRulesForNativeSSH(supportsNative bool, rule *PolicyRule, peer *ComponentPeer) bool { + return supportsNative && peer.SSHEnabled && peer.ServerSSHAllowed && rule.Protocol == PolicyRuleProtocolTCP } func peerSupportedFirewallFeatures(peerVer string) supportedFeatures { @@ -117,13 +115,13 @@ func peerSupportedFirewallFeatures(peerVer string) supportedFeatures { var features supportedFeatures - meetMinVer, err := posture.MeetsMinVersion(firewallRuleMinNativeSSHVer, peerVer) + meetMinVer, err := version.MeetsMinVersion(firewallRuleMinNativeSSHVer, peerVer) features.nativeSSH = err == nil && meetMinVer if features.nativeSSH { features.portRanges = true } else { - meetMinVer, err = posture.MeetsMinVersion(firewallRuleMinPortRangesVer, peerVer) + meetMinVer, err = version.MeetsMinVersion(firewallRuleMinPortRangesVer, peerVer) features.portRanges = err == nil && meetMinVer } diff --git a/shared/management/types/firewall_rule.go b/shared/management/types/firewall_rule.go index 87dcfe307..67cb581a2 100644 --- a/shared/management/types/firewall_rule.go +++ b/shared/management/types/firewall_rule.go @@ -9,7 +9,6 @@ import ( log "github.com/sirupsen/logrus" - nbpeer "github.com/netbirdio/netbird/management/server/peer" nbroute "github.com/netbirdio/netbird/route" ) @@ -51,7 +50,7 @@ func (r *FirewallRule) Equal(other *FirewallRule) bool { // For static routes, source ranges match the destination family (v4 or v6). // For dynamic routes (domain-based), separate v4 and v6 rules are generated // so the routing peer's forwarding chain allows both address families. -func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int, includeIPv6 bool) []*RouteFirewallRule { +func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*ComponentPeer, direction int, includeIPv6 bool) []*RouteFirewallRule { rulesExists := make(map[string]struct{}) rules := make([]*RouteFirewallRule, 0) @@ -107,7 +106,7 @@ func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule } // splitPeerSourcesByFamily separates peer IPs into v4 (/32) and v6 (/128) source ranges. -func splitPeerSourcesByFamily(groupPeers []*nbpeer.Peer) (v4, v6 []string) { +func splitPeerSourcesByFamily(groupPeers []*ComponentPeer) (v4, v6 []string) { v4 = make([]string, 0, len(groupPeers)) v6 = make([]string, 0, len(groupPeers)) for _, peer := range groupPeers { diff --git a/shared/management/types/firewall_rule_test.go b/shared/management/types/firewall_rule_test.go index 9de4ca04a..c21cfa2df 100644 --- a/shared/management/types/firewall_rule_test.go +++ b/shared/management/types/firewall_rule_test.go @@ -8,13 +8,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" ) func TestSplitPeerSourcesByFamily(t *testing.T) { - peers := []*nbpeer.Peer{ + peers := []*ComponentPeer{ { IP: netip.MustParseAddr("100.64.0.1"), IPv6: netip.MustParseAddr("fd00::1"), @@ -36,7 +35,7 @@ func TestSplitPeerSourcesByFamily(t *testing.T) { } func TestGenerateRouteFirewallRules_V4Route(t *testing.T) { - peers := []*nbpeer.Peer{ + peers := []*ComponentPeer{ { IP: netip.MustParseAddr("100.64.0.1"), IPv6: netip.MustParseAddr("fd00::1"), @@ -65,7 +64,7 @@ func TestGenerateRouteFirewallRules_V4Route(t *testing.T) { } func TestGenerateRouteFirewallRules_V6Route(t *testing.T) { - peers := []*nbpeer.Peer{ + peers := []*ComponentPeer{ { IP: netip.MustParseAddr("100.64.0.1"), IPv6: netip.MustParseAddr("fd00::1"), @@ -93,7 +92,7 @@ func TestGenerateRouteFirewallRules_V6Route(t *testing.T) { } func TestGenerateRouteFirewallRules_DynamicRoute_DualStack(t *testing.T) { - peers := []*nbpeer.Peer{ + peers := []*ComponentPeer{ { IP: netip.MustParseAddr("100.64.0.1"), IPv6: netip.MustParseAddr("fd00::1"), @@ -126,7 +125,7 @@ func TestGenerateRouteFirewallRules_DynamicRoute_DualStack(t *testing.T) { } func TestGenerateRouteFirewallRules_DynamicRoute_NoV6Peers(t *testing.T) { - peers := []*nbpeer.Peer{ + peers := []*ComponentPeer{ {IP: netip.MustParseAddr("100.64.0.1")}, {IP: netip.MustParseAddr("100.64.0.2")}, } @@ -150,7 +149,7 @@ func TestGenerateRouteFirewallRules_DynamicRoute_NoV6Peers(t *testing.T) { } func TestGenerateRouteFirewallRules_IncludeIPv6False(t *testing.T) { - peers := []*nbpeer.Peer{ + peers := []*ComponentPeer{ { IP: netip.MustParseAddr("100.64.0.1"), IPv6: netip.MustParseAddr("fd00::1"), diff --git a/shared/management/types/network.go b/shared/management/types/network.go index fe67bfd97..72a5cc5b3 100644 --- a/shared/management/types/network.go +++ b/shared/management/types/network.go @@ -15,8 +15,6 @@ import ( "golang.org/x/exp/maps" nbdns "github.com/netbirdio/netbird/dns" - nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/shared/management/status" @@ -39,11 +37,11 @@ const ( ) type NetworkMap struct { - Peers []*nbpeer.Peer + Peers []*ComponentPeer Network *Network Routes []*route.Route DNSConfig nbdns.Config - OfflinePeers []*nbpeer.Peer + OfflinePeers []*ComponentPeer FirewallRules []*FirewallRule RoutesFirewallRules []*RouteFirewallRule ForwardingRules []*ForwardingRule @@ -53,15 +51,46 @@ type NetworkMap struct { func (nm *NetworkMap) Merge(other *NetworkMap) { nm.Peers = mergeUniquePeersByID(nm.Peers, other.Peers) - nm.Routes = util.MergeUnique(nm.Routes, other.Routes) + nm.Routes = mergeUnique(nm.Routes, other.Routes) nm.OfflinePeers = mergeUniquePeersByID(nm.OfflinePeers, other.OfflinePeers) - nm.FirewallRules = util.MergeUnique(nm.FirewallRules, other.FirewallRules) - nm.RoutesFirewallRules = util.MergeUnique(nm.RoutesFirewallRules, other.RoutesFirewallRules) - nm.ForwardingRules = util.MergeUnique(nm.ForwardingRules, other.ForwardingRules) + nm.FirewallRules = mergeUnique(nm.FirewallRules, other.FirewallRules) + nm.RoutesFirewallRules = mergeUnique(nm.RoutesFirewallRules, other.RoutesFirewallRules) + nm.ForwardingRules = mergeUnique(nm.ForwardingRules, other.ForwardingRules) } -func mergeUniquePeersByID(peers1, peers2 []*nbpeer.Peer) []*nbpeer.Peer { - result := make(map[string]*nbpeer.Peer) +type comparableObject[T any] interface { + Equal(other T) bool +} + +func mergeUnique[T comparableObject[T]](arr1, arr2 []T) []T { + var result []T + + for _, item := range arr1 { + if !containsEqual(result, item) { + result = append(result, item) + } + } + + for _, item := range arr2 { + if !containsEqual(result, item) { + result = append(result, item) + } + } + + return result +} + +func containsEqual[T comparableObject[T]](slice []T, element T) bool { + for _, item := range slice { + if item.Equal(element) { + return true + } + } + return false +} + +func mergeUniquePeersByID(peers1, peers2 []*ComponentPeer) []*ComponentPeer { + result := make(map[string]*ComponentPeer) for _, peer := range peers1 { result[peer.ID] = peer } diff --git a/management/server/util/util_test.go b/shared/management/types/network_merge_test.go similarity index 88% rename from management/server/util/util_test.go rename to shared/management/types/network_merge_test.go index 5c928b369..a7ef24c1e 100644 --- a/management/server/util/util_test.go +++ b/shared/management/types/network_merge_test.go @@ -1,4 +1,4 @@ -package util +package types import ( "testing" @@ -17,7 +17,7 @@ func (t testObject) Equal(other testObject) bool { func Test_MergeUniqueArraysWithoutDuplicates(t *testing.T) { arr1 := []testObject{{value: 1}, {value: 2}} arr2 := []testObject{{value: 2}, {value: 3}} - result := MergeUnique(arr1, arr2) + result := mergeUnique(arr1, arr2) assert.Len(t, result, 3) assert.Contains(t, result, testObject{value: 1}) assert.Contains(t, result, testObject{value: 2}) @@ -27,14 +27,14 @@ func Test_MergeUniqueArraysWithoutDuplicates(t *testing.T) { func Test_MergeUniqueHandlesEmptyArrays(t *testing.T) { arr1 := []testObject{} arr2 := []testObject{} - result := MergeUnique(arr1, arr2) + result := mergeUnique(arr1, arr2) assert.Empty(t, result) } func Test_MergeUniqueHandlesOneEmptyArray(t *testing.T) { arr1 := []testObject{{value: 1}, {value: 2}} arr2 := []testObject{} - result := MergeUnique(arr1, arr2) + result := mergeUnique(arr1, arr2) assert.Len(t, result, 2) assert.Contains(t, result, testObject{value: 1}) assert.Contains(t, result, testObject{value: 2}) diff --git a/shared/management/types/networkmap_components.go b/shared/management/types/networkmap_components.go index fdb70f2f7..a708e99e1 100644 --- a/shared/management/types/networkmap_components.go +++ b/shared/management/types/networkmap_components.go @@ -12,9 +12,6 @@ import ( "github.com/netbirdio/netbird/client/ssh/auth" nbdns "github.com/netbirdio/netbird/dns" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" - routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" ) @@ -27,22 +24,22 @@ type NetworkMapComponents struct { DNSSettings *DNSSettings CustomZoneDomain string - Peers map[string]*nbpeer.Peer - Groups map[string]*Group + Peers map[string]*ComponentPeer + Groups map[string]*ComponentGroup Policies []*Policy Routes []*route.Route NameServerGroups []*nbdns.NameServerGroup AllDNSRecords []nbdns.SimpleRecord AccountZones []nbdns.CustomZone ResourcePoliciesMap map[string][]*Policy - RoutersMap map[string]map[string]*routerTypes.NetworkRouter - NetworkResources []*resourceTypes.NetworkResource + RoutersMap map[string]map[string]*ComponentRouter + NetworkResources []*ComponentResource GroupIDToUserIDs map[string][]string AllowedUserIDs map[string]struct{} PostureFailedPeers map[string]map[string]struct{} - RouterPeers map[string]*nbpeer.Peer + RouterPeers map[string]*ComponentPeer // NetworkXIDToPublicID maps Network.ID (xid) → PublicID. // Consumed by the envelope encoder to @@ -78,15 +75,15 @@ func EmptyNetworkMapComponents(nm *NetworkMapComponents) *NetworkMapComponents { return nm } -func (c *NetworkMapComponents) GetPeerInfo(peerID string) *nbpeer.Peer { +func (c *NetworkMapComponents) GetPeerInfo(peerID string) *ComponentPeer { return c.Peers[peerID] } -func (c *NetworkMapComponents) GetRouterPeerInfo(peerID string) *nbpeer.Peer { +func (c *NetworkMapComponents) GetRouterPeerInfo(peerID string) *ComponentPeer { return c.RouterPeers[peerID] } -func (c *NetworkMapComponents) GetGroupInfo(groupID string) *Group { +func (c *NetworkMapComponents) GetGroupInfo(groupID string) *ComponentGroup { return c.Groups[groupID] } @@ -142,7 +139,7 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap { includeIPv6 := false if p := c.Peers[targetPeerID]; p != nil { - includeIPv6 = p.SupportsIPv6() && p.IPv6.IsValid() + includeIPv6 = p.SupportsIPv6 && p.IPv6.IsValid() } routesUpdate := filterAndExpandRoutes(c.getRoutesToSync(targetPeerID, peersToConnect, peerGroups), includeIPv6) routesFirewallRules := c.getPeerRoutesFirewallRules(ctx, targetPeerID, includeIPv6) @@ -200,7 +197,7 @@ func (c *NetworkMapComponents) IsEmpty() bool { return c.empty } -func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ([]*nbpeer.Peer, []*FirewallRule, map[string]map[string]struct{}, bool) { +func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ([]*ComponentPeer, []*FirewallRule, map[string]map[string]struct{}, bool) { targetPeer := c.GetPeerInfo(targetPeerID) if targetPeer == nil { return nil, nil, nil, false @@ -220,7 +217,7 @@ func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ( continue } - var sourcePeers, destinationPeers []*nbpeer.Peer + var sourcePeers, destinationPeers []*ComponentPeer var peerInSources, peerInDestinations bool if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" { @@ -303,13 +300,13 @@ func (c *NetworkMapComponents) getAllowedUserIDs() map[string]struct{} { return make(map[string]struct{}) } -func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *nbpeer.Peer) (func(*PolicyRule, []*nbpeer.Peer, int), func() ([]*nbpeer.Peer, []*FirewallRule)) { +func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *ComponentPeer) (func(*PolicyRule, []*ComponentPeer, int), func() ([]*ComponentPeer, []*FirewallRule)) { rulesExists := make(map[string]struct{}) peersExists := make(map[string]struct{}) rules := make([]*FirewallRule, 0) - peers := make([]*nbpeer.Peer, 0) + peers := make([]*ComponentPeer, 0) - return func(rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int) { + return func(rule *PolicyRule, groupPeers []*ComponentPeer, direction int) { protocol := rule.Protocol if protocol == PolicyRuleProtocolNetbirdSSH { protocol = PolicyRuleProtocolTCP @@ -361,15 +358,15 @@ func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *nbpeer.Peer) ( PortsJoined: portsJoined, }) } - }, func() ([]*nbpeer.Peer, []*FirewallRule) { + }, func() ([]*ComponentPeer, []*FirewallRule) { return peers, rules } } -func (c *NetworkMapComponents) getAllPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string) ([]*nbpeer.Peer, bool) { +func (c *NetworkMapComponents) getAllPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string) ([]*ComponentPeer, bool) { peerInGroups := false uniquePeerIDs := c.getUniquePeerIDsFromGroupsIDs(groups) - filteredPeers := make([]*nbpeer.Peer, 0, len(uniquePeerIDs)) + filteredPeers := make([]*ComponentPeer, 0, len(uniquePeerIDs)) for _, p := range uniquePeerIDs { peerInfo := c.GetPeerInfo(p) @@ -421,22 +418,22 @@ func (c *NetworkMapComponents) getUniquePeerIDsFromGroupsIDs(groups []string) [] return ids } -func (c *NetworkMapComponents) getPeerFromResource(resource Resource, peerID string) ([]*nbpeer.Peer, bool) { +func (c *NetworkMapComponents) getPeerFromResource(resource Resource, peerID string) ([]*ComponentPeer, bool) { if resource.ID == peerID { - return []*nbpeer.Peer{}, true + return []*ComponentPeer{}, true } peerInfo := c.GetPeerInfo(resource.ID) if peerInfo == nil { - return []*nbpeer.Peer{}, false + return []*ComponentPeer{}, false } - return []*nbpeer.Peer{peerInfo}, false + return []*ComponentPeer{peerInfo}, false } -func (c *NetworkMapComponents) filterPeersByLoginExpiration(aclPeers []*nbpeer.Peer) ([]*nbpeer.Peer, []*nbpeer.Peer) { - peersToConnect := make([]*nbpeer.Peer, 0, len(aclPeers)) - var expiredPeers []*nbpeer.Peer +func (c *NetworkMapComponents) filterPeersByLoginExpiration(aclPeers []*ComponentPeer) ([]*ComponentPeer, []*ComponentPeer) { + peersToConnect := make([]*ComponentPeer, 0, len(aclPeers)) + var expiredPeers []*ComponentPeer for _, p := range aclPeers { expired, _ := p.LoginExpired(c.AccountSettings.PeerLoginExpiration) @@ -518,7 +515,7 @@ func filterAndExpandRoutes(routes []*route.Route, includeIPv6 bool) []*route.Rou return filtered } -func (c *NetworkMapComponents) getRoutesToSync(peerID string, aclPeers []*nbpeer.Peer, peerGroups LookupMap) []*route.Route { +func (c *NetworkMapComponents) getRoutesToSync(peerID string, aclPeers []*ComponentPeer, peerGroups LookupMap) []*route.Route { routes, peerDisabledRoutes := c.getRoutingPeerRoutes(peerID) peerRoutesMembership := make(LookupMap) for _, r := range append(routes, peerDisabledRoutes...) { @@ -732,7 +729,7 @@ func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID return fwRules } -func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}) []*nbpeer.Peer { +func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}) []*ComponentPeer { distPeersWithPolicy := make(map[string]struct{}) for _, id := range rule.Sources { group := c.GetGroupInfo(id) @@ -759,7 +756,7 @@ func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []st } } - distributionGroupPeers := make([]*nbpeer.Peer, 0, len(distPeersWithPolicy)) + distributionGroupPeers := make([]*ComponentPeer, 0, len(distPeersWithPolicy)) for pID := range distPeersWithPolicy { peerInfo := c.GetPeerInfo(pID) if peerInfo == nil { @@ -799,8 +796,8 @@ func (c *NetworkMapComponents) getNetworkResourcesRoutesToSync(peerID string) (b func (c *NetworkMapComponents) processResourcePolicies( peerID string, - resource *resourceTypes.NetworkResource, - networkRoutingPeers map[string]*routerTypes.NetworkRouter, + resource *ComponentResource, + networkRoutingPeers map[string]*ComponentRouter, addSourcePeers bool, allSourcePeers map[string]struct{}, ) []*route.Route { @@ -833,7 +830,7 @@ func (c *NetworkMapComponents) getResourcePolicyPeers(policy *Policy) []string { return c.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups()) } -func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *resourceTypes.NetworkResource, peerID string, router *routerTypes.NetworkRouter) []*route.Route { +func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *ComponentResource, peerID string, router *ComponentRouter) []*route.Route { resourceAppliedPolicies := c.ResourcePoliciesMap[resource.ID] var routes []*route.Route @@ -847,7 +844,7 @@ func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *resourceTypes return routes } -func (c *NetworkMapComponents) networkResourceToRoute(resource *resourceTypes.NetworkResource, peer *nbpeer.Peer, router *routerTypes.NetworkRouter) *route.Route { +func (c *NetworkMapComponents) networkResourceToRoute(resource *ComponentResource, peer *ComponentPeer, router *ComponentRouter) *route.Route { r := &route.Route{ ID: route.ID(resource.ID + ":" + peer.ID), AccountID: resource.AccountID, @@ -861,7 +858,7 @@ func (c *NetworkMapComponents) networkResourceToRoute(resource *resourceTypes.Ne Description: resource.Description, } - if resource.Type == resourceTypes.Host || resource.Type == resourceTypes.Subnet { + if resource.Type == ComponentResourceHost || resource.Type == ComponentResourceSubnet { r.Network = resource.Prefix r.NetworkType = route.IPv4Network @@ -870,7 +867,7 @@ func (c *NetworkMapComponents) networkResourceToRoute(resource *resourceTypes.Ne } } - if resource.Type == resourceTypes.Domain { + if resource.Type == ComponentResourceDomain { domainList, err := domain.FromStringList([]string{resource.Domain}) if err == nil { r.Domains = domainList @@ -948,11 +945,11 @@ func (c *NetworkMapComponents) getPoliciesSourcePeers(policies []*Policy) map[st func (c *NetworkMapComponents) addNetworksRoutingPeers( networkResourcesRoutes []*route.Route, peerID string, - peersToConnect []*nbpeer.Peer, - expiredPeers []*nbpeer.Peer, + peersToConnect []*ComponentPeer, + expiredPeers []*ComponentPeer, isRouter bool, sourcePeers map[string]struct{}, -) []*nbpeer.Peer { +) []*ComponentPeer { networkRoutesPeers := make(map[string]struct{}, len(networkResourcesRoutes)) for _, r := range networkResourcesRoutes { @@ -1002,8 +999,8 @@ type FirewallRuleContext struct { PortsJoined string } -func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *nbpeer.Peer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule { - if !peer.IPv6.IsValid() || !targetPeer.SupportsIPv6() || !targetPeer.IPv6.IsValid() { +func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *ComponentPeer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule { + if !peer.IPv6.IsValid() || !targetPeer.SupportsIPv6 || !targetPeer.IPv6.IsValid() { return rules } diff --git a/shared/management/types/networkmap_components_compact.go b/shared/management/types/networkmap_components_compact.go index b60f8bdb1..a1f53690d 100644 --- a/shared/management/types/networkmap_components_compact.go +++ b/shared/management/types/networkmap_components_compact.go @@ -2,9 +2,6 @@ package types import ( nbdns "github.com/netbirdio/netbird/dns" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" - routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/route" ) @@ -21,7 +18,7 @@ type NetworkMapComponentsCompact struct { DNSSettings *DNSSettings CustomZoneDomain string - AllPeers []*nbpeer.Peer + AllPeers []*ComponentPeer PeerIndexes []int RouterPeerIndexes []int @@ -34,8 +31,8 @@ type NetworkMapComponentsCompact struct { AllDNSRecords []nbdns.SimpleRecord AccountZones []nbdns.CustomZone - RoutersMap map[string]map[string]*routerTypes.NetworkRouter - NetworkResources []*resourceTypes.NetworkResource + RoutersMap map[string]map[string]*ComponentRouter + NetworkResources []*ComponentResource GroupIDToUserIDs map[string][]string AllowedUserIDs map[string]struct{} @@ -44,7 +41,7 @@ type NetworkMapComponentsCompact struct { func (c *NetworkMapComponents) ToCompact() *NetworkMapComponentsCompact { peerToIndex := make(map[string]int) - var allPeers []*nbpeer.Peer + var allPeers []*ComponentPeer for id, peer := range c.Peers { if _, exists := peerToIndex[id]; !exists { @@ -150,7 +147,7 @@ func (c *NetworkMapComponents) ToCompact() *NetworkMapComponentsCompact { } func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents { - peers := make(map[string]*nbpeer.Peer, len(c.PeerIndexes)) + peers := make(map[string]*ComponentPeer, len(c.PeerIndexes)) for _, idx := range c.PeerIndexes { if idx >= 0 && idx < len(c.AllPeers) { peer := c.AllPeers[idx] @@ -158,7 +155,7 @@ func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents { } } - routerPeers := make(map[string]*nbpeer.Peer, len(c.RouterPeerIndexes)) + routerPeers := make(map[string]*ComponentPeer, len(c.RouterPeerIndexes)) for _, idx := range c.RouterPeerIndexes { if idx >= 0 && idx < len(c.AllPeers) { peer := c.AllPeers[idx] @@ -166,7 +163,7 @@ func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents { } } - groups := make(map[string]*Group, len(c.Groups)) + groups := make(map[string]*ComponentGroup, len(c.Groups)) for id, gc := range c.Groups { peerIDs := make([]string, 0, len(gc.PeerIndexes)) for _, idx := range gc.PeerIndexes { @@ -174,7 +171,7 @@ func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents { peerIDs = append(peerIDs, c.AllPeers[idx].ID) } } - groups[id] = &Group{ + groups[id] = &ComponentGroup{ ID: id, Name: gc.Name, Peers: peerIDs, diff --git a/sharedsock/sock_linux.go b/sharedsock/sock_linux.go index 4855e1aed..150e8a722 100644 --- a/sharedsock/sock_linux.go +++ b/sharedsock/sock_linux.go @@ -24,7 +24,7 @@ import ( ) // ErrSharedSockStopped indicates that shared socket has been stopped -var ErrSharedSockStopped = fmt.Errorf("shared socked stopped") +var ErrSharedSockStopped = fmt.Errorf("shared socket stopped") // SharedSocket is a net.PacketConn that initiates two raw sockets (ipv4 and ipv6) and listens to UDP packets filtered // by BPF instructions (e.g., IncomingSTUNFilter that checks and sends only STUN packets to the listeners (ReadFrom)). diff --git a/version/version.go b/version/version.go index 074305bd6..b92e5ac7e 100644 --- a/version/version.go +++ b/version/version.go @@ -71,6 +71,30 @@ func NetbirdCommit() string { return revision } +// sanitizeVersion removes anything after the pre-release tag (e.g., "-dev", "-alpha", etc.) +func sanitizeVersion(version string) string { + parts := strings.Split(version, "-") + return parts[0] +} + +// MeetsMinVersion checks if the peer's version meets or exceeds the minimum required version +func MeetsMinVersion(minVer, peerVer string) (bool, error) { + peerVer = sanitizeVersion(peerVer) + minVer = sanitizeVersion(minVer) + + peerNBVer, err := v.NewVersion(peerVer) + if err != nil { + return false, err + } + + constraints, err := v.NewConstraint(">= " + minVer) + if err != nil { + return false, err + } + + return constraints.Check(peerNBVer), nil +} + // IsDevelopmentVersion reports whether the given version string identifies // a non-release / development build. It is the single source of truth for // "is this a dev build" checks across the codebase; use it instead of diff --git a/version/version_test.go b/version/version_test.go index cdba6b804..f05bcbd87 100644 --- a/version/version_test.go +++ b/version/version_test.go @@ -1,6 +1,10 @@ package version -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/assert" +) func TestIsDevelopmentVersion(t *testing.T) { tests := []struct { @@ -26,3 +30,68 @@ func TestIsDevelopmentVersion(t *testing.T) { }) } } + +func TestMeetsMinVersion(t *testing.T) { + tests := []struct { + name string + minVer string + peerVer string + want bool + wantErr bool + }{ + { + name: "Peer version greater than min version", + minVer: "0.26.0", + peerVer: "0.60.1", + want: true, + wantErr: false, + }, + { + name: "Peer version equals min version", + minVer: "1.0.0", + peerVer: "1.0.0", + want: true, + wantErr: false, + }, + { + name: "Peer version less than min version", + minVer: "1.0.0", + peerVer: "0.9.9", + want: false, + wantErr: false, + }, + { + name: "Peer version with pre-release tag greater than min version", + minVer: "1.0.0", + peerVer: "1.0.1-alpha", + want: true, + wantErr: false, + }, + { + name: "Invalid peer version format", + minVer: "1.0.0", + peerVer: "dev", + want: false, + wantErr: true, + }, + { + name: "Invalid min version format", + minVer: "invalid.version", + peerVer: "1.0.0", + want: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MeetsMinVersion(tt.minVer, tt.peerVer) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.want, got) + }) + } +}