feat(netflow): автоматизировать настройку туннеля коллектора
This commit is contained in:
1 parent
fcca92706b
commit
db81cf0f97
33 files changed
+1503
-22
No files matched your search
@@ -0,0 +1,367 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user