Added optional exclusions for local networks
This commit is contained in:
parent
e5b485b72e
commit
50864b29ea
@ -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()
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user