From c0e692048a0160f5b3e97df1320adef09c4d35d1 Mon Sep 17 00:00:00 2001 From: Ezri Brimhall Date: Tue, 3 Feb 2026 14:55:21 -0700 Subject: [PATCH] Switched to drop-ins for routes --- src/vpn_manager_globalprotect/vpnc.py | 82 +++++++++++++++++---------- 1 file changed, 52 insertions(+), 30 deletions(-) diff --git a/src/vpn_manager_globalprotect/vpnc.py b/src/vpn_manager_globalprotect/vpnc.py index f2cebb9..3a3486f 100644 --- a/src/vpn_manager_globalprotect/vpnc.py +++ b/src/vpn_manager_globalprotect/vpnc.py @@ -50,6 +50,7 @@ class Config: # This is well below what systemd-networkd will use by default, so these rules will take precedence. # Access through the non-private property for auto-incrementing!!! _routingpolicyrule_prio = 16000 + _route_id = 0 @property def routingpolicyrule_prio(self) -> int: @@ -57,6 +58,12 @@ class Config: self._routingpolicyrule_prio += 1 return val + @property + def route_id(self) -> int: + val = self._route_id + self._route_id += 1 + return val + def __init__(self): self.vpnd_connection_name = os.environ.get("VPND_CONNECTION_NAME") @@ -113,46 +120,56 @@ class Config: ) ) - def write_route(self, stream: TextIOBase, net: ipaddress.IPv4Network): + def write_route(self, dir: Path, net: ipaddress.IPv4Network): """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-{route_id}.conf").open("w") as stream: + stream.writelines( + [ + "[RoutingPolicyRule]\n", + f"To={net}\n", + f"Table={self.split_enforcement_table}\n" + f"Priority={self.routingpolicyrule_prio}\n", + ] + ) + else: + with (dir / f"route-{route_id}.conf").open("w") as stream: + stream.writelines( + [ + "[Route]\n", + "Gateway=0.0.0.0\n", + f"To={net}\n", + # systemd-networkd defaults to a route metric of 1024. We set a very low metric to shadow any routes that aren't meant + # to explicitly override this, but still allows explicit overrides if desired. + "Metric=64\n", + ] + ) + + def write_exclusion(self, dir: Path, net: ipaddress.IPv4Network): + """Write a routing policy rule to exclude the given network from our routing table.""" + if not self.vpnd_enforce_split_tunnel: + return + route_id = self.route_id + with (dir / f"exclusion-{route_id}.conf").open("w") as stream: stream.writelines( [ "[RoutingPolicyRule]\n", f"To={net}\n", - f"Table={self.split_enforcement_table}\n" + f"Table=main\n", f"Priority={self.routingpolicyrule_prio}\n", ] ) - else: - stream.writelines( - [ - "[Route]\n", - "Gateway=0.0.0.0\n", - f"To={net}\n", - # systemd-networkd defaults to a route metric of 1024. We set a very low metric to shadow any routes that aren't meant - # to explicitly override this, but still allows explicit overrides if desired. - "Metric=64\n", - ] - ) - def write_exclusion(self, stream: TextIOBase, net: ipaddress.IPv4Network): - """Write a routing policy rule to exclude the given network from our routing table.""" - if not self.vpnd_enforce_split_tunnel: - return - stream.writelines( - [ - "[RoutingPolicyRule]\n", - f"To={net}\n", - f"Table=main\n", - f"Priority={self.routingpolicyrule_prio}\n", - ] - ) - - def write_config(self, stream: TextIOBase): + def write_config(self, path: Path): """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. + stream = path.open("w") if not stream.writable(): raise ValueError("Stream is not writable") stream.writelines( @@ -182,10 +199,15 @@ class Config: f"Priority={self.routingpolicyrule_prio}\n", ] ) + dir = path.with_suffix(".d") + if not dir.exists(): + dir.mkdir() + for net in self.split_tunnel_inclusions: + self.write_route(dir, net) 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(stream, net) + 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( @@ -197,6 +219,7 @@ class Config: f"Priority={self.routingpolicyrule_prio}\n", ] ) + stream.close() async def main(): @@ -224,8 +247,7 @@ async def configure_with_networkd(): if config.reason in {Reason.CONNECT, Reason.ATTEMPT_RECONNECT, Reason.RECONNECT}: # only write out the file if the reason indicates a new connection. - with open(Path("/run/systemd/network") / filename, "w") as f: - config.write_config(f) + config.write_config(Path("/run/systemd/network") / filename) elif config.reason == Reason.PRE_INIT: # idk what this does, but the script does it, so we will too. try: