Refactor and start work on UI

This commit is contained in:
LilyRose2798 2024-04-18 06:15:08 +10:00
parent c6eea6a9fe
commit 634fb8a64d
6 changed files with 337 additions and 214 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
*.json *.json
__pycache__

78
poetry.lock generated
View File

@ -59,6 +59,29 @@ files = [
tests = ["pytest (>=3.2.1,!=3.3.0)"] tests = ["pytest (>=3.2.1,!=3.3.0)"]
typecheck = ["mypy"] typecheck = ["mypy"]
[[package]]
name = "cattrs"
version = "23.2.3"
description = "Composable complex class support for attrs and dataclasses."
optional = false
python-versions = ">=3.8"
files = [
{file = "cattrs-23.2.3-py3-none-any.whl", hash = "sha256:0341994d94971052e9ee70662542699a3162ea1e0c62f7ce1b4a57f563685108"},
{file = "cattrs-23.2.3.tar.gz", hash = "sha256:a934090d95abaa9e911dac357e3a8699e0b4b14f8529bcc7d2b1ad9d51672b9f"},
]
[package.dependencies]
attrs = ">=23.1.0"
[package.extras]
bson = ["pymongo (>=4.4.0)"]
cbor2 = ["cbor2 (>=5.4.6)"]
msgpack = ["msgpack (>=1.0.5)"]
orjson = ["orjson (>=3.9.2)"]
pyyaml = ["pyyaml (>=6.0)"]
tomlkit = ["tomlkit (>=0.11.8)"]
ujson = ["ujson (>=5.7.0)"]
[[package]] [[package]]
name = "cffi" name = "cffi"
version = "1.16.0" version = "1.16.0"
@ -178,20 +201,33 @@ test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-co
test-randomorder = ["pytest-randomly"] test-randomorder = ["pytest-randomly"]
[[package]] [[package]]
name = "dataclass-wizard" name = "customtkinter"
version = "0.22.3" version = "5.2.2"
description = "Marshal dataclasses to/from JSON. Use field properties with initial values. Construct a dataclass schema with JSON input." description = "Create modern looking GUIs with Python"
optional = false optional = false
python-versions = "*" python-versions = ">=3.7"
files = [ files = [
{file = "dataclass-wizard-0.22.3.tar.gz", hash = "sha256:4c46591782265058f1148cfd1f54a3a91221e63986fdd04c9d59f4ced61f4424"}, {file = "customtkinter-5.2.2-py3-none-any.whl", hash = "sha256:14ad3e7cd3cb3b9eb642b9d4e8711ae80d3f79fb82545ad11258eeffb2e6b37c"},
{file = "dataclass_wizard-0.22.3-py2.py3-none-any.whl", hash = "sha256:63751203e54b9b9349212cc185331da73c1adc99c51312575eb73bb5c00c1962"}, {file = "customtkinter-5.2.2.tar.gz", hash = "sha256:fd8db3bafa961c982ee6030dba80b4c2e25858630756b513986db19113d8d207"},
]
[package.dependencies]
darkdetect = "*"
packaging = "*"
[[package]]
name = "darkdetect"
version = "0.8.0"
description = "Detect OS Dark Mode from Python"
optional = false
python-versions = ">=3.6"
files = [
{file = "darkdetect-0.8.0-py3-none-any.whl", hash = "sha256:a7509ccf517eaad92b31c214f593dbcf138ea8a43b2935406bbd565e15527a85"},
{file = "darkdetect-0.8.0.tar.gz", hash = "sha256:b5428e1170263eb5dea44c25dc3895edd75e6f52300986353cd63533fe7df8b1"},
] ]
[package.extras] [package.extras]
dev = ["Sphinx (==5.3.0)", "bump2version (==1.0.1)", "coverage (>=6.2)", "dataclass-factory (==2.12)", "dataclasses-json (==0.5.6)", "flake8 (>=3)", "jsons (==1.6.1)", "pip (>=21.3.1)", "pytest (==7.0.1)", "pytest-cov (==3.0.0)", "pytest-mock (>=3.6.1)", "pytimeparse (==1.1.8)", "sphinx-issues (==3.0.1)", "sphinx-issues (==4.0.0)", "tox (==3.24.5)", "twine (==3.8.0)", "watchdog[watchmedo] (==2.1.6)", "wheel (==0.37.1)", "wheel (==0.42.0)"] macos-listener = ["pyobjc-framework-Cocoa"]
timedelta = ["pytimeparse (>=1.1.7)"]
yaml = ["PyYAML (>=5.3)"]
[[package]] [[package]]
name = "decorator" name = "decorator"
@ -252,6 +288,17 @@ files = [
{file = "invoke-2.2.0.tar.gz", hash = "sha256:ee6cbb101af1a859c7fe84f2a264c059020b0cb7fe3535f9424300ab568f6bd5"}, {file = "invoke-2.2.0.tar.gz", hash = "sha256:ee6cbb101af1a859c7fe84f2a264c059020b0cb7fe3535f9424300ab568f6bd5"},
] ]
[[package]]
name = "packaging"
version = "24.0"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.7"
files = [
{file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"},
{file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"},
]
[[package]] [[package]]
name = "paramiko" name = "paramiko"
version = "3.4.0" version = "3.4.0"
@ -310,17 +357,6 @@ cffi = ">=1.4.1"
docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"] docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"]
tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"] tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"]
[[package]]
name = "segno"
version = "1.6.1"
description = "QR Code and Micro QR Code generator for Python"
optional = false
python-versions = ">=3.5"
files = [
{file = "segno-1.6.1-py3-none-any.whl", hash = "sha256:e90c6ff82c633f757a96d4b1fb06cc932589b5237f33be653f52252544ac64df"},
{file = "segno-1.6.1.tar.gz", hash = "sha256:f23da78b059251c36e210d0cf5bfb1a9ec1604ae6e9f3d42f9a7c16d306d847e"},
]
[[package]] [[package]]
name = "wrapt" name = "wrapt"
version = "1.16.0" version = "1.16.0"
@ -403,4 +439,4 @@ files = [
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.11" python-versions = "^3.11"
content-hash = "b31352156c64013b2b409098145b904d4664fd65edb56bc37020b449145136cd" content-hash = "e01c9fdf03da702f9839b29039b9b141a079b40b4210d2a288b572d07481afa5"

View File

@ -9,9 +9,9 @@ readme = "README.md"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.11" python = "^3.11"
fabric = "^3.2.2" fabric = "^3.2.2"
dataclass-wizard = "^0.22.3"
attrs = "^23.2.0" attrs = "^23.2.0"
segno = "^1.6.1" customtkinter = "^5.2.2"
cattrs = "^23.2.3"
[build-system] [build-system]

View File

@ -1,29 +1,20 @@
from __future__ import annotations from __future__ import annotations
from base64 import standard_b64encode, urlsafe_b64decode, urlsafe_b64encode from base64 import standard_b64encode, urlsafe_b64decode, urlsafe_b64encode
from secrets import token_bytes from secrets import token_bytes
from attrs import define, field from typing import Tuple, Any, Sequence, TextIO, TypeVar, Union, Literal
from typing import Tuple, Any, Sequence, TextIO, TypeVar, Union
import json import json
import re import re
from ipaddress import ( from ipaddress import IPv4Address, IPv4Interface, IPv6Address, IPv6Interface, ip_address, ip_interface
IPv4Address,
IPv4Interface,
IPv6Address,
IPv6Interface,
ip_address,
ip_interface,
)
from attrs import asdict, define, field from attrs import asdict, define, field
from attrs.converters import optional from attrs.converters import optional
from attrs.validators import optional as validator_optional, in_ as validator_in
from attrs.setters import convert as setters_convert from attrs.setters import convert as setters_convert
from segno import QRCode, make_qr
Point = Tuple[int, int] Point = Tuple[int, int]
P = 2**255 - 19 P = 2**255 - 19
_A = 486662 _A = 486662
def _point_add(point_n: Point, point_m: Point, point_diff: Point) -> Point: def _point_add(point_n: Point, point_m: Point, point_diff: Point) -> Point:
"""Given the projection of two points and their difference, return their sum.""" """Given the projection of two points and their difference, return their sum."""
(xn, zn) = point_n (xn, zn) = point_n
@ -338,7 +329,7 @@ class WireguardConfig:
) )
fwmark: int | None = field(converter=optional(int), default=None) fwmark: int | None = field(converter=optional(int), default=None)
listen_port: int | None = field(converter=optional(int), default=None) listen_port: int | None = field(converter=optional(int), default=None)
table: bool | None = field(converter=optional(bool), default=None) table: Literal["auto", "off"] | None = field(validator=validator_optional(validator_in(["auto", "off"])), default=None)
peers: dict[WireguardKey, WireguardPeer] = field(factory=dict) peers: dict[WireguardKey, WireguardPeer] = field(factory=dict)
# wg-quick format extensions # wg-quick format extensions
@ -431,7 +422,7 @@ class WireguardConfig:
elif key == "listenport": elif key == "listenport":
self.listen_port = int(value) self.listen_port = int(value)
elif key == "table": elif key == "table":
self.table = bool(value) self.table = value
elif key == "address": elif key == "address":
self.addresses.extend(ip_interface(addr) for addr in value.split(", ")) self.addresses.extend(ip_interface(addr) for addr in value.split(", "))
elif key == "dns": elif key == "dns":
@ -479,8 +470,7 @@ class WireguardConfig:
conf.extend([f"DNS = {addr}" for addr in self.dns_servers]) conf.extend([f"DNS = {addr}" for addr in self.dns_servers])
conf.extend([f"DNS = {domain}" for domain in self.search_domains]) conf.extend([f"DNS = {domain}" for domain in self.search_domains])
if self.table is not None: if self.table is not None:
val = "auto" if self.table else "off" conf.append(f"Table = {self.table}")
conf.append(f"Table = {val}")
conf.extend([f"PreUp = {cmd}" for cmd in self.preup]) conf.extend([f"PreUp = {cmd}" for cmd in self.preup])
conf.extend([f"PostUp = {cmd}" for cmd in self.postup]) conf.extend([f"PostUp = {cmd}" for cmd in self.postup])
@ -508,7 +498,3 @@ class WireguardConfig:
conf.append(f"options ndots:{opt_ndots}") conf.append(f"options ndots:{opt_ndots}")
conf.append("") conf.append("")
return "\n".join(conf) return "\n".join(conf)
def to_qrcode(self) -> QRCode:
config = self.to_wgconfig(wgquick_format=True)
return make_qr(config, mode="byte", encoding="utf-8", eci=True)

