[client] Merge main into peer event bus refactor

This commit is contained in:
Zoltán Papp
2026-09-10 01:48:41 +02:00
1102 changed files with 94422 additions and 19596 deletions
+197 -66
View File
@@ -23,6 +23,7 @@ import (
"golang.zx2c4.com/wireguard/tun/netstack"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/client/anonymize"
nberrors "github.com/netbirdio/netbird/client/errors"
"github.com/netbirdio/netbird/client/firewall"
"github.com/netbirdio/netbird/client/firewall/firewalld"
@@ -59,6 +60,7 @@ import (
"github.com/netbirdio/netbird/client/internal/syncstore"
"github.com/netbirdio/netbird/client/internal/updater"
"github.com/netbirdio/netbird/client/jobexec"
"github.com/netbirdio/netbird/client/netevents"
cProto "github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/system"
nbdns "github.com/netbirdio/netbird/dns"
@@ -93,6 +95,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")
@@ -136,6 +145,7 @@ type EngineConfig struct {
RosenpassPermissive bool
ServerSSHAllowed bool
RemoteJobsAllowed bool
EnableSSHRoot *bool
EnableSSHSFTP *bool
EnableSSHLocalPortForwarding *bool
@@ -181,6 +191,9 @@ type EngineServices struct {
UpdateManager *updater.Manager
ClientMetrics *metrics.ClientMetrics
MetricsCtx context.Context
// NetMgr gates the reconnection loops on OS-reported network
// availability; nil disables gating.
NetMgr *netevents.Manager
}
// Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers.
@@ -204,6 +217,10 @@ type Engine struct {
config *EngineConfig
mobileDep MobileDependency
// netMgr gates the peer reconnection guards on OS-reported network
// availability; nil disables gating.
netMgr *netevents.Manager
// STUNs is a list of STUN servers used by ICE
STUNs []*stun.URI
// TURNs is a list of STUN servers used by ICE
@@ -249,6 +266,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
@@ -312,6 +331,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,
@@ -337,6 +360,7 @@ func NewEngine(
syncMsgMux: &sync.Mutex{},
config: config,
mobileDep: mobileDep,
netMgr: services.NetMgr,
STUNs: []*stun.URI{},
TURNs: []*stun.URI{},
networkSerial: 0,
@@ -735,6 +759,11 @@ func (e *Engine) initFirewall() error {
return fmt.Errorf("set firewall: %w", err)
}
// TODO: the firewall backends dedup filter rules by content, so a
// management route ACL with identical content would collapse onto the
// untracked drop rules installed here, and a later management delete
// could remove them. Needs backend refcounting or per-consumer key
// namespacing.
if e.config.BlockLANAccess {
e.blockLanAccess()
}
@@ -747,14 +776,14 @@ func (e *Engine) initFirewall() error {
port := firewallManager.Port{Values: []uint16{uint16(rosenpassPort)}}
// IPv4-only: rosenpass peers connect via AllowedIps[0] which is always v4.
if _, err := e.firewall.AddPeerFiltering(
if _, err := e.firewall.AddFilterRule(
nil,
net.IP{0, 0, 0, 0},
[]netip.Prefix{netip.PrefixFrom(netip.IPv4Unspecified(), 0)},
firewallManager.Network{},
firewallManager.ProtocolUDP,
nil,
&port,
firewallManager.ActionAccept,
"",
); err != nil {
log.Errorf("failed to allow rosenpass interface traffic: %v", err)
return nil
@@ -804,7 +833,7 @@ func (e *Engine) blockLanAccess() {
if network.Addr().Is6() {
source = v6
}
if _, err := e.firewall.AddRouteFiltering(
if _, err := e.firewall.AddFilterRule(
nil,
[]netip.Prefix{source},
firewallManager.Network{Prefix: network},
@@ -863,8 +892,7 @@ func (e *Engine) modifyPeers(peersUpdate []*mgmProto.RemotePeerConfig) error {
}
// third, add the peer connections again
for _, p := range modified {
err := e.addNewPeer(p)
if err != nil {
if err := e.addNewPeer(p); err != nil {
return err
}
}
@@ -1216,9 +1244,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.
@@ -1229,6 +1255,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
}
@@ -1251,9 +1278,32 @@ func (e *Engine) applyInfoFlags(info *system.Info) {
e.config.EnableSSHLocalPortForwarding,
e.config.EnableSSHRemotePortForwarding,
e.config.DisableSSHAuth,
&e.config.RemoteJobsAllowed,
)
}
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.
@@ -1336,6 +1386,13 @@ func (e *Engine) receiveJobEvents() {
ID: msg.ID,
Status: mgmProto.JobStatus_failed,
}
// Remote jobs are an explicit opt-in. When not enabled on this
// peer, every job is refused before any work is done.
if !e.config.RemoteJobsAllowed {
log.Warnf("refusing remote job: remote jobs are not enabled on this peer (enable with --allow-remote-jobs)")
resp.Reason = []byte("remote jobs are not enabled on this peer")
return &resp
}
switch params := msg.WorkloadParameters.(type) {
case *mgmProto.JobRequest_Bundle:
bundleResult, err := e.handleBundle(params.Bundle)
@@ -1365,7 +1422,25 @@ func (e *Engine) receiveJobEvents() {
}
func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobResponse_Bundle, error) {
log.Infof("handle remote debug bundle request: %s", params.String())
// The upload URL can carry a host, credentials, or query tokens, so it is
// kept out of the info-level line; the full parameters stay available at
// debug level for troubleshooting.
log.Infof("handle remote debug bundle request: anonymize=%v anonymize_level=%q log_file_count=%d bundle_for=%v bundle_for_time=%d",
params.GetAnonymize(), params.GetAnonymizeLevel(), params.GetLogFileCount(), params.GetBundleFor(), params.GetBundleForTime())
log.Debugf("remote debug bundle request parameters: %s", params.String())
// Resolve the upload destination: an MDM override, when set, takes
// precedence over the management-supplied URL. Both are validated the same
// way; an empty result falls back to the default upload server downstream.
uploadURL := params.GetUploadUrl()
if override := e.config.ProfileConfig.DebugBundleUploadURL; override != "" {
log.Infof("using MDM debug bundle upload URL override instead of the management-supplied value")
uploadURL = override
}
if err := validateBundleUploadURL(uploadURL); err != nil {
return nil, err
}
syncResponse, err := e.GetLatestSyncResponse()
if err != nil {
log.Warnf("get latest sync response: %v", err)
@@ -1386,13 +1461,14 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
bundleJobParams := debug.BundleConfig{
Anonymize: params.Anonymize,
AnonymizeLevel: anonymize.ParseLevel(params.AnonymizeLevel),
IncludeSystemInfo: true,
LogFileCount: uint32(params.LogFileCount),
}
waitFor := time.Duration(params.BundleForTime) * time.Minute
uploadKey, err := e.jobExecutor.BundleJob(e.ctx, bundleDeps, bundleJobParams, waitFor, e.config.ProfileConfig.ManagementURL.String())
uploadKey, err := e.jobExecutor.BundleJob(e.ctx, bundleDeps, bundleJobParams, waitFor, e.config.ProfileConfig.ManagementURL.String(), uploadURL)
if err != nil {
return nil, err
}
@@ -1405,21 +1481,27 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
return response, nil
}
// validateBundleUploadURL sanity-checks a management-supplied upload URL for a
// remote debug bundle job. It delegates to profilemanager.ValidateBundleUploadURL
// so the executor and the MDM policy override share one definition of the rule
// (empty accepted; otherwise a well-formed https URL with a host) and cannot
// drift. The host is deliberately left unconstrained pending a decision on
// management-directed uploads.
func validateBundleUploadURL(raw string) error {
return profilemanager.ValidateBundleUploadURL(raw)
}
// receiveManagementEvents connects to the Management Service event stream to receive updates from the management service
// E.g. when a new peer has been registered and we are allowed to connect to it.
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
@@ -1485,8 +1567,12 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
return nil
}
if err := e.connMgr.UpdatedRemoteFeatureFlag(e.ctx, networkMap.GetPeerConfig().GetLazyConnectionEnabled()); err != nil {
log.Errorf("failed to update lazy connection feature flag: %v", err)
// Only update the flag when the sync carries a peer config; a nil peer config
// (e.g. a partial update) must not reset the cached flag to false.
if peerConfig := networkMap.GetPeerConfig(); peerConfig != nil {
if err := e.connMgr.UpdatedRemoteFeatureFlag(e.ctx, peerConfig.GetLazyConnectionEnabled()); err != nil {
log.Errorf("failed to update lazy connection feature flag: %v", err)
}
}
if e.firewall != nil {
@@ -1552,8 +1638,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()
@@ -1571,8 +1656,7 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
// 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")
excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers)
e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers)
e.connMgr.SetExcludeList(e.ctx, e.toExcludedLazyPeers(remotePeers))
done()
e.networkSerial = serial
@@ -1816,15 +1900,15 @@ 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) error {
for _, p := range peersUpdate {
err := e.addNewPeer(p)
if err != nil {
if err := e.addNewPeer(p); err != nil {
return err
}
}
return nil
}
// addNewPeer add peer if connection doesn't exist
// addNewPeer add peer if connection doesn't exist. A peer that is not lazy by
// 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()))
@@ -1859,7 +1943,8 @@ func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig) error {
log.Warnf("error adding peer %s to status recorder, got error: %v", peerKey, err)
}
if exists := e.connMgr.AddPeerConn(e.ctx, peerKey, conn); 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)
}
@@ -1893,6 +1978,7 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
PermissiveMode: e.config.RosenpassPermissive,
},
ICEConfig: e.createICEConfig(),
NetMgr: e.netMgr,
}
serviceDependencies := peer.ServiceDependencies{
@@ -2447,7 +2533,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
@@ -2493,7 +2644,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)
}
@@ -2561,7 +2719,7 @@ func (e *Engine) SetCapture(pc device.PacketCapture) error {
}
afc := capture.NewAFPacketCapture(intf.Name(), sess)
if err := afc.Start(); err != nil {
if err := afc.Start(); err != nil { //nolint:staticcheck // always errors on non-Linux builds
return fmt.Errorf("start AF_PACKET capture on %s: %w", intf.Name(), err)
}
e.afpacketCapture = afc
@@ -2608,7 +2766,7 @@ func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewal
var merr *multierror.Error
forwardingRules := make([]firewallManager.ForwardRule, 0, len(rules))
for _, rule := range rules {
proto, err := convertToFirewallProtocol(rule.GetProtocol())
proto, err := acl.ConvertToFirewallProtocol(rule.GetProtocol())
if err != nil {
merr = multierror.Append(merr, fmt.Errorf("failed to convert protocol '%s': %w", rule.GetProtocol(), err))
continue
@@ -2650,46 +2808,19 @@ func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewal
return forwardingRules, nberrors.FormatErrorOrNil(merr)
}
func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers []*mgmProto.RemotePeerConfig) map[string]bool {
// toExcludedLazyPeers returns the peers that must have an always-active
// 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)
// Ingress forward targets: inbound forwarded traffic is initiated remotely and
// cannot wake a lazy connection, so the peer routing the target must stay
// permanently connected. AllowedIPs are already parsed on the peer conn, so
// reuse those typed prefixes instead of re-parsing the network map strings.
for _, r := range rules {
for _, p := range peers {
if e.peerRoutesAddr(p, r.TranslatedAddress) {
log.Infof("exclude forwarder peer from lazy connection: %s", p.GetWgPubKey())
excludedPeers[p.GetWgPubKey()] = true
}
for _, p := range peers {
if !e.connMgr.PeerLazyDefault(p.GetLazyState()) {
excludedPeers[p.GetWgPubKey()] = true
}
}
return excludedPeers
}
// peerRoutesAddr reports whether the peer is a router for addr, matched against
// the peer's already-parsed AllowedIPs from the store (the same typed value the
// lazy manager consumes) rather than re-parsing the network map strings.
func (e *Engine) peerRoutesAddr(p *mgmProto.RemotePeerConfig, addr netip.Addr) bool {
prefixes, ok := e.peerStore.AllowedIPs(p.GetWgPubKey())
if !ok {
return false
}
return prefixesContain(prefixes, addr)
}
// 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 {