"""Local, single-interface WireGuard controller. Never executes wg-quick/hooks.""" import base64 import binascii import http.client import http.server import ipaddress import json import os from pathlib import Path import re import socket import socketserver import subprocess import sys import tempfile import threading IFACE = "wg-flow" CONFIG = Path(os.environ.get("FLOW_AGENT_CONFIG_DIR", "/etc/wireguard")) SOCKET = os.environ.get("FLOW_AGENT_SOCKET", "/run/mm-netflow-host/agent.sock") LOCK = threading.RLock() MAX_BODY = 262144 class AgentError(Exception): pass def run(*args, input=None): try: result = subprocess.run(args, input=input, text=True, capture_output=True, timeout=10) except (OSError, subprocess.TimeoutExpired): raise AgentError("Команда WireGuard/IP недоступна или превысила время ожидания") from None if result.returncode: # stderr/stdout can contain keys and must never reach an HTTP response/log. raise AgentError("Операция WireGuard/IP не выполнена") return result.stdout.strip() def key(value): try: if not isinstance(value, str) or len(value) != 44 or len(base64.b64decode(value, validate=True)) != 32: raise ValueError() except (ValueError, binascii.Error): raise AgentError("Некорректный ключ WireGuard") from None return value def port(value): if type(value) is not int or not 1 <= value <= 65535: raise AgentError("Некорректный UDP-порт") return value def endpoint(value): if not isinstance(value, str) or not re.fullmatch(r"(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9._-]+):[0-9]{1,5}", value): raise AgentError("Некорректный endpoint") port(int(value.rsplit(":", 1)[1])) return value def parse_conf(text): interface, peers, section = {}, [], None allowed = {"interface": {"PrivateKey", "Address", "ListenPort", "MTU"}, "peer": {"PublicKey", "PresharedKey", "AllowedIPs", "Endpoint", "PersistentKeepalive"}} for raw in text.splitlines(): line = raw.split("#", 1)[0].strip() if not line: continue if line == "[Interface]" and section is None: section, current = "interface", interface elif line == "[Peer]" and section is not None: section, current = "peer", {} peers.append(current) else: name, sep, value = line.partition("=") name, value = name.strip(), value.strip() if not sep or section is None or name not in allowed[section] or name in current or not value: raise AgentError("Конфигурация wg-flow содержит неподдерживаемые или повторные параметры; требуется ручная проверка") current[name] = value if not interface or not {"PrivateKey", "Address", "ListenPort"} <= interface.keys(): raise AgentError("Неполная конфигурация wg-flow") key(interface["PrivateKey"]) try: ipaddress.IPv4Interface(interface["Address"]) port(int(interface["ListenPort"])) if "MTU" in interface and not 576 <= int(interface["MTU"]) <= 9000: raise ValueError() for peer in peers: key(peer.get("PublicKey")) if "PresharedKey" in peer: key(peer["PresharedKey"]) if "Endpoint" in peer: endpoint(peer["Endpoint"]) if peer.get("AllowedIPs"): networks(peer["AllowedIPs"].split(",")) if "PersistentKeepalive" in peer and not 0 <= int(peer["PersistentKeepalive"]) <= 65535: raise ValueError() except (ValueError, TypeError): raise AgentError("Некорректные параметры wg-flow") from None if len({p["PublicKey"] for p in peers}) != len(peers): raise AgentError("Повторный публичный ключ пира") return interface, peers def networks(values): if not isinstance(values, list) or not values: raise AgentError("AllowedIPs должны содержать адреса") try: return [ipaddress.ip_network(v.strip(), strict=False) for v in values] except (ValueError, AttributeError): raise AgentError("Некорректные AllowedIPs") from None def validate(raw): fields = {"collectorIp", "prefix", "flowListenPort", "wgListenPort", "hostPublicKey", "hostPrivateKey", "peers"} if not isinstance(raw, dict) or set(raw) != fields: raise AgentError("Некорректный снимок NetFlow") try: collector = ipaddress.IPv4Address(raw["collectorIp"]) prefix = ipaddress.IPv4Network(raw["prefix"], strict=False) if prefix.prefixlen != 24 or collector not in prefix or collector in (prefix.network_address, prefix.broadcast_address): raise ValueError() except (ValueError, TypeError): raise AgentError("Требуется IPv4 overlay /24 и адрес коллектора внутри него") from None port(raw["flowListenPort"]) port(raw["wgListenPort"]) key(raw["hostPublicKey"]) key(raw["hostPrivateKey"]) if run("wg", "pubkey", input=raw["hostPrivateKey"] + "\n") != raw["hostPublicKey"]: raise AgentError("Публичный и приватный ключи MM не соответствуют друг другу") binding = json.loads((CONFIG / "binding.json").read_text()) for name in ("collectorIp", "prefix", "flowListenPort", "wgListenPort"): if binding.get(name) != raw[name]: raise AgentError("Адрес, префикс или порт отличаются от установки агента; повторите установщик") if not isinstance(raw["peers"], list) or len(raw["peers"]) > 253: raise AgentError("Некорректный список пиров") peers, ids, keys = [], set(), set() for peer in raw["peers"]: if not isinstance(peer, dict) or not {"serverId", "publicKey", "allowedIps"} <= peer.keys() or set(peer) - {"serverId", "publicKey", "allowedIps", "endpoint"}: raise AgentError("Некорректный пир") peer_key = key(peer["publicKey"]) if type(peer["serverId"]) is not int or peer["serverId"] <= 0 or peer["serverId"] in ids or peer_key in keys or peer_key == raw["hostPublicKey"]: raise AgentError("Повторный или некорректный пир") ids.add(peer["serverId"]) keys.add(peer_key) nets = networks(peer["allowedIps"]) if len(nets) != 1 or nets[0].version != 4 or nets[0].prefixlen != 32 or nets[0].network_address not in prefix or nets[0].network_address in (collector, prefix.network_address, prefix.broadcast_address): raise AgentError("Пиру разрешён только собственный IPv4 /32 внутри overlay") data = {"PublicKey": peer_key, "AllowedIPs": str(nets[0])} if peer.get("endpoint"): data.update(Endpoint=endpoint(peer["endpoint"]), PersistentKeepalive="25") peers.append(data) return peers def merge_peers(existing, desired): merged = {peer["PublicKey"]: dict(peer) for peer in existing} for peer in desired: public = peer["PublicKey"] # Replacing a PSK would silently break a previously working tunnel. if merged.get(public, {}).get("PresharedKey", "A" * 43 + "=") != "A" * 43 + "=": raise AgentError("Управляемый пир использует preshared key; требуется ручная проверка") merged[public] = dict(peer) rows = list(merged.values()) wanted = {p["PublicKey"] for p in desired} for i, left in enumerate(rows): for right in rows[i + 1:]: if left["PublicKey"] not in wanted and right["PublicKey"] not in wanted: continue if not left.get("AllowedIPs") or not right.get("AllowedIPs"): continue if any(a.version == b.version and a.overlaps(b) for a in networks(left["AllowedIPs"].split(",")) for b in networks(right["AllowedIPs"].split(","))): raise AgentError("AllowedIPs управляемого пира пересекаются с другим пиром") return rows def render(interface, peers, native=False): rows = ["[Interface]"] rows += [f"{k} = {v}" for k, v in interface.items() if not native or k not in ("Address", "MTU")] for peer in peers: rows += ["", "[Peer]"] + [f"{k} = {v}" for k, v in peer.items() if k != "AllowedIPs" or v] return "\n".join(rows) + "\n" def atomic_write(path, text): fd, name = tempfile.mkstemp(prefix=".flow-", dir=path.parent) try: with os.fdopen(fd, "w") as stream: stream.write(text) stream.flush() os.fsync(stream.fileno()) os.replace(name, path) if os.name == "posix": directory = os.open(path.parent, os.O_RDONLY) try: os.fsync(directory) finally: os.close(directory) finally: if os.path.exists(name): os.unlink(name) def sync(text): # wg requires a file. mkstemp creates it with mode 0600. fd, name = tempfile.mkstemp(prefix="flow-sync-") try: with os.fdopen(fd, "w") as stream: stream.write(text) run("wg", "syncconf", IFACE, name) finally: os.unlink(name) def live(): dump = run("wg", "show", IFACE, "dump").splitlines() if not dump or len(dump[0].split("\t")) != 4: raise AgentError("Интерфейс wg-flow недоступен") interface = dump[0].split("\t") peers, raw_peers = [], [] for line in dump[1:]: cols = line.split("\t") if len(cols) != 8: raise AgentError("Некорректный ответ WireGuard") public, psk, ep, ips, handshake, rx, tx, keepalive = cols peers.append({"publicKey": public, "allowedIps": ips.split(",") if ips != "(none)" else [], "lastHandshakeAt": int(handshake) or None, "receivedBytes": int(rx), "sentBytes": int(tx)}) item = {"PublicKey": public, "AllowedIPs": ips if ips != "(none)" else ""} if psk != "(none)": item["PresharedKey"] = psk if ep != "(none)": item["Endpoint"] = ep item["PersistentKeepalive"] = keepalive raw_peers.append(item) addresses = json.loads(run("ip", "-j", "-4", "address", "show", "dev", IFACE)) cidrs = [f"{a['local']}/{a['prefixlen']}" for row in addresses for a in row.get("addr_info", []) if a.get("family") == "inet"] return {"interfaceUp": any("UP" in row.get("flags", []) for row in addresses), "publicKey": interface[1], "listenPort": int(interface[2]), "addresses": cidrs, "peers": peers}, raw_peers def check(raw, apply=False): desired = validate(raw) path = CONFIG / "wg-flow.conf" previous = path.read_text() interface, stored = parse_conf(previous) expected_address = f"{raw['collectorIp']}/{raw['prefix'].split('/')[1]}" if run("wg", "pubkey", input=interface["PrivateKey"] + "\n") != raw["hostPublicKey"] or interface["Address"] != expected_address or int(interface["ListenPort"]) != raw["wgListenPort"]: raise AgentError("Ключ, адрес или порт существующего wg-flow отличаются от MM; требуется ручная проверка") status, runtime = live() if not status["interfaceUp"]: raise AgentError("Интерфейс wg-flow выключен; проверьте wg-quick на хосте") if status["publicKey"] != raw["hostPublicKey"] or expected_address not in status["addresses"] or status["listenPort"] != raw["wgListenPort"]: raise AgentError("Работающий wg-flow отличается от конфигурации; требуется ручная проверка") # Preserve both persistent and runtime-only third-party peers. Detect conflicts before writing. combined = {p["PublicKey"]: p for p in runtime} combined.update({p["PublicKey"]: p for p in stored}) merged = merge_peers(list(combined.values()), desired) runtime_merged = merge_peers(runtime, desired) # Do not reset a third-party peer's runtime settings to an older persistent file. # Preserve roaming endpoints of managed peers unless the persisted endpoint was edited. stored_by_key = {p["PublicKey"]: p for p in stored} runtime_by_key = {p["PublicKey"]: p for p in runtime} desired_by_key = {p["PublicKey"]: p for p in desired} for peer in runtime_merged: public = peer["PublicKey"] if public in desired_by_key and stored_by_key.get(public, {}).get("Endpoint") == desired_by_key[public].get("Endpoint"): if runtime_by_key.get(public, {}).get("Endpoint"): peer["Endpoint"] = runtime_by_key[public]["Endpoint"] def matches(actual, persistent=False): by_key = {p["PublicKey"]: p for p in actual} return all(p["PublicKey"] in by_key and all(by_key[p["PublicKey"]].get(k) == v for k, v in p.items() if persistent or k != "Endpoint") for p in desired) matched = matches(stored, persistent=True) and matches(runtime) if apply: rollback = run("wg", "showconf", IFACE) try: atomic_write(path, render(interface, merged)) # Keep the running interface header verbatim (including a possible fwmark). header = rollback.split("[Peer]", 1)[0].rstrip() peer_config = render({}, runtime_merged, native=True).removeprefix("[Interface]\n") sync(header + "\n" + peer_config) status, after = live() if not matches(after): raise AgentError("Применение пиров не подтверждено") except Exception: failures = [] for action in (lambda: atomic_write(path, previous), lambda: sync(rollback)): try: action() except Exception: failures.append(True) raise AgentError("Применение не удалось; " + ("восстановление требует ручной проверки" if failures else "предыдущая конфигурация восстановлена")) from None matched = True return {**status, "configMatches": matched} class Handler(http.server.BaseHTTPRequestHandler): def log_message(self, *_): pass def handle_request(self): try: with LOCK: if self.command == "GET" and self.path == "/status": result, _ = live() result["configMatches"] = None elif self.command == "POST" and self.path in ("/check", "/apply"): size = int(self.headers.get("Content-Length", "0")) if not 0 < size <= MAX_BODY or self.headers.get("Transfer-Encoding"): raise AgentError("Некорректный размер запроса") raw = json.loads(self.rfile.read(size)) result = check(raw, apply=self.path == "/apply") else: self.respond(404, {"error": "Операция не поддерживается"}) return self.respond(200, result) except AgentError as error: self.respond(409, {"error": str(error)}) except Exception: self.respond(503, {"error": "Агент NetFlow не готов; проверьте конфигурацию и интерфейс хоста"}) def setup(self): super().setup() self.connection.settimeout(15) def respond(self, code, body): data = json.dumps(body, ensure_ascii=False).encode() self.send_response(code) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) do_GET = handle_request do_POST = handle_request class Server(socketserver.ThreadingMixIn, getattr(socketserver, "UnixStreamServer", socketserver.TCPServer)): daemon_threads = True class UnixConnection(http.client.HTTPConnection): def connect(self): self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.sock.settimeout(3) self.sock.connect(SOCKET) def main(): if not hasattr(socketserver, "UnixStreamServer"): raise SystemExit("NetFlow agent requires Linux Unix sockets") if "--healthcheck" in sys.argv: client = UnixConnection("localhost") client.request("GET", "/status") response = client.getresponse() sys.exit(0 if response.status == 200 and json.loads(response.read()).get("interfaceUp") else 1) os.umask(0o077) Path(SOCKET).parent.mkdir(parents=True, exist_ok=True) if os.path.lexists(SOCKET): Path(SOCKET).unlink() with Server(SOCKET, Handler) as server: os.chmod(SOCKET, 0o660) print("NetFlow agent: Unix API ready", flush=True) server.serve_forever() if __name__ == "__main__": main()