feat: Add Split Tunnel feature (Windows PoC)

Implement initial proof-of-concept for split tunnel functionality on Windows,
allowing applications to route traffic through a designated network interface
while bypassing default system routing.

Features:
- Split tunnel module with TCP/UDP proxy infrastructure
- Firewall integration with split tunnel verdict handling
- SplitTunneling context attached to connections
- Configuration options: enable toggle, interface selection, and policy rules
- UI display of split tunnel connection details in connection info panel
- Subsystem configuration for user-level access

Windows-specific implementation:
- Uses proxy-based interface routing on Windows
- Automatic or manual interface detection and binding
- Support for IPv4 and IPv6 traffic

Note: Linux implementation is under development. SPN takes precedence over
split tunnel when both are enabled, ensuring SPN connections bypass this feature.
This commit is contained in:
Alexandr Stelnykovych 2026-04-24 18:04:01 +03:00
parent 29cc58fecb
commit ee8cde31f6
17 changed files with 682 additions and 7 deletions

View file

@ -4,7 +4,7 @@ import { Observable, forkJoin, of } from "rxjs";
import { catchError, map, mergeMap } from "rxjs/operators";
import { AppProfileService } from "./app-profile.service";
import { AppProfile } from "./app-profile.types";
import { DNSContext, IPScope, Reason, TLSContext, TunnelContext, Verdict } from "./network.types";
import { DNSContext, IPScope, Reason, SplitTunContext, TLSContext, TunnelContext, Verdict } from "./network.types";
import { PORTMASTER_HTTP_API_ENDPOINT, PortapiService } from "./portapi.service";
import { Container } from "postcss";
@ -162,6 +162,7 @@ export interface NetqueryConnection {
blockedEntities?: string[];
reason?: Reason;
tunnel?: TunnelContext;
split_tun?: SplitTunContext;
dns?: DNSContext;
tls?: TLSContext;
};

View file

