diff --git a/client/embed/embed.go b/client/embed/embed.go index 079e03c63..5a3d11f24 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -85,6 +85,11 @@ type Options struct { DisableIPv6 bool // BlockInbound blocks all inbound connections from peers BlockInbound bool + // EnableRosenpass enables the Rosenpass post-quantum key exchange. + EnableRosenpass bool + // RosenpassPermissive lets a Rosenpass-enabled peer still connect to peers + // that do not run Rosenpass (falling back to the plain WireGuard PSK). + RosenpassPermissive bool // BlockLANAccess blocks the embedded peer from reaching the host's // LAN (RFC 1918, link-local, loopback) when it's used as a routing // peer. Mirrors profilemanager.ConfigInput.BlockLANAccess. Useful @@ -210,6 +215,8 @@ func New(opts Options) (*Client, error) { DisableIPv6: &opts.DisableIPv6, BlockInbound: &opts.BlockInbound, BlockLANAccess: &opts.BlockLANAccess, + RosenpassEnabled: &opts.EnableRosenpass, + RosenpassPermissive: &opts.RosenpassPermissive, WireguardPort: opts.WireguardPort, MTU: opts.MTU, DNSLabels: parsedLabels, diff --git a/client/internal/conn_mgr.go b/client/internal/conn_mgr.go index 2b9e32130..8b01eabcf 100644 --- a/client/internal/conn_mgr.go +++ b/client/internal/conn_mgr.go @@ -39,11 +39,10 @@ const ( // The only exception is ActivatePeer, which is safe for concurrent use so the // DNS warm-up path can call it without contending on the engine mutex. type ConnMgr struct { - peerStore *peerstore.Store - statusRecorder *peer.Status - iface lazyconn.WGIface - force lazyForce - rosenpassEnabled bool + peerStore *peerstore.Store + statusRecorder *peer.Status + iface lazyconn.WGIface + force lazyForce // remoteLazyEnabled caches the account-wide lazy feature flag from management. // It is the default for peers that do not carry a per-peer lazy hint. remoteLazyEnabled bool @@ -75,11 +74,10 @@ func (e *ConnMgr) SetRoutedIPsReconciler(fn func(peerKey string) error) { func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerStore *peerstore.Store, iface lazyconn.WGIface) *ConnMgr { e := &ConnMgr{ - peerStore: peerStore, - statusRecorder: statusRecorder, - iface: iface, - force: resolveLazyForce(engineConfig.LazyConnection), - rosenpassEnabled: engineConfig.RosenpassEnabled, + peerStore: peerStore, + statusRecorder: statusRecorder, + iface: iface, + force: resolveLazyForce(engineConfig.LazyConnection), } return e } @@ -87,19 +85,14 @@ func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerSto // Start initializes the connection manager. The lazy connection manager always runs so that // per-peer lazy defaults (e.g. proxy peers) work even when the account flag is off; the // account flag and the local override decide the default lazy state per peer (see -// PeerLazyDefault). Rosenpass is the only condition that disables it. +// PeerLazyDefault). Rosenpass peers stay lazy-capable too: their connections just never idle +// on their own, since rosenpass rekey traffic keeps them active. func (e *ConnMgr) Start(ctx context.Context) { if e.lazyConnMgr != nil { log.Errorf("lazy connection manager is already started") return } - if e.rosenpassEnabled { - log.Warnf("rosenpass is enabled, lazy connection manager will not be started") - e.statusRecorder.UpdateLazyConnection(false) - return - } - e.initLazyManager(ctx) e.statusRecorder.UpdateLazyConnection(e.PeerLazyDefault(mgmProto.LazyState_LazyStateDefault)) } diff --git a/client/internal/conn_mgr_test.go b/client/internal/conn_mgr_test.go index 6711c6e54..e3723b5ff 100644 --- a/client/internal/conn_mgr_test.go +++ b/client/internal/conn_mgr_test.go @@ -214,7 +214,7 @@ func TestToExcludedLazyPeers(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { e := &Engine{connMgr: &ConnMgr{force: tt.force, remoteLazyEnabled: tt.remoteEnabled}} - got := e.toExcludedLazyPeers(nil, peers) + got := e.toExcludedLazyPeers(peers) if len(got) != len(tt.want) { t.Fatalf("toExcludedLazyPeers() = %v, want %v", got, tt.want) diff --git a/client/internal/engine.go b/client/internal/engine.go index 389418c25..fd2ac1d80 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -833,7 +833,7 @@ func (e *Engine) blockLanAccess() { // modifyPeers updates peers that have been modified (e.g. IP address has been changed). // It closes the existing connection, removes it from the peerConns map, and creates a new one. -func (e *Engine) modifyPeers(peersUpdate []*mgmProto.RemotePeerConfig, forwardingRules []firewallManager.ForwardRule) error { +func (e *Engine) modifyPeers(peersUpdate []*mgmProto.RemotePeerConfig) error { // first, check if peers have been modified var modified []*mgmProto.RemotePeerConfig @@ -872,8 +872,7 @@ func (e *Engine) modifyPeers(peersUpdate []*mgmProto.RemotePeerConfig, forwardin } // third, add the peer connections again for _, p := range modified { - err := e.addNewPeer(p, forwardingRules) - if err != nil { + if err := e.addNewPeer(p); err != nil { return err } } @@ -1566,8 +1565,7 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { // Ingress forward rules done = e.phase("forward_rules") - forwardingRules, err := e.updateForwardRules(networkMap.GetForwardingRules()) - if err != nil { + if _, err := e.updateForwardRules(networkMap.GetForwardingRules()); err != nil { log.Errorf("failed to update forward rules, err: %v", err) } done() @@ -1578,14 +1576,14 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { e.updateOfflinePeers(networkMap.GetOfflinePeers()) done() - remotePeers, err := e.reconcilePeers(networkMap, forwardingRules) + remotePeers, err := e.reconcilePeers(networkMap) if err != nil { return err } // must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store done = e.phase("lazy_exclude") - e.connMgr.SetExcludeList(e.ctx, e.toExcludedLazyPeers(forwardingRules, remotePeers)) + e.connMgr.SetExcludeList(e.ctx, e.toExcludedLazyPeers(remotePeers)) done() e.networkSerial = serial @@ -1595,10 +1593,8 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { // reconcilePeers applies the remote peer list from the network map (removing, // modifying and adding peers, then updating SSH config) and returns the remote -// peers with our own peer filtered out, for use by later sync steps. The -// forwarding rules are used to decide whether a newly added peer needs an -// always-active connection. -func (e *Engine) reconcilePeers(networkMap *mgmProto.NetworkMap, forwardingRules []firewallManager.ForwardRule) ([]*mgmProto.RemotePeerConfig, error) { +// peers with our own peer filtered out, for use by later sync steps. +func (e *Engine) reconcilePeers(networkMap *mgmProto.NetworkMap) ([]*mgmProto.RemotePeerConfig, error) { // Filter out own peer from the remote peers list localPubKey := e.config.WgPrivateKey.PublicKey().String() remotePeers := make([]*mgmProto.RemotePeerConfig, 0, len(networkMap.GetRemotePeers())) @@ -1626,14 +1622,14 @@ func (e *Engine) reconcilePeers(networkMap *mgmProto.NetworkMap, forwardingRules } done = e.phase("modified_peers") - err = e.modifyPeers(remotePeers, forwardingRules) + err = e.modifyPeers(remotePeers) done() if err != nil { return nil, err } done = e.phase("added_peers") - err = e.addNewPeers(remotePeers, forwardingRules) + err = e.addNewPeers(remotePeers) done() if err != nil { return nil, err @@ -1829,10 +1825,9 @@ func addrToString(addr netip.Addr) string { } // addNewPeers adds peers that were not know before but arrived from the Management service with the update -func (e *Engine) addNewPeers(peersUpdate []*mgmProto.RemotePeerConfig, forwardingRules []firewallManager.ForwardRule) error { +func (e *Engine) addNewPeers(peersUpdate []*mgmProto.RemotePeerConfig) error { for _, p := range peersUpdate { - err := e.addNewPeer(p, forwardingRules) - if err != nil { + if err := e.addNewPeer(p); err != nil { return err } } @@ -1840,8 +1835,8 @@ func (e *Engine) addNewPeers(peersUpdate []*mgmProto.RemotePeerConfig, forwardin } // addNewPeer add peer if connection doesn't exist. A peer that is not lazy by -// policy (or is a forwarder) gets an always-active connection instead. -func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig, forwardingRules []firewallManager.ForwardRule) error { +// policy gets an always-active connection instead. +func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig) error { peerKey := peerConfig.GetWgPubKey() peerIPs := make([]netip.Prefix, 0, len(peerConfig.GetAllowedIps())) if _, ok := e.peerStore.PeerConn(peerKey); ok { @@ -1875,7 +1870,8 @@ func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig, forwardingRul log.Warnf("error adding peer %s to status recorder, got error: %v", peerKey, err) } - if exists := e.connMgr.AddPeerConn(e.ctx, peerKey, conn, e.isPermanentPeer(peerConfig, forwardingRules)); exists { + permanent := !e.connMgr.PeerLazyDefault(peerConfig.GetLazyState()) + if exists := e.connMgr.AddPeerConn(e.ctx, peerKey, conn, permanent); exists { conn.Close(false) return fmt.Errorf("peer already exists: %s", peerKey) } @@ -2668,55 +2664,18 @@ func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewal } // toExcludedLazyPeers returns the peers that must have an always-active -// connection, so the caller can reconcile the lazy manager's exclude list. -func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers []*mgmProto.RemotePeerConfig) map[string]bool { +// connection: those that are not lazy by policy (the per-peer lazy state or the +// account flag, subject to the local override). +func (e *Engine) toExcludedLazyPeers(peers []*mgmProto.RemotePeerConfig) map[string]bool { excludedPeers := make(map[string]bool) for _, p := range peers { - if e.isPermanentPeer(p, rules) { + if !e.connMgr.PeerLazyDefault(p.GetLazyState()) { excludedPeers[p.GetWgPubKey()] = true } } return excludedPeers } -// isPermanentPeer reports whether a peer needs an always-active connection: it -// is not lazy by policy (the per-peer lazy hint or account flag, subject to the -// local override), or it is an ingress forward target. Inbound forwarded traffic -// is initiated remotely and cannot wake a lazy connection, so the peer routing -// the target must stay permanently connected. -func (e *Engine) isPermanentPeer(p *mgmProto.RemotePeerConfig, rules []firewallManager.ForwardRule) bool { - if !e.connMgr.PeerLazyDefault(p.GetLazyState()) { - return true - } - - // Match against the incoming config's AllowedIPs rather than the peer store: - // isPermanentPeer runs in addNewPeer before the peer is in the store, so a - // store lookup would miss a forward target and register it as lazy. - prefixes := make([]netip.Prefix, 0, len(p.GetAllowedIps())) - for _, ipStr := range p.GetAllowedIps() { - if prefix, err := netip.ParsePrefix(ipStr); err == nil { - prefixes = append(prefixes, prefix) - } - } - for _, r := range rules { - if prefixesContain(prefixes, r.TranslatedAddress) { - log.Infof("exclude forwarder peer from lazy connection: %s", p.GetWgPubKey()) - return true - } - } - return false -} - -// prefixesContain reports whether addr falls within any of the prefixes. -func prefixesContain(prefixes []netip.Prefix, addr netip.Addr) bool { - for _, prefix := range prefixes { - if prefix.Contains(addr) { - return true - } - } - return false -} - // isChecksEqual checks if two slices of checks are equal. func isChecksEqual(checks1, checks2 []*mgmProto.Checks) bool { normalize := func(checks []*mgmProto.Checks) []string { diff --git a/client/internal/engine_lazy_exclude_test.go b/client/internal/engine_lazy_exclude_test.go deleted file mode 100644 index 815db2596..000000000 --- a/client/internal/engine_lazy_exclude_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package internal - -import ( - "net/netip" - "testing" - - "github.com/stretchr/testify/require" - - firewallManager "github.com/netbirdio/netbird/client/firewall/manager" - "github.com/netbirdio/netbird/client/internal/peer" - "github.com/netbirdio/netbird/client/internal/peerstore" - mgmProto "github.com/netbirdio/netbird/shared/management/proto" -) - -func TestPrefixesContain(t *testing.T) { - tests := []struct { - name string - prefixes []string - addr string - want bool - }{ - {name: "own overlay /32 matches", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.145", want: true}, - {name: "addr inside routed subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.121.208.4", want: true}, - {name: "addr outside subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.122.0.1", want: false}, - {name: "different /32", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.146", want: false}, - {name: "ipv6 /128 matches", prefixes: []string{"fd00::1/128"}, addr: "fd00::1", want: true}, - {name: "no prefixes", prefixes: nil, addr: "10.121.208.4", want: false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - prefixes := make([]netip.Prefix, 0, len(tt.prefixes)) - for _, p := range tt.prefixes { - prefixes = append(prefixes, netip.MustParsePrefix(p)) - } - require.Equal(t, tt.want, prefixesContain(prefixes, netip.MustParseAddr(tt.addr))) - }) - } -} - -// TestToExcludedLazyPeers_ForwardTarget guards a regression: the forward-target -// peer (the peer routing a ForwardRule.TranslatedAddress) must be excluded from -// lazy connections, matched via the peer's already-parsed AllowedIPs. -func TestToExcludedLazyPeers_ForwardTarget(t *testing.T) { - const targetPeerKey = "cccccccccccccccccccccccccccccccccccccccccc0=" - const otherPeerKey = "dddddddddddddddddddddddddddddddddddddddddd0=" - - store := peerstore.NewConnStore() - store.AddPeerConn(targetPeerKey, newTestConn(t, targetPeerKey, "100.110.8.145/32")) - store.AddPeerConn(otherPeerKey, newTestConn(t, otherPeerKey, "100.110.9.10/32")) - - // Lazy on for normal peers, so the only exclusion under test is the forward target. - e := &Engine{peerStore: store, connMgr: &ConnMgr{force: lazyForceOn}} - - peers := []*mgmProto.RemotePeerConfig{ - {WgPubKey: targetPeerKey, AllowedIps: []string{"100.110.8.145/32"}}, - {WgPubKey: otherPeerKey, AllowedIps: []string{"100.110.9.10/32"}}, - } - rules := []firewallManager.ForwardRule{ - {TranslatedAddress: netip.MustParseAddr("100.110.8.145")}, - } - - excluded := e.toExcludedLazyPeers(rules, peers) - - require.True(t, excluded[targetPeerKey], "forward-target peer must be excluded from lazy connections") - require.False(t, excluded[otherPeerKey], "non-target peer must not be excluded") - require.Len(t, excluded, 1) -} - -func TestToExcludedLazyPeers_NoRules(t *testing.T) { - // Lazy on for normal peers and no forward rules, so nothing is excluded. - e := &Engine{peerStore: peerstore.NewConnStore(), connMgr: &ConnMgr{force: lazyForceOn}} - - peers := []*mgmProto.RemotePeerConfig{ - {WgPubKey: "peer-a", AllowedIps: []string{"100.110.8.145/32"}}, - } - - require.Empty(t, e.toExcludedLazyPeers(nil, peers)) -} - -func newTestConn(t *testing.T, key, allowedIP string) *peer.Conn { - t.Helper() - conn, err := peer.NewConn(peer.ConnConfig{ - Key: key, - WgConfig: peer.WgConfig{AllowedIps: []netip.Prefix{netip.MustParsePrefix(allowedIP)}}, - }, peer.ServiceDependencies{}) - require.NoError(t, err) - return conn -} diff --git a/proxy/internal/roundtrip/netbird.go b/proxy/internal/roundtrip/netbird.go index cb2e7f930..ae3308a3e 100644 --- a/proxy/internal/roundtrip/netbird.go +++ b/proxy/internal/roundtrip/netbird.go @@ -30,6 +30,12 @@ import ( const deviceNamePrefix = "ingress-proxy-" +// envProxyRosenpass toggles Rosenpass (permissive) on the embedded proxy client. Defaults to on. +const envProxyRosenpass = "NB_PROXY_ROSENPASS" //nolint:gosec // env var name, not a credential + +// envProxyClientLogLevel sets the embedded NetBird client's log level. +const envProxyClientLogLevel = "NB_PROXY_CLIENT_LOG_LEVEL" + const clientStopTimeout = 30 * time.Second const createProxyPeerTimeout = 30 * time.Second @@ -353,11 +359,11 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account // NB_PROXY_CLIENT_LOG_LEVEL (e.g. "trace") to surface the embedded NetBird // client's relay / signal / handshake detail for local debugging. clientLogLevel := log.WarnLevel.String() - if v := strings.TrimSpace(os.Getenv("NB_PROXY_CLIENT_LOG_LEVEL")); v != "" { + if v := strings.TrimSpace(os.Getenv(envProxyClientLogLevel)); v != "" { if lvl, err := log.ParseLevel(v); err == nil { clientLogLevel = lvl.String() } else { - n.logger.Warnf("invalid NB_PROXY_CLIENT_LOG_LEVEL %q, using %q: %v", v, clientLogLevel, err) + n.logger.Warnf("invalid %s %q, using %q: %v", envProxyClientLogLevel, v, clientLogLevel, err) } } @@ -367,15 +373,26 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account } }) + // Rosenpass runs in permissive mode by default so the embedded proxy can + // establish connections with Rosenpass-enabled peers (which otherwise fail + // on a PSK mismatch) while still falling back to plain WireGuard for peers + // that do not run Rosenpass. Set NB_PROXY_ROSENPASS=false to disable it. + rosenpassEnabled := true + if v, ok := envBool(envProxyRosenpass, n.logger); ok { + rosenpassEnabled = v + } + // Create embedded NetBird client with the generated private key. // The peer has already been created via CreateProxyPeer RPC with the public key. wgPort := int(n.clientCfg.WGPort) embedOpts := embed.Options{ - DeviceName: deviceNamePrefix + n.proxyID, - ManagementURL: n.clientCfg.MgmtAddr, - PrivateKey: privateKey.String(), - LogLevel: clientLogLevel, - BlockInbound: n.clientCfg.BlockInbound, + DeviceName: deviceNamePrefix + n.proxyID, + ManagementURL: n.clientCfg.MgmtAddr, + PrivateKey: privateKey.String(), + LogLevel: clientLogLevel, + BlockInbound: n.clientCfg.BlockInbound, + EnableRosenpass: rosenpassEnabled, + RosenpassPermissive: rosenpassEnabled, // The embedded proxy peer must never be a stepping stone into // the proxy host's LAN: it only exists to reach NetBird mesh // targets or, when direct_upstream is set, the host network @@ -899,6 +916,8 @@ func logEmbedOptions(logger *log.Logger, accountID types.AccountID, serviceID ty "mtu": mtu, "block_inbound": opts.BlockInbound, "block_lan_access": opts.BlockLANAccess, + "rosenpass_enabled": opts.EnableRosenpass, + "rosenpass_permissive": opts.RosenpassPermissive, "disable_ipv6": opts.DisableIPv6, "disable_client_routes": opts.DisableClientRoutes, "no_userspace": opts.NoUserspace,