mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-18 04:39:06 +02:00
Merge branch 'main' into profile-ownership
This commit is contained in:
@@ -54,12 +54,20 @@ type DNSForwarder struct {
|
||||
ttl uint32
|
||||
statusRecorder *peer.Status
|
||||
|
||||
dnsServer *dns.Server
|
||||
mux *dns.ServeMux
|
||||
tcpServer *dns.Server
|
||||
tcpMux *dns.ServeMux
|
||||
mux *dns.ServeMux
|
||||
tcpMux *dns.ServeMux
|
||||
|
||||
mutex sync.RWMutex
|
||||
mutex sync.RWMutex
|
||||
// closed records that Close has run, so a Listen still in flight does not
|
||||
// go on to serve sockets nobody will shut down.
|
||||
closed bool
|
||||
// The sockets are kept alongside the servers because closing them is the
|
||||
// only stop that always works: a server whose ActivateAndServe has not run
|
||||
// yet refuses to shut down, and would otherwise start serving afterwards.
|
||||
udpConn net.PacketConn
|
||||
tcpLn net.Listener
|
||||
dnsServer *dns.Server
|
||||
tcpServer *dns.Server
|
||||
fwdEntries []*ForwarderEntry
|
||||
firewall firewaller
|
||||
resolver resolver
|
||||
@@ -106,7 +114,7 @@ func (f *DNSForwarder) Listen(entries []*ForwarderEntry) error {
|
||||
mux := dns.NewServeMux()
|
||||
f.mux = mux
|
||||
mux.HandleFunc(".", f.handleDNSQueryUDP)
|
||||
f.dnsServer = &dns.Server{
|
||||
dnsServer := &dns.Server{
|
||||
PacketConn: udpLn,
|
||||
Handler: mux,
|
||||
}
|
||||
@@ -114,22 +122,32 @@ func (f *DNSForwarder) Listen(entries []*ForwarderEntry) error {
|
||||
tcpMux := dns.NewServeMux()
|
||||
f.tcpMux = tcpMux
|
||||
tcpMux.HandleFunc(".", f.handleDNSQueryTCP)
|
||||
f.tcpServer = &dns.Server{
|
||||
tcpServer := &dns.Server{
|
||||
Listener: tcpLn,
|
||||
Handler: tcpMux,
|
||||
}
|
||||
|
||||
f.UpdateDomains(entries)
|
||||
if !f.publish(udpLn, tcpLn, dnsServer, tcpServer, entries) {
|
||||
log.Infof("DNS forwarder on %s was closed before it started serving", addrDesc)
|
||||
if err := udpLn.Close(); err != nil {
|
||||
log.Debugf("close UDP listener of a closed forwarder: %v", err)
|
||||
}
|
||||
if err := tcpLn.Close(); err != nil {
|
||||
log.Debugf("close TCP listener of a closed forwarder: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
log.Debugf("DNS forwarder serving %d domains", len(entries))
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
|
||||
go func() {
|
||||
log.Infof("DNS UDP listener running on %s", addrDesc)
|
||||
errCh <- f.dnsServer.ActivateAndServe()
|
||||
errCh <- dnsServer.ActivateAndServe()
|
||||
}()
|
||||
go func() {
|
||||
log.Infof("DNS TCP listener running on %s", addrDesc)
|
||||
errCh <- f.tcpServer.ActivateAndServe()
|
||||
errCh <- tcpServer.ActivateAndServe()
|
||||
}()
|
||||
|
||||
return <-errCh
|
||||
@@ -151,6 +169,46 @@ func (f *DNSForwarder) createTCPListener(netstackNet *netstack.Net) (net.Listene
|
||||
return net.ListenTCP("tcp", net.TCPAddrFromAddrPort(f.listenAddress))
|
||||
}
|
||||
|
||||
// publish hands the sockets, servers and entries to the forwarder so Close can
|
||||
// reach them and Domains can report them, and says whether serving may begin.
|
||||
// Listen runs on its own goroutine, so a Close can arrive before it gets this
|
||||
// far; false means the caller must close what it created instead of serving on
|
||||
// it.
|
||||
//
|
||||
// The entries go in under the same lock rather than afterwards. Anything that
|
||||
// reads them in between would otherwise see a forwarder that is listening and
|
||||
// serves no domain, which for a caller rebuilding one means it comes back
|
||||
// refusing every routed query.
|
||||
func (f *DNSForwarder) publish(
|
||||
udpConn net.PacketConn,
|
||||
tcpLn net.Listener,
|
||||
dnsServer, tcpServer *dns.Server,
|
||||
entries []*ForwarderEntry,
|
||||
) bool {
|
||||
f.mutex.Lock()
|
||||
defer f.mutex.Unlock()
|
||||
|
||||
if f.closed {
|
||||
return false
|
||||
}
|
||||
|
||||
f.udpConn = udpConn
|
||||
f.tcpLn = tcpLn
|
||||
f.dnsServer = dnsServer
|
||||
f.tcpServer = tcpServer
|
||||
f.fwdEntries = entries
|
||||
return true
|
||||
}
|
||||
|
||||
// Domains returns the entries currently being served. The slice is replaced
|
||||
// wholesale by UpdateDomains rather than mutated, so the caller may read it but
|
||||
// must not write to it.
|
||||
func (f *DNSForwarder) Domains() []*ForwarderEntry {
|
||||
f.mutex.RLock()
|
||||
defer f.mutex.RUnlock()
|
||||
return f.fwdEntries
|
||||
}
|
||||
|
||||
func (f *DNSForwarder) UpdateDomains(entries []*ForwarderEntry) {
|
||||
f.mutex.Lock()
|
||||
defer f.mutex.Unlock()
|
||||
@@ -189,19 +247,45 @@ func (f *DNSForwarder) removeStaleCacheEntries(oldEntries, newEntries []*Forward
|
||||
}
|
||||
|
||||
func (f *DNSForwarder) Close(ctx context.Context) error {
|
||||
// Marked closed under the lock so a Listen that has not published its
|
||||
// servers yet gives up instead of racing this shutdown. The shutdowns
|
||||
// themselves block, so they run outside it.
|
||||
f.mutex.Lock()
|
||||
f.closed = true
|
||||
dnsServer, tcpServer := f.dnsServer, f.tcpServer
|
||||
udpConn, tcpLn := f.udpConn, f.tcpLn
|
||||
f.mutex.Unlock()
|
||||
|
||||
var result *multierror.Error
|
||||
|
||||
if f.dnsServer != nil {
|
||||
if err := f.dnsServer.ShutdownContext(ctx); err != nil {
|
||||
if dnsServer != nil {
|
||||
if err := shutdownServer(ctx, dnsServer); err != nil {
|
||||
result = multierror.Append(result, fmt.Errorf("UDP shutdown: %w", err))
|
||||
}
|
||||
}
|
||||
if f.tcpServer != nil {
|
||||
if err := f.tcpServer.ShutdownContext(ctx); err != nil {
|
||||
if tcpServer != nil {
|
||||
if err := shutdownServer(ctx, tcpServer); err != nil {
|
||||
result = multierror.Append(result, fmt.Errorf("TCP shutdown: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// The sockets are closed even when the shutdowns above reported nothing to
|
||||
// do. A server that has been published but has not reached
|
||||
// ActivateAndServe refuses to shut down, and closing what it was about to
|
||||
// serve on is what stops it: the alternative is a listener still answering
|
||||
// on an interface that has gone away. A shutdown that did run has already
|
||||
// closed these, so the second close is expected to fail.
|
||||
if udpConn != nil {
|
||||
if err := udpConn.Close(); err != nil {
|
||||
log.Debugf("close UDP socket of the DNS forwarder: %v", err)
|
||||
}
|
||||
}
|
||||
if tcpLn != nil {
|
||||
if err := tcpLn.Close(); err != nil {
|
||||
log.Debugf("close TCP socket of the DNS forwarder: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nberrors.FormatErrorOrNil(result)
|
||||
}
|
||||
|
||||
@@ -514,3 +598,16 @@ func attachEDE(resp *dns.Msg, code uint16, text string) {
|
||||
}
|
||||
opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: code, ExtraText: text})
|
||||
}
|
||||
|
||||
// shutdownServer shuts a server down gracefully, treating "never started" as
|
||||
// success. A server that was published but has not reached ActivateAndServe
|
||||
// has nothing to wind down, and the caller closes its socket regardless, which
|
||||
// is what actually stops it. dns exports no sentinel for this, so the message
|
||||
// is all there is to match on.
|
||||
func shutdownServer(ctx context.Context, server *dns.Server) error {
|
||||
err := server.ShutdownContext(ctx)
|
||||
if err == nil || strings.Contains(err.Error(), "server not started") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1238,3 +1238,55 @@ func TestDNSForwarder_EmptyQuery(t *testing.T) {
|
||||
|
||||
assert.Nil(t, mockWriter.GetLastResponse(), "Should not write response for empty query")
|
||||
}
|
||||
|
||||
// TestDNSForwarder_ClosedBeforeItServes covers Listen reaching the point of
|
||||
// serving after the forwarder has already been closed. Listen runs on its own
|
||||
// goroutine, so it can get there late, and a socket it starts serving then is
|
||||
// one nothing will ever close: on Android it keeps answering on an interface
|
||||
// that has been replaced. The close is sequenced first here rather than raced,
|
||||
// which pins the same state deterministically.
|
||||
func TestDNSForwarder_ClosedBeforeItServes(t *testing.T) {
|
||||
f := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 60, nil, nil, nil)
|
||||
|
||||
require.NoError(t, f.Close(context.Background()), "closing a forwarder that never started")
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- f.Listen(nil) }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
assert.NoError(t, err, "a closed forwarder should give up quietly, not serve")
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Listen went on to serve after the forwarder was closed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDNSForwarder_CloseStopsUnactivatedServers covers the window between
|
||||
// Listen publishing its servers and reaching ActivateAndServe. A server that
|
||||
// has not been activated refuses to shut down, so Close has to close the
|
||||
// sockets itself or they are left serving.
|
||||
func TestDNSForwarder_CloseStopsUnactivatedServers(t *testing.T) {
|
||||
f := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 60, nil, nil, nil)
|
||||
|
||||
udpConn, err := f.createUDPListener(nil)
|
||||
require.NoError(t, err, "create UDP listener")
|
||||
tcpLn, err := f.createTCPListener(nil)
|
||||
require.NoError(t, err, "create TCP listener")
|
||||
|
||||
// Published but deliberately never activated, which is the state Listen is
|
||||
// in for the moment before it starts serving.
|
||||
require.True(t, f.publish(udpConn, tcpLn, &dns.Server{PacketConn: udpConn}, &dns.Server{Listener: tcpLn}, nil),
|
||||
"publishing to an open forwarder")
|
||||
|
||||
tcpAddr := tcpLn.Addr().String()
|
||||
require.NoError(t, f.Close(context.Background()), "close should report no error for servers it could not shut down")
|
||||
|
||||
_, err = tcpLn.Accept()
|
||||
assert.Error(t, err, "the TCP socket should be closed after Close")
|
||||
|
||||
conn, err := net.DialTimeout("tcp", tcpAddr, time.Second)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
t.Fatal("the forwarder is still accepting connections after Close")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,16 @@ func (m *Manager) UpdateDomains(entries []*ForwarderEntry) {
|
||||
m.dnsForwarder.UpdateDomains(entries)
|
||||
}
|
||||
|
||||
// Domains returns the entries currently being served, or nil when the
|
||||
// forwarder is not running.
|
||||
func (m *Manager) Domains() []*ForwarderEntry {
|
||||
if m.dnsForwarder == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return m.dnsForwarder.Domains()
|
||||
}
|
||||
|
||||
func (m *Manager) Stop(ctx context.Context) error {
|
||||
if m.dnsForwarder == nil {
|
||||
return nil
|
||||
|
||||
+114
-12
@@ -94,6 +94,13 @@ const (
|
||||
// exec, os.Stat); without this bound a single stuck call freezes handleSync, and
|
||||
// thus syncMsgMux, for as long as the call hangs (observed multi-minute freezes).
|
||||
systemInfoTimeout = 15 * time.Second
|
||||
|
||||
// dnsForwarderStopTimeout bounds how long stopping the DNS forwarder waits
|
||||
// for the queries still in flight. One waiting on an unresponsive upstream
|
||||
// would otherwise hold the stop for the whole upstream timeout, and the
|
||||
// stop runs with syncMsgMux held. The sockets are closed either way, so
|
||||
// giving up costs a query that was already failing.
|
||||
dnsForwarderStopTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
var ErrResetConnection = fmt.Errorf("reset connection")
|
||||
@@ -258,6 +265,8 @@ type Engine struct {
|
||||
// checks are the client-applied posture checks that need to be evaluated on the client
|
||||
checks []*mgmProto.Checks
|
||||
|
||||
infoSource system.InfoSource
|
||||
|
||||
relayManager *relayClient.Manager
|
||||
stateManager *statemanager.Manager
|
||||
portForwardManager *portforward.Manager
|
||||
@@ -321,6 +330,10 @@ type localIpUpdater interface {
|
||||
UpdateLocalIPs() error
|
||||
}
|
||||
|
||||
// overlayRebind rebuilds one subsystem's sockets on the current interface. The
|
||||
// error it returns names its own subsystem, since the caller can only log it.
|
||||
type overlayRebind func() error
|
||||
|
||||
// NewEngine creates a new Connection Engine with probes attached
|
||||
func NewEngine(
|
||||
clientCtx context.Context,
|
||||
@@ -1230,9 +1243,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
|
||||
if isChecksEqual(e.checks, checks) {
|
||||
return nil
|
||||
}
|
||||
e.checks = checks
|
||||
|
||||
info, ok := system.GetInfoWithChecksTimeout(e.ctx, systemInfoTimeout, checks, e.overlayAddresses()...)
|
||||
info, ok := e.infoSource.Refresh(e.ctx, systemInfoTimeout, checks, e.overlayAddresses()...)
|
||||
if !ok {
|
||||
// Gathering timed out; skip the meta sync this cycle rather than blocking the
|
||||
// sync loop (and syncMsgMux) on a stuck system call. A later sync will retry.
|
||||
@@ -1243,6 +1254,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
|
||||
if err := e.mgmClient.SyncMeta(info); err != nil {
|
||||
return fmt.Errorf("could not sync meta: error %s", err)
|
||||
}
|
||||
e.checks = checks
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1269,6 +1281,28 @@ func (e *Engine) applyInfoFlags(info *system.Info) {
|
||||
)
|
||||
}
|
||||
|
||||
func (e *Engine) currentSystemInfo(ctx context.Context) *system.Info {
|
||||
info := e.infoSource.Current(ctx, e.overlayAddresses()...)
|
||||
e.applyInfoFlags(info)
|
||||
return info
|
||||
}
|
||||
|
||||
// syncInfoFunc returns the info callback for the management sync stream. The
|
||||
// first connect sends the info refreshed right before it instead of gathering
|
||||
// again; every reconnect gathers a fresh one. The stream retry loop calls the
|
||||
// callback sequentially, so the handoff needs no synchronization.
|
||||
func (e *Engine) syncInfoFunc(refreshed *system.Info) func(ctx context.Context) *system.Info {
|
||||
return func(ctx context.Context) *system.Info {
|
||||
if refreshed == nil {
|
||||
return e.currentSystemInfo(ctx)
|
||||
}
|
||||
info := refreshed
|
||||
refreshed = nil
|
||||
e.applyInfoFlags(info)
|
||||
return info
|
||||
}
|
||||
}
|
||||
|
||||
// overlayAddresses returns our own WireGuard overlay address (v4 and v6) so it
|
||||
// can be excluded from the reported network addresses; the interface coming and
|
||||
// going otherwise churns the peer meta on the management server.
|
||||
@@ -1462,15 +1496,11 @@ func (e *Engine) receiveManagementEvents() {
|
||||
e.shutdownWg.Add(1)
|
||||
go func() {
|
||||
defer e.shutdownWg.Done()
|
||||
info, ok := system.GetInfoWithChecksTimeout(e.ctx, systemInfoTimeout, e.checks, e.overlayAddresses()...)
|
||||
info, ok := e.infoSource.Refresh(e.ctx, systemInfoTimeout, e.checks, e.overlayAddresses()...)
|
||||
if !ok {
|
||||
// Gathering timed out; connect the stream with base info so management
|
||||
// connectivity still comes up rather than blocking here.
|
||||
info = system.GetInfo(e.ctx)
|
||||
log.Warnf("posture checks not refreshed before the sync connect, sending the previous results")
|
||||
}
|
||||
e.applyInfoFlags(info)
|
||||
|
||||
err := e.mgmClient.Sync(e.ctx, info, e.handleSync)
|
||||
err := e.mgmClient.Sync(e.ctx, e.syncInfoFunc(info), e.handleSync)
|
||||
if err != nil {
|
||||
// happens if management is unavailable for a long time.
|
||||
// We want to cancel the operation of the whole client
|
||||
@@ -2502,7 +2532,72 @@ func (e *Engine) RenewTun(fd int) error {
|
||||
return fmt.Errorf("wireguard interface not initialized")
|
||||
}
|
||||
|
||||
return wgInterface.RenewTun(fd)
|
||||
if err := wgInterface.RenewTun(fd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
e.rebindOverlayListeners()
|
||||
return nil
|
||||
}
|
||||
|
||||
// rebindOverlayListeners gives the servers that listen on an overlay address
|
||||
// sockets on the interface as it is now.
|
||||
//
|
||||
// A socket belongs to the interface generation it was created on. Renewing the
|
||||
// TUN builds a new interface and moves the overlay addresses to it, which
|
||||
// leaves the old sockets in LISTEN with the uspfilter still logging packets
|
||||
// arriving for them, while every accept fails with EINVAL for the life of the
|
||||
// socket: from the outside the server looks alive and answers nothing. On
|
||||
// Android this happens during a normal startup, where the first TUN is
|
||||
// established before the routes are known and replaced once they arrive.
|
||||
//
|
||||
// Rebinding costs whatever those sockets were carrying, which the renewal has
|
||||
// already broken. Errors are logged rather than returned: the renewal itself
|
||||
// succeeded, and failing it would hand the caller a working interface and an
|
||||
// error.
|
||||
func (e *Engine) rebindOverlayListeners() {
|
||||
e.syncMsgMux.Lock()
|
||||
defer e.syncMsgMux.Unlock()
|
||||
|
||||
for _, rebind := range e.overlayRebinds() {
|
||||
if err := rebind(); err != nil {
|
||||
log.Errorf("after TUN renewal: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// overlayRebinds is every subsystem of this engine that holds sockets bound to
|
||||
// an overlay address, and how to rebuild each one's.
|
||||
//
|
||||
// A subsystem that starts listening on an overlay address belongs in this list.
|
||||
// Leaving it out costs nothing that review would notice and produces a listener
|
||||
// that stays in LISTEN, is logged as receiving packets, and refuses every
|
||||
// connection for the life of the process.
|
||||
func (e *Engine) overlayRebinds() []overlayRebind {
|
||||
return []overlayRebind{
|
||||
e.restartSSHListeners,
|
||||
e.restartDNSForwarder,
|
||||
}
|
||||
}
|
||||
|
||||
// restartDNSForwarder rebuilds the DNS forwarder serving the same domains.
|
||||
// No-op when it is not running. See Engine.rebindOverlayListeners.
|
||||
func (e *Engine) restartDNSForwarder() error {
|
||||
if e.dnsForwardMgr == nil {
|
||||
return nil
|
||||
}
|
||||
// Read from the forwarder before it goes away, so the replacement serves
|
||||
// the domains in force now rather than a copy kept somewhere else.
|
||||
entries := e.dnsForwardMgr.Domains()
|
||||
e.stopDNSForwarder()
|
||||
// Both halves log their own failures, so the only thing left to report is
|
||||
// the outcome: a start that failed left the manager nil, and the forwarder
|
||||
// is now down rather than merely rebound.
|
||||
e.startDNSForwarder(entries)
|
||||
if e.dnsForwardMgr == nil {
|
||||
return errors.New("rebind DNS forwarder: it did not come back up")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateDNSForwarder start or stop the DNS forwarder based on the domains and the feature flag
|
||||
@@ -2548,7 +2643,14 @@ func (e *Engine) stopDNSForwarder() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := e.dnsForwardMgr.Stop(context.Background()); err != nil {
|
||||
// Bounded because the shutdown waits for queries still in flight, and one
|
||||
// waiting on an unresponsive upstream holds it for as long as that lookup
|
||||
// is allowed to take. This runs with syncMsgMux held, so that wait is one
|
||||
// the whole engine spends.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), dnsForwarderStopTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := e.dnsForwardMgr.Stop(ctx); err != nil {
|
||||
log.Errorf("failed to stop DNS forward: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -193,7 +193,7 @@ func TestEngine_Sync(t *testing.T) {
|
||||
// feed updates to Engine via mocked Management client
|
||||
updates := make(chan *mgmtProto.SyncResponse)
|
||||
defer close(updates)
|
||||
syncFunc := func(ctx context.Context, info *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error {
|
||||
syncFunc := func(ctx context.Context, _ func(context.Context) *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error {
|
||||
for msg := range updates {
|
||||
err := msgHandler(msg)
|
||||
if err != nil {
|
||||
|
||||
@@ -24,6 +24,8 @@ type sshServer interface {
|
||||
Stop() error
|
||||
GetStatus() (bool, []sshserver.SessionInfo)
|
||||
UpdateSSHAuth(config *sshauth.Config)
|
||||
JWTConfig() *sshserver.JWTConfig
|
||||
AuthConfig() *sshauth.Config
|
||||
}
|
||||
|
||||
func (e *Engine) setupSSHPortRedirection() error {
|
||||
@@ -77,7 +79,7 @@ func (e *Engine) updateSSH(sshConf *mgmProto.SSHConfig) error {
|
||||
|
||||
if e.config.DisableSSHAuth != nil && *e.config.DisableSSHAuth {
|
||||
log.Info("starting SSH server without JWT authentication (authentication disabled by config)")
|
||||
return e.startSSHServer(nil)
|
||||
return e.startSSHServer(nil, nil)
|
||||
}
|
||||
|
||||
if protoJWT := sshConf.GetJwtConfig(); protoJWT != nil {
|
||||
@@ -95,7 +97,7 @@ func (e *Engine) updateSSH(sshConf *mgmProto.SSHConfig) error {
|
||||
MaxTokenAge: protoJWT.GetMaxTokenAge(),
|
||||
}
|
||||
|
||||
return e.startSSHServer(jwtConfig)
|
||||
return e.startSSHServer(jwtConfig, nil)
|
||||
}
|
||||
|
||||
return errors.New("SSH server requires valid JWT configuration")
|
||||
@@ -231,8 +233,33 @@ func (e *Engine) cleanupSSHConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// startSSHServer initializes and starts the SSH server with proper configuration.
|
||||
func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig) error {
|
||||
// restartSSHListeners rebuilds the SSH server so it listens on new sockets, on
|
||||
// the same terms it was started with. No-op when it is not running. See
|
||||
// Engine.rebindOverlayListeners for why this is needed.
|
||||
func (e *Engine) restartSSHListeners() error {
|
||||
if e.sshServer == nil {
|
||||
return nil
|
||||
}
|
||||
// Read from the server before it goes away. A rebuilt one starts with an
|
||||
// empty authorizer, which fails closed, so without carrying the
|
||||
// authorization over every JWT login is refused until the next network map
|
||||
// happens to bring one.
|
||||
jwtConfig, authConfig := e.sshServer.JWTConfig(), e.sshServer.AuthConfig()
|
||||
if err := e.stopSSHServer(); err != nil {
|
||||
return fmt.Errorf("rebind SSH listeners: %w", err)
|
||||
}
|
||||
if err := e.startSSHServer(jwtConfig, authConfig); err != nil {
|
||||
return fmt.Errorf("rebind SSH listeners: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// startSSHServer initializes and starts the SSH server with proper
|
||||
// configuration. authConfig is the fine-grained authorization to open with, and
|
||||
// is applied before the server accepts anything: a server that starts listening
|
||||
// with an empty authorizer refuses the logins that arrive in the meantime.
|
||||
// Nil leaves it as management has not sent one yet.
|
||||
func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig, authConfig *sshauth.Config) error {
|
||||
if e.wgInterface == nil {
|
||||
return errors.New("wg interface not initialized")
|
||||
}
|
||||
@@ -240,6 +267,7 @@ func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig) error {
|
||||
serverConfig := &sshserver.Config{
|
||||
HostKeyPEM: e.config.SSHKey,
|
||||
JWT: jwtConfig,
|
||||
Auth: authConfig,
|
||||
}
|
||||
server := sshserver.New(serverConfig)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
@@ -31,6 +32,7 @@ import (
|
||||
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/monotime"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
@@ -253,6 +255,118 @@ func TestEngine_SSHServerConsistency(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestEngine_FirstSyncInfoCarriesLoginChecks(t *testing.T) {
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
exe, err := os.Executable()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
|
||||
defer cancel()
|
||||
|
||||
infos := make(chan *system.Info, 1)
|
||||
mgmClient := &mgmt.MockClient{
|
||||
SyncFunc: func(ctx context.Context, getInfo func(context.Context) *system.Info, _ func(*mgmtProto.SyncResponse) error) error {
|
||||
infos <- getInfo(ctx)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
||||
engine := NewEngine(ctx, cancel, &EngineConfig{
|
||||
WgIfaceName: "utun104",
|
||||
WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
|
||||
WgPrivateKey: key,
|
||||
WgPort: 33100,
|
||||
MTU: iface.DefaultMTU,
|
||||
}, EngineServices{
|
||||
SignalClient: &signal.MockClient{},
|
||||
MgmClient: mgmClient,
|
||||
RelayManager: relayMgr,
|
||||
StatusRecorder: peer.NewRecorder("https://mgm"),
|
||||
Checks: []*mgmtProto.Checks{{Files: []string{exe}}},
|
||||
}, MobileDependency{})
|
||||
|
||||
engine.receiveManagementEvents()
|
||||
|
||||
select {
|
||||
case info := <-infos:
|
||||
require.Len(t, info.Files, 1)
|
||||
assert.Equal(t, exe, info.Files[0].Path)
|
||||
assert.True(t, info.Files[0].Exist)
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Fatal("timeout waiting for the first sync info")
|
||||
}
|
||||
engine.shutdownWg.Wait()
|
||||
}
|
||||
|
||||
func TestEngine_SyncInfoFuncReusesRefreshedInfoOnce(t *testing.T) {
|
||||
engine := &Engine{config: &EngineConfig{}}
|
||||
|
||||
refreshed := &system.Info{Hostname: "from-refresh"}
|
||||
getInfo := engine.syncInfoFunc(refreshed)
|
||||
|
||||
first := getInfo(context.Background())
|
||||
assert.Same(t, refreshed, first, "the first connect should send the refreshed info instead of gathering again")
|
||||
|
||||
second := getInfo(context.Background())
|
||||
assert.NotSame(t, refreshed, second, "the reconnect should gather a fresh info")
|
||||
assert.NotEqual(t, "from-refresh", second.Hostname, "the fresh info should not carry the refreshed hostname")
|
||||
}
|
||||
|
||||
func TestEngine_SyncInfoFuncGathersWhenRefreshFailed(t *testing.T) {
|
||||
engine := &Engine{config: &EngineConfig{}}
|
||||
|
||||
info := engine.syncInfoFunc(nil)(context.Background())
|
||||
require.NotNil(t, info, "a failed refresh should fall back to gathering the info")
|
||||
assert.NotEmpty(t, info.Hostname, "the gathered info should carry the hostname")
|
||||
}
|
||||
|
||||
func TestEngine_UpdateChecksIfNewRetriesAfterFailedSyncMeta(t *testing.T) {
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
exe, err := os.Executable()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
|
||||
defer cancel()
|
||||
|
||||
syncMetaCalls := 0
|
||||
mgmClient := &mgmt.MockClient{
|
||||
SyncMetaFunc: func(*system.Info) error {
|
||||
syncMetaCalls++
|
||||
if syncMetaCalls == 1 {
|
||||
return errors.New("management unavailable")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
||||
engine := NewEngine(ctx, cancel, &EngineConfig{
|
||||
WgIfaceName: "utun105",
|
||||
WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
|
||||
WgPrivateKey: key,
|
||||
WgPort: 33100,
|
||||
MTU: iface.DefaultMTU,
|
||||
}, EngineServices{
|
||||
SignalClient: &signal.MockClient{},
|
||||
MgmClient: mgmClient,
|
||||
RelayManager: relayMgr,
|
||||
StatusRecorder: peer.NewRecorder("https://mgm"),
|
||||
}, MobileDependency{})
|
||||
|
||||
checks := []*mgmtProto.Checks{{Files: []string{exe}}}
|
||||
|
||||
require.Error(t, engine.updateChecksIfNew(checks))
|
||||
require.NoError(t, engine.updateChecksIfNew(checks))
|
||||
require.NoError(t, engine.updateChecksIfNew(checks))
|
||||
|
||||
assert.Equal(t, 2, syncMetaCalls)
|
||||
}
|
||||
|
||||
func TestEngine_UpdateNetworkMap(t *testing.T) {
|
||||
// test setup
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
|
||||
@@ -59,10 +59,6 @@ var DefaultInterfaceBlacklist = []string{
|
||||
"Tailscale", "tailscale", "docker", "veth", "br-", "lo",
|
||||
}
|
||||
|
||||
// loadMDMPolicy is the package-level indirection used by apply() to read the
|
||||
// active MDM policy. Tests override this to inject a fake policy.
|
||||
var loadMDMPolicy = mdm.LoadPolicy
|
||||
|
||||
// ConfigInput carries configuration changes to the client
|
||||
type ConfigInput struct {
|
||||
ManagementURL string
|
||||
@@ -204,16 +200,28 @@ type Config struct {
|
||||
|
||||
MTU uint16
|
||||
|
||||
// policy is the MDM policy that produced the currently-set values for
|
||||
// any MDM-enforced fields. Set by applyMDMPolicy at the tail of apply()
|
||||
// and reset on every apply() invocation. Never persisted to disk.
|
||||
// Callers query enforcement state via Policy() and the mdm.Policy API
|
||||
// (HasKey, ManagedKeys, IsEmpty).
|
||||
// policy is the MDM policy that produced the currently-set values
|
||||
// for any MDM-enforced fields. Set by ApplyMDMPolicy on every
|
||||
// invocation. Never persisted to disk. Callers query enforcement
|
||||
// state via Policy() and the mdm.Policy API (HasKey, ManagedKeys,
|
||||
// IsEmpty).
|
||||
policy *mdm.Policy `json:"-"`
|
||||
|
||||
Owners []string
|
||||
}
|
||||
|
||||
// ApplyMDMPolicy overlays the supplied MDM Policy on top of the current
|
||||
// Config values and records it as Policy(). The overlay is not reversible:
|
||||
// an empty Policy only clears the enforcement metadata, so resolve the base
|
||||
// Config again (from disk or JSON) before applying a changed policy, the way
|
||||
// the lifecycle owners do on every load.
|
||||
func (config *Config) ApplyMDMPolicy(policy *mdm.Policy) {
|
||||
if config == nil {
|
||||
return
|
||||
}
|
||||
config.applyMDMPolicy(policy)
|
||||
}
|
||||
|
||||
// Policy returns the MDM policy applied to this Config. Returns a non-nil
|
||||
// empty Policy when MDM enforcement is inactive; callers can always invoke
|
||||
// HasKey / ManagedKeys / IsEmpty without a nil check.
|
||||
@@ -723,9 +731,11 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
// MDM is the last override layer: any key present in the policy
|
||||
// supersedes defaults, on-disk config, env vars and CLI input.
|
||||
config.applyMDMPolicy(loadMDMPolicy())
|
||||
// Initialise the MDM overlay to "no enforcement" so Config.Policy()
|
||||
// never returns a stale or nil policy on a freshly applied Config.
|
||||
// Lifecycle owners that want to enforce a real MDM policy invoke
|
||||
// Config.ApplyMDMPolicy(loader.Load()) after this returns.
|
||||
config.applyMDMPolicy(mdm.NewPolicy(nil))
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package profilemanager
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
)
|
||||
|
||||
// ErrMDMManagedFields marks a config change rejected because it diverges from
|
||||
// MDM-enforced values.
|
||||
var ErrMDMManagedFields = errors.New("fields managed by MDM cannot be modified")
|
||||
|
||||
// MDMConflicts returns the names of MDM-managed keys whose requested value in
|
||||
// the ConfigInput differs from the policy-enforced value; a field set to the
|
||||
// enforced value is a no-op echo, not a conflict.
|
||||
func MDMConflicts(input ConfigInput, policy *mdm.Policy) []string {
|
||||
pskGot := input.PreSharedKey
|
||||
if isPreSharedKeyHidden(pskGot) {
|
||||
pskGot = nil
|
||||
}
|
||||
var port *int64
|
||||
if input.WireguardPort != nil {
|
||||
v := int64(*input.WireguardPort)
|
||||
port = &v
|
||||
}
|
||||
return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{
|
||||
mdm.ConflictURL(mdm.KeyManagementURL, input.ManagementURL),
|
||||
mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot),
|
||||
mdm.ConflictBool(mdm.KeyRosenpassEnabled, input.RosenpassEnabled),
|
||||
mdm.ConflictBool(mdm.KeyRosenpassPermissive, input.RosenpassPermissive),
|
||||
mdm.ConflictBool(mdm.KeyDisableAutoConnect, input.DisableAutoConnect),
|
||||
mdm.ConflictBool(mdm.KeyAllowServerSSH, input.ServerSSHAllowed),
|
||||
mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, input.RemoteJobsAllowed),
|
||||
mdm.ConflictBool(mdm.KeyDisableClientRoutes, input.DisableClientRoutes),
|
||||
mdm.ConflictBool(mdm.KeyDisableServerRoutes, input.DisableServerRoutes),
|
||||
mdm.ConflictBool(mdm.KeyBlockInbound, input.BlockInbound),
|
||||
mdm.ConflictInt64(mdm.KeyWireguardPort, port),
|
||||
mdm.ConflictBool(mdm.KeyEnableLocalMetrics, input.LocalMetricsEnabled),
|
||||
mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, input.LocalMetricsAddress),
|
||||
})
|
||||
}
|
||||
|
||||
// CheckMDMConflicts returns an ErrMDMManagedFields-wrapped error naming the
|
||||
// conflicting keys, or nil when the input does not fight the policy.
|
||||
func CheckMDMConflicts(input ConfigInput, policy *mdm.Policy) error {
|
||||
conflicts := MDMConflicts(input, policy)
|
||||
if len(conflicts) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: %v", ErrMDMManagedFields, conflicts)
|
||||
}
|
||||
@@ -10,24 +10,58 @@ import (
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
)
|
||||
|
||||
// withMDMPolicy temporarily overrides the package-level loadMDMPolicy hook so
|
||||
// apply() observes the supplied Policy. The original loader is restored at
|
||||
// test cleanup.
|
||||
func withMDMPolicy(t *testing.T, policy *mdm.Policy) {
|
||||
// fakeFetcher implements mdm.PolicyFetcher returning a pre-set policy
|
||||
// map. Test helper used to construct a Loader without touching the OS
|
||||
// or any package-level state.
|
||||
type fakeFetcher struct{ values map[string]any }
|
||||
|
||||
func (f *fakeFetcher) Fetch() map[string]any { return f.values }
|
||||
|
||||
// loaderFor builds an mdm.Loader whose loadPlatform returns the
|
||||
// supplied Policy's underlying values.
|
||||
func loaderFor(policy *mdm.Policy) *mdm.Loader {
|
||||
if policy == nil || policy.IsEmpty() {
|
||||
return mdm.NewLoader(&fakeFetcher{values: nil})
|
||||
}
|
||||
values := make(map[string]any)
|
||||
for _, k := range policy.ManagedKeys() {
|
||||
if v, ok := policy.GetString(k); ok {
|
||||
values[k] = v
|
||||
continue
|
||||
}
|
||||
if v, ok := policy.GetInt(k); ok {
|
||||
values[k] = v
|
||||
continue
|
||||
}
|
||||
if v, ok := policy.GetBool(k); ok {
|
||||
values[k] = v
|
||||
continue
|
||||
}
|
||||
if v, ok := policy.GetStringSlice(k); ok {
|
||||
values[k] = v
|
||||
}
|
||||
}
|
||||
return mdm.NewLoader(&fakeFetcher{values: values})
|
||||
}
|
||||
|
||||
// configWithMDM is the test convenience that builds a Config via
|
||||
// UpdateOrCreateConfig and overlays the supplied MDM policy on top —
|
||||
// mirrors the production pattern (Server.getConfig / Client.applyMDMOverlay)
|
||||
// where the Loader lives outside Config and the apply step is driven
|
||||
// by the lifecycle owner.
|
||||
func configWithMDM(t *testing.T, input ConfigInput, policy *mdm.Policy) *Config {
|
||||
t.Helper()
|
||||
prev := loadMDMPolicy
|
||||
loadMDMPolicy = func() *mdm.Policy { return policy }
|
||||
t.Cleanup(func() { loadMDMPolicy = prev })
|
||||
cfg, err := UpdateOrCreateConfig(input)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
cfg.ApplyMDMPolicy(loaderFor(policy).Load())
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestApply_MDMEmpty_NoEnforcement(t *testing.T) {
|
||||
withMDMPolicy(t, mdm.NewPolicy(nil))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
}, mdm.NewPolicy(nil))
|
||||
|
||||
assert.True(t, cfg.Policy().IsEmpty(), "no MDM source ⇒ empty Policy")
|
||||
assert.False(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
|
||||
@@ -39,18 +73,15 @@ func TestApply_MDMEmpty_NoEnforcement(t *testing.T) {
|
||||
|
||||
func TestApply_MDMOnly_OverridesDefaults(t *testing.T) {
|
||||
const mdmURL = "https://corp.mdm.example.com:443"
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
}, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyManagementURL: mdmURL,
|
||||
mdm.KeyDisableClientRoutes: true,
|
||||
mdm.KeyBlockInbound: true,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.Equal(t, mdmURL, cfg.ManagementURL.String())
|
||||
assert.True(t, cfg.DisableClientRoutes)
|
||||
assert.True(t, cfg.BlockInbound)
|
||||
@@ -65,16 +96,12 @@ func TestApply_MDMBeatsCLIInput(t *testing.T) {
|
||||
const mdmURL = "https://mdm.example.com:443"
|
||||
const cliURL = "https://cli.example.com:443"
|
||||
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyManagementURL: mdmURL,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
ManagementURL: cliURL,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
}, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyManagementURL: mdmURL,
|
||||
}))
|
||||
|
||||
// MDM wins over CLI-supplied management URL.
|
||||
assert.Equal(t, mdmURL, cfg.ManagementURL.String())
|
||||
@@ -82,16 +109,12 @@ func TestApply_MDMBeatsCLIInput(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApply_MDMInvalidURL_KeepsPreviousValue(t *testing.T) {
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
}, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyManagementURL: "not-a-url",
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
// Invalid MDM URL is logged and skipped: default URL stays in place
|
||||
// to keep the client functional.
|
||||
assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String())
|
||||
@@ -106,24 +129,20 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) {
|
||||
tmp := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
// Seed without MDM.
|
||||
withMDMPolicy(t, mdm.NewPolicy(nil))
|
||||
_, err := UpdateOrCreateConfig(ConfigInput{
|
||||
configWithMDM(t, ConfigInput{
|
||||
ConfigPath: tmp,
|
||||
DisableClientRoutes: boolPtr(false),
|
||||
RosenpassEnabled: boolPtr(false),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}, mdm.NewPolicy(nil))
|
||||
|
||||
// Now enable MDM enforcement for these keys.
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: tmp,
|
||||
}, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyDisableClientRoutes: true,
|
||||
mdm.KeyRosenpassEnabled: true,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.True(t, cfg.DisableClientRoutes, "MDM override should flip on-disk false to true")
|
||||
assert.True(t, cfg.RosenpassEnabled)
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes))
|
||||
@@ -134,22 +153,19 @@ func TestApply_MDMLocalMetrics(t *testing.T) {
|
||||
tmp := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
// Seed without MDM.
|
||||
withMDMPolicy(t, mdm.NewPolicy(nil))
|
||||
_, err := UpdateOrCreateConfig(ConfigInput{
|
||||
configWithMDM(t, ConfigInput{
|
||||
ConfigPath: tmp,
|
||||
LocalMetricsEnabled: boolPtr(false),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}, mdm.NewPolicy(nil))
|
||||
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
// Now enable MDM enforcement for these keys.
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: tmp,
|
||||
}, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyEnableLocalMetrics: true,
|
||||
mdm.KeyLocalMetricsAddress: "127.0.0.1:9292",
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.True(t, cfg.LocalMetricsEnabled, "MDM override should flip on-disk false to true")
|
||||
assert.Equal(t, "127.0.0.1:9292", cfg.LocalMetricsAddress)
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyEnableLocalMetrics))
|
||||
@@ -171,16 +187,12 @@ func TestApply_MDMLazyConnection(t *testing.T) {
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
}, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyLazyConnection: c.raw,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.Equal(t, c.want, cfg.LazyConnection)
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyLazyConnection))
|
||||
})
|
||||
@@ -188,22 +200,83 @@ func TestApply_MDMLazyConnection(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) {
|
||||
const maskSentinel = "**********"
|
||||
const maskSentinel = mdm.PreSharedKeyRedactedSentinel
|
||||
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
}, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyPreSharedKey: maskSentinel,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
// Mask sentinel must not be persisted as the actual PSK.
|
||||
assert.NotEqual(t, maskSentinel, cfg.PreSharedKey)
|
||||
// Key still marked managed so user writes are still rejected.
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyPreSharedKey))
|
||||
}
|
||||
|
||||
func TestMDMConflicts_PreSharedKey(t *testing.T) {
|
||||
policy := mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyPreSharedKey: "mdm-enforced-psk",
|
||||
})
|
||||
empty := ""
|
||||
sentinel := mdm.PreSharedKeyRedactedSentinel
|
||||
same := "mdm-enforced-psk"
|
||||
other := "user-psk"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
psk *string
|
||||
want []string
|
||||
}{
|
||||
{name: "unset", psk: nil, want: nil},
|
||||
{name: "explicit empty", psk: &empty, want: []string{mdm.KeyPreSharedKey}},
|
||||
{name: "sentinel echo", psk: &sentinel, want: nil},
|
||||
{name: "same value", psk: &same, want: nil},
|
||||
{name: "divergent", psk: &other, want: []string{mdm.KeyPreSharedKey}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, MDMConflicts(ConfigInput{PreSharedKey: tc.psk}, policy))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMDMConflicts_RemoteJobsAndLocalMetrics(t *testing.T) {
|
||||
policy := mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyRemoteJobsAllowed: false,
|
||||
mdm.KeyEnableLocalMetrics: true,
|
||||
mdm.KeyLocalMetricsAddress: "127.0.0.1:9999",
|
||||
})
|
||||
sameAddr := "127.0.0.1:9999"
|
||||
otherAddr := "0.0.0.0:9999"
|
||||
emptyAddr := ""
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input ConfigInput
|
||||
want []string
|
||||
}{
|
||||
{name: "unset", input: ConfigInput{}, want: nil},
|
||||
{name: "echo", input: ConfigInput{
|
||||
RemoteJobsAllowed: boolPtr(false),
|
||||
LocalMetricsEnabled: boolPtr(true),
|
||||
LocalMetricsAddress: &sameAddr,
|
||||
}, want: nil},
|
||||
{name: "remote jobs divergent", input: ConfigInput{RemoteJobsAllowed: boolPtr(true)}, want: []string{mdm.KeyRemoteJobsAllowed}},
|
||||
{name: "metrics disabled", input: ConfigInput{LocalMetricsEnabled: boolPtr(false)}, want: []string{mdm.KeyEnableLocalMetrics}},
|
||||
{name: "metrics address divergent", input: ConfigInput{LocalMetricsAddress: &otherAddr}, want: []string{mdm.KeyLocalMetricsAddress}},
|
||||
{name: "metrics address explicit empty", input: ConfigInput{LocalMetricsAddress: &emptyAddr}, want: []string{mdm.KeyLocalMetricsAddress}},
|
||||
{name: "all divergent", input: ConfigInput{
|
||||
RemoteJobsAllowed: boolPtr(true),
|
||||
LocalMetricsEnabled: boolPtr(false),
|
||||
LocalMetricsAddress: &otherAddr,
|
||||
}, want: []string{mdm.KeyRemoteJobsAllowed, mdm.KeyEnableLocalMetrics, mdm.KeyLocalMetricsAddress}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, MDMConflicts(tc.input, policy))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -472,27 +473,13 @@ func (m *DefaultManager) CurrentRouteRange() []string {
|
||||
m.mux.Lock()
|
||||
defer m.mux.Unlock()
|
||||
|
||||
if m.disableClientRoutes {
|
||||
return nil
|
||||
}
|
||||
|
||||
filtered := m.routeSelector.FilterSelectedExitNodes(m.clientRoutes)
|
||||
var nets []string
|
||||
for _, routes := range filtered {
|
||||
for _, r := range routes {
|
||||
if r.IsDynamic() {
|
||||
continue
|
||||
}
|
||||
nets = append(nets, r.NetString())
|
||||
}
|
||||
}
|
||||
|
||||
if m.fakeIPManager != nil {
|
||||
nets = append(nets, m.fakeIPManager.GetFakeIPBlock().String(), m.fakeIPManager.GetFakeIPv6Block().String())
|
||||
nets := m.overlayNetworks()
|
||||
if !m.disableClientRoutes {
|
||||
nets = append(nets, m.clientRouteRange()...)
|
||||
}
|
||||
|
||||
sort.Strings(nets)
|
||||
return nets
|
||||
return slices.Compact(nets)
|
||||
}
|
||||
|
||||
// GetRouteSelector returns the route selector
|
||||
@@ -856,6 +843,42 @@ func (m *DefaultManager) logExitNodeUpdate(info exitNodeInfo, preferred route.Ne
|
||||
len(info.allIDs), preferred, len(info.userSelected), len(info.userDeselected), len(info.selectedByManagement))
|
||||
}
|
||||
|
||||
// overlayNetworks returns the v4 and v6 overlay networks of the WireGuard interface, each only when it is set.
|
||||
func (m *DefaultManager) overlayNetworks() []string {
|
||||
if m.wgInterface == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
addr := m.wgInterface.Address()
|
||||
var nets []string
|
||||
if addr.Network.IsValid() {
|
||||
nets = append(nets, addr.Network.String())
|
||||
}
|
||||
if addr.IPv6Net.IsValid() {
|
||||
nets = append(nets, addr.IPv6Net.String())
|
||||
}
|
||||
return nets
|
||||
}
|
||||
|
||||
// clientRouteRange returns the static client route networks of the selected exit nodes together with the fake IP blocks.
|
||||
func (m *DefaultManager) clientRouteRange() []string {
|
||||
filtered := m.routeSelector.FilterSelectedExitNodes(m.clientRoutes)
|
||||
var nets []string
|
||||
for _, routes := range filtered {
|
||||
for _, r := range routes {
|
||||
if r.IsDynamic() {
|
||||
continue
|
||||
}
|
||||
nets = append(nets, r.NetString())
|
||||
}
|
||||
}
|
||||
|
||||
if m.fakeIPManager != nil {
|
||||
nets = append(nets, m.fakeIPManager.GetFakeIPBlock().String(), m.fakeIPManager.GetFakeIPv6Block().String())
|
||||
}
|
||||
return nets
|
||||
}
|
||||
|
||||
// minNetID returns the lexicographically smallest NetID, for a deterministic
|
||||
// default pick that stays stable across restarts.
|
||||
func minNetID(ids []route.NetID) route.NetID {
|
||||
|
||||
@@ -4,8 +4,6 @@ package notifier
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/listener"
|
||||
@@ -75,19 +73,3 @@ func (n *Notifier) notifyLocked() {
|
||||
func (n *Notifier) Close() {
|
||||
// unused
|
||||
}
|
||||
|
||||
func routesToStrings(routes []*route.Route) []string {
|
||||
nets := make([]string, 0, len(routes))
|
||||
for _, r := range routes {
|
||||
nets = append(nets, r.NetString())
|
||||
}
|
||||
return nets
|
||||
}
|
||||
|
||||
func hasRouteDiff(a []*route.Route, b []*route.Route) bool {
|
||||
as := routesToStrings(a)
|
||||
bs := routesToStrings(b)
|
||||
sort.Strings(as)
|
||||
sort.Strings(bs)
|
||||
return !slices.Equal(as, bs)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"github.com/netbirdio/netbird/route"
|
||||
)
|
||||
|
||||
// routePrefixes returns the distinct prefixes a route set covers, sorted.
|
||||
// Duplicates are dropped deliberately: an HA group hands us one route per
|
||||
// peer serving the same prefix, and the platform is given the prefix, not the
|
||||
// candidates. Counting them would report a change every time a peer joins or
|
||||
// leaves a group, and on Android each report renews the TUN.
|
||||
func routePrefixes(routes []*route.Route) []string {
|
||||
nets := make([]string, 0, len(routes))
|
||||
for _, r := range routes {
|
||||
nets = append(nets, r.NetString())
|
||||
}
|
||||
sort.Strings(nets)
|
||||
return slices.Compact(nets)
|
||||
}
|
||||
|
||||
// hasRouteDiff reports whether the prefixes the two route sets cover differ.
|
||||
func hasRouteDiff(a []*route.Route, b []*route.Route) bool {
|
||||
return !slices.Equal(routePrefixes(a), routePrefixes(b))
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/netbirdio/netbird/route"
|
||||
)
|
||||
|
||||
func routeFor(id route.ID, prefix string) *route.Route {
|
||||
return &route.Route{
|
||||
ID: id,
|
||||
NetID: "net",
|
||||
Network: netip.MustParsePrefix(prefix),
|
||||
}
|
||||
}
|
||||
|
||||
// TestHasRouteDiff_IgnoresHACandidateCount is the reason the comparison
|
||||
// deduplicates. Every notification renews the TUN, and a renewed TUN
|
||||
// invalidates the sockets the embedded servers are listening on, so a peer
|
||||
// joining or leaving an HA group must not count as a route change when the
|
||||
// prefixes the TUN carries are identical.
|
||||
func TestHasRouteDiff_IgnoresHACandidateCount(t *testing.T) {
|
||||
onePeer := []*route.Route{routeFor("a", "10.0.0.0/24")}
|
||||
twoPeers := []*route.Route{
|
||||
routeFor("a", "10.0.0.0/24"),
|
||||
routeFor("b", "10.0.0.0/24"),
|
||||
}
|
||||
|
||||
assert.False(t, hasRouteDiff(onePeer, twoPeers),
|
||||
"a second peer serving the same prefix is not a route change")
|
||||
assert.False(t, hasRouteDiff(twoPeers, onePeer),
|
||||
"losing one of two peers serving the same prefix is not a route change")
|
||||
}
|
||||
|
||||
func TestHasRouteDiff_ReportsRealChanges(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a []*route.Route
|
||||
b []*route.Route
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "added prefix",
|
||||
a: []*route.Route{routeFor("a", "10.0.0.0/24")},
|
||||
b: []*route.Route{routeFor("a", "10.0.0.0/24"), routeFor("b", "10.0.1.0/24")},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "removed prefix",
|
||||
a: []*route.Route{routeFor("a", "10.0.0.0/24"), routeFor("b", "10.0.1.0/24")},
|
||||
b: []*route.Route{routeFor("a", "10.0.0.0/24")},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "replaced prefix",
|
||||
a: []*route.Route{routeFor("a", "10.0.0.0/24")},
|
||||
b: []*route.Route{routeFor("a", "10.0.1.0/24")},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "same prefix, different order",
|
||||
a: []*route.Route{routeFor("a", "10.0.1.0/24"), routeFor("b", "10.0.0.0/24")},
|
||||
b: []*route.Route{routeFor("b", "10.0.0.0/24"), routeFor("a", "10.0.1.0/24")},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "all routes gone",
|
||||
a: []*route.Route{routeFor("a", "10.0.0.0/24")},
|
||||
b: nil,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "both empty",
|
||||
a: nil,
|
||||
b: nil,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, hasRouteDiff(tc.a, tc.b),
|
||||
"route diff for %s", tc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,12 @@ import (
|
||||
"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.
|
||||
// reconcileWGMock is a minimal iface.WGIface that records AddAllowedIP calls and reports the
|
||||
// configured address; every other method is an inert stub because the tests exercise none of them.
|
||||
type reconcileWGMock struct {
|
||||
mu sync.Mutex
|
||||
adds map[string][]netip.Prefix
|
||||
addr wgaddr.Address
|
||||
}
|
||||
|
||||
func (m *reconcileWGMock) AddAllowedIP(peerKey string, allowedIP netip.Prefix) error {
|
||||
@@ -42,7 +43,7 @@ func (m *reconcileWGMock) added(peerKey string) []netip.Prefix {
|
||||
|
||||
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) Address() wgaddr.Address { return m.addr }
|
||||
func (m *reconcileWGMock) ToInterface() *net.Interface { return nil }
|
||||
func (m *reconcileWGMock) IsUserspaceBind() bool { return false }
|
||||
func (m *reconcileWGMock) GetFilter() device.PacketFilter { return nil }
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
//go:build !windows
|
||||
|
||||
package routemanager
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/routeselector"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
)
|
||||
|
||||
func TestCurrentRouteRange_OverlayNetworkWithClientRoutesDisabled(t *testing.T) {
|
||||
m := &DefaultManager{
|
||||
wgInterface: &reconcileWGMock{addr: wgaddr.MustParseWGAddress("100.91.96.107/16")},
|
||||
disableClientRoutes: true,
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"100.91.0.0/16"}, m.CurrentRouteRange(), "overlay network must be routed even when client routes are disabled")
|
||||
}
|
||||
|
||||
func TestCurrentRouteRange_OverlayNetworksAndClientRoutes(t *testing.T) {
|
||||
addr := wgaddr.MustParseWGAddress("100.91.96.107/16")
|
||||
addr.IPv6 = netip.MustParseAddr("fd00:1234::1")
|
||||
addr.IPv6Net = netip.MustParsePrefix("fd00:1234::/64")
|
||||
|
||||
static := &route.Route{ID: "static", NetID: "lan", Network: netip.MustParsePrefix("192.168.50.0/24"), NetworkType: route.IPv4Network}
|
||||
dynamic := &route.Route{ID: "dynamic", NetID: "dyn", NetworkType: route.DomainNetwork}
|
||||
|
||||
m := &DefaultManager{
|
||||
wgInterface: &reconcileWGMock{addr: addr},
|
||||
routeSelector: routeselector.NewRouteSelector(),
|
||||
clientRoutes: route.HAMap{
|
||||
static.GetHAUniqueID(): {static},
|
||||
dynamic.GetHAUniqueID(): {dynamic},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"100.91.0.0/16", "192.168.50.0/24", "fd00:1234::/64"}, m.CurrentRouteRange(), "overlay networks and static client routes must be listed, dynamic routes skipped")
|
||||
}
|
||||
|
||||
func TestCurrentRouteRange_NoInterfaceAddress(t *testing.T) {
|
||||
m := &DefaultManager{
|
||||
wgInterface: &reconcileWGMock{},
|
||||
disableClientRoutes: true,
|
||||
}
|
||||
|
||||
assert.Empty(t, m.CurrentRouteRange(), "an unset interface address must not produce a route entry")
|
||||
}
|
||||
|
||||
func TestCurrentRouteRange_IPv6WithoutIPv4Network(t *testing.T) {
|
||||
addr := wgaddr.Address{
|
||||
IPv6: netip.MustParseAddr("fd00:1234::1"),
|
||||
IPv6Net: netip.MustParsePrefix("fd00:1234::/64"),
|
||||
}
|
||||
m := &DefaultManager{
|
||||
wgInterface: &reconcileWGMock{addr: addr},
|
||||
disableClientRoutes: true,
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"fd00:1234::/64"}, m.CurrentRouteRange(), "a v6 overlay network must not depend on a v4 network being set")
|
||||
}
|
||||
|
||||
func TestCurrentRouteRange_IPv6AddressWithoutNetwork(t *testing.T) {
|
||||
addr := wgaddr.MustParseWGAddress("100.91.96.107/16")
|
||||
addr.IPv6 = netip.MustParseAddr("fd00:1234::1")
|
||||
|
||||
m := &DefaultManager{
|
||||
wgInterface: &reconcileWGMock{addr: addr},
|
||||
disableClientRoutes: true,
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"100.91.0.0/16"}, m.CurrentRouteRange(), "a v6 address without a network must not produce a route entry")
|
||||
}
|
||||
|
||||
func TestCurrentRouteRange_DeduplicatesPrefixes(t *testing.T) {
|
||||
// Two HA peers serve the same prefix, and a client route announces the overlay network itself.
|
||||
haPeerA := &route.Route{ID: "ha-a", NetID: "lan", Peer: "peer-a", Network: netip.MustParsePrefix("192.168.50.0/24"), NetworkType: route.IPv4Network}
|
||||
haPeerB := &route.Route{ID: "ha-b", NetID: "lan", Peer: "peer-b", Network: netip.MustParsePrefix("192.168.50.0/24"), NetworkType: route.IPv4Network}
|
||||
overlay := &route.Route{ID: "overlay", NetID: "overlay", Network: netip.MustParsePrefix("100.91.0.0/16"), NetworkType: route.IPv4Network}
|
||||
|
||||
m := &DefaultManager{
|
||||
wgInterface: &reconcileWGMock{addr: wgaddr.MustParseWGAddress("100.91.96.107/16")},
|
||||
routeSelector: routeselector.NewRouteSelector(),
|
||||
clientRoutes: route.HAMap{
|
||||
haPeerA.GetHAUniqueID(): {haPeerA, haPeerB},
|
||||
overlay.GetHAUniqueID(): {overlay},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"100.91.0.0/16", "192.168.50.0/24"}, m.CurrentRouteRange(), "every prefix must be listed once regardless of how many routes carry it")
|
||||
}
|
||||
Reference in New Issue
Block a user