@ -210,6 +210,13 @@ export interface TunnelContext {
RoutingAlg: 'default';
}
export interface SplitTunContext {
// Interface is the name of the network interface the connection is bound to.
Interface: string;
// IP is the IP address used to bind the connection to the interface.
IP: string;
}
export interface GeoIPInfo {
IP: string;
Country: string;

View file

@ -427,11 +427,16 @@ export class ConfigSettingsViewComponent
(s) => s.Key === subsys.ToggleOptionKey
);
if (!!toggleOption) {
if (
(toggleOption.Value !== undefined && !toggleOption.Value) ||
(toggleOption.Value === undefined &&
!toggleOption.DefaultValue)
) {
// Determine the effective enabled state: per-app value takes
// priority, then the globally-configured value (GlobalDefault),
// and finally the hardcoded DefaultValue.
const effectiveEnabled =
toggleOption.Value !== undefined
? !!toggleOption.Value
: toggleOption.GlobalDefault !== undefined
? !!toggleOption.GlobalDefault
: !!toggleOption.DefaultValue;
if (!effectiveEnabled) {
subsys.isDisabled = true;
// remove all settings for all subsystem categories

View file

@ -7,7 +7,7 @@ export interface SubsystemWithExpertise extends Subsystem {
hasUserDefinedValues: boolean;
}
export var subsystems : SubsystemWithExpertise[] = [
export const subsystems : SubsystemWithExpertise[] = [
{
minimumExpertise: ExpertiseLevelNumber.developer,
isDisabled: false,
@ -268,5 +268,30 @@ export var subsystems : SubsystemWithExpertise[] = [
Deleted: 0,
Key: "runtime:subsystems/spn"
}
},
{
minimumExpertise: ExpertiseLevelNumber.user, // User level since UI is user-facing
isDisabled: false,
hasUserDefinedValues: false,
ID: "splittun",
Name: "Split Tunnel",
Description: "Route traffic through specified interface to bypass default routing",
Modules: [
{
Name: "splittun",
Enabled: true
}
],
ToggleOptionKey: "splittun/use", // Links to the boolean enable/disable option
ExpertiseLevel: "user",
ReleaseLevel: 0,
ConfigKeySpace: "config:splittun/",
_meta: {
Created: 0,
Modified: 0,
Expires: 0,
Deleted: 0,
Key: "runtime:subsystems/splittun"
}
}
];

View file

@ -255,6 +255,17 @@
</ng-container>
</div>
<div *ngIf="!!conn.extra_data?.split_tun">
<h3 class="text-primary text-xxs">Split Tunnel</h3>
<div *ngIf="conn.extra_data?.split_tun as splitTun" class="meta">
<span class="inline-flex items-center gap-1 flex-wrap">
<span class="text-secondary">This connection is forcibly routed through interface</span>
<span>{{ splitTun.Interface }}</span>
<span class="text-secondary">({{ splitTun.IP }})</span>
</span>
</div>
</div>
<div *ngIf="!!bwData.length" class="col-span-3 block border-t border-gray-400 py-2">
<h2 class="text-secondary uppercase w-full text-center text-xxs">Data Usage</h2>
<sfng-netquery-line-chart class="block w-full !h-36" [data]="bwData" [config]="{

View file

@ -25,6 +25,7 @@ import (
"github.com/safing/portmaster/service/network/netutils"
"github.com/safing/portmaster/service/network/packet"
"github.com/safing/portmaster/service/process"
"github.com/safing/portmaster/service/profile"
"github.com/safing/portmaster/service/resolver"
"github.com/safing/portmaster/spn/access"
)
@ -571,6 +572,11 @@ func FilterConnection(ctx context.Context, conn *network.Connection, pkt packet.
// Check if connection should be tunneled.
if checkTunnel {
checkTunneling(ctx, conn)
if conn.Verdict != network.VerdictRerouteToTunnel {
// SPN takes precedence over Split Tunnel, so only check split tunneling if not already set to tunnel.
checkSplitTunneling(ctx, conn)
}
}
// Request tunneling if no tunnel is set and connection should be tunneled.
@ -585,6 +591,12 @@ func FilterConnection(ctx context.Context, conn *network.Connection, pkt packet.
// connection and the data will help with debugging and displaying in the UI.
conn.Failed(fmt.Sprintf("failed to request tunneling: %s", err), "")
}
} else if conn.Verdict == network.VerdictRerouteToSplitTun {
// Request split tunneling
err := requestSplitTunneling(ctx, conn)
if err != nil {
conn.Failed(fmt.Sprintf("failed to request split-tunneling: %s", err), profile.CfgOptionSplitTunUseKey)
}
}
}

View file

@ -0,0 +1,103 @@
package firewall
import (
"context"
"errors"
"github.com/safing/portmaster/base/log"
"github.com/safing/portmaster/service/network"
"github.com/safing/portmaster/service/network/packet"
"github.com/safing/portmaster/service/profile"
"github.com/safing/portmaster/service/profile/endpoints"
"github.com/safing/portmaster/service/splittun"
)
func checkSplitTunneling(ctx context.Context, conn *network.Connection) {
// Check if the connection should be tunneled at all.
switch {
case conn.Entity.IPScope.IsLocalhost():
// Can't tunnel Local connections.
return
case conn.Inbound:
// Can't tunnel incoming connections.
return
case conn.Verdict != network.VerdictAccept:
// Connection will be blocked.
return
case conn.IPProtocol != packet.TCP && conn.IPProtocol != packet.UDP:
// Unsupported protocol.
return
case conn.Process().Pid == ownPID:
// Bypass tunneling for own connections.
return
case !splittun.IsReady():
return
}
// Get profile.
layeredProfile := conn.Process().Profile()
if layeredProfile == nil {
conn.Failed("no profile set", "")
return
}
// Update profile.
if layeredProfile.NeedsUpdate() {
// Update revision counter in connection.
conn.ProfileRevisionCounter = layeredProfile.Update(
conn.Process().MatchingData(),
conn.Process().CreateProfileCallback,
)
conn.SaveWhenFinished()
} else {
// Check if the revision counter of the connection needs updating.
revCnt := layeredProfile.RevisionCnt()
if conn.ProfileRevisionCounter != revCnt {
conn.ProfileRevisionCounter = revCnt
conn.SaveWhenFinished()
}
}
// Check if split-tunneling is enabled for this app at all.
if !layeredProfile.UseSplitTun() {
return
}
// Check if tunneling is enabled for entity.
conn.Entity.FetchData(ctx)
result, _ := layeredProfile.MatchSplitTunUsagePolicy(ctx, conn.Entity)
switch result {
case endpoints.MatchError:
conn.Failed("failed to check Split Tunnel rules", profile.CfgOptionSplitTunUsagePolicyKey)
return
case endpoints.Denied:
return
case endpoints.Permitted, endpoints.NoMatch:
}
conn.SaveWhenFinished()
conn.SetVerdictDirectly(network.VerdictRerouteToSplitTun)
}
func requestSplitTunneling(ctx context.Context, conn *network.Connection) error {
// Get profile.
layeredProfile := conn.Process().Profile()
if layeredProfile == nil {
return errors.New("no profile set")
}
interfaceToBind := layeredProfile.SplitTunInterface()
// Queue request in splittun module.
splitTunCtx, err := splittun.AwaitRequest(conn, interfaceToBind)
if err != nil {
return err
}
// Store context on the connection so the UI can display interface information.
conn.SplitTunContext = splitTunCtx
log.Tracer(ctx).Trace("filter: split tunneling requested")
return nil
}

View file

@ -35,6 +35,7 @@ import (
"github.com/safing/portmaster/service/process"
"github.com/safing/portmaster/service/profile"
"github.com/safing/portmaster/service/resolver"
"github.com/safing/portmaster/service/splittun"
"github.com/safing/portmaster/service/status"
"github.com/safing/portmaster/service/sync"
"github.com/safing/portmaster/service/ui"
@ -102,6 +103,8 @@ type Instance struct {
control *control.Control
interop *interop.Interoperability
splittun *splittun.SplitTunModule
access *access.Access
// SPN modules
@ -283,6 +286,11 @@ func New(svcCfg *ServiceConfig) (*Instance, error) { //nolint:maintidx
return instance, fmt.Errorf("create access module: %w", err)
}
instance.splittun, err = splittun.New(instance)
if err != nil {
return instance, fmt.Errorf("create splittun module: %w", err)
}
// SPN modules
instance.cabin, err = cabin.New(instance)
if err != nil {
@ -355,6 +363,8 @@ func New(svcCfg *ServiceConfig) (*Instance, error) { //nolint:maintidx
instance.filterLists,
instance.customlist,
instance.splittun,
instance.interop, // required to start before interception
// Grouped pausable interception modules:

View file

@ -226,6 +226,10 @@ func convertConnection(conn *network.Connection) (*Conn, error) {
c.ExitNode = &exitNode
}
if conn.SplitTunContext != nil {
extraData["split_tun"] = conn.SplitTunContext
}
if conn.DNSContext != nil {
extraData["dns"] = conn.DNSContext
}

View file

@ -56,6 +56,15 @@ type ProcessContext struct {
Source string
}
// SplitTunContext holds additional information about the split tunnel
// that a connection is routed through.
type SplitTunContext struct {
// Interface is the name of the network interface the connection is bound to.
Interface string
// IP is the IP address used to bind the connection to the interface.
IP net.IP
}
// ConnectionType is a type of connection.
type ConnectionType int8
@ -170,6 +179,10 @@ type Connection struct { //nolint:maligned // TODO: fix alignment
GetExitNodeID() string
StopTunnel() error
}
// SplitTunContext holds additional information about the split tunnel
// that this connection is routed through. It is set when the connection
// verdict is VerdictRerouteToSplitTun and the interface has been resolved.
SplitTunContext *SplitTunContext
// HistoryEnabled is set to true when the connection should be persisted
// in the history database.

View file

@ -17,6 +17,7 @@ var (
cfgDefaultAction uint8
cfgEndpoints endpoints.Endpoints
cfgServiceEndpoints endpoints.Endpoints
cfgSplitTunUsagePolicy endpoints.Endpoints
cfgSPNUsagePolicy endpoints.Endpoints
cfgSPNTransitHubPolicy endpoints.Endpoints
cfgSPNExitHubPolicy endpoints.Endpoints
@ -74,6 +75,13 @@ func updateGlobalConfigProfile(_ context.Context) error {
lastErr = err
}
list = cfgOptionSplitTunUsagePolicy()
cfgSplitTunUsagePolicy, err = endpoints.ParseEndpoints(list)
if err != nil {
// TODO: module error?
lastErr = err
}
list = cfgOptionSPNUsagePolicy()
cfgSPNUsagePolicy, err = endpoints.ParseEndpoints(list)
if err != nil {

View file

@ -141,6 +141,19 @@ var (
cfgOptionExitHubPolicyOrder = 147
// Setting "DNS Exit Node Rules" at order 148.
// Split Tunnel.
CfgOptionSplitTunUseKey = "splittun/use"
cfgOptionSplitTunUse config.BoolOption
cfgOptionSplitTunUseOrder = 210
CfgOptionSplitTunInterfaceKey = "splittun/networkInterface"
cfgOptionSplitTunInterface config.StringOption
cfgOptionSplitTunInterfaceOrder = 211
CfgOptionSplitTunUsagePolicyKey = "splittun/usagePolicy"
cfgOptionSplitTunUsagePolicy config.StringArrayOption
cfgOptionSplitTunUsagePolicyOrder = 212
)
var (
@ -819,5 +832,92 @@ By default, the Portmaster tries to choose the node closest to the destination a
cfgOptionRoutingAlgorithm = config.Concurrent.GetAsString(CfgOptionRoutingAlgorithmKey, DefaultRoutingProfileID)
cfgStringOptions[CfgOptionRoutingAlgorithmKey] = cfgOptionRoutingAlgorithm
//
// Split Tunnel
//
// Split Tunnel: Use
err = config.Register(&config.Option{
Name: "Use Split Tunnel",
Key: CfgOptionSplitTunUseKey,
Description: `Route specific traffic through a different network interface, bypassing default system routing (useful for avoiding VPNs for certain apps).
Important: SPN overrides Split Tunnel when enabled, so this option has no effect on SPN connections.`,
OptType: config.OptTypeBool,
DefaultValue: false,
Annotations: config.Annotations{
config.SettablePerAppAnnotation: true,
config.DisplayOrderAnnotation: cfgOptionSplitTunUseOrder,
config.CategoryAnnotation: "General",
},
})
if err != nil {
return err
}
cfgOptionSplitTunUse = config.Concurrent.GetAsBool(CfgOptionSplitTunUseKey, false)
cfgBoolOptions[CfgOptionSplitTunUseKey] = cfgOptionSplitTunUse
// Split Tunnel: Network Interface
err = config.Register(&config.Option{
Name: "Network Interface",
Key: CfgOptionSplitTunInterfaceKey,
Description: `Specify the network interface to route Split Tunnel traffic through. You can define it by:
- Interface name: "Ethernet", "Wi-Fi", "wlan0", etc.
- Interface IP address: "192.168.1.1", "10.0.0.1", etc.
- Interface MAC address: "00:1A:2B:3C:4D:5E", "01:23:45:67:89:AB", etc.
Leave empty or set to 'auto' to let Portmaster detect the physical network interface and ignore virtual VPN interfaces. This helps bypass VPN tunnels. For better reliability, you can specify the interface manually if 'auto' does not work as expected.
Important: The connection will be dropped if the network interface cannot be detected or becomes unavailable.
Important: SPN overrides Split Tunnel when enabled, so this option has no effect on SPN connections.`,
Sensitive: true,
OptType: config.OptTypeString,
DefaultValue: "auto",
Annotations: config.Annotations{
config.SettablePerAppAnnotation: true,
config.DisplayOrderAnnotation: cfgOptionSplitTunInterfaceOrder,
config.CategoryAnnotation: "General",
},
})
if err != nil {
return err
}
cfgOptionSplitTunInterface = config.Concurrent.GetAsString(CfgOptionSplitTunInterfaceKey, "")
cfgStringOptions[CfgOptionSplitTunInterfaceKey] = cfgOptionSplitTunInterface
// Split Tunnel: Rules
splitTunRulesVerdictNames := map[string]string{
"-": "Exclude", // Default.
"+": "Allow",
}
err = config.Register(&config.Option{
Name: "Split Tunnel Rules",
Key: CfgOptionSplitTunUsagePolicyKey,
Description: `Customize which websites should or should not be routed through the Split Tunnel. Only active if "Use Split Tunnel" is enabled.
Important: SPN overrides Split Tunnel when enabled, so this option has no effect on SPN connections.`,
Help: rulesHelp,
Sensitive: true,
OptType: config.OptTypeStringArray,
DefaultValue: []string{},
Annotations: config.Annotations{
config.SettablePerAppAnnotation: true,
config.StackableAnnotation: true,
config.CategoryAnnotation: "General",
config.DisplayOrderAnnotation: cfgOptionSplitTunUsagePolicyOrder,
config.DisplayHintAnnotation: endpoints.DisplayHintEndpointList,
endpoints.EndpointListVerdictNamesAnnotation: splitTunRulesVerdictNames,
},
ValidationRegex: endpoints.ListEntryValidationRegex,
ValidationFunc: endpoints.ValidateEndpointListConfigOption,
})
if err != nil {
return err
}
cfgOptionSplitTunUsagePolicy = config.Concurrent.GetAsStringArray(CfgOptionSplitTunUsagePolicyKey, []string{})
cfgStringArrayOptions[CfgOptionSplitTunUsagePolicyKey] = cfgOptionSplitTunUsagePolicy
return nil
}

View file

@ -50,6 +50,8 @@ type LayeredProfile struct {
SPNRoutingAlgorithm config.StringOption `json:"-"`
EnableHistory config.BoolOption `json:"-"`
KeepHistory config.IntOption `json:"-"`
UseSplitTun config.BoolOption `json:"-"`
SplitTunInterface config.StringOption `json:"-"`
}
// NewLayeredProfile returns a new layered profile based on the given local profile.
@ -113,6 +115,14 @@ func NewLayeredProfile(localProfile *Profile) *LayeredProfile {
CfgOptionDomainHeuristicsKey,
cfgOptionDomainHeuristics,
)
lp.UseSplitTun = lp.wrapBoolOption(
CfgOptionSplitTunUseKey,
cfgOptionSplitTunUse,
)
lp.SplitTunInterface = lp.wrapStringOption(
CfgOptionSplitTunInterfaceKey,
cfgOptionSplitTunInterface,
)
lp.UseSPN = lp.wrapBoolOption(
CfgOptionUseSPNKey,
cfgOptionUseSPN,
@ -349,6 +359,22 @@ func (lp *LayeredProfile) MatchServiceEndpoint(ctx context.Context, entity *inte
return cfgServiceEndpoints.Match(ctx, entity)
}
// MatchSplitTunUsagePolicy checks if the given endpoint matches an entry in any Split Tunnel usage policy in any of the profiles. This functions requires the layered profile to be read locked.
func (lp *LayeredProfile) MatchSplitTunUsagePolicy(ctx context.Context, entity *intel.Entity) (endpoints.EPResult, endpoints.Reason) {
for _, layer := range lp.layers {
if layer.splitTunUsagePolicy.IsSet() {
result, reason := layer.splitTunUsagePolicy.Match(ctx, entity)
if endpoints.IsDecision(result) {
return result, reason
}
}
}
cfgLock.RLock()
defer cfgLock.RUnlock()
return cfgSplitTunUsagePolicy.Match(ctx, entity)
}
// MatchSPNUsagePolicy checks if the given endpoint matches an entry in any of the profiles. This functions requires the layered profile to be read locked.
func (lp *LayeredProfile) MatchSPNUsagePolicy(ctx context.Context, entity *intel.Entity) (endpoints.EPResult, endpoints.Reason) {
for _, layer := range lp.layers {

View file

@ -124,6 +124,7 @@ type Profile struct { //nolint:maligned // not worth the effort
spnUsagePolicy endpoints.Endpoints
spnTransitHubPolicy endpoints.Endpoints
spnExitHubPolicy endpoints.Endpoints
splitTunUsagePolicy endpoints.Endpoints
// Lifecycle Management
outdated *abool.AtomicBool
@ -203,6 +204,15 @@ func (profile *Profile) parseConfig() error {
}
}
list, ok = profile.configPerspective.GetAsStringArray(CfgOptionSplitTunUsagePolicyKey)
profile.splitTunUsagePolicy = nil
if ok {
profile.splitTunUsagePolicy, err = endpoints.ParseEndpoints(list)
if err != nil {
lastErr = err
}
}
list, ok = profile.configPerspective.GetAsStringArray(CfgOptionSPNUsagePolicyKey)
profile.spnUsagePolicy = nil
if ok {

View file

@ -0,0 +1,68 @@
package splittun
import (
"errors"
"sync/atomic"
"github.com/safing/portmaster/service/mgr"
)
const SplitTunPort = 719
type SplitTunModule struct {
mgr *mgr.Manager
instance instance
}
var (
module *SplitTunModule
shimLoaded atomic.Bool
ready atomic.Bool // ready indicates whether the module is fully initialized and ready to handle requests.
)
func IsReady() bool {
return ready.Load()
}
func New(instance instance) (*SplitTunModule, error) {
if !shimLoaded.CompareAndSwap(false, true) {
return nil, errors.New("only one instance allowed")
}
m := mgr.New("SplitTunModule")
module = &SplitTunModule{
mgr: m,
instance: instance,
}
if err := prep(); err != nil {
return nil, err
}
return module, nil
}
func prep() error {
return nil
}
func (s *SplitTunModule) Manager() *mgr.Manager {
return s.mgr
}
func (s *SplitTunModule) Start() error {
err := startProxies(s.mgr)
if err != nil {
return err
}
ready.Store(true)
return nil
}
func (s *SplitTunModule) Stop() error {
ready.Store(false)
return stopProxies()
}
// INSTANCE
type instance interface{}

157
service/splittun/proxies.go Normal file
View file

@ -0,0 +1,157 @@
package splittun
import (
"fmt"
"net"
"sync"
"github.com/safing/portmaster/service/mgr"
"github.com/safing/portmaster/service/netenv"
"github.com/safing/portmaster/service/network"
"github.com/safing/portmaster/service/network/packet"
"github.com/safing/portmaster/service/splittun/proxy"
)
var (
proxiesLocker sync.RWMutex
manager *mgr.Manager
tcp4Proxy *proxy.TCPProxy
tcp6Proxy *proxy.TCPProxy
udp4Proxy *proxy.UDPProxy
udp6Proxy *proxy.UDPProxy
)
type proxiedEgressFinder interface {
FindProxiedEgressConnection(destIP net.IP, destPort uint16) []*proxy.ConnContext
}
func IsProxiedConnectionInfo(connInfo *network.Connection) bool {
if connInfo == nil || connInfo.Entity == nil || connInfo.LocalIP == nil || connInfo.Entity.IP == nil {
return false
}
proxiesLocker.RLock()
var finder proxiedEgressFinder
switch connInfo.IPProtocol {
case packet.TCP:
switch connInfo.IPVersion {
case packet.IPv4:
finder = tcp4Proxy
case packet.IPv6:
finder = tcp6Proxy
}
case packet.UDP:
switch connInfo.IPVersion {
case packet.IPv4:
finder = udp4Proxy
case packet.IPv6:
finder = udp6Proxy
}
}
if finder == nil {
proxiesLocker.RUnlock()
return false
}
// TODO: The current FindProxiedEgressConnection path allocates a slice on each lookup in cache.go.
// Consider adding a HasProxiedEgressConnection boolean method in the cache/proxy layer
// to avoid allocating a result slice when only existence is needed. This can reduce GC pressure under load.
isProxied := len(finder.FindProxiedEgressConnection(connInfo.Entity.IP, connInfo.Entity.Port)) > 0
proxiesLocker.RUnlock()
return isProxied
}
func startProxies(mgr *mgr.Manager) error {
var (
tcp4 *proxy.TCPProxy
tcp6 *proxy.TCPProxy
udp4 *proxy.UDPProxy
udp6 *proxy.UDPProxy
err error
)
_ = stopProxies()
tcp4, err = proxy.NewTCPProxy(fmt.Sprintf("0.0.0.0:%d", SplitTunPort), "tcp4", proxyDecider, &proxyLogger{prefix: "tcp4", mgr: mgr})
if err != nil {
return fmt.Errorf("failed to start TCPv4 proxy: %w", err)
}
tcp6, err = proxy.NewTCPProxy(fmt.Sprintf("[::]:%d", SplitTunPort), "tcp6", proxyDecider, &proxyLogger{prefix: "tcp6", mgr: mgr})
if err != nil {
return fmt.Errorf("failed to start TCPv6 proxy: %w", err)
}
if netenv.IPv6Enabled() {
udp4, err = proxy.NewUDPProxy(fmt.Sprintf("0.0.0.0:%d", SplitTunPort), "udp4", proxyDecider, &proxyLogger{prefix: "udp4", mgr: mgr})
if err != nil {
return fmt.Errorf("failed to start UDPv4 proxy: %w", err)
}
udp6, err = proxy.NewUDPProxy(fmt.Sprintf("[::]:%d", SplitTunPort), "udp6", proxyDecider, &proxyLogger{prefix: "udp6", mgr: mgr})
if err != nil {
return fmt.Errorf("failed to start UDPv6 proxy: %w", err)
}
}
proxiesLocker.Lock()
manager = mgr
tcp4Proxy = tcp4
tcp6Proxy = tcp6
udp4Proxy = udp4
udp6Proxy = udp6
proxiesLocker.Unlock()
return nil
}
func stopProxies() error {
proxiesLocker.Lock()
mgr := manager
tcp4 := tcp4Proxy
tcp6 := tcp6Proxy
udp4 := udp4Proxy
udp6 := udp6Proxy
tcp4Proxy = nil
tcp6Proxy = nil
udp4Proxy = nil
udp6Proxy = nil
proxiesLocker.Unlock()
if tcp4 != nil {
tcp4.Shutdown(mgr.Ctx())
}
if tcp6 != nil {
tcp6.Shutdown(mgr.Ctx())
}
if udp4 != nil {
udp4.Shutdown(mgr.Ctx())
}
if udp6 != nil {
udp6.Shutdown(mgr.Ctx())
}
return nil
}
// PROXY LOGGER WRAPPER
type proxyLogger struct {
prefix string
mgr *mgr.Manager
}
func (l proxyLogger) Debugf(format string, args ...interface{}) {
l.mgr.Debug(l.getLogLine(format, args...))
}
func (l proxyLogger) Warnf(format string, args ...interface{}) {
l.mgr.Warn(l.getLogLine(format, args...))
}
func (l proxyLogger) Infof(format string, args ...interface{}) {
l.mgr.Info(l.getLogLine(format, args...))
}
func (l proxyLogger) Errorf(format string, args ...interface{}) {
l.mgr.Error(l.getLogLine(format, args...))
}
func (l proxyLogger) getLogLine(format string, args ...interface{}) string {
return fmt.Sprintf("%s: "+format, append([]interface{}{l.prefix}, args...)...)
}

View file

@ -0,0 +1,115 @@
package splittun
import (
"fmt"
"net"
"strconv"
"sync"
"github.com/safing/portmaster/service/netenv"
"github.com/safing/portmaster/service/network"
"github.com/safing/portmaster/service/network/packet"
)
type request struct {
connInfo *network.Connection
bindIP net.IP
}
var (
requestsLock sync.Mutex
pendingRequests map[string]*request = make(map[string]*request) // key: "localIP:localPort"
)
// AwaitRequest registers a connection for handling when it arrives at the proxy.
// The bindInterface must be unique info which identifies the interface to bind to:
// - interface local IP address (e.g. "192.168.1.1")
// - interface name (e.g. "eth0")
// - MAC address (e.g. "00:1A:2B:3C:4D:5E")
// - "auto" - to try detecting "default" (non-VPN) interface automatically (not reliable)
func AwaitRequest(connInfo *network.Connection, bindInterface string) (*network.SplitTunContext, error) {
var bindIP net.IP
var interfaceName string
if bindInterface == "" || bindInterface == "auto" {
// "auto" is the default and means to try detecting the "default" (non-VPN) interface automatically.
// This is not reliable, but can be convenient for users who don't want to configure an interface.
ifaces, err := netenv.GetBestPhysicalDefaultInterfaces()
if err != nil {
return nil, err
}
var selectedIface *netenv.InterfaceInfo
if connInfo.IPVersion == packet.IPv6 && ifaces.ForIPv6 != nil {
selectedIface = ifaces.ForIPv6
bindIP = selectedIface.IPv6
} else if ifaces.ForIPv4 != nil {
selectedIface = ifaces.ForIPv4
bindIP = selectedIface.IPv4
} else {
return nil, fmt.Errorf("no suitable default physical interface found for IP version %d", connInfo.IPVersion)
}
interfaceName = selectedIface.Interface.Name
} else {
// Getting the interface IP address to bind the proxy connection to.
iface, err := netenv.GetInterface(bindInterface)
if err != nil {
return nil, err
}
if connInfo.IPVersion == packet.IPv6 {
bindIP = iface.IPv6
} else {
bindIP = iface.IPv4
}
if bindIP == nil {
return nil, fmt.Errorf("interface %q has no usable address for IP version %d", bindInterface, connInfo.IPVersion)
}
interfaceName = iface.Interface.Name
}
// Create unique key for the pending connection
key := net.JoinHostPort(connInfo.LocalIP.String(), strconv.Itoa(int(connInfo.LocalPort)))
requestsLock.Lock()
defer requestsLock.Unlock()
// Register the request
if _, exists := pendingRequests[key]; exists {
return nil, fmt.Errorf("a pending request for %s already exists", key)
}
pendingRequests[key] = &request{
connInfo: connInfo,
bindIP: bindIP,
}
return &network.SplitTunContext{
Interface: interfaceName,
IP: bindIP,
}, nil
}
// consumeRequest retrieves and removes a pending request for the given address.
func consumeRequest(address string) (r *request, err error) {
requestsLock.Lock()
r, ok := pendingRequests[address]
if ok {
delete(pendingRequests, address)
requestsLock.Unlock()
return r, nil
}
requestsLock.Unlock()
return nil, fmt.Errorf("no pending request for %s", address)
}
// proxyDecider is called by the proxy when a new connection arrives, to determine where to forward it.
func proxyDecider(local net.Addr, peer net.Addr) (remoteIP net.IP, remotePort uint16, localIP net.IP, extraInfo any, err error) {
r, err := consumeRequest(peer.String())
if err != nil {
return nil, 0, nil, nil, err
}
return r.connInfo.Entity.IP, uint16(r.connInfo.Entity.Port), r.bindIP, r.connInfo, nil
}