From 50864b29ead24d4e4b8171c5553cbfb5bb45b890 Mon Sep 17 00:00:00 2001 From: Ezri Brimhall Date: Wed, 4 Feb 2026 10:21:50 -0700 Subject: [PATCH] Added optional exclusions for local networks --- src/vpn_manager_globalprotect/vpnc.py | 96 +++++++++++++++++++-------- 1 file changed, 67 insertions(+), 29 deletions(-) diff --git a/src/vpn_manager_globalprotect/vpnc.py b/src/vpn_manager_globalprotect/vpnc.py index d2c2c77..1255554 100644 --- a/src/vpn_manager_globalprotect/vpnc.py +++ b/src/vpn_manager_globalprotect/vpnc.py @@ -5,13 +5,15 @@ import sys import asyncio from vpn_manager.common.dbus_extensions import MessageBus from vpn_manager.common.context import bus as bus_var -from dbus_fast import BusType +from dbus_fast import BusType, Variant +from dbus_fast.aio import ProxyInterface import enum import ipaddress from typing import TypeVar, Generic from io import TextIOBase from pathlib import Path import math +import json class Reason(enum.StrEnum): @@ -46,6 +48,7 @@ class Config: vpnd_connection_name: str | None vpnd_uplink_table_id: str | None vpnd_enforce_split_tunnel: bool + vpnd_allow_local_net: bool split_enforcement_table = 1005 # This is well below what systemd-networkd will use by default, so these rules will take precedence. @@ -83,6 +86,12 @@ class Config: # "VPND_ENFORCE_SPLIT_TUNNEL", "true" # ).lower() in {"yes", "true"} + # Whether to create exclusions for local networks (that we are attached to directly) even if + # the server-provided inclusions and exclusions would disallow it. + self.vpnd_allow_local_net = os.environ.get( + "VPND_ALLOW_LOCAL_NET", "yes" + ).lower() in {"yes", "true", "1"} + self.reason = Reason(os.environ["reason"]) self.vpn_gateway = ipaddress.ip_address(os.environ["VPNGATEWAY"]) self.vpn_pid = int(os.environ["VPNPID"]) if "VPNPID" in os.environ else None @@ -135,10 +144,6 @@ class Config: """Write a systemd-networkd route config for the given network to the given stream.""" route_id = self.route_id if self.vpnd_enforce_split_tunnel: - if self.vpn_gateway in net: - # Refuse to create a policy rule that would block the uplink. - # this is probably a global route, which we will already have. - return with (dir / f"inclusion-{str(net).replace('/', '_')}.conf").open( "w" ) as stream: @@ -178,7 +183,41 @@ class Config: ] ) - def write_config(self, path: Path): + async def create_localnet_exclusions(self, dir: Path, manager: ProxyInterface): + """Create exclusions for locally-connected networks.""" + bus = bus_var.get() + intro = None + for idx, name, path in await manager.call_list_links(): + if intro is None: + intro = await bus.introspect("org.freedesktop.network1", path) + iface = bus.get_proxy_object( + "org.freedesktop.network1", path, intro + ).get_interface("org.freedesktop.network1.Link") + if await iface.get_ipv4_address_state() == "routable": + # Only process interfaces with a routable IPv4 address + desc = json.loads(await iface.call_describe()) + # we'll create an exclusion for any kernel-protocol, link-scoped IPv4 route on this interface. + if not isinstance(desc, dict): + continue + if desc.get("Name", self.tun_device) == self.tun_device: + # Ignore the device that we are managing (would pass later checks during a reconfigure on reconnect) + continue + for route in desc.get("Routes", []): + if not isinstance(route, dict): + continue + if ( + route.get("Family") == 2 + and route.get("ScopeString") == "link" + and route.get("ProtocolString") == "kernel" + ): + self.write_exclusion( + dir, + ipaddress.IPv4Network( + f"{'.'.join(route.get('Destination', [255, 255, 255, 255]))}/{route.get('DestinationPrefixLength', 32)}" + ), + ) + + async def write_config(self, path: Path, manager: ProxyInterface): """Generate a transient systemd-networkd config file.""" # This file will not enforce split-tunneling restrictions, and will not shadow existing routes. # Any route _inclusions_ will be added as [Route]s, while _exclusions_ will be ignored. @@ -203,6 +242,7 @@ class Config: f"Table={self.split_enforcement_table if self.vpnd_enforce_split_tunnel else 'main'}\n", ] ) + dir = path.with_suffix(".network.d") if self.vpnd_uplink_table_id is not None: # We have an "uplink" routing table that won't include the tunnel routes. Create a routing policy rule to send # traffic to the VPN gateway through this routing table, so we don't need to modify other network configurations. @@ -214,7 +254,15 @@ class Config: f"Priority={self.routingpolicyrule_prio}\n", ] ) - dir = path.with_suffix(".network.d") + elif self.vpnd_enforce_split_tunnel: + stream.writelines( + [ + "[RoutingPolicyRule]\n", + f"To={self.vpn_gateway}\n", + "Table=main\n", + f"Priority={self.routingpolicyrule_prio}\n", + ] + ) if dir.is_dir(): for file in dir.iterdir(): # Remove all files in the directory. @@ -223,24 +271,14 @@ class Config: dir.unlink() else: dir.mkdir() - for net in self.split_tunnel_inclusions: - self.write_route(dir, net) self._route_id = 0 if self.vpnd_enforce_split_tunnel: - # configure exclusions after inclusions. inclusions don't make sense without this, since they'd just be captured by the default. for net in self.split_tunnel_exclusions: self.write_exclusion(dir, net) - # Set a final catch-all rule (which should still have significantly lower priority than any auto-numbered rules) which will redirect - # anything not intended for the gateway IP itself to our routing table. - stream.writelines( - [ - "[RoutingPolicyRule]\n", - f"To={self.vpn_gateway}\n", - "InvertRule=yes\n", - f"Table={self.split_enforcement_table}\n", - f"Priority={self.routingpolicyrule_prio}\n", - ] - ) + if self.vpnd_allow_local_net: + await self.create_localnet_exclusions(dir, manager) + for net in self.split_tunnel_inclusions: + self.write_route(dir, net) stream.close() def destroy_config(self, path: Path): @@ -280,6 +318,13 @@ async def configure_with_networkd(): filename = f"10-{config.tun_device}.network" path = Path("/run/systemd/network") / filename + bus = bus_var.get() + intro = await bus.introspect( + "org.freedesktop.network1", "/org/freedesktop/network1" + ) + manager = bus.get_proxy_object( + "org.freedesktop.network1", "/org/freedesktop/network1", intro + ).get_interface("org.freedesktop.network1.Manager") if config.reason in {Reason.CONNECT, Reason.ATTEMPT_RECONNECT, Reason.RECONNECT}: # only write out the file if the reason indicates a new connection. @@ -299,14 +344,7 @@ async def configure_with_networkd(): return elif config.reason == Reason.DISCONNECT: config.destroy_config(path) - # always reload networkd state unless it's the config. - bus = bus_var.get() - intro = await bus.introspect( - "org.freedesktop.network1", "/org/freedesktop/network1" - ) - manager = bus.get_proxy_object( - "org.freedesktop.network1", "/org/freedesktop/network1", intro - ).get_interface("org.freedesktop.network1.Manager") + # always reload networkd state unless it's pre_init. await manager.call_reload()