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
This commit is contained in:
Alexandr Stelnykovych
2026-03-26 23:20:34 +02:00
parent 7523eb038b
commit 2252fd17ed
11 changed files with 357 additions and 29 deletions
+8 -8
View File
@@ -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 {
+5
View File
@@ -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.
+3 -4
View File
@@ -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)
}
+4
View File
@@ -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
}
+177 -8
View File
@@ -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
}
+7 -1
View File
@@ -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)
+5
View File
@@ -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 {
+86
View File
@@ -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
}
+49 -8
View File
@@ -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.
+6
View File
@@ -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),
+7
View File
@@ -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)