You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
207 lines
12 KiB
207 lines
12 KiB
"""Сетевые операции допускаются только в namespace, созданных этим запуском.""" |
|
|
|
import json |
|
import os |
|
from pathlib import Path |
|
import select |
|
import subprocess |
|
import time |
|
|
|
from topology import COUNTRIES, NATS, NODES, WANS, wan |
|
|
|
|
|
def nat_rules(gateway): |
|
clients = [node for node in NODES if node.nat == gateway.name] |
|
destinations = sorted({n.ip for n in NODES if not n.nat} | {g.ip for g in NATS}) |
|
lines = ["table ip lab {", |
|
" set active { type inet_service; flags dynamic,timeout; timeout 60s; }", |
|
" set contacts { type ipv4_addr . inet_service . inet_service; flags dynamic,timeout; timeout 60s; }", |
|
" chain refresh { type filter hook postrouting priority mangle; policy accept;"] |
|
for node in clients: |
|
match = f' oifname "wan" ip saddr {node.ip} udp sport {node.udp_port}' |
|
if gateway.mode == "cone": |
|
lines.append(f"{match} update @active {{ {node.mapped_port} timeout 60s }}") |
|
elif gateway.mode == "restricted": |
|
lines.append(f"{match} update @contacts {{ ip daddr . udp dport . {node.mapped_port} timeout 60s }}") |
|
lines += [" }", " chain prerouting { type nat hook prerouting priority dstnat; policy accept;"] |
|
for node in clients: |
|
match = f' iifname "wan" udp dport {node.mapped_port}' |
|
if gateway.mode == "cone": |
|
lines.append(f"{match} udp dport @active dnat to {node.ip}:{node.udp_port}") |
|
elif gateway.mode == "restricted": |
|
lines.append(f"{match} ip saddr . udp sport . udp dport @contacts dnat to {node.ip}:{node.udp_port}") |
|
lines += [" }", " chain postrouting { type nat hook postrouting priority srcnat; policy accept;"] |
|
for node in clients: |
|
match = f' oifname "wan" ip saddr {node.ip} udp sport {node.udp_port}' |
|
if gateway.mode == "strict": |
|
for index, destination in enumerate(destinations): |
|
port = 40000 + node.number * 100 + index |
|
lines.append(f"{match} ip daddr {destination} snat to {gateway.ip}:{port}") |
|
else: |
|
lines.append(f"{match} snat to {gateway.ip}:{node.mapped_port}") |
|
lines += [' oifname "wan" masquerade', " }", |
|
" chain forward { type filter hook forward priority filter; policy drop;", |
|
" ct state established,related accept", ' iifname "lan" oifname "wan" accept', |
|
" ct status dnat accept", " counter drop", " }", "}"] |
|
return "\n".join(lines) + "\n" |
|
|
|
|
|
class Network: |
|
def __init__(self, directory, seed, host_namespace): |
|
self.directory = Path(directory) |
|
self.seed = seed |
|
self.host_namespace = host_namespace |
|
self.controller = os.readlink("/proc/self/ns/net") |
|
if self.controller == host_namespace or not host_namespace: |
|
raise RuntimeError("Refusing network changes: controller must be in a new network namespace") |
|
self.namespaces = {} |
|
self.children = [] |
|
self.serial = 0 |
|
self.log = (self.directory / "network.jsonl").open("w", buffering=1) |
|
|
|
def command(self, name, arguments, *, input=None, check=True): |
|
prefix = self.prefix(name) |
|
started = time.monotonic() |
|
result = subprocess.run(prefix + list(arguments), input=input, text=True, capture_output=True, timeout=20) |
|
self.log.write(json.dumps({"time": started, "namespace": name, "argv": list(arguments), |
|
"rc": result.returncode, "stdout": result.stdout, "stderr": result.stderr}) + "\n") |
|
if check and result.returncode: |
|
raise RuntimeError(f"{name}: {' '.join(arguments)}: {result.stderr.strip() or result.stdout.strip()}") |
|
return result |
|
|
|
def prefix(self, name): |
|
if os.readlink("/proc/self/ns/net") != self.controller: |
|
raise RuntimeError("Controller changed network namespace") |
|
if name is None: |
|
return [] |
|
child, inode = self.namespaces[name] |
|
path = f"/proc/{child.pid}/ns/net" |
|
if child.poll() is not None or os.readlink(path) != inode or inode == self.host_namespace: |
|
raise RuntimeError(f"Namespace {name} is no longer owned by this run") |
|
return ["nsenter", f"--net={path}", "--"] |
|
|
|
def spawn(self, name, arguments, **kwargs): |
|
child = subprocess.Popen(self.prefix(name) + list(arguments), **kwargs) |
|
self.children.append(child) |
|
return child |
|
|
|
def create_namespace(self, name): |
|
keeper = "import os,sys; print(os.getpid(),flush=True); sys.stdin.buffer.read()" |
|
child = subprocess.Popen(["unshare", "--net", "--", "python3", "-u", "-c", keeper], |
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) |
|
self.children.append(child) |
|
if not select.select([child.stdout], [], [], 5)[0] or child.stdout.readline().strip() != str(child.pid): |
|
raise RuntimeError(f"Cannot create namespace {name}") |
|
inode = os.readlink(f"/proc/{child.pid}/ns/net") |
|
if inode in {self.controller, self.host_namespace}: |
|
raise RuntimeError(f"Namespace isolation failed for {name}") |
|
self.namespaces[name] = (child, inode) |
|
self.command(name, ["ip", "link", "set", "lo", "up"]) |
|
self.command(name, ["sysctl", "-qw", "net.ipv6.conf.all.disable_ipv6=1"]) |
|
self.command(name, ["sysctl", "-qw", "net.ipv4.conf.all.rp_filter=0"]) |
|
self.command(name, ["sysctl", "-qw", "net.ipv4.conf.default.rp_filter=0"]) |
|
|
|
def link(self, left, left_interface, right, right_interface): |
|
self.serial += 1 |
|
a, b = f"v{self.serial}a", f"v{self.serial}b" |
|
self.command(None, ["ip", "link", "add", a, "type", "veth", "peer", "name", b]) |
|
for temporary, name, interface in ((a, left, left_interface), (b, right, right_interface)): |
|
self.command(None, ["ip", "link", "set", temporary, "netns", str(self.namespaces[name][0].pid)]) |
|
self.command(name, ["ip", "link", "set", temporary, "name", interface]) |
|
self.command(name, ["ethtool", "-K", interface, "gro", "off", "gso", "off", "tso", "off"]) |
|
self.command(name, ["ip", "link", "set", interface, "up"]) |
|
|
|
def bridge(self, name, address): |
|
self.command(name, ["ip", "link", "add", "lan", "type", "bridge"]) |
|
self.command(name, ["ip", "addr", "add", address + "/24", "dev", "lan"]) |
|
self.command(name, ["ip", "link", "set", "lan", "up"]) |
|
self.command(name, ["sysctl", "-qw", "net.ipv4.ip_forward=1"]) |
|
|
|
def attach(self, node, gateway, port): |
|
self.link(node, "eth0" if node.startswith("n") else "wan", gateway, port) |
|
self.command(gateway, ["ip", "link", "set", port, "master", "lan"]) |
|
|
|
def netem(self, node, source_country, *, rtt=None, loss=None): |
|
index = next(i + 1 for i, c in enumerate(COUNTRIES) if c.name == source_country) |
|
same = source_country == node.country |
|
link = None if same else wan(source_country, node.country) |
|
delay = (2 if same else link.rtt_ms) / 2 if rtt is None else rtt / 2 |
|
jitter = 0.2 if same else link.jitter_ms |
|
loss = (0 if same else link.loss_percent) if loss is None else loss |
|
self.command(node.name, ["tc", "qdisc", "replace", "dev", "ifb0", "parent", f"1:{index}", |
|
"handle", f"{index + 10}:", "netem", "limit", "4096", "delay", f"{delay}ms", |
|
f"{jitter}ms", "distribution", "normal", "loss", "random", f"{loss}%", |
|
"seed", str(self.seed + node.number * 10 + index)]) |
|
|
|
def receiver(self, node): |
|
self.command(node.name, ["ip", "link", "add", "ifb0", "type", "ifb"]) |
|
self.command(node.name, ["ip", "link", "set", "ifb0", "up"]) |
|
self.command(node.name, ["tc", "qdisc", "add", "dev", "eth0", "clsact"]) |
|
self.command(node.name, ["tc", "filter", "add", "dev", "eth0", "ingress", "matchall", "action", |
|
"mirred", "egress", "redirect", "dev", "ifb0"]) |
|
own = next(i + 1 for i, c in enumerate(COUNTRIES) if c.name == node.country) |
|
self.command(node.name, ["tc", "qdisc", "add", "dev", "ifb0", "root", "handle", "1:", "htb", "default", str(own)]) |
|
for index, country in enumerate(COUNTRIES, 1): |
|
self.command(node.name, ["tc", "class", "add", "dev", "ifb0", "parent", "1:", "classid", f"1:{index}", |
|
"htb", "rate", "1gbit", "ceil", "1gbit"]) |
|
self.netem(node, country.name) |
|
self.command(node.name, ["tc", "filter", "add", "dev", "ifb0", "parent", "1:", "protocol", "ip", |
|
"prio", str(index), "flower", "src_ip", country.subnet, "classid", f"1:{index}"]) |
|
|
|
def bandwidth(self, link, mbps): |
|
for country in (link.a, link.b): |
|
self.command("r" + country, ["tc", "qdisc", "replace", "dev", "w_" + link.a + link.b, |
|
"root", "tbf", "rate", f"{mbps}mbit", "burst", "64kb", "latency", "100ms"]) |
|
|
|
def build(self): |
|
for name in [n.name for n in NODES] + [c.router for c in COUNTRIES] + [g.name for g in NATS]: |
|
self.create_namespace(name) |
|
for country in COUNTRIES: |
|
self.bridge(country.router, country.gateway) |
|
for gateway in NATS: |
|
self.bridge(gateway.name, "10.64.1.1") |
|
self.attach(gateway.name, "r" + gateway.country, "p" + gateway.name) |
|
self.command(gateway.name, ["ip", "addr", "add", gateway.ip + "/24", "dev", "wan"]) |
|
country = next(c for c in COUNTRIES if c.name == gateway.country) |
|
self.command(gateway.name, ["ip", "route", "add", "default", "via", country.gateway]) |
|
rules = nat_rules(gateway) |
|
(self.directory / f"{gateway.name}.nft").write_text(rules) |
|
self.command(gateway.name, ["nft", "-f", "-"], input=rules) |
|
for node in NODES: |
|
self.attach(node.name, node.nat or "r" + node.country, "p" + node.name) |
|
self.command(node.name, ["ip", "addr", "add", node.ip + "/24", "dev", "eth0"]) |
|
self.command(node.name, ["ip", "route", "add", "default", "via", node.gateway]) |
|
self.receiver(node) |
|
for link in WANS: |
|
interface = "w_" + link.a + link.b |
|
self.link("r" + link.a, interface, "r" + link.b, interface) |
|
for country, address, peer, destination in ((link.a, link.ip_a, link.ip_b, link.b), |
|
(link.b, link.ip_b, link.ip_a, link.a)): |
|
subnet = next(c.subnet for c in COUNTRIES if c.name == destination) |
|
self.command("r" + country, ["ip", "addr", "add", address + "/30", "dev", interface]) |
|
self.command("r" + country, ["ip", "route", "add", subnet, "via", peer]) |
|
self.bandwidth(link, link.mbps) |
|
state = {name: {"pid": child.pid, "namespace": inode} for name, (child, inode) in self.namespaces.items()} |
|
(self.directory / "namespaces.json").write_text(json.dumps(state, indent=2) + "\n") |
|
|
|
def snapshot(self, label): |
|
snapshot = {} |
|
for name in self.namespaces: |
|
snapshot[name] = {"links": json.loads(self.command(name, ["ip", "-j", "link"]).stdout), |
|
"routes": json.loads(self.command(name, ["ip", "-j", "route"]).stdout), |
|
"qdisc": json.loads(self.command(name, ["tc", "-j", "-s", "qdisc"]).stdout)} |
|
if name.startswith("g_"): |
|
snapshot[name]["nft"] = json.loads(self.command(name, ["nft", "-j", "list", "ruleset"]).stdout) |
|
(self.directory / f"network-{label}.json").write_text(json.dumps(snapshot, indent=2) + "\n") |
|
|
|
def close(self): |
|
for child in reversed(self.children): |
|
if child.poll() is None: |
|
child.terminate() |
|
for child in reversed(self.children): |
|
try: |
|
child.wait(timeout=3) |
|
except subprocess.TimeoutExpired: |
|
child.kill() |
|
child.wait(timeout=3) |
|
self.log.close()
|
|
|