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 +}