Added optional exclusions for local networks

This commit is contained in:
Ezri Brimhall 2026-02-04 10:21:50 -07:00
parent e5b485b72e
commit 50864b29ea
Signed by: ezri
GPG Key ID: 058A78E5680C6F24

View File

@ -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()