diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/netquery.service.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/netquery.service.ts
index 9769d454..8824ff0d 100644
--- a/desktop/angular/projects/safing/portmaster-api/src/lib/netquery.service.ts
+++ b/desktop/angular/projects/safing/portmaster-api/src/lib/netquery.service.ts
@@ -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;
};
diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/network.types.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/network.types.ts
index 35a75861..bdc56dcf 100644
--- a/desktop/angular/projects/safing/portmaster-api/src/lib/network.types.ts
+++ b/desktop/angular/projects/safing/portmaster-api/src/lib/network.types.ts
@@ -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;
diff --git a/desktop/angular/src/app/shared/config/config-settings.ts b/desktop/angular/src/app/shared/config/config-settings.ts
index 513e6c4b..30bbdba7 100644
--- a/desktop/angular/src/app/shared/config/config-settings.ts
+++ b/desktop/angular/src/app/shared/config/config-settings.ts
@@ -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
diff --git a/desktop/angular/src/app/shared/config/subsystems.ts b/desktop/angular/src/app/shared/config/subsystems.ts
index 97bdfab1..a6978dea 100644
--- a/desktop/angular/src/app/shared/config/subsystems.ts
+++ b/desktop/angular/src/app/shared/config/subsystems.ts
@@ -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"
}
+}
];
diff --git a/desktop/angular/src/app/shared/netquery/connection-details/conn-details.html b/desktop/angular/src/app/shared/netquery/connection-details/conn-details.html
index 73abd31f..3a78f435 100644
--- a/desktop/angular/src/app/shared/netquery/connection-details/conn-details.html
+++ b/desktop/angular/src/app/shared/netquery/connection-details/conn-details.html
@@ -255,6 +255,17 @@
+
+
Split Tunnel
+
+
+ This connection is forcibly routed through interface
+ {{ splitTun.Interface }}
+ ({{ splitTun.IP }})
+
+
+
+
Data Usage
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...)...)
+}
diff --git a/service/splittun/requests.go b/service/splittun/requests.go
new file mode 100644
index 00000000..f7cbadae
--- /dev/null
+++ b/service/splittun/requests.go
@@ -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
+}