From 2252fd17edc6c756e167c15bc7f588cf05ad16b0 Mon Sep 17 00:00:00 2001 From: Alexandr Stelnykovych Date: Thu, 26 Mar 2026 23:20:34 +0200 Subject: [PATCH] ivpn/linux: route SPN hub traffic around VPN tunnel (split tunnel) Add a synchronous HookMgr[T] that lets callers register pre-connect hooks before SPN dials a home hub. The IVPN interop layer subscribes to this hook and uses Linux ip-rule/ip-route to steer SPN hub IPs through a dedicated routing table (717) pointing to the non-VPN default gateway, preventing SPN control traffic from being tunnelled into IVPN. - service/mgr: add generic HookMgr[T] (synchronous, cancellable) - spn/captain: expose HookSPNConnecting; invoke it in connectToHomeHub - service/netenv: add GatewayInfo + GatewaysInfo() with interface/mask - service/interop/ivpn: add ensureSpnHubBypassVpnRoutes managing policy routing; call it from the SPN pre-connect hook and on VPN stop/connect - nfq/packet: add hex comments next to mark constants https://github.com/safing/portmaster-shadow/issues/34 --- service/firewall/interception/nfq/packet.go | 16 +- service/instance.go | 5 + service/interop/ivpn/evt_handlers.go | 7 +- service/interop/ivpn/hook_default.go | 4 + service/interop/ivpn/hook_linux.go | 185 +++++++++++++++++++- service/interop/ivpn/ivpn.go | 8 +- service/interop/module.go | 5 + service/mgr/hooks.go | 86 +++++++++ service/netenv/environment_linux.go | 57 +++++- spn/captain/module.go | 6 + spn/captain/navigation.go | 7 + 11 files changed, 357 insertions(+), 29 deletions(-) create mode 100644 service/mgr/hooks.go diff --git a/service/firewall/interception/nfq/packet.go b/service/firewall/interception/nfq/packet.go index 49ceda16..3e46d52b 100644 --- a/service/firewall/interception/nfq/packet.go +++ b/service/firewall/interception/nfq/packet.go @@ -18,14 +18,14 @@ import ( // See TODO on packet.mark() on their relevance // and a possibility to remove most IPtables rules. const ( - MarkAccept = 1700 - MarkBlock = 1701 - MarkDrop = 1702 - MarkAcceptAlways = 1710 - MarkBlockAlways = 1711 - MarkDropAlways = 1712 - MarkRerouteNS = 1799 - MarkRerouteSPN = 1717 + MarkAccept = 1700 // 0x6a4 + MarkBlock = 1701 // 0x6a5 + MarkDrop = 1702 // 0x6a6 + MarkAcceptAlways = 1710 // 0x6ae + MarkBlockAlways = 1711 // 0x6af + MarkDropAlways = 1712 // 0x6b0 + MarkRerouteNS = 1799 // 0x707 + MarkRerouteSPN = 1717 // 0x6b5 ) func markToString(mark int) string { diff --git a/service/instance.go b/service/instance.go index c3ad4349..ad567574 100644 --- a/service/instance.go +++ b/service/instance.go @@ -44,6 +44,7 @@ import ( "github.com/safing/portmaster/spn/captain" "github.com/safing/portmaster/spn/crew" "github.com/safing/portmaster/spn/docks" + "github.com/safing/portmaster/spn/hub" "github.com/safing/portmaster/spn/navigator" "github.com/safing/portmaster/spn/patrol" "github.com/safing/portmaster/spn/ships" @@ -644,6 +645,10 @@ func (i *Instance) GetEventSPNConnected() *mgr.EventMgr[struct{}] { return i.captain.EventSPNConnected } +func (i *Instance) GetHookSPNConnecting() *mgr.HookMgr[hub.Announcement] { + return i.captain.HookSPNConnecting +} + // Special functions // SetCmdLineOperation sets a command line operation to be executed instead of starting the system. This is useful when functions need all modules to be prepared for a special operation. diff --git a/service/interop/ivpn/evt_handlers.go b/service/interop/ivpn/evt_handlers.go index b7e62581..064abc32 100644 --- a/service/interop/ivpn/evt_handlers.go +++ b/service/interop/ivpn/evt_handlers.go @@ -44,6 +44,8 @@ func (i *InteropIvpn) onConnectionStopped(wc *mgr.WorkerCtx, _ string, _ string) i.setStatus(&status) wc.Debug("IVPN: VPN connection stopped") + + _ = i.ensureSPNCompatibility(wc) } // notification handler: VPN connection established successfully @@ -62,8 +64,5 @@ func (i *InteropIvpn) onConnectedResp(wc *mgr.WorkerCtx, _ string, messageData s wc.Debug(fmt.Sprintf("IVPN: VPN connection established (vpnType:%v; localIPv4:%v; localIPv6:%v)", connectedResp.VpnType, connectedResp.ClientIP, connectedResp.ClientIPv6)) - err = i.ensureSPNCompatibility(wc) - if err != nil { - wc.Warn(fmt.Sprintf("IVPN: %v", err)) - } + _ = i.ensureSPNCompatibility(wc) } diff --git a/service/interop/ivpn/hook_default.go b/service/interop/ivpn/hook_default.go index 31638823..3e245e00 100644 --- a/service/interop/ivpn/hook_default.go +++ b/service/interop/ivpn/hook_default.go @@ -4,10 +4,14 @@ package ivpn import ( "github.com/safing/portmaster/service/mgr" + "github.com/safing/portmaster/spn/hub" ) type platformSpecific struct{} +func (i *InteropIvpn) spnConnectingHook(w *mgr.WorkerCtx, hookCtx hub.Announcement) (cancel bool, err error) { + return true, nil +} func (i *InteropIvpn) ensureSPNCompatibility(wc *mgr.WorkerCtx) error { return nil } diff --git a/service/interop/ivpn/hook_linux.go b/service/interop/ivpn/hook_linux.go index daa56d25..877ec36b 100644 --- a/service/interop/ivpn/hook_linux.go +++ b/service/interop/ivpn/hook_linux.go @@ -12,22 +12,38 @@ import ( "github.com/ivpn/desktop-app/daemon/protocol/ivpnclient" "github.com/safing/portmaster/service/mgr" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/spn/hub" ) type platformSpecific struct { - spnWgNftRuleHandle atomic.Int32 // nft rule handle we registered for SPN compatibility with WireGuard - spnWgIptRuleIP atomic.Value // last WG local IP used for iptables fallback rule (string) + spnWgNftRuleHandle atomic.Int32 // nft rule handle we registered for SPN compatibility with WireGuard + spnWgIptRuleIP atomic.Value // last WG local IP used for iptables fallback rule (string) + spnHubInfo atomic.Pointer[hub.Announcement] // last SPN hub info (hub.Info) } const ( + // NOTE: The nft table name is currently tied to IVPN's wg-quick setup. + // If IVPN changes the WG interface naming, this constant may need adjustment. nftTableWgQuickIvpn = "wg-quick-wgivpn" nftRuleCommentSPNCompat = "portmaster-spn-lo-rnat" + spnSlitTunRouteTableID = "717" + spnSlitTunRulePriority = "717" ) +func (i *InteropIvpn) spnConnectingHook(wc *mgr.WorkerCtx, homeHub hub.Announcement) (cancel bool, err error) { + return false, i.ensureSpnHubBypassVpnRoutes(wc, &homeHub) +} + func (i *InteropIvpn) ensureSPNCompatibility(wc *mgr.WorkerCtx) error { - err := i.reconcileWgCompatRule(wc) + err := i.ensureWgSpnCompatRule(wc) if err != nil { - return fmt.Errorf("failed to reconcile WireGuard compatibility rule: %w", err) + wc.Warn(fmt.Sprintf("IVPN: failed to ensure WireGuard compatibility rule: %v", err)) + } + + err = i.ensureSpnHubBypassVpnRoutes(wc, i.extra.spnHubInfo.Load()) + if err != nil { + wc.Warn(fmt.Sprintf("IVPN: failed to ensure VPN and SPN tunnel routes: %v", err)) } return nil } @@ -50,10 +66,7 @@ func (i *InteropIvpn) ensureSPNCompatibility(wc *mgr.WorkerCtx) error { // Rule lifecycle is managed here: // - Remove previously managed rule (nft/iptables) first. // - Recreate only when WireGuard is connected and SPN is enabled. -// -// NOTE: The nft table/chain name is currently tied to IVPN's wg-quick setup. -// If IVPN changes the WG interface naming, this constant may need adjustment. -func (i *InteropIvpn) reconcileWgCompatRule(wc *mgr.WorkerCtx) error { +func (i *InteropIvpn) ensureWgSpnCompatRule(wc *mgr.WorkerCtx) error { status := i.getStatus() connectedInfo := status.connectedInfo @@ -168,3 +181,159 @@ func parseNftInsertHandle(data []byte) (int, error) { } return 0, fmt.Errorf("no rule entry found in nft output") } + +// ensureSpnHubBypassVpnRoutes keeps Linux policy routing in sync so +// traffic to the selected SPN hub is sent via the system default gateway, not +// through the active VPN tunnel. +// +// Why this is needed: +// - When IVPN is connected, default routing points to the VPN interface. +// - SPN hub control/data path must reach the hub directly on the non-VPN uplink. +// - Without this rule/table setup, SPN hub traffic can be tunneled into VPN +// +// The function removes stale rules/routes from previous hub state, installs a +// dedicated routing table default route via the non-VPN gateway, and adds a +// high-priority destination rule for the current hub IP. +func (i *InteropIvpn) ensureSpnHubBypassVpnRoutes(wc *mgr.WorkerCtx, hubInfo *hub.Announcement) error { + oldHubInfo := i.extra.spnHubInfo.Swap(hubInfo) + + ipPath, _ := exec.LookPath("ip") + if ipPath == "" { + return fmt.Errorf("failed to ensure VPN and SPN tunnel routes: ip command not found") + } + + deleteRule := func(family, destination string) { + if destination == "" { + return + } + _ = exec.Command(ipPath, family, "rule", "del", "pref", spnSlitTunRulePriority, + "to", destination, "lookup", spnSlitTunRouteTableID).Run() + } + + // Clean up old rules for previous hub destination (if any). + if oldHubInfo != nil { + if oldHubInfo.IPv4 != nil { + deleteRule("-4", oldHubInfo.IPv4.String()+"/32") + } + if oldHubInfo.IPv6 != nil { + deleteRule("-6", oldHubInfo.IPv6.String()+"/128") + } + _ = exec.Command(ipPath, "-4", "route", "flush", "table", spnSlitTunRouteTableID).Run() + _ = exec.Command(ipPath, "-6", "route", "flush", "table", spnSlitTunRouteTableID).Run() + } + + // If VPN is not connected - we do not need to set up the rules. + connectedInfo := i.getStatus().connectedInfo + if connectedInfo == nil || connectedInfo.IsPaused { + return nil + } + vpnInterfaceIP := net.ParseIP(connectedInfo.ClientIP) + + // If SPN not enabled - we do not need the rule + // And erase stale info about the spnHub + if !i.cfgSpnEnabled() || hubInfo == nil { + i.extra.spnHubInfo.Store(nil) + return nil + } + + // Check the default gateway: + // - the only one default gateway must be present + // - the VPN connection gateway (interface) must be ignored + var gw *netenv.GatewayInfo = nil + gateways := netenv.GatewaysInfo() + for _, g := range gateways { + if g.Mask == nil || g.IP == nil || g.Interface == "" { + continue + } + // Mask: /0 - candidate default gateway. + if ones, _ := g.Mask.Size(); ones == 0 { + // Skip the gateway if it belongs to the VPN tunnel interface (heuristic by IP). + if has, err := hasInterfaceIp(g.Interface, vpnInterfaceIP); err == nil && has { + continue + } + // in case more than 1 default gateway exists, we can not be sure which one is correct + if gw != nil { + return nil + } + gw = &g + } + } + + // Initialize route table + + family := "-4" + hubRule := "" + if gw.IP.To4() != nil { + if err := exec.Command(ipPath, "-4", "route", "replace", "default", + "via", gw.IP.String(), "dev", gw.Interface, "table", spnSlitTunRouteTableID).Run(); err != nil { + return fmt.Errorf("failed to set IPv4 default route for SPN slit tunnel table: %w", err) + } + + if hubInfo.IPv4 != nil { + hubRule = hubInfo.IPv4.String() + "/32" + } + } else { + family = "-6" + if err := exec.Command(ipPath, "-6", "route", "replace", "default", + "via", gw.IP.String(), "dev", gw.Interface, "table", spnSlitTunRouteTableID).Run(); err != nil { + return fmt.Errorf("failed to set IPv6 default route for SPN slit tunnel table: %w", err) + } + + if hubInfo.IPv6 != nil { + hubRule = hubInfo.IPv6.String() + "/128" + } + } + + if hubRule == "" { + return nil + } + + // Remove potential stale rule for current hub destination before adding. + deleteRule(family, hubRule) + + // Initialize rule to route SPN traffic + if err := exec.Command( + ipPath, + family, + "rule", "add", + "pref", spnSlitTunRulePriority, + "to", hubRule, + "lookup", spnSlitTunRouteTableID, + ).Run(); err != nil { + return fmt.Errorf("failed to add SPN hub policy route rule (%s): %w", hubRule, err) + } + + wc.Debug(fmt.Sprintf("IVPN: Reconciled SPN hub route rule (%s -> table %s via %s dev %s)", hubRule, spnSlitTunRouteTableID, gw.IP.String(), gw.Interface)) + + return nil +} + +// hasInterfaceIp checks if the given IP address is assigned to the specified network interface. +func hasInterfaceIp(ifName string, ip net.IP) (bool, error) { + iface, err := net.InterfaceByName(ifName) + if err != nil { + return false, err + } + + addrs, err := iface.Addrs() + if err != nil { + return false, err + } + + for _, addr := range addrs { + var currentIP net.IP + + switch v := addr.(type) { + case *net.IPNet: + currentIP = v.IP + case *net.IPAddr: + currentIP = v.IP + } + + if currentIP != nil && currentIP.Equal(ip) { + return true, nil + } + } + + return false, nil +} diff --git a/service/interop/ivpn/ivpn.go b/service/interop/ivpn/ivpn.go index e754d42d..9a28c9b6 100644 --- a/service/interop/ivpn/ivpn.go +++ b/service/interop/ivpn/ivpn.go @@ -14,6 +14,7 @@ import ( "github.com/safing/portmaster/service/mgr" "github.com/safing/portmaster/service/network" "github.com/safing/portmaster/service/network/packet" + "github.com/safing/portmaster/spn/hub" ) const DNS_LOCKED_DESCRIPTION = "Portmaster controls all DNS resolution on this system, which prevents conflicts with IVPN's DNS settings. To let IVPN manage DNS again, remove all DNS servers from Portmaster's DNS configuration." @@ -25,6 +26,7 @@ type interopBase interface { DnsListenAddress() string DnsNameServers() []string EvtConfigChange() <-chan struct{} + GetHookSPNConnecting() *mgr.HookMgr[hub.Announcement] Interception() *interception.Interception Manager() *mgr.Manager } @@ -79,6 +81,10 @@ func (i *InteropIvpn) setStatus(status *clientStatus) { func (i *InteropIvpn) Start() error { i.connHandler = i.owner.Manager().NewWorkerMgr("ivpn client interoperability", i.connectIvpnClient, nil) + // Subscribe to SPN connecting hook to ensure SPN compatibility rules + // are applied before SPN connects to the network. + i.owner.GetHookSPNConnecting().AddHook("ivpn", i.spnConnectingHook) + firstTryChan := make(chan struct{}) i.firstTryDone.Store(&firstTryChan) @@ -168,7 +174,7 @@ func (i *InteropIvpn) connectIvpnClient(wc *mgr.WorkerCtx) error { } if helloResp.ServiceBinary == "" { - // IVPN client version > v3.15.0 must provide the service binary path in the hello response. + // IVPN client version > v3.15.1 must provide the service binary path in the hello response. wc.Warn(fmt.Sprintf("Detected IVPN Client version '%v' is incompatible. The hello response did not include all required fields.", helloResp.Version)) notif := i.showNotificationWarnOldVersion() notifWarnOldVersion.Store(notif) diff --git a/service/interop/module.go b/service/interop/module.go index 9365dd28..e9cca47d 100644 --- a/service/interop/module.go +++ b/service/interop/module.go @@ -11,6 +11,7 @@ import ( "github.com/safing/portmaster/service/interop/ivpn" "github.com/safing/portmaster/service/mgr" "github.com/safing/portmaster/service/network" + "github.com/safing/portmaster/spn/hub" ) // Interface for separate interoperability objects. @@ -25,6 +26,7 @@ type interopModule interface { type instance interface { Config() *config.Config Interception() *interception.Interception + GetHookSPNConnecting() *mgr.HookMgr[hub.Announcement] } // Module for interoperability with third-party applications @@ -64,6 +66,9 @@ func (u *Interoperability) EvtConfigChange() <-chan struct{} { func (u *Interoperability) Interception() *interception.Interception { return u.instance.Interception() } +func (u *Interoperability) GetHookSPNConnecting() *mgr.HookMgr[hub.Announcement] { + return u.instance.GetHookSPNConnecting() +} func start() error { for _, im := range module.interopModules { diff --git a/service/mgr/hooks.go b/service/mgr/hooks.go new file mode 100644 index 00000000..68d6d774 --- /dev/null +++ b/service/mgr/hooks.go @@ -0,0 +1,86 @@ +package mgr + +import ( + "slices" + "sync" +) + +// HookMgr manages synchronous hooks. +// Unlike EventMgr, Invoke blocks until every registered hook has returned, +// making it suitable for pre-operation hooks where the caller must wait for all +// hooks to complete. +type HookMgr[T any] struct { + name string + mgr *Manager + lock sync.RWMutex + + callbacks []*EventCallback[T] +} + +// NewHookMgr returns a new hook manager. +func NewHookMgr[T any](name string, mgr *Manager) *HookMgr[T] { + return &HookMgr[T]{ + name: name, + mgr: mgr, + } +} + +// AddHook registers a hook that will be called synchronously by Invoke. +// Use the same EventCallbackFunc signature as EventMgr: +// - returning cancel=true removes the hook from future invocations. +// - returning a non-nil error causes Invoke to stop and return that error. +func (cm *HookMgr[T]) AddHook(hookName string, hook EventCallbackFunc[T]) { + cm.lock.Lock() + defer cm.lock.Unlock() + + cm.callbacks = append(cm.callbacks, &EventCallback[T]{ + name: hookName, + callback: hook, + }) +} + +// Invoke calls all registered hooks synchronously in registration order. +// It blocks until every hook has returned. +// If a hook returns an error, Invoke stops immediately and returns that error. +// Hooks that return cancel=true are removed from future invocations. +func (cm *HookMgr[T]) Invoke(wc *WorkerCtx, data T) error { + cm.lock.RLock() + snapshot := make([]*EventCallback[T], len(cm.callbacks)) + copy(snapshot, cm.callbacks) + cm.lock.RUnlock() + + var anyCanceled bool + for _, ec := range snapshot { + if ec.canceled.Load() { + anyCanceled = true + continue + } + + cancel, err := ec.callback(wc, data) + if err != nil { + if cm.mgr != nil { + cm.mgr.Warn( + "hook failed", + "hook_mgr", cm.name, + "hook", ec.name, + "err", err, + ) + } + return err + } + if cancel { + ec.canceled.Store(true) + anyCanceled = true + } + } + + if anyCanceled { + cm.lock.Lock() + defer cm.lock.Unlock() + cm.callbacks = slices.DeleteFunc(cm.callbacks, func(ec *EventCallback[T]) bool { + return ec.canceled.Load() + }) + } + + return nil +} diff --git a/service/netenv/environment_linux.go b/service/netenv/environment_linux.go index a7e47e5b..d08d5ece 100644 --- a/service/netenv/environment_linux.go +++ b/service/netenv/environment_linux.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "net" "os" + "strconv" "strings" "sync" @@ -16,6 +17,7 @@ import ( var ( gateways = make([]net.IP, 0) + gatewaysInfo = make([]GatewayInfo, 0) // same as gateways but with additional info gatewaysLock sync.Mutex gatewaysNetworkChangedFlag = GetNetworkChangedFlag() @@ -24,24 +26,47 @@ var ( nameserversNetworkChangedFlag = GetNetworkChangedFlag() ) +type GatewayInfo struct { + IP net.IP + Interface string + Mask net.IPMask +} + // Gateways returns the currently active gateways. func Gateways() []net.IP { gatewaysLock.Lock() defer gatewaysLock.Unlock() - // Check if the network changed, if not, return cache. + refreshGatewaysCache() + + return gateways +} + +// GatewaysInfo returns the currently active gateways with interface metadata. +func GatewaysInfo() []GatewayInfo { + gatewaysLock.Lock() + defer gatewaysLock.Unlock() + + refreshGatewaysCache() + + return gatewaysInfo +} + +func refreshGatewaysCache() { + // Check if the network changed, if not, keep cache. if !gatewaysNetworkChangedFlag.IsSet() { - return gateways + return } gatewaysNetworkChangedFlag.Refresh() gateways = make([]net.IP, 0) + gatewaysInfo = make([]GatewayInfo, 0) var decoded []byte // open file route, err := os.Open("/proc/net/route") if err != nil { log.Warningf("environment: could not read /proc/net/route: %s", err) - return gateways + return } defer func() { _ = route.Close() @@ -53,10 +78,11 @@ func Gateways() []net.IP { // parse for scanner.Scan() { - line := strings.SplitN(scanner.Text(), "\t", 4) + line := strings.Fields(scanner.Text()) if len(line) < 4 { continue } + iface := line[0] if line[1] == "00000000" { decoded, err = hex.DecodeString(line[2]) if err != nil { @@ -68,7 +94,17 @@ func Gateways() []net.IP { continue } gate := net.IPv4(decoded[3], decoded[2], decoded[1], decoded[0]) + mask := net.IPv4Mask(0, 0, 0, 0) + if len(line) > 7 { + decodedMask, decodeMaskErr := hex.DecodeString(line[7]) + if decodeMaskErr != nil { + log.Warningf("environment: could not parse netmask %s from /proc/net/route: %s", line[7], decodeMaskErr) + } else if len(decodedMask) == 4 { + mask = net.IPv4Mask(decodedMask[3], decodedMask[2], decodedMask[1], decodedMask[0]) + } + } gateways = append(gateways, gate) + gatewaysInfo = append(gatewaysInfo, GatewayInfo{IP: gate, Interface: iface, Mask: mask}) } } @@ -76,7 +112,7 @@ func Gateways() []net.IP { v6route, err := os.Open("/proc/net/ipv6_route") if err != nil { log.Warningf("environment: could not read /proc/net/ipv6_route: %s", err) - return gateways + return } defer func() { _ = v6route.Close() @@ -88,11 +124,17 @@ func Gateways() []net.IP { // parse for scanner.Scan() { - line := strings.SplitN(scanner.Text(), " ", 6) + line := strings.Fields(scanner.Text()) if len(line) < 6 { continue } + iface := line[len(line)-1] if line[0] == "00000000000000000000000000000000" && line[4] != "00000000000000000000000000000000" { + mask := net.CIDRMask(0, 128) + prefixLength, parsePrefixLengthErr := strconv.ParseInt(line[1], 16, 32) + if parsePrefixLengthErr == nil { + mask = net.CIDRMask(int(prefixLength), 128) + } decoded, err := hex.DecodeString(line[4]) if err != nil { log.Warningf("environment: could not parse gateway %s from /proc/net/ipv6_route: %s", line[2], err) @@ -104,10 +146,9 @@ func Gateways() []net.IP { } gate := net.IP(decoded) gateways = append(gateways, gate) + gatewaysInfo = append(gatewaysInfo, GatewayInfo{IP: gate, Interface: iface, Mask: mask}) } } - - return gateways } // Nameservers returns the currently active nameservers. diff --git a/spn/captain/module.go b/spn/captain/module.go index 6ebee1a1..e56ae4e2 100644 --- a/spn/captain/module.go +++ b/spn/captain/module.go @@ -18,6 +18,7 @@ import ( "github.com/safing/portmaster/service/updates" "github.com/safing/portmaster/spn/conf" "github.com/safing/portmaster/spn/crew" + "github.com/safing/portmaster/spn/hub" "github.com/safing/portmaster/spn/navigator" "github.com/safing/portmaster/spn/patrol" "github.com/safing/portmaster/spn/ships" @@ -26,6 +27,9 @@ import ( // SPNConnectedEvent is the name of the event that is fired when the SPN has connected and is ready. const SPNConnectedEvent = "spn connect" +// SPNConnectingHook is the name of the hook that is invoked synchronously before the SPN connects to a remote hub. +const SPNConnectingHook = "spn connecting" + // Captain is the main module of the SPN. type Captain struct { mgr *mgr.Manager @@ -38,6 +42,7 @@ type Captain struct { states *mgr.StateMgr EventSPNConnected *mgr.EventMgr[struct{}] + HookSPNConnecting *mgr.HookMgr[hub.Announcement] // Called before SPN connects to a remote hub } // Manager returns the module manager. @@ -237,6 +242,7 @@ func New(instance instance) (*Captain, error) { states: mgr.NewStateMgr(m), EventSPNConnected: mgr.NewEventMgr[struct{}](SPNConnectedEvent, m), + HookSPNConnecting: mgr.NewHookMgr[hub.Announcement](SPNConnectingHook, m), publicIdentityUpdater: m.NewWorkerMgr("maintain public identity", maintainPublicIdentity, nil), statusUpdater: m.NewWorkerMgr("maintain public status", maintainPublicStatus, nil), diff --git a/spn/captain/navigation.go b/spn/captain/navigation.go index 096a8af9..af0658e9 100644 --- a/spn/captain/navigation.go +++ b/spn/captain/navigation.go @@ -139,6 +139,13 @@ func connectToHomeHub(wCtx *mgr.WorkerCtx, dst *hub.Hub) error { ctx, cancel := context.WithTimeout(wCtx.Ctx(), 5*time.Minute) defer cancel() + // Invoke hooks (if any) before opening connection + if dst.Info != nil { + if err := module.HookSPNConnecting.Invoke(wCtx, *dst.Info); err != nil { + return fmt.Errorf("pre-connect hook rejected: %w", err) + } + } + // Set and clean up exceptions. setExceptions(dst.Info.IPv4, dst.Info.IPv6) defer setExceptions(nil, nil)