57
woven-ui.py Executable file
View File

@ -0,0 +1,57 @@
#!/usr/bin/env python3
from sys import platform
from argparse import ArgumentParser
from contextlib import redirect_stdout
from os import devnull
from sys import stdout, stderr, exit
from pathlib import Path
from woven import WovenConfig
from json import JSONDecodeError
from cattrs.errors import ClassValidationError
import customtkinter as ctk
def start(config_path: str):
ctk.set_appearance_mode("System")
ctk.set_default_color_theme("dark-blue")
if platform != "darwin" and not platform.startswith("win"):
ctk.set_widget_scaling(1.25)
ctk.set_window_scaling(1.25)
app = ctk.CTk()
app.geometry("1200x800")
app.title("Woven")
config = None
try:
config = WovenConfig.load(config_path)
except FileNotFoundError:
print(f"No file found at '{config_path}'")
except JSONDecodeError as e:
print(f"Invalid JSON encountered in configuration file: {e}")
except ClassValidationError as e:
print(f"The following validation errors occurred when loading the configuration file:", file = stderr)
for e in e.exceptions:
print(f"{type(e).__name__}: {e}", file = stderr)
if config is not None:
config_textbox = ctk.CTkTextbox(master = app)
config_textbox.place(relx = 0.5, rely = 0.0, anchor = ctk.N)
button = ctk.CTkButton(master = app, text = "Load config", command = lambda: print("button pressed"))
button.place(relx = 0.5, rely = 0.5, anchor = ctk.CENTER)
app.mainloop()
def main():
parser = ArgumentParser("woven-ui")
parser.add_argument("-q", "--quiet", action = "store_true", help = "decrease output verbosity")
parser.add_argument("-c", "--config", default = "config.json", help = "The path to the config file")
args = parser.parse_args()
with redirect_stdout(open(devnull, "w") if args.quiet else stdout):
start(args.config)
if __name__ == "__main__":
main()

359
woven.py
View File

@ -1,202 +1,245 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from __future__ import annotations
from fabric import Connection, Config from fabric import Connection, Config
from invoke.exceptions import UnexpectedExit from invoke.exceptions import UnexpectedExit
from pathlib import Path from pathlib import Path
from io import StringIO from io import StringIO
from json import loads from json import loads, dump, JSONDecodeError
from os import devnull from os import devnull, PathLike
from sys import stdout, stderr, exit from sys import stdout, stderr, exit
from contextlib import redirect_stdout from contextlib import redirect_stdout
from argparse import ArgumentParser from argparse import ArgumentParser
from dataclasses import dataclass from ipaddress import IPv4Interface, IPv4Network, IPv6Interface, IPv6Network
from dataclass_wizard import fromdict
from ipaddress import IPv4Interface, IPv4Network, IPv6Interface, IPv6Network, AddressValueError, NetmaskValueError
from itertools import combinations from itertools import combinations
from math import comb
from typing import Literal, TypeVar, Callable
from wireguard_tools import WireguardConfig, WireguardPeer, WireguardKey from wireguard_tools import WireguardConfig, WireguardPeer, WireguardKey
from attrs import define, field, Attribute
from attrs.validators import optional as validator_optional, in_ as validator_in
from cattrs import structure, unstructure
from cattrs.errors import ClassValidationError
@dataclass @define
class WovenNode: class WovenNode:
listen_address: str address: str
listen_gateway: str gateway: str
interface_name: str interface: str
routed_ipv4_subnets: list[str] ipv4Ranges: list[str]
routed_ipv6_subnets: list[str] ipv6Ranges: list[str]
@dataclass T = TypeVar("T", int, float)
def _range_validator(min_value: T, max_value: T) -> Callable[[T], T]:
def _validate(cls, attribute: Attribute, value: T) -> T:
if not min_value <= value <= max_value:
raise ValueError(f"field \"{attribute.name}\" must be between {min_value} and {max_value}")
return _validate
@define
class WovenConfig: class WovenConfig:
ptp_ipv4_network: str ptpIpv4Range: IPv4Network = field(converter = IPv4Network)
ptp_ipv6_network: str ptpIpv6Range: IPv6Network = field(converter = IPv6Network)
min_port: int minPort: int = field(validator = _range_validator(0, 0xFFFF))
max_port: int maxPort: int = field(validator = _range_validator(0, 0xFFFF))
nodes: dict[str, WovenNode] nodes: dict[str, WovenNode]
ptp_ipv4_subnet: int = 30 ptpIpv4Prefix: int = field(default = 30, validator = _range_validator(0, 32))
ptp_ipv6_subnet: int = 64 ptpIpv6Prefix: int = field(default = 64, validator = _range_validator(0, 128))
tunnelPrefix: str = ""
tunnelSuffix: str = "loop"
tunnelSeparator: str = "-"
wireguardDir: Path = field(default = Path("/etc/wireguard"), converter = Path)
wireguardConfigExt: str = field(default = "conf", converter = lambda x: str(x).lstrip("."))
table: Literal["auto", "off"] = field(default = "off", validator = validator_optional(validator_in(["auto", "off"])))
allowedIps: list[str] = field(factory = lambda: ["0.0.0.0/0", "::/0"])
persistentKeepalive: int = field(default = 20, validator = _range_validator(0, 1 << 31 - 1))
ssh_config = Config(overrides = { "run": { "hide": True } }) def _surround(self, val: str) -> str:
id_suffix = "loop" prefix_str = f"{self.tunnelPrefix}{self.tunnelSeparator}" if self.tunnelPrefix else ""
wg_dir = "/etc/wireguard" suffix_str = f"{self.tunnelSeparator}{self.tunnelSuffix}" if self.tunnelSuffix else ""
wg_config_ext = ".conf" return f"{prefix_str}{val}{suffix_str}"
wg_config_glob = f"{wg_dir}/*-{id_suffix}{wg_config_ext}"
table = False def get_tunnel_name(self, from_id: str, to_id: str) -> str:
allowed_ips = ["0.0.0.0/0", "::/0"] return self._surround(f"{from_id}{self.tunnelSeparator}{to_id}")
persistent_keepalive = 20
def generate_wg_configs(config: WovenConfig): def get_config_path(self, tunnel_name: str) -> str:
try: return self.wireguardDir / f"{tunnel_name}.{self.wireguardConfigExt}"
ptp_ipv4_network = IPv4Network(config.ptp_ipv4_network)
except AddressValueError:
raise ValueError("invalid IPv4 PtP network address")
except NetmaskValueError:
raise ValueError("invalid IPv4 PtP network subnet")
try:
ptp_ipv6_network = IPv6Network(config.ptp_ipv6_network)
except AddressValueError:
raise ValueError("invalid IPv6 PtP network address")
except NetmaskValueError:
raise ValueError("invalid IPv6 PtP network subnet")
ptp_ipv4_network_iter = ptp_ipv4_network.subnets(new_prefix = config.ptp_ipv4_subnet) @property
ptp_ipv6_network_iter = ptp_ipv6_network.subnets(new_prefix = config.ptp_ipv6_subnet) def wireguardConfigGlob(self) -> str:
port_iter = iter(range(config.min_port, config.max_port)) return str(self.wireguardDir / f"{self._surround('*')}.{self.wireguardConfigExt}")
cs = { id: Connection(node.listen_address, user = "root", config = ssh_config) for id, node in config.nodes.items() } @staticmethod
def load(path: str | bytes | PathLike) -> WovenConfig:
return structure(loads(Path(path).read_text(encoding = "UTF-8")), WovenConfig)
for id, c in cs.items(): def save(self, path: str | bytes | PathLike) -> None:
print(f"stopping services for {id}...", end = " ", flush = True) Path(path).write_text(dump(unstructure(self)), encoding = "UTF-8")
c.run(f"for f in {wg_config_glob}; do systemctl stop wg-quick@$(basename $f {wg_config_ext}).service; done")
print("done")
print(f"removing existing configs for {id}...", end = " ", flush = True)
try:
c.run(f"rm {wg_config_glob}")
except UnexpectedExit:
pass
print("done")
for (id_a, node_a), (id_b, node_b) in combinations(config.nodes.items(), 2): def validate(self) -> None:
print(f"creating configs for {id_a} <-> {id_b} tunnel...", end = " ", flush = True) tunnel_count = comb(len(self.nodes), 2)
if int(2 ** (self.ptpIpv4Prefix - self.ptpIpv4Range.prefixlen)) < tunnel_count:
try:
ptp_ipv4_network = next(ptp_ipv4_network_iter)
except StopIteration:
raise ValueError("not enough IPv4 PtP networks to assign") raise ValueError("not enough IPv4 PtP networks to assign")
try: if int(2 ** (self.ptpIpv6Prefix - self.ptpIpv6Range.prefixlen)) < tunnel_count:
ptp_ipv6_network = next(ptp_ipv6_network_iter)
except StopIteration:
raise ValueError("not enough IPv6 PtP networks to assign") raise ValueError("not enough IPv6 PtP networks to assign")
try: if int(2 ** (32 - self.ptpIpv4Prefix)) - 2 < 2:
port = next(port_iter) raise ValueError("not enough IPv4 addresses in each PtP network")
except StopIteration: if int(2 ** (128 - self.ptpIpv6Prefix)) - 2 < 2:
raise ValueError("not enough IPv6 addresses in each PtP network")
if self.maxPort - self.minPort < tunnel_count:
raise ValueError("not enough ports to assign") raise ValueError("not enough ports to assign")
ipv4_iter = ptp_ipv4_network.hosts() def __attrs_post_init__(self):
try: self.validate()
ipv4_a = next(ipv4_iter)
ipv4_b = next(ipv4_iter)
except StopIteration:
raise ValueError("not enough IPv4 addresses in each PtP network")
ipv6_iter = ptp_ipv6_network.hosts() def apply(self, ssh_config = Config(overrides = { "run": { "hide": True } })) -> None:
try: ptp_ipv4_network_iter = self.ptpIpv4Range.subnets(new_prefix = self.ptpIpv4Prefix)
ipv6_a = next(ipv6_iter) ptp_ipv6_network_iter = self.ptpIpv6Range.subnets(new_prefix = self.ptpIpv6Prefix)
ipv6_b = next(ipv6_iter) port_iter = iter(range(self.minPort, self.maxPort))
except StopIteration:
raise ValueError("not enough IPv6 addresses in each PtP network")
key_a = WireguardKey.generate() cs = { id: Connection(node.address, user = "root", config = ssh_config) for id, node in self.nodes.items() }
key_a_pub = key_a.public_key()
key_b = WireguardKey.generate() for id, c in cs.items():
key_b_pub = key_b.public_key() print(f"stopping services for {id}...", end = " ", flush = True)
c.run(f"for f in {self.wireguardConfigGlob}; do systemctl stop wg-quick@$(basename $f {self.wireguardConfigExt}).service; done")
print("done")
print(f"removing existing configs for {id}...", end = " ", flush = True)
try:
c.run(f"rm {self.wireguardConfigGlob}")
except UnexpectedExit:
pass
print("done")
name_a = f"{id_a}-{id_b}-{id_suffix}" for (id_a, node_a), (id_b, node_b) in combinations(self.nodes.items(), 2):
addresses_a = [IPv4Interface(f"{ipv4_a}/{ptp_ipv4_network.prefixlen}"), IPv6Interface(f"{ipv6_a}/{ptp_ipv6_network.prefixlen}")] print(f"creating configs for {id_a} <-> {id_b} tunnel...", end = " ", flush = True)
preup_a = [f"ip ro replace {node_b.listen_address}/32 dev {node_a.interface_name} via {node_a.listen_gateway} metric 10 src {node_a.listen_address}"]
predown_a = [f"ip ro del {node_b.listen_address}/32 dev {node_a.interface_name} via {node_a.listen_gateway} metric 10 src {node_a.listen_address}"]
postup_a = [f"ip ro replace {sn} dev {name_a} via {ipv4_b} metric 10" for sn in node_b.routed_ipv4_subnets] + [f"ip -6 ro replace {sn} dev {name_a} via {ipv6_b} metric 10" for sn in node_b.routed_ipv6_subnets]
postdown_a = [f"ip ro del {sn} dev {name_a} via {ipv4_b} metric 10" for sn in node_b.routed_ipv4_subnets] + [f"ip -6 ro del {sn} dev {name_a} via {ipv6_b} metric 10" for sn in node_b.routed_ipv6_subnets]
config_a = WireguardConfig( try:
addresses = addresses_a, ptp_ipv4_network = next(ptp_ipv4_network_iter)
listen_port = port, except StopIteration:
private_key = key_a, raise ValueError("not enough IPv4 PtP networks to assign")
table = table, try:
preup = preup_a, ptp_ipv6_network = next(ptp_ipv6_network_iter)
predown = predown_a, except StopIteration:
postup = postup_a, raise ValueError("not enough IPv6 PtP networks to assign")
postdown = postdown_a, try:
peers = { port = next(port_iter)
key_b_pub: WireguardPeer( except StopIteration:
public_key = key_b_pub, raise ValueError("not enough ports to assign")
allowed_ips = allowed_ips,
endpoint_host = node_b.listen_address,
endpoint_port = port,
persistent_keepalive = persistent_keepalive
)
}
)
name_b = f"{id_b}-{id_a}-{id_suffix}" ipv4_iter = ptp_ipv4_network.hosts()
addresses_b = [IPv4Interface(f"{ipv4_b}/{ptp_ipv4_network.prefixlen}"), IPv6Interface(f"{ipv6_b}/{ptp_ipv6_network.prefixlen}")] try:
preup_b = [f"ip ro replace {node_a.listen_address}/32 dev {node_b.interface_name} via {node_b.listen_gateway} metric 10 src {node_b.listen_address}"] ipv4_a = next(ipv4_iter)
predown_b = [f"ip ro del {node_a.listen_address}/32 dev {node_b.interface_name} via {node_b.listen_gateway} metric 10 src {node_b.listen_address}"] ipv4_b = next(ipv4_iter)
postup_b = [f"ip ro replace {sn} dev {name_b} via {ipv4_a} metric 10" for sn in node_a.routed_ipv4_subnets] + [f"ip -6 ro replace {sn} dev {name_b} via {ipv6_a} metric 10" for sn in node_a.routed_ipv6_subnets] except StopIteration:
postdown_b = [f"ip ro del {sn} dev {name_b} via {ipv4_a} metric 10" for sn in node_a.routed_ipv4_subnets] + [f"ip -6 ro del {sn} dev {name_b} via {ipv6_a} metric 10" for sn in node_a.routed_ipv6_subnets] raise ValueError("not enough IPv4 addresses in each PtP network")
config_b = WireguardConfig( ipv6_iter = ptp_ipv6_network.hosts()
addresses = addresses_b, try:
listen_port = port, ipv6_a = next(ipv6_iter)
private_key = key_b, ipv6_b = next(ipv6_iter)
table = table, except StopIteration:
preup = preup_b, raise ValueError("not enough IPv6 addresses in each PtP network")
predown = predown_b,
postup = postup_b,
postdown = postdown_b,
peers = {
key_a_pub: WireguardPeer(
public_key = key_a_pub,
allowed_ips = allowed_ips,
endpoint_host = node_a.listen_address,
endpoint_port = port,
persistent_keepalive = persistent_keepalive
)
}
)
print("done")
print(f"saving {id_a} side of {id_a} <-> {id_b} tunnel...", end = " ", flush = True) key_a = WireguardKey.generate()
cs[id_a].put(StringIO(config_a.to_wgconfig(wgquick_format = True)), f"/etc/wireguard/{name_a}.conf") key_a_pub = key_a.public_key()
print("done")
print(f"saving {id_b} side of {id_a} <-> {id_b} tunnel...", end = " ", flush = True)
cs[id_b].put(StringIO(config_b.to_wgconfig(wgquick_format = True)), f"/etc/wireguard/{name_b}.conf")
print("done")
for id, c in cs.items(): key_b = WireguardKey.generate()
print(f"starting services for {id}...", end = " ", flush = True) key_b_pub = key_b.public_key()
c.run(f"for f in {wg_config_glob}; do systemctl start wg-quick@$(basename $f {wg_config_ext}).service; done")
print("done")
tunnel_name_a = self.get_tunnel_name(id_a, id_b)
addresses_a = [IPv4Interface(f"{ipv4_a}/{self.ptpIpv4Range.prefixlen}"), IPv6Interface(f"{ipv6_a}/{self.ptpIpv6Range.prefixlen}")]
preup_a = [f"ip ro replace {node_b.address}/32 dev {node_a.interface} via {node_a.gateway} metric 10 src {node_a.address}"]
predown_a = [f"ip ro del {node_b.address}/32 dev {node_a.interface} via {node_a.gateway} metric 10 src {node_a.address}"]
postup_a = [f"ip ro replace {sn} dev {tunnel_name_a} via {ipv4_b} metric 10" for sn in node_b.ipv4Ranges] + [f"ip -6 ro replace {sn} dev {tunnel_name_a} via {ipv6_b} metric 10" for sn in node_b.ipv6Ranges]
postdown_a = [f"ip ro del {sn} dev {tunnel_name_a} via {ipv4_b} metric 10" for sn in node_b.ipv4Ranges] + [f"ip -6 ro del {sn} dev {tunnel_name_a} via {ipv6_b} metric 10" for sn in node_b.ipv6Ranges]
@dataclass config_a = WireguardConfig(
class WovenArgs: addresses = addresses_a,
quiet: str listen_port = port,
config: str private_key = key_a,
table = self.table,
preup = preup_a,
predown = predown_a,
postup = postup_a,
postdown = postdown_a,
peers = {
key_b_pub: WireguardPeer(
public_key = key_b_pub,
allowed_ips = self.allowedIps,
endpoint_host = node_b.address,
endpoint_port = port,
persistent_keepalive = self.persistentKeepalive
)
}
)
tunnel_name_b = self.get_tunnel_name(id_b, id_a)
addresses_b = [IPv4Interface(f"{ipv4_b}/{self.ptpIpv4Range.prefixlen}"), IPv6Interface(f"{ipv6_b}/{self.ptpIpv6Range.prefixlen}")]
preup_b = [f"ip ro replace {node_a.address}/32 dev {node_b.interface} via {node_b.gateway} metric 10 src {node_b.address}"]
predown_b = [f"ip ro del {node_a.address}/32 dev {node_b.interface} via {node_b.gateway} metric 10 src {node_b.address}"]
postup_b = [f"ip ro replace {sn} dev {tunnel_name_b} via {ipv4_a} metric 10" for sn in node_a.ipv4Ranges] + [f"ip -6 ro replace {sn} dev {tunnel_name_b} via {ipv6_a} metric 10" for sn in node_a.ipv6Ranges]
postdown_b = [f"ip ro del {sn} dev {tunnel_name_b} via {ipv4_a} metric 10" for sn in node_a.ipv4Ranges] + [f"ip -6 ro del {sn} dev {tunnel_name_b} via {ipv6_a} metric 10" for sn in node_a.ipv6Ranges]
config_b = WireguardConfig(
addresses = addresses_b,
listen_port = port,
private_key = key_b,
table = self.table,
preup = preup_b,
predown = predown_b,
postup = postup_b,
postdown = postdown_b,
peers = {
key_a_pub: WireguardPeer(
public_key = key_a_pub,
allowed_ips = self.allowedIps,
endpoint_host = node_a.address,
endpoint_port = port,
persistent_keepalive = self.persistentKeepalive
)
}
)
print("done")
print(f"saving {id_a} side of {id_a} <-> {id_b} tunnel...", end = " ", flush = True)
cs[id_a].put(StringIO(config_a.to_wgconfig(wgquick_format = True)), str(self.get_config_path(tunnel_name_a)))
print("done")
print(f"saving {id_b} side of {id_a} <-> {id_b} tunnel...", end = " ", flush = True)
cs[id_b].put(StringIO(config_b.to_wgconfig(wgquick_format = True)), str(self.get_config_path(tunnel_name_b)))
print("done")
for id, c in cs.items():
print(f"starting services for {id}...", end = " ", flush = True)
c.run(f"for f in {self.wireguardConfigGlob}; do systemctl start wg-quick@$(basename $f .{self.wireguardConfigExt}).service; done")
print("done")
def main(): def main():
try: parser = ArgumentParser("woven")
parser = ArgumentParser("woven") parser.add_argument("-q", "--quiet", action = "store_true", help = "decrease output verbosity")
parser.add_argument("-q", "--quiet", action = "store_true", help = "decrease output verbosity") parser.add_argument("-c", "--config", default = "config.json", help = "the path to the config file")
parser.add_argument("-c", "--config", default = "config.json", help = "The path to the config file") parser.add_argument("-v", "--validate", action = "store_true", help = "only validate the config without applying it")
args = parser.parse_args(namespace = WovenArgs) args = parser.parse_args()
with redirect_stdout(open(devnull, "w") if args.quiet else stdout): with redirect_stdout(open(devnull, "w") if args.quiet else stdout):
config_path = Path(args.config) try:
config = fromdict(WovenConfig, loads(config_path.read_bytes())) config = WovenConfig.load(args.config)
generate_wg_configs(config) except FileNotFoundError:
except ValueError as e: print(f"No configuration file found at '{args.config}'", file = stderr)
print(f"error: {e}", file = stderr) exit(1)
exit(1) except JSONDecodeError as e:
print(f"Invalid JSON encountered in configuration file: {e}", file = stderr)
exit(1)
except ClassValidationError as e:
print(f"The following validation errors occurred when loading the configuration file:", file = stderr)
for e in e.exceptions:
print(f"{type(e).__name__}: {e}", file = stderr)
exit(1)
if args.validate:
return
try:
config.apply()
except Exception as e:
print(f"error applying configuration: {e}", file = stderr)
exit(1)
if __name__ == "__main__": if __name__ == "__main__":
main() main()