Add table off

This commit is contained in:
LilyRose2798 2024-04-17 01:29:15 +10:00
parent f619c3cf04
commit 658a2e1250
4 changed files with 530 additions and 47 deletions

43
poetry.lock generated
View File

@ -310,20 +310,6 @@ cffi = ">=1.4.1"
docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"]
tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"]
[[package]]
name = "pyroute2"
version = "0.7.12"
description = "Python Netlink library"
optional = false
python-versions = "*"
files = [
{file = "pyroute2-0.7.12-py3-none-any.whl", hash = "sha256:9df8d0fcb5fb0a724603bcfdef76ffbd287f00f69e9fb660c20a06962b24691a"},
{file = "pyroute2-0.7.12.tar.gz", hash = "sha256:54d226fc3ff2732f49bac9b26853c50c9d05be05a4d9daf09c7cf6d77301eff3"},
]
[package.dependencies]
win-inet-pton = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "segno"
version = "1.6.1"
@ -335,33 +321,6 @@ files = [
{file = "segno-1.6.1.tar.gz", hash = "sha256:f23da78b059251c36e210d0cf5bfb1a9ec1604ae6e9f3d42f9a7c16d306d847e"},
]
[[package]]
name = "win-inet-pton"
version = "1.1.0"
description = "Native inet_pton and inet_ntop implementation for Python on Windows (with ctypes)."
optional = false
python-versions = "*"
files = [
{file = "win_inet_pton-1.1.0-py2.py3-none-any.whl", hash = "sha256:eaf0193cbe7152ac313598a0da7313fb479f769343c0c16c5308f64887dc885b"},
{file = "win_inet_pton-1.1.0.tar.gz", hash = "sha256:dd03d942c0d3e2b1cf8bab511844546dfa5f74cb61b241699fa379ad707dea4f"},
]
[[package]]
name = "wireguard-tools"
version = "0.4.7"
description = "Pure python reimplementation of wireguard-tools"
optional = false
python-versions = "<4.0,>=3.7"
files = [
{file = "wireguard_tools-0.4.7-py3-none-any.whl", hash = "sha256:cc1ec9fd3c10eb91b61c9aa7358b6d437708411631f44b8bedfebc8bad38a8b0"},
{file = "wireguard_tools-0.4.7.tar.gz", hash = "sha256:f5b9c4e00b4e716c74c625c698bddbaacd43eb0d7d990294d0d3059ebc544a3a"},
]
[package.dependencies]
attrs = ">=22.1.0"
pyroute2 = ">=0.7.3,<0.8.0"
segno = ">=1.5.2,<2.0.0"
[[package]]
name = "wrapt"
version = "1.16.0"
@ -444,4 +403,4 @@ files = [
[metadata]
lock-version = "2.0"
python-versions = "^3.11"
content-hash = "faf3f760b4a4219972634720fcf4f0ac98d6dd601b4e246a4087e6a023a6fb8b"
content-hash = "b31352156c64013b2b409098145b904d4664fd65edb56bc37020b449145136cd"

View File

@ -9,8 +9,9 @@ readme = "README.md"
[tool.poetry.dependencies]
python = "^3.11"
fabric = "^3.2.2"
wireguard-tools = "^0.4.7"
dataclass-wizard = "^0.22.3"
attrs = "^23.2.0"
segno = "^1.6.1"
[build-system]

514
wireguard_tools.py Normal file
View File

@ -0,0 +1,514 @@
from __future__ import annotations
from base64 import standard_b64encode, urlsafe_b64decode, urlsafe_b64encode
from secrets import token_bytes
from attrs import define, field
from typing import Tuple, Any, Sequence, TextIO, TypeVar, Union
import json
import re
from ipaddress import (
IPv4Address,
IPv4Interface,
IPv6Address,
IPv6Interface,
ip_address,
ip_interface,
)
from attrs import asdict, define, field
from attrs.converters import optional
from attrs.setters import convert as setters_convert
from segno import QRCode, make_qr
Point = Tuple[int, int]
P = 2**255 - 19
_A = 486662
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."""
(xn, zn) = point_n
(xm, zm) = point_m
(x_diff, z_diff) = point_diff
x = (z_diff << 2) * (xm * xn - zm * zn) ** 2
z = (x_diff << 2) * (xm * zn - zm * xn) ** 2
return x % P, z % P
def _point_double(point_n: Point) -> Point:
"""Double a point provided in projective coordinates."""
(xn, zn) = point_n
xn2 = xn**2
zn2 = zn**2
x = (xn2 - zn2) ** 2
xzn = xn * zn
z = 4 * xzn * (xn2 + _A * xzn + zn2)
return x % P, z % P
def _const_time_swap(a: Point, b: Point, swap: bool) -> tuple[Point, Point]:
"""Swap two values in constant time."""
index = int(swap) * 2
temp = (a, b, b, a)
return temp[index], temp[index + 1]
def _raw_curve25519(base: int, n: int) -> int:
"""Raise the point base to the power n."""
zero = (1, 0)
one = (base, 1)
mP, m1P = zero, one
for i in reversed(range(256)):
bit = bool(n & (1 << i))
mP, m1P = _const_time_swap(mP, m1P, bit)
mP, m1P = _point_double(mP), _point_add(mP, m1P, one)
mP, m1P = _const_time_swap(mP, m1P, bit)
x, z = mP
inv_z = pow(z, P - 2, P)
return (x * inv_z) % P
def _unpack_number(s: bytes) -> int:
"""Unpack 32 bytes to a 256 bit value."""
if len(s) != 32:
msg = "Curve25519 values must be 32 bytes"
raise ValueError(msg)
return int.from_bytes(s, "little")
def _pack_number(n: int) -> bytes:
"""Pack a value into 32 bytes."""
return n.to_bytes(32, "little")
def _fix_base_point(n: int) -> int:
# RFC7748 section 5
# u-coordinates are ... encoded as an array of bytes ... When receiving
# such an array, implementations of X25519 MUST mask the most significant
# bit in the final byte.
n &= ~(128 << 8 * 31)
return n
def _fix_secret(n: int) -> int:
"""Mask a value to be an acceptable exponent."""
n &= ~7
n &= ~(128 << 8 * 31)
n |= 64 << 8 * 31
return n
def curve25519(base_point_raw: bytes, secret_raw: bytes) -> bytes:
"""Raise the base point to a given power."""
base_point = _fix_base_point(_unpack_number(base_point_raw))
secret = _fix_secret(_unpack_number(secret_raw))
return _pack_number(_raw_curve25519(base_point, secret))
def curve25519_base(secret_raw: bytes) -> bytes:
"""Raise the generator point to a given power."""
secret = _fix_secret(_unpack_number(secret_raw))
return _pack_number(_raw_curve25519(9, secret))
class X25519PublicKey:
def __init__(self, x: int) -> None:
self.x = x
@classmethod
def from_public_bytes(cls, data: bytes) -> X25519PublicKey:
return cls(_fix_base_point(_unpack_number(data)))
def public_bytes(self) -> bytes:
return _pack_number(self.x)
class X25519PrivateKey:
def __init__(self, a: int) -> None:
self.a = a
@classmethod
def from_private_bytes(cls, data: bytes) -> X25519PrivateKey:
return cls(_fix_secret(_unpack_number(data)))
def private_bytes(self) -> bytes:
return _pack_number(self.a)
def public_key(self) -> bytes:
return _pack_number(_raw_curve25519(9, self.a))
def exchange(self, peer_public_key: X25519PublicKey | bytes) -> bytes:
if isinstance(peer_public_key, bytes):
peer_public_key = X25519PublicKey.from_public_bytes(peer_public_key)
return _pack_number(_raw_curve25519(peer_public_key.x, self.a))
def convert_wireguard_key(value: str | bytes | WireguardKey) -> bytes:
"""Decode a wireguard key to its byte string form.
Accepts urlsafe encoded base64 keys with possibly missing padding.
Validates that the resulting key value is a 32-byte byte string.
"""
if isinstance(value, WireguardKey):
return value.keydata
if isinstance(value, bytes):
raw_key = value
elif len(value) == 64:
raw_key = bytes.fromhex(value)
else:
raw_key = urlsafe_b64decode(value + "==")
if len(raw_key) != 32:
msg = "Invalid WireGuard key length"
raise ValueError(msg)
return raw_key
@define(frozen=True)
class WireguardKey:
"""Representation of a WireGuard key."""
keydata: bytes = field(converter=convert_wireguard_key)
@classmethod
def generate(cls) -> WireguardKey:
"""Generate a new private key."""
random_data = token_bytes(32)
# turn it into a proper curve25519 private key by fixing/clamping the value
private_bytes = X25519PrivateKey.from_private_bytes(random_data).private_bytes()
return cls(private_bytes)
def public_key(self) -> WireguardKey:
"""Derive public key from private key."""
public_bytes = X25519PrivateKey.from_private_bytes(self.keydata).public_key()
return WireguardKey(public_bytes)
def __bool__(self) -> bool:
return int.from_bytes(self.keydata, "little") != 0
def __repr__(self) -> str:
return f"WireguardKey('{self}')"
def __str__(self) -> str:
"""Return a base64 encoded representation of the key."""
return standard_b64encode(self.keydata).decode("utf-8")
@property
def urlsafe(self) -> str:
"""Return a urlsafe base64 encoded representation of the key."""
return urlsafe_b64encode(self.keydata).decode("utf-8").rstrip("=")
@property
def hex(self) -> str:
"""Return a hexadecimal encoded representation of the key."""
return self.keydata.hex()
SimpleJsonTypes = Union[str, int, float, bool, None]
T = TypeVar("T")
def _ipaddress_or_host(
host: IPv4Address | IPv6Address | str,
) -> IPv4Address | IPv6Address | str:
if isinstance(host, (IPv4Address, IPv6Address)):
return host
try:
return ip_address(host)
except ValueError:
return host
def _list_of_ipaddress(
hosts: Sequence[IPv4Address | IPv6Address | str],
) -> Sequence[IPv4Address | IPv6Address]:
return [ip_address(host) for host in hosts]
def _list_of_ipinterface(
hosts: Sequence[IPv4Interface | IPv6Interface | str],
) -> Sequence[IPv4Interface | IPv6Interface]:
return [ip_interface(host) for host in hosts]
@define(on_setattr=setters_convert)
class WireguardPeer:
public_key: WireguardKey = field(converter=WireguardKey)
preshared_key: WireguardKey | None = field(
converter=optional(WireguardKey),
default=None,
)
endpoint_host: IPv4Address | IPv6Address | str | None = field(
converter=optional(_ipaddress_or_host),
default=None,
)
endpoint_port: int | None = field(converter=optional(int), default=None)
persistent_keepalive: int | None = field(converter=optional(int), default=None)
allowed_ips: list[IPv4Interface | IPv6Interface] = field(
converter=_list_of_ipinterface,
factory=list,
)
# comment tags that can be parsed by prometheus-wireguard-exporter
friendly_name: str | None = None
friendly_json: dict[str, SimpleJsonTypes] | None = None
# peer statistics from device
last_handshake: float | None = field(
converter=optional(float),
default=None,
eq=False,
)
rx_bytes: int | None = field(converter=optional(int), default=None, eq=False)
tx_bytes: int | None = field(converter=optional(int), default=None, eq=False)
@classmethod
def from_dict(cls, config_dict: dict[str, Any]) -> WireguardPeer:
endpoint = config_dict.pop("endpoint", None)
if endpoint is not None:
host, port = endpoint.rsplit(":", 1)
config_dict["endpoint_host"] = host
config_dict["endpoint_port"] = int(port)
return cls(**config_dict)
def asdict(self) -> dict[str, Any]:
def _filter(_attr: Any, value: Any) -> bool:
return value is not None
def _serializer(_instance: type, _field: Any, value: T) -> T | str:
if isinstance(
value,
(IPv4Address, IPv4Interface, IPv6Address, IPv6Interface, WireguardKey),
):
return str(value)
return value
return asdict(self, filter=_filter, value_serializer=_serializer)
@classmethod
def from_wgconfig(cls, config: Sequence[tuple[str, str]]) -> WireguardPeer:
conf: dict[str, Any] = {}
for key_, value in config:
key = key_.lower()
if key == "publickey":
conf["public_key"] = WireguardKey(value)
elif key == "presharedkey":
conf["preshared_key"] = WireguardKey(value)
elif key == "endpoint":
host, port = value.rsplit(":", 1)
conf["endpoint_host"] = host
conf["endpoint_port"] = int(port)
elif key == "persistentkeepalive":
conf["persistent_keepalive"] = int(value)
elif key == "allowedips":
conf.setdefault("allowed_ips", []).extend(
ip_interface(addr) for addr in value.split(", ")
)
elif key == "# friendly_name":
conf["friendly_name"] = value
elif key == "# friendly_json":
conf["friendly_json"] = json.loads(value)
return cls(**conf)
def as_wgconfig_snippet(self) -> list[str]:
conf = ["\n[Peer]"]
if self.friendly_name:
conf.append(f"# friendly_name = {self.friendly_name}")
if self.friendly_json is not None:
value = json.dumps(self.friendly_json)
conf.append(f"# friendly_json = {value}")
conf.append(f"PublicKey = {self.public_key}")
if self.preshared_key:
conf.append(f"PresharedKey = {self.preshared_key}")
if self.endpoint_host:
conf.append(f"Endpoint = {self.endpoint_host}:{self.endpoint_port}")
if self.persistent_keepalive:
conf.append(f"PersistentKeepalive = {self.persistent_keepalive}")
conf.extend([f"AllowedIPs = {addr}" for addr in self.allowed_ips])
return conf
@define(on_setattr=setters_convert)
class WireguardConfig:
private_key: WireguardKey | None = field(
converter=optional(WireguardKey),
default=None,
repr=lambda _: "(hidden)",
)
fwmark: 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)
peers: dict[WireguardKey, WireguardPeer] = field(factory=dict)
# wg-quick format extensions
addresses: list[IPv4Interface | IPv6Interface] = field(
converter=_list_of_ipinterface,
factory=list,
)
dns_servers: list[IPv4Address | IPv6Address] = field(
converter=_list_of_ipaddress,
factory=list,
)
search_domains: list[str] = field(factory=list)
mtu: int | None = field(converter=optional(int), default=None)
preup: list[str] = field(factory=list)
postup: list[str] = field(factory=list)
predown: list[str] = field(factory=list)
postdown: list[str] = field(factory=list)
# wireguard-android specific extensions
included_applications: list[str] = field(factory=list)
excluded_applications: list[str] = field(factory=list)
@classmethod
def from_dict(cls, config_dict: dict[str, Any]) -> WireguardConfig:
config_dict = config_dict.copy()
dns = config_dict.pop("dns", [])
peers = config_dict.pop("peers", [])
config = cls(**config_dict)
for item in dns:
config._add_dns_entry(item)
for peer_dict in peers:
peer = WireguardPeer.from_dict(peer_dict)
config.add_peer(peer)
return config
def asdict(self) -> dict[str, Any]:
def _filter(_attr: Any, value: Any) -> bool:
return value is not None
def _serializer(
_instance: type,
_field: Any,
value: T,
) -> list[dict[str, Any]] | T | str:
if isinstance(value, dict):
return list(value.values())
if isinstance(
value,
(IPv4Address, IPv4Interface, IPv6Address, IPv6Interface, WireguardKey),
):
return str(value)
return value
return asdict(self, filter=_filter, value_serializer=_serializer)
@classmethod
def from_wgconfig(cls, configfile: TextIO) -> WireguardConfig:
text = configfile.read()
_pre, *parts = re.split(r"\[(Interface|Peer)\]\n", text, flags=re.I)
sections = [section.lower() for section in parts[0::2]]
if sections.count("interface") > 1:
msg = "More than one [Interface] section in config file"
raise ValueError(msg)
config = cls()
for section, content in zip(sections, parts[1::2]):
key_value = [
(match.group(1), match.group(3))
for match in re.finditer(r"^((# )?\w+)\s*=\s*(.+)$", content, re.M)
]
if section == "interface":
config._update_from_conf(key_value)
else:
peer = WireguardPeer.from_wgconfig(key_value)
config.add_peer(peer)
return config
def _update_from_conf(self, key_value: Sequence[tuple[str, str]]) -> None:
for key_, value in key_value:
key = key_.lower()
if key == "privatekey":
self.private_key = WireguardKey(value)
elif key == "fwmark":
self.fwmark = int(value)
elif key == "listenport":
self.listen_port = int(value)
elif key == "table":
self.table = bool(value)
elif key == "address":
self.addresses.extend(ip_interface(addr) for addr in value.split(", "))
elif key == "dns":
for item in value.split(", "):
self._add_dns_entry(item)
elif key == "mtu":
self.mtu = int(value)
elif key == "includedapplications":
self.included_applications.extend(item for item in value.split(", "))
elif key == "excludedapplications":
self.excluded_applications.extend(item for item in value.split(", "))
elif key == "preup":
self.preup.append(value)
elif key == "postup":
self.postup.append(value)
elif key == "predown":
self.predown.append(value)
elif key == "postdown":
self.postdown.append(value)
def _add_dns_entry(self, item: str) -> None:
try:
self.dns_servers.append(ip_address(item))
except ValueError:
self.search_domains.append(item)
def add_peer(self, peer: WireguardPeer) -> None:
self.peers[peer.public_key] = peer
def del_peer(self, peer_key: WireguardKey) -> None:
del self.peers[peer_key]
def to_wgconfig(self, wgquick_format: bool = False) -> str:
conf = ["[Interface]"]
if self.private_key is not None:
conf.append(f"PrivateKey = {self.private_key}")
if self.listen_port is not None:
conf.append(f"ListenPort = {self.listen_port}")
if self.table is not None:
val = "on" if self.table else "off"
conf.append(f"Table = {val}")
if self.fwmark is not None:
conf.append(f"FwMark = {self.fwmark}")
if wgquick_format:
if self.mtu is not None:
conf.append(f"MTU = {self.mtu}")
conf.extend([f"Address = {addr}" for addr in self.addresses])
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"PreUp = {cmd}" for cmd in self.preup])
conf.extend([f"PostUp = {cmd}" for cmd in self.postup])
conf.extend([f"PreDown = {cmd}" for cmd in self.predown])
conf.extend([f"PostDown = {cmd}" for cmd in self.postdown])
# wireguard-android specific extensions
if self.included_applications:
apps = ", ".join(self.included_applications)
conf.append(f"IncludedApplications = {apps}")
if self.excluded_applications:
apps = ", ".join(self.excluded_applications)
conf.append(f"ExcludedApplications = {apps}")
for peer in self.peers.values():
conf.extend(peer.as_wgconfig_snippet())
conf.append("")
return "\n".join(conf)
def to_resolvconf(self, opt_ndots: int | None = None) -> str:
conf = [f"nameserver {addr}" for addr in self.dns_servers]
if self.search_domains:
search_domains = " ".join(self.search_domains)
conf.append(f"search {search_domains}")
if opt_ndots is not None:
conf.append(f"options ndots:{opt_ndots}")
conf.append("")
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)

View File

@ -2,7 +2,6 @@
from fabric import Connection
from pathlib import Path
from wireguard_tools import WireguardConfig, WireguardPeer, WireguardKey
from sys import stderr
from io import StringIO
from json import loads
@ -11,6 +10,7 @@ from dataclasses import dataclass
from dataclass_wizard import fromdict
from ipaddress import IPv4Interface, IPv4Network, IPv6Interface, IPv6Network, AddressValueError, NetmaskValueError
from itertools import combinations
from .wireguard_tools import WireguardConfig, WireguardPeer, WireguardKey
@dataclass
class WovenArgs:
@ -50,7 +50,8 @@ def generate_wg_configs(config: WovenConfig):
cs = { id: Connection(node.listen_address, user = "root") for id, node in config.nodes.items() }
for c in cs.values():
c.run(f"rm /etc/wireguard/*-loop.conf")
c.run("for f in /etc/wireguard/*-loop.conf; do systemctl stop wg-quick@$(basename $f .conf).service; done")
c.run("rm /etc/wireguard/*-loop.conf")
ptp_ipv4_network_iter = ptp_ipv4_network.subnets(new_prefix = config.ptp_ipv4_subnet)
ptp_ipv6_network_iter = ptp_ipv6_network.subnets(new_prefix = config.ptp_ipv6_subnet)
@ -94,6 +95,7 @@ def generate_wg_configs(config: WovenConfig):
addresses = [ipv4_a, ipv6_a],
listen_port = port,
private_key = key_a,
table = False,
preup=[f"ip ro add {node_b.listen_address}/32 dev {node_a.interface_name} via {node_a.listen_gateway} metric 10 src {node_a.listen_address}"],
predown=[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}"],
peers = {
@ -111,6 +113,7 @@ def generate_wg_configs(config: WovenConfig):
addresses = [ipv4_b, ipv6_b],
listen_port = port,
private_key = key_b,
table = False,
preup=[f"ip ro add {node_a.listen_address}/32 dev {node_b.interface_name} via {node_b.listen_gateway} metric 10 src {node_b.listen_address}"],
predown=[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}"],
peers = {
@ -124,8 +127,14 @@ def generate_wg_configs(config: WovenConfig):
}
)
cs[id_a].put(StringIO(config_a.to_wgconfig(wgquick_format = True)), f"/etc/wireguard/{id_a}-{id_b}-loop.conf")
cs[id_b].put(StringIO(config_b.to_wgconfig(wgquick_format = True)), f"/etc/wireguard/{id_b}-{id_a}-loop.conf")
name_a = f"{id_a}-{id_b}-loop"
cs[id_a].put(StringIO(config_a.to_wgconfig(wgquick_format = True)), f"/etc/wireguard/{name_a}.conf")
# cs[id_a].run(f"systemctl start wg-quick@{name_a}.service")
name_b = f"{id_b}-{id_a}-loop"
cs[id_b].put(StringIO(config_b.to_wgconfig(wgquick_format = True)), f"/etc/wireguard/{name_b}.conf")
# cs[id_b].run(f"systemctl start wg-quick@{name_b}.service")
def main():
try: