/*
* Copyright (c) 2026 Proton AG
*
* This file is part of ProtonVPN.
*
* ProtonVPN is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonVPN is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonVPN. If not, see .
*/
using System.Net;
using ProtonVPN.Common.Core.Networking;
using ProtonVPN.Common.Legacy;
using ProtonVPN.Common.Legacy.Vpn;
using ProtonVPN.Configurations.Contracts;
using ProtonVPN.Logging.Contracts;
using ProtonVPN.Logging.Contracts.Events.NetworkLogs;
using ProtonVPN.OperatingSystems.Network.Contracts;
using ProtonVPN.OperatingSystems.Network.Contracts.Routing;
using ProtonVPN.Vpn.Gateways;
namespace ProtonVPN.Vpn.SplitTunnel;
public class SplitTunnelRouting : ISplitTunnelRouting
{
private const int PERMIT_ROUTE_METRIC = 32000;
private readonly ILogger _logger;
private readonly IStaticConfiguration _config;
private readonly IGatewayCache _gatewayCache;
private readonly IIpv4GatewayResolver _ipv4GatewayResolver;
private readonly IRoutingTableHelper _routingTableHelper;
private readonly INetworkUtilities _networkUtilities;
private readonly ISystemNetworkInterfaces _networkInterfaces;
private readonly INetworkInterfaceProvider _networkInterfaceProvider;
public SplitTunnelRouting(
ILogger logger,
IStaticConfiguration config,
IGatewayCache gatewayCache,
IIpv4GatewayResolver ipv4GatewayResolver,
IRoutingTableHelper routingTableHelper,
INetworkUtilities networkUtilities,
ISystemNetworkInterfaces networkInterfaces,
INetworkInterfaceProvider networkInterfaceProvider)
{
_logger = logger;
_config = config;
_gatewayCache = gatewayCache;
_ipv4GatewayResolver = ipv4GatewayResolver;
_routingTableHelper = routingTableHelper;
_networkUtilities = networkUtilities;
_networkInterfaces = networkInterfaces;
_networkInterfaceProvider = networkInterfaceProvider;
}
public void SetUpRoutingTable(VpnConfig vpnConfig, string localIp, bool isIpv6Supported)
{
INetworkInterface tunnelInterface = _networkInterfaceProvider.GetByVpnProtocol(vpnConfig.VpnProtocol, vpnConfig.OpenVpnAdapter);
INetworkInterface[] networkInterfaces = _networkInterfaces.GetInterfaces();
switch (vpnConfig.SplitTunnelMode)
{
case SplitTunnelMode.Permit:
SetUpPermitModeRoutes(vpnConfig, localIp, isIpv6Supported, tunnelInterface, networkInterfaces);
break;
case SplitTunnelMode.Block:
SetUpBlockModeRoutes(vpnConfig, tunnelInterface, networkInterfaces);
break;
}
}
private void SetUpPermitModeRoutes(
VpnConfig vpnConfig,
string localIpv4Address,
bool isIpv6Supported,
INetworkInterface tunnelInterface,
INetworkInterface[] networkInterfaces)
{
IPAddress? gatewayAddress = _gatewayCache.Get();
if (gatewayAddress is null)
{
_logger.Error("Failed to configure IP split tunnel routes because the gateway address is missing.");
return;
}
NetworkAddress.TryParse("0.0.0.0/0", out NetworkAddress defaultIpv4NetworkAddress);
NetworkAddress.TryParse("::/0", out NetworkAddress defaultIpv6NetworkAddress);
NetworkAddress.TryParse(localIpv4Address, out NetworkAddress localNetworkIpv4Address);
NetworkAddress serverGatewayIpv4Address = new(gatewayAddress);
// Normally, this should only work for WireGuard, but seems to be working fine for OpenVPN as well
NetworkAddress.TryParse(_config.WireGuard.DefaultServerGatewayIpv6Address, out NetworkAddress serverGatewayIpv6Address);
//Remove default WireGuard route as it has metric 0, but instead we add the same route with low priority
//so that we still have the route for include mode apps to be routed through the tunnel.
_routingTableHelper.DeleteRoute(new()
{
Destination = defaultIpv4NetworkAddress,
Gateway = localNetworkIpv4Address,
InterfaceIndex = tunnelInterface.Index,
IsIpv6 = false,
});
_routingTableHelper.CreateRoute(new()
{
Destination = defaultIpv4NetworkAddress,
Gateway = localNetworkIpv4Address,
InterfaceIndex = tunnelInterface.Index,
Metric = PERMIT_ROUTE_METRIC,
IsIpv6 = false,
});
_routingTableHelper.CreateRoute(new()
{
Destination = serverGatewayIpv4Address,
Gateway = localNetworkIpv4Address,
InterfaceIndex = tunnelInterface.Index,
Metric = PERMIT_ROUTE_METRIC,
IsIpv6 = false,
});
if (isIpv6Supported)
{
_routingTableHelper.DeleteRoute(new()
{
Destination = defaultIpv6NetworkAddress,
Gateway = defaultIpv6NetworkAddress,
InterfaceIndex = tunnelInterface.Index,
IsIpv6 = true,
});
_routingTableHelper.CreateRoute(new()
{
Destination = defaultIpv6NetworkAddress,
Gateway = defaultIpv6NetworkAddress,
InterfaceIndex = tunnelInterface.Index,
Metric = PERMIT_ROUTE_METRIC,
IsIpv6 = true,
});
NetworkAddress? ipv6GatewayAddress = _networkUtilities.GetDefaultIpv6Gateway(tunnelInterface, networkInterfaces);
if (ipv6GatewayAddress is null)
{
// If we cannot find a default gateway, we add a route to the loopback interface
// to prevent not included traffic from being routed via the tunnel.
_routingTableHelper.CreateRoute(GetIpv6LoopbackRoute(defaultIpv6NetworkAddress));
}
}
foreach (string ip in vpnConfig.SplitTunnelIPs)
{
if (NetworkAddress.TryParse(ip, out NetworkAddress address))
{
_routingTableHelper.CreateRoute(new()
{
Destination = address,
Gateway = address.IsIpV6 ? serverGatewayIpv6Address : localNetworkIpv4Address,
InterfaceIndex = tunnelInterface.Index,
Metric = PERMIT_ROUTE_METRIC,
IsIpv6 = address.IsIpV6,
});
}
}
}
private RouteConfiguration GetIpv6LoopbackRoute(NetworkAddress destination)
{
return new()
{
Destination = destination,
Gateway = null,
InterfaceIndex = _routingTableHelper.GetLoopbackInterfaceIndex() ?? 0,
Metric = 0,
IsIpv6 = true,
};
}
private void SetUpBlockModeRoutes(VpnConfig vpnConfig, INetworkInterface tunnelInterface, INetworkInterface[] networkInterfaces)
{
if (!_ipv4GatewayResolver.TryGetBestIpv4Gateway(
_config.GetHardwareId(vpnConfig.VpnProtocol, vpnConfig.OpenVpnAdapter),
out Ipv4GatewayInfo? ipv4GatewayInfo) || ipv4GatewayInfo is null)
{
return;
}
INetworkInterface bestIpv4Interface = ipv4GatewayInfo.Interface;
NetworkAddress ipv4GatewayAddress = ipv4GatewayInfo.GatewayAddress;
uint ipv4InterfaceMetric = ipv4GatewayInfo.InterfaceMetric;
NetworkAddress? ipv6GatewayAddress = _networkUtilities.GetDefaultIpv6Gateway(tunnelInterface, networkInterfaces);
uint? loopbackInterfaceIndex = _routingTableHelper.GetLoopbackInterfaceIndex();
foreach (string ip in vpnConfig.SplitTunnelIPs)
{
if (NetworkAddress.TryParse(ip, out NetworkAddress address))
{
NetworkAddress? gateway = address.IsIpV6
? ipv6GatewayAddress
: ipv4GatewayAddress;
uint? interfaceIndex = gateway is null
? loopbackInterfaceIndex
: bestIpv4Interface.Index;
if (interfaceIndex is null)
{
_logger.Error($"Ignoring route create with IP {address} address due to a missing interface index.");
continue;
}
_routingTableHelper.CreateRoute(new()
{
Destination = address,
Gateway = gateway,
InterfaceIndex = interfaceIndex.Value,
Metric = ipv4InterfaceMetric,
IsIpv6 = address.IsIpV6,
});
}
}
}
public void DeleteRoutes(VpnConfig vpnConfig)
{
switch (vpnConfig.SplitTunnelMode)
{
case SplitTunnelMode.Block:
foreach (string ip in vpnConfig.SplitTunnelIPs)
{
if (NetworkAddress.TryParse(ip, out NetworkAddress address))
{
_routingTableHelper.DeleteRoute(address.Ip.ToString(), address.IsIpV6);
}
}
break;
case SplitTunnelMode.Permit:
if (NetworkAddress.TryParse("::/0", out NetworkAddress defaultIpv6NetworkAddress))
{
_routingTableHelper.DeleteRoute(GetIpv6LoopbackRoute(defaultIpv6NetworkAddress));
}
break;
}
}
}