mirror of
https://github.com/safing/portmaster
synced 2026-04-28 03:20:31 +00:00
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:
parent
29cc58fecb
commit
ee8cde31f6
17 changed files with 682 additions and 7 deletions
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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]="{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
103
service/firewall/split-tunnel.go
Normal file
103
service/firewall/split-tunnel.go
Normal 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
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
68
service/splittun/module.go
Normal file
68
service/splittun/module.go
Normal 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
157
service/splittun/proxies.go
Normal 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...)...)
|
||||
}
|
||||
115
service/splittun/requests.go
Normal file
115
service/splittun/requests.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue