Multi-interface management, topology view, performance tuning, precompiled CSS
- Interface model with per-interface subnet/port/keys; import/adopt existing wg-quick configs (key-less imported peers, optional key rotation), cascade delete - Split wireguard.py into a package (keys via cryptography X25519, status, addressing, conf parse/render, sync, host tuning) - ECharts horizontal topology view (interface -> peers -> site subnets) - Advanced options: MTU, MSS clamping, FwMark/Table, custom PostUp/PostDown, per-peer keepalive override - Runtime settings (sample interval/retention, online threshold, UI refresh) with traffic sample pruning; host tuning (UDP buffers, backlog, GRO forwarding) - Precompiled Tailwind CSS replacing Play CDN runtime (fixes FOUC); stable table layout and diffed polling renders - Host network mode in compose; NAT/isolation iptables moved into app sync Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
192960ad3e
commit
1b227c2470
@@ -0,0 +1,75 @@
|
||||
from .addressing import (
|
||||
next_free_address,
|
||||
server_address,
|
||||
subnets_overlap,
|
||||
validate_address,
|
||||
validate_cidr_list,
|
||||
validate_interface_name,
|
||||
validate_subnet,
|
||||
)
|
||||
from .conf import (
|
||||
ParsedConfig,
|
||||
ParsedPeer,
|
||||
backup_config,
|
||||
config_path,
|
||||
discover_configs,
|
||||
parse_config,
|
||||
render_client_config,
|
||||
render_server_config,
|
||||
server_allowed_ips,
|
||||
write_server_config,
|
||||
)
|
||||
from .keys import generate_keypair, genkey, genpsk, pubkey
|
||||
from .status import InterfaceStatus, PeerStatus, get_status, system_interfaces
|
||||
from .sync import (
|
||||
interface_down,
|
||||
interface_up,
|
||||
is_managed,
|
||||
sync_isolation,
|
||||
sync_mss_clamp,
|
||||
sync_mtu,
|
||||
sync_nat,
|
||||
sync_peers,
|
||||
sync_routes,
|
||||
)
|
||||
from . import status as status_module
|
||||
from . import tuning
|
||||
|
||||
__all__ = [
|
||||
"InterfaceStatus",
|
||||
"ParsedConfig",
|
||||
"ParsedPeer",
|
||||
"PeerStatus",
|
||||
"backup_config",
|
||||
"config_path",
|
||||
"discover_configs",
|
||||
"generate_keypair",
|
||||
"genkey",
|
||||
"genpsk",
|
||||
"get_status",
|
||||
"interface_down",
|
||||
"interface_up",
|
||||
"is_managed",
|
||||
"next_free_address",
|
||||
"parse_config",
|
||||
"pubkey",
|
||||
"render_client_config",
|
||||
"render_server_config",
|
||||
"server_address",
|
||||
"server_allowed_ips",
|
||||
"subnets_overlap",
|
||||
"status_module",
|
||||
"sync_isolation",
|
||||
"sync_mss_clamp",
|
||||
"sync_mtu",
|
||||
"sync_nat",
|
||||
"sync_peers",
|
||||
"sync_routes",
|
||||
"system_interfaces",
|
||||
"tuning",
|
||||
"validate_address",
|
||||
"validate_cidr_list",
|
||||
"validate_interface_name",
|
||||
"validate_subnet",
|
||||
"write_server_config",
|
||||
]
|
||||
@@ -0,0 +1,73 @@
|
||||
import ipaddress
|
||||
import re
|
||||
|
||||
|
||||
def server_address(subnet: str) -> str:
|
||||
network = ipaddress.ip_network(subnet)
|
||||
return f"{next(network.hosts())}/{network.prefixlen}"
|
||||
|
||||
|
||||
def validate_interface_name(name: str) -> str:
|
||||
name = name.strip()
|
||||
if not re.fullmatch(r"[a-zA-Z0-9_=+.-]{1,15}", name):
|
||||
raise ValueError(f"Invalid interface name: {name}")
|
||||
return name
|
||||
|
||||
|
||||
def validate_subnet(subnet: str) -> str:
|
||||
try:
|
||||
network = ipaddress.ip_network(subnet.strip(), strict=True)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid subnet: {subnet}")
|
||||
if network.num_addresses < 4:
|
||||
raise ValueError(f"Subnet {network} is too small")
|
||||
return str(network)
|
||||
|
||||
|
||||
def subnets_overlap(subnet: str, others: list[str]) -> str | None:
|
||||
network = ipaddress.ip_network(subnet)
|
||||
for other in others:
|
||||
if network.overlaps(ipaddress.ip_network(other)):
|
||||
return other
|
||||
return None
|
||||
|
||||
|
||||
def validate_address(address: str, subnet: str, taken: list[str]) -> str:
|
||||
network = ipaddress.ip_network(subnet)
|
||||
try:
|
||||
ip = ipaddress.ip_address(address.split("/")[0].strip())
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid IP address: {address}")
|
||||
if ip not in network:
|
||||
raise ValueError(f"{ip} is not in subnet {subnet}")
|
||||
if ip in (network.network_address, network.broadcast_address):
|
||||
raise ValueError(f"{ip} is not a usable host address")
|
||||
if ip == next(network.hosts()):
|
||||
raise ValueError(f"{ip} is reserved for the server")
|
||||
if ip in {ipaddress.ip_interface(a).ip for a in taken}:
|
||||
raise ValueError(f"{ip} is already assigned to another peer")
|
||||
return f"{ip}/32"
|
||||
|
||||
|
||||
def next_free_address(subnet: str, taken: list[str]) -> str:
|
||||
network = ipaddress.ip_network(subnet)
|
||||
used = {ipaddress.ip_interface(a).ip for a in taken}
|
||||
hosts = network.hosts()
|
||||
used.add(next(hosts))
|
||||
for host in hosts:
|
||||
if host not in used:
|
||||
return f"{host}/32"
|
||||
raise RuntimeError(f"No free addresses left in {subnet}")
|
||||
|
||||
|
||||
def validate_cidr_list(value: str) -> str:
|
||||
networks = []
|
||||
for part in value.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
networks.append(str(ipaddress.ip_network(part, strict=False)))
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid CIDR: {part}")
|
||||
return ", ".join(networks)
|
||||
@@ -0,0 +1,188 @@
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import settings
|
||||
from ..crypto import decrypt
|
||||
from ..models import Interface, Peer
|
||||
from .addressing import server_address
|
||||
|
||||
|
||||
def config_path(name: str) -> Path:
|
||||
return settings.wg_config_dir / f"{name}.conf"
|
||||
|
||||
|
||||
def discover_configs() -> list[str]:
|
||||
if not settings.wg_config_dir.exists():
|
||||
return []
|
||||
return sorted(p.stem for p in settings.wg_config_dir.glob("*.conf"))
|
||||
|
||||
|
||||
def server_allowed_ips(peer: Peer) -> str:
|
||||
allowed = peer.address
|
||||
if peer.extra_allowed_ips:
|
||||
allowed += f", {peer.extra_allowed_ips}"
|
||||
return allowed
|
||||
|
||||
|
||||
def render_server_config(iface: Interface, peers: list[Peer]) -> str:
|
||||
lines = [
|
||||
"[Interface]",
|
||||
f"PrivateKey = {decrypt(iface.private_key_enc)}",
|
||||
f"Address = {server_address(iface.subnet)}",
|
||||
f"ListenPort = {iface.listen_port}",
|
||||
]
|
||||
if iface.mtu:
|
||||
lines.append(f"MTU = {iface.mtu}")
|
||||
if iface.fwmark:
|
||||
lines.append(f"FwMark = {iface.fwmark}")
|
||||
if iface.route_table:
|
||||
lines.append(f"Table = {iface.route_table}")
|
||||
if iface.mss_clamp:
|
||||
rule = (
|
||||
"-o %i -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu"
|
||||
)
|
||||
lines.append(f"PostUp = iptables -t mangle -A FORWARD {rule}")
|
||||
lines.append(f"PostDown = iptables -t mangle -D FORWARD {rule} || true")
|
||||
# wg-quick adds routes for /32 peer addresses automatically; extra
|
||||
# site subnets need explicit routes so return traffic enters the tunnel.
|
||||
for peer in peers:
|
||||
if peer.enabled and peer.extra_allowed_ips:
|
||||
for subnet in peer.extra_allowed_ips.split(","):
|
||||
subnet = subnet.strip()
|
||||
lines.append(f"PostUp = ip route replace {subnet} dev %i")
|
||||
for command in (iface.post_up or "").splitlines():
|
||||
command = command.strip()
|
||||
if command:
|
||||
lines.append(f"PostUp = {command}")
|
||||
for command in (iface.post_down or "").splitlines():
|
||||
command = command.strip()
|
||||
if command:
|
||||
lines.append(f"PostDown = {command}")
|
||||
for peer in peers:
|
||||
if not peer.enabled:
|
||||
continue
|
||||
lines += [
|
||||
"",
|
||||
"[Peer]",
|
||||
f"# {peer.name}",
|
||||
f"PublicKey = {peer.public_key}",
|
||||
]
|
||||
if peer.preshared_key_enc:
|
||||
lines.append(f"PresharedKey = {decrypt(peer.preshared_key_enc)}")
|
||||
lines.append(f"AllowedIPs = {server_allowed_ips(peer)}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def render_client_config(peer: Peer, iface: Interface) -> str:
|
||||
if not peer.has_private_key:
|
||||
raise ValueError(
|
||||
"Peer has no private key (imported). Rotate keys to generate a config."
|
||||
)
|
||||
lines = [
|
||||
"[Interface]",
|
||||
f"PrivateKey = {decrypt(peer.private_key_enc)}",
|
||||
f"Address = {peer.address}",
|
||||
f"DNS = {peer.dns or iface.dns}",
|
||||
]
|
||||
if iface.mtu:
|
||||
lines.append(f"MTU = {iface.mtu}")
|
||||
lines += [
|
||||
"",
|
||||
"[Peer]",
|
||||
f"PublicKey = {iface.public_key}",
|
||||
]
|
||||
if peer.preshared_key_enc:
|
||||
lines.append(f"PresharedKey = {decrypt(peer.preshared_key_enc)}")
|
||||
keepalive = (
|
||||
peer.persistent_keepalive
|
||||
if peer.persistent_keepalive is not None
|
||||
else iface.persistent_keepalive
|
||||
)
|
||||
lines += [
|
||||
f"Endpoint = {iface.host}:{iface.listen_port}",
|
||||
f"AllowedIPs = {peer.client_allowed_ips or iface.allowed_ips}",
|
||||
]
|
||||
if keepalive:
|
||||
lines.append(f"PersistentKeepalive = {keepalive}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def write_server_config(iface: Interface, peers: list[Peer]) -> None:
|
||||
settings.wg_config_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = config_path(iface.name)
|
||||
path.touch(mode=0o600)
|
||||
path.write_text(render_server_config(iface, peers))
|
||||
|
||||
|
||||
def backup_config(name: str) -> Path | None:
|
||||
path = config_path(name)
|
||||
if not path.exists():
|
||||
return None
|
||||
stamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
backup = path.with_name(f"{name}.conf.bak-{stamp}")
|
||||
backup.write_bytes(path.read_bytes())
|
||||
backup.chmod(0o600)
|
||||
return backup
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedPeer:
|
||||
public_key: str = ""
|
||||
preshared_key: str = ""
|
||||
allowed_ips: list[str] = field(default_factory=list)
|
||||
name: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedConfig:
|
||||
private_key: str = ""
|
||||
address: str = ""
|
||||
listen_port: int | None = None
|
||||
peers: list[ParsedPeer] = field(default_factory=list)
|
||||
|
||||
|
||||
def parse_config(text: str) -> ParsedConfig:
|
||||
parsed = ParsedConfig()
|
||||
section = ""
|
||||
current: ParsedPeer | None = None
|
||||
pending_comment = ""
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("#"):
|
||||
comment = line.lstrip("# ").strip()
|
||||
if section == "peer" and current is not None and not current.name:
|
||||
current.name = comment
|
||||
else:
|
||||
pending_comment = comment
|
||||
continue
|
||||
if line.startswith("["):
|
||||
section = line.strip("[]").lower()
|
||||
if section == "peer":
|
||||
current = ParsedPeer(name=pending_comment)
|
||||
parsed.peers.append(current)
|
||||
pending_comment = ""
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip().lower()
|
||||
value = value.strip()
|
||||
if section == "interface":
|
||||
if key == "privatekey":
|
||||
parsed.private_key = value
|
||||
elif key == "address":
|
||||
parsed.address = value.split(",")[0].strip()
|
||||
elif key == "listenport":
|
||||
parsed.listen_port = int(value)
|
||||
elif section == "peer" and current is not None:
|
||||
if key == "publickey":
|
||||
current.public_key = value
|
||||
elif key == "presharedkey":
|
||||
current.preshared_key = value
|
||||
elif key == "allowedips":
|
||||
current.allowed_ips += [v.strip() for v in value.split(",") if v.strip()]
|
||||
parsed.peers = [p for p in parsed.peers if p.public_key]
|
||||
return parsed
|
||||
@@ -0,0 +1,24 @@
|
||||
import base64
|
||||
import secrets
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
||||
|
||||
|
||||
def genkey() -> str:
|
||||
private = X25519PrivateKey.generate()
|
||||
return base64.b64encode(private.private_bytes_raw()).decode()
|
||||
|
||||
|
||||
def genpsk() -> str:
|
||||
return base64.b64encode(secrets.token_bytes(32)).decode()
|
||||
|
||||
|
||||
def pubkey(private_key: str) -> str:
|
||||
raw = base64.b64decode(private_key)
|
||||
public = X25519PrivateKey.from_private_bytes(raw).public_key()
|
||||
return base64.b64encode(public.public_bytes_raw()).decode()
|
||||
|
||||
|
||||
def generate_keypair() -> tuple[str, str]:
|
||||
private = genkey()
|
||||
return private, pubkey(private)
|
||||
@@ -0,0 +1,8 @@
|
||||
import subprocess
|
||||
|
||||
|
||||
def run(args: list[str], input_text: str | None = None) -> str:
|
||||
result = subprocess.run(
|
||||
args, input=input_text, capture_output=True, text=True, check=True
|
||||
)
|
||||
return result.stdout.strip()
|
||||
@@ -0,0 +1,81 @@
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .runner import run
|
||||
|
||||
DEFAULT_ONLINE_THRESHOLD_SECONDS = 180
|
||||
|
||||
# Adjustable at runtime from the settings page.
|
||||
online_threshold_seconds = DEFAULT_ONLINE_THRESHOLD_SECONDS
|
||||
|
||||
|
||||
@dataclass
|
||||
class PeerStatus:
|
||||
public_key: str
|
||||
endpoint: str | None = None
|
||||
latest_handshake: datetime | None = None
|
||||
rx_bytes: int = 0
|
||||
tx_bytes: int = 0
|
||||
|
||||
@property
|
||||
def online(self) -> bool:
|
||||
if self.latest_handshake is None:
|
||||
return False
|
||||
delta = datetime.now(timezone.utc) - self.latest_handshake
|
||||
return delta.total_seconds() < online_threshold_seconds
|
||||
|
||||
|
||||
@dataclass
|
||||
class InterfaceStatus:
|
||||
name: str
|
||||
public_key: str | None = None
|
||||
listen_port: int | None = None
|
||||
up: bool = False
|
||||
peers: dict[str, PeerStatus] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def total_rx(self) -> int:
|
||||
return sum(p.rx_bytes for p in self.peers.values())
|
||||
|
||||
@property
|
||||
def total_tx(self) -> int:
|
||||
return sum(p.tx_bytes for p in self.peers.values())
|
||||
|
||||
|
||||
def get_status(name: str) -> InterfaceStatus:
|
||||
status = InterfaceStatus(name=name)
|
||||
try:
|
||||
output = run(["wg", "show", name, "dump"])
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return status
|
||||
|
||||
status.up = True
|
||||
lines = output.splitlines()
|
||||
if lines:
|
||||
fields = lines[0].split("\t")
|
||||
if len(fields) >= 3:
|
||||
status.public_key = fields[1]
|
||||
status.listen_port = int(fields[2])
|
||||
for line in lines[1:]:
|
||||
fields = line.split("\t")
|
||||
if len(fields) < 8:
|
||||
continue
|
||||
peer = PeerStatus(public_key=fields[0])
|
||||
if fields[2] != "(none)":
|
||||
peer.endpoint = fields[2]
|
||||
handshake = int(fields[4])
|
||||
if handshake:
|
||||
peer.latest_handshake = datetime.fromtimestamp(handshake, tz=timezone.utc)
|
||||
peer.rx_bytes = int(fields[5])
|
||||
peer.tx_bytes = int(fields[6])
|
||||
status.peers[peer.public_key] = peer
|
||||
return status
|
||||
|
||||
|
||||
def system_interfaces() -> list[str]:
|
||||
try:
|
||||
output = run(["wg", "show", "interfaces"])
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return []
|
||||
return output.split()
|
||||
@@ -0,0 +1,118 @@
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from ..models import Interface, Peer
|
||||
from .conf import config_path, write_server_config
|
||||
from .runner import run
|
||||
from .status import InterfaceStatus, get_status
|
||||
|
||||
|
||||
def is_managed(iface: Interface, status: InterfaceStatus) -> bool:
|
||||
return status.public_key == iface.public_key
|
||||
|
||||
|
||||
def interface_up(iface: Interface) -> None:
|
||||
status = get_status(iface.name)
|
||||
if status.up and not is_managed(iface, status):
|
||||
raise RuntimeError(
|
||||
f"Interface {iface.name} is up but uses a foreign key; "
|
||||
"refusing to manage it. Import it first or pick another name."
|
||||
)
|
||||
if not status.up:
|
||||
run(["wg-quick", "up", str(config_path(iface.name))])
|
||||
sync_nat(iface)
|
||||
sync_isolation(iface)
|
||||
sync_mss_clamp(iface)
|
||||
|
||||
|
||||
def interface_down(iface: Interface) -> None:
|
||||
status = get_status(iface.name)
|
||||
if status.up and is_managed(iface, status):
|
||||
run(["wg-quick", "down", str(config_path(iface.name))])
|
||||
|
||||
|
||||
def sync_routes(iface: Interface, peers: list[Peer]) -> None:
|
||||
for peer in peers:
|
||||
if not peer.extra_allowed_ips:
|
||||
continue
|
||||
for subnet in peer.extra_allowed_ips.split(","):
|
||||
subnet = subnet.strip()
|
||||
args = ["ip", "route", "replace" if peer.enabled else "del", subnet]
|
||||
if peer.enabled:
|
||||
args += ["dev", iface.name]
|
||||
try:
|
||||
run(args)
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
|
||||
|
||||
def sync_nat(iface: Interface) -> None:
|
||||
try:
|
||||
route = run(["ip", "route", "show", "default"])
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return
|
||||
match = re.search(r"\bdev\s+(\S+)", route)
|
||||
if not match:
|
||||
return
|
||||
rule = ["POSTROUTING", "-s", iface.subnet, "-o", match.group(1), "-j", "MASQUERADE"]
|
||||
try:
|
||||
run(["iptables", "-t", "nat", "-C", *rule])
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except subprocess.CalledProcessError:
|
||||
try:
|
||||
run(["iptables", "-t", "nat", "-A", *rule])
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
|
||||
|
||||
def sync_isolation(iface: Interface) -> None:
|
||||
rule = ["-i", iface.name, "-o", iface.name, "-j", "DROP"]
|
||||
try:
|
||||
if iface.peer_isolation:
|
||||
try:
|
||||
run(["iptables", "-C", "FORWARD", *rule])
|
||||
except subprocess.CalledProcessError:
|
||||
run(["iptables", "-I", "FORWARD", "1", *rule])
|
||||
else:
|
||||
run(["iptables", "-D", "FORWARD", *rule])
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
|
||||
def sync_mss_clamp(iface: Interface) -> None:
|
||||
rule = [
|
||||
"-o", iface.name, "-p", "tcp", "--tcp-flags", "SYN,RST", "SYN",
|
||||
"-j", "TCPMSS", "--clamp-mss-to-pmtu",
|
||||
]
|
||||
try:
|
||||
if iface.mss_clamp:
|
||||
try:
|
||||
run(["iptables", "-t", "mangle", "-C", "FORWARD", *rule])
|
||||
except subprocess.CalledProcessError:
|
||||
run(["iptables", "-t", "mangle", "-A", "FORWARD", *rule])
|
||||
else:
|
||||
run(["iptables", "-t", "mangle", "-D", "FORWARD", *rule])
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
|
||||
def sync_mtu(iface: Interface) -> None:
|
||||
if not iface.mtu:
|
||||
return
|
||||
try:
|
||||
run(["ip", "link", "set", "dev", iface.name, "mtu", str(iface.mtu)])
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
|
||||
def sync_peers(iface: Interface, peers: list[Peer]) -> None:
|
||||
write_server_config(iface, peers)
|
||||
status = get_status(iface.name)
|
||||
if status.up and is_managed(iface, status):
|
||||
stripped = run(["wg-quick", "strip", str(config_path(iface.name))])
|
||||
run(["wg", "syncconf", iface.name, "/dev/stdin"], input_text=stripped)
|
||||
sync_routes(iface, peers)
|
||||
sync_mtu(iface)
|
||||
sync_isolation(iface)
|
||||
sync_mss_clamp(iface)
|
||||
@@ -0,0 +1,92 @@
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .runner import run
|
||||
|
||||
|
||||
def _sysctl_get(key: str) -> str:
|
||||
try:
|
||||
return run(["sysctl", "-n", key])
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return ""
|
||||
|
||||
|
||||
def _sysctl_set(key: str, value: str) -> bool:
|
||||
try:
|
||||
run(["sysctl", "-w", f"{key}={value}"])
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return False
|
||||
|
||||
|
||||
def default_out_interface() -> str:
|
||||
try:
|
||||
route = run(["ip", "route", "show", "default"])
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return ""
|
||||
match = re.search(r"\bdev\s+(\S+)", route)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class HostTuning:
|
||||
rmem_max: str = ""
|
||||
wmem_max: str = ""
|
||||
netdev_max_backlog: str = ""
|
||||
ip_forward: str = ""
|
||||
gro_forwarding: str = ""
|
||||
out_interface: str = ""
|
||||
|
||||
|
||||
def read_host_tuning() -> HostTuning:
|
||||
tuning = HostTuning(
|
||||
rmem_max=_sysctl_get("net.core.rmem_max"),
|
||||
wmem_max=_sysctl_get("net.core.wmem_max"),
|
||||
netdev_max_backlog=_sysctl_get("net.core.netdev_max_backlog"),
|
||||
ip_forward=_sysctl_get("net.ipv4.ip_forward"),
|
||||
out_interface=default_out_interface(),
|
||||
)
|
||||
if tuning.out_interface:
|
||||
try:
|
||||
output = run(["ethtool", "-k", tuning.out_interface])
|
||||
match = re.search(r"rx-udp-gro-forwarding:\s*(\S+)", output)
|
||||
if match:
|
||||
tuning.gro_forwarding = match.group(1)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
pass
|
||||
return tuning
|
||||
|
||||
|
||||
def apply_udp_buffers(size_bytes: int) -> list[str]:
|
||||
errors = []
|
||||
for key in ("net.core.rmem_max", "net.core.wmem_max"):
|
||||
if not _sysctl_set(key, str(size_bytes)):
|
||||
errors.append(f"failed to set {key}")
|
||||
return errors
|
||||
|
||||
|
||||
def apply_backlog(value: int) -> list[str]:
|
||||
if _sysctl_set("net.core.netdev_max_backlog", str(value)):
|
||||
return []
|
||||
return ["failed to set net.core.netdev_max_backlog"]
|
||||
|
||||
|
||||
def apply_gro_forwarding(enable: bool) -> list[str]:
|
||||
device = default_out_interface()
|
||||
if not device:
|
||||
return ["no default route interface found"]
|
||||
state = "on" if enable else "off"
|
||||
try:
|
||||
run(
|
||||
[
|
||||
"ethtool", "-K", device,
|
||||
"rx-udp-gro-forwarding", state,
|
||||
"rx-gro-list", "off" if enable else "on",
|
||||
]
|
||||
)
|
||||
return []
|
||||
except FileNotFoundError:
|
||||
return ["ethtool not installed"]
|
||||
except subprocess.CalledProcessError as exc:
|
||||
return [f"ethtool failed on {device}: {exc.stderr.strip() or exc}"]
|
||||
Reference in New Issue
Block a user