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.
107 lines
6.1 KiB
107 lines
6.1 KiB
"""Проверка детектора uTun на настоящем NAT; контроль не создаёт контакт с помощником.""" |
|
|
|
import json |
|
|
|
from topology import NATS, NODES |
|
|
|
|
|
def profile(statistics, protocol): |
|
return next(item for item in statistics["address_profiles"] if item["socket"] == protocol) |
|
|
|
|
|
def check_profiles(scenario): |
|
agents = scenario.agents |
|
public = [node for node in NODES if not node.nat] |
|
transport = "tcp" if scenario.args.tcp_only else "udp" |
|
connected = set() |
|
|
|
def connect(source, target): |
|
pair = (source.name, target.name) |
|
if pair in connected: |
|
return |
|
snapshot = agents[target.name].rpc("snapshot")["snapshot"] |
|
agents[source.name].rpc("nat_link", snapshot=snapshot, socket=transport) |
|
target_id = agents[target.name].identity |
|
scenario.wait(f"nat-link-{source.name}-{target.name}", |
|
lambda data: target_id in data[source.name]["direct_up"], 20) |
|
connected.add(pair) |
|
|
|
def store_owner(owner, observer): |
|
agents[observer.name].rpc("nat_store", snapshot=agents[owner.name].rpc("snapshot")["snapshot"]) |
|
|
|
def measure(owner, observer, protocol): |
|
before = profile(agents[owner.name].rpc("stats"), protocol) |
|
agents[owner.name].rpc("nat_check", socket=protocol, observer=agents[observer.name].identity) |
|
data = scenario.wait(f"nat-profile-{owner.name}-{protocol}", lambda statistics: |
|
profile(statistics[owner.name], protocol)["rounds"] > before["rounds"] and |
|
profile(statistics[owner.name], protocol)["phase"] == 0, 40) |
|
return profile(data[owner.name], protocol) |
|
|
|
# Отсутствие второго наблюдателя не превращается в STRICT/DIRECT. |
|
first, second = public[:2] |
|
connect(first, second) |
|
if not scenario.args.tcp_only: |
|
incomplete = measure(first, second, "udp") |
|
if (incomplete["mapping"], incomplete["inbound"], incomplete["reason"]) != ("UNKNOWN", "UNKNOWN", "no_helper"): |
|
raise RuntimeError(f"NAT no-helper test: {incomplete}") |
|
|
|
verified = {} |
|
for owner in public + [node for node in NODES if node.nat]: |
|
observer = second if owner.name == first.name else first |
|
if not scenario.args.tcp_only: |
|
contacts = set(profile(agents[owner.name].rpc("stats"), "udp")["contacts"]) |
|
helper = next((node for node in public if node.name not in {owner.name, observer.name} and node.ip not in contacts), None) |
|
if not helper: |
|
raise RuntimeError(f"{owner.name}: no independent helper remains before cold measurement; contacts={contacts}") |
|
connect(observer, helper) |
|
connect(owner, observer) |
|
store_owner(owner, observer) |
|
protocols = ["tcp"] if scenario.args.tcp_only else ["udp", "tcp"] if scenario.args.tcp else ["udp"] |
|
observed = {} |
|
for protocol in protocols: |
|
if protocol == "tcp" and owner.nat: |
|
# Приватный TCP listener не имеет обнаруженного публичного endpoint. |
|
agents[owner.name].rpc("nat_check", socket="tcp", observer=agents[observer.name].identity) |
|
data = scenario.wait(f"nat-private-tcp-{owner.name}", lambda statistics: |
|
profile(statistics[owner.name], "tcp")["reason"] == "no_listener", 5) |
|
actual = profile(data[owner.name], "tcp") |
|
expected = ("UNKNOWN", "UNKNOWN") |
|
else: |
|
actual = measure(owner, observer, protocol) |
|
if protocol == "tcp" or not owner.nat: |
|
expected = ("UNCHANGED", "OPEN") |
|
else: |
|
gateway = next(item for item in NATS if item.name == owner.nat) |
|
expected = {"cone": ("EIM", "OPEN"), "restricted": ("EIM", "CONTACT_REQUIRED"), |
|
"strict": ("DEPENDENT", "UNKNOWN")}[gateway.mode] |
|
if (actual["mapping"], actual["inbound"]) != expected: |
|
raise RuntimeError(f"{owner.name}/{protocol}: expected {expected}, detector returned {actual}") |
|
if expected[0] == "EIM": |
|
gateway = next(item for item in NATS if item.name == owner.nat) |
|
external = f"{gateway.ip}:{owner.mapped_port}" |
|
elif expected[0] == "UNCHANGED": |
|
external = f"{owner.ip}:32000" |
|
else: |
|
external = "" |
|
if actual["external"] != external: |
|
raise RuntimeError(f"{owner.name}/{protocol}: foreign/invalid external endpoint: {actual}, expected {external}") |
|
observed[protocol] = actual |
|
print(f"NAT {owner.name}/{protocol}: {actual['mapping']} / {actual['inbound']}" |
|
f" external={actual['external'] or '-'} reason={actual['reason']}", flush=True) |
|
verified[owner.name] = observed |
|
(scenario.network.directory / "nat-profiles.json").write_text(json.dumps(verified, indent=2) + "\n") |
|
|
|
# Повтор на использовавшемся порту сохраняет mapping, но не обновляет OPEN |
|
# по контактировавшим IP. Проверка запускается после всех холодных измерений. |
|
first_profile = verified[first.name].get("udp") |
|
if first_profile: |
|
again = measure(first, second, "udp") |
|
if again["mapping"] != "UNCHANGED" or again["inbound"] not in {"OPEN", "UNKNOWN"}: |
|
raise RuntimeError(f"{first.name}: warm measurement produced invalid classification: {again}") |
|
if again["inbound"] == "UNKNOWN" and again["reason"] != "contact_history": |
|
raise RuntimeError(f"{first.name}: UNKNOWN without contact-history explanation: {again}") |
|
|
|
result = {"nodes_checked": len(verified), "protocols": protocols, "no_helper_checked": not scenario.args.tcp_only, |
|
"warm_recheck_checked": bool(first_profile), "verified": verified} |
|
(scenario.network.directory / "nat-result.json").write_text(json.dumps(result, indent=2) + "\n") |
|
return result
|
|
|