Gogs преди 1 седмица
родител
ревизия
66db438889

+ 17 - 9
README.md

@@ -23,7 +23,7 @@
 ## 重要说明
 
 - 当前透明模式先实现的是 **TCP**
-- UDP 冗余框架保留在协议设计里,但这版未做透明 UDP 接管
+- UDP 默认关闭,需要显式开启
 - 这是为了先让你现有视频、网页、下载这类 TCP 场景可直接用起来
 
 ## 配置文件
@@ -32,9 +32,10 @@
 
 ```json
 {
-  "strategy": "top2",
-  "redundancy": 2,
-  "tcp_warmup_bytes": 262144,
+  "strategy": "top3",
+  "redundancy": 3,
+  "tcp_warmup_bytes": 1048576,
+  "tcp_loser_grace_ms": 1500,
   "probe_interval": 15,
   "relays": [
     {"name": "hk1", "host": "1.2.3.4", "port": 9009, "token": "demo", "weight": 100},
@@ -109,6 +110,12 @@ sudo /home/mynetspeeder/scripts/stop-transparent.sh
 python3 -m mynetspeeder probe --config /home/mynetspeeder/config.json --once
 ```
 
+汇总透明模式胜率:
+
+```bash
+python3 -m mynetspeeder summary --log-file /var/log/mynetspeeder-edge.log
+```
+
 ## 工作方式
 
 透明模式启动后:
@@ -119,6 +126,7 @@ python3 -m mynetspeeder probe --config /home/mynetspeeder/config.json --once
   - 当前主 VPS 直接连目标
   - 若干在线子节点 relay 代为连接目标
 - 前 `tcp_warmup_bytes` 的上行数据会更积极地并发发给所有候选路径
+- winner 出现后,loser 会额外保留 `tcp_loser_grace_ms` 毫秒,再关闭
 - 谁先回下行数据,谁成为胜出路径
 - 其它路径会关闭
 
@@ -139,7 +147,7 @@ python3 -m mynetspeeder probe --config /home/mynetspeeder/config.json --once
 ## 现有限制
 
 - 透明接管当前只支持 IPv4 TCP
-- 当前未实现透明 UDP
+- UDP 默认关闭,需要显式开启
 - `iptables` 规则是全局级别,除 `mynetspeeder` 自己和 relay IP 外,其他本机 TCP 流量也会被接管
 
 
@@ -163,12 +171,12 @@ sudo /home/mynetspeeder/scripts/stop-transparent.sh
 当前版本新增:
 
 - IPv6 透明 TCP 接管
-- UDP 透明接管基础版
-- `iptables` + `ip6tables` 的 TCP/UDP REDIRECT 规则
+- UDP 透明接管(默认关闭)
+- `iptables` + `ip6tables` 的 TCP REDIRECT 规则,UDP 仅在显式开启时生效
 
 说明:
 
-- UDP 透明接管已支持基础转发,但实际效果仍依赖目标协议和内核行为
+- UDP 只有在显式开启时才会接管
 - 如遇特定 UDP/QUIC 场景异常,优先先验证 TCP 是否正常
 
 
@@ -178,7 +186,7 @@ sudo /home/mynetspeeder/scripts/stop-transparent.sh
 
 - `singbox` 用户的透明 TCP 接管
 - IPv4 / IPv6 TCP 透明监听
-- IPv4 / IPv6 UDP 透明监听基础版
+- IPv4 / IPv6 UDP 透明监听(默认关闭)
 - 无 relay 时自动只走当前主机 `direct`
 
 推荐启动命令:

BIN
__pycache__/cli.cpython-313.pyc


BIN
__pycache__/config.cpython-313.pyc


BIN
__pycache__/scheduler.cpython-313.pyc


BIN
__pycache__/transparent_edge.cpython-313.pyc


+ 80 - 1
cli.py

@@ -3,6 +3,9 @@ from __future__ import annotations
 import argparse
 import asyncio
 import json
+import re
+from collections import defaultdict
+from pathlib import Path
 
 from . import __version__
 from .config import Config
@@ -11,6 +14,11 @@ from .relay_client import RelayManager
 from .transparent_edge import TransparentEdge
 
 
+WIN_RE = re.compile(
+    r"tcp win session=(?P<session>\d+) target=(?P<host>[^:]+):(?P<port>\d+) winner=(?P<winner>\S+) .*?direct=(?P<direct>\d+) .*?relay=(?P<relay>\d+)"
+)
+
+
 def build_parser() -> argparse.ArgumentParser:
     parser = argparse.ArgumentParser(prog="mynetspeeder")
     parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
@@ -26,12 +34,19 @@ def build_parser() -> argparse.ArgumentParser:
     edge.add_argument("--listen-host", default="127.0.0.1")
     edge.add_argument("--listen-port", type=int, default=19080)
     edge.add_argument("--config", required=True)
+    edge.add_argument("--enable-udp", action="store_true")
     edge.set_defaults(handler=handle_edge)
 
     probe = sub.add_parser("probe", help="查看子节点探测与在线状态")
     probe.add_argument("--config", required=True)
     probe.add_argument("--once", action="store_true")
     probe.set_defaults(handler=handle_probe)
+
+    summary = sub.add_parser("summary", help="汇总透明模式日志里的胜率")
+    summary.add_argument("--log-file", default="/var/log/mynetspeeder-edge.log")
+    summary.add_argument("--top", type=int, default=10)
+    summary.add_argument("--json", action="store_true")
+    summary.set_defaults(handler=handle_summary)
     return parser
 
 
@@ -41,7 +56,7 @@ def handle_relay(args: argparse.Namespace) -> int:
 
 
 def handle_edge(args: argparse.Namespace) -> int:
-    asyncio.run(TransparentEdge(args.listen_host, args.listen_port, Config.load(args.config)).start())
+    asyncio.run(TransparentEdge(args.listen_host, args.listen_port, Config.load(args.config), enable_udp=args.enable_udp).start())
     return 0
 
 
@@ -59,6 +74,70 @@ def handle_probe(args: argparse.Namespace) -> int:
     return 0
 
 
+def handle_summary(args: argparse.Namespace) -> int:
+    log_path = Path(args.log_file)
+    if not log_path.exists():
+        raise SystemExit(f"log file not found: {log_path}")
+
+    total = 0
+    direct = 0
+    relay = 0
+    winners: dict[str, int] = defaultdict(int)
+    targets: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
+
+    for line in log_path.read_text(errors="replace").splitlines():
+        match = WIN_RE.search(line)
+        if not match:
+            continue
+        total += 1
+        winner = match.group("winner")
+        host = match.group("host")
+        port = match.group("port")
+        key = f"{host}:{port}"
+        winners[winner] += 1
+        targets[key][winner] += 1
+        if winner == "direct":
+            direct += 1
+        else:
+            relay += 1
+
+    ordered_targets = sorted(
+        targets.items(),
+        key=lambda item: sum(item[1].values()),
+        reverse=True,
+    )[: max(args.top, 0)]
+
+    result = {
+        "log_file": str(log_path),
+        "total": total,
+        "direct": direct,
+        "relay": relay,
+        "winners": dict(sorted(winners.items(), key=lambda item: (-item[1], item[0]))),
+        "targets": [
+            {
+                "target": target,
+                "wins": dict(sorted(counts.items(), key=lambda item: (-item[1], item[0]))),
+            }
+            for target, counts in ordered_targets
+        ],
+    }
+
+    if args.json:
+        print(json.dumps(result, ensure_ascii=False, indent=2))
+        return 0
+
+    print(f"log: {log_path}")
+    print(f"total: {total} direct: {direct} relay: {relay}")
+    print("winners:")
+    for name, count in result["winners"].items():
+        print(f"  {name}: {count}")
+    print("targets:")
+    for item in result["targets"]:
+        wins = ", ".join(f"{name}={count}" for name, count in item["wins"].items())
+        print(f"  {item['target']}: {wins}")
+    return 0
+
+
 def main() -> int:
     parser = build_parser()
     args = parser.parse_args()

+ 4 - 3
config.json

@@ -1,7 +1,8 @@
 {
-  "strategy": "top2",
-  "redundancy": 2,
-  "tcp_warmup_bytes": 262144,
+  "strategy": "top3",
+  "redundancy": 3,
+  "tcp_warmup_bytes": 1048576,
+  "tcp_loser_grace_ms": 1500,
   "probe_interval": 3,
   "relays": [{"name": "hk1", "host": "141.140.15.30", "port": 9009, "token": "130", "weight": 100}]
 }

+ 9 - 7
config.py

@@ -5,7 +5,7 @@ from dataclasses import dataclass
 from pathlib import Path
 from typing import Literal
 
-Strategy = Literal["broadcast", "top2", "backup"]
+Strategy = Literal["broadcast", "top2", "top3", "top4", "backup"]
 
 
 @dataclass
@@ -20,10 +20,11 @@ class RelayNode:
 @dataclass
 class Config:
     relays: list[RelayNode]
-    strategy: Strategy = "top2"
-    redundancy: int = 2
-    tcp_warmup_bytes: int = 262144
+    strategy: Strategy = "top3"
+    redundancy: int = 3
+    tcp_warmup_bytes: int = 1048576
     probe_interval: float = 15.0
+    tcp_loser_grace_ms: int = 1500
 
     @classmethod
     def load(cls, path: str) -> "Config":
@@ -31,8 +32,9 @@ class Config:
         relays = [RelayNode(**item) for item in raw["relays"]]
         return cls(
             relays=relays,
-            strategy=raw.get("strategy", "top2"),
-            redundancy=raw.get("redundancy", 2),
-            tcp_warmup_bytes=raw.get("tcp_warmup_bytes", 262144),
+            strategy=raw.get("strategy", "top3"),
+            redundancy=raw.get("redundancy", 3),
+            tcp_warmup_bytes=raw.get("tcp_warmup_bytes", 1048576),
             probe_interval=raw.get("probe_interval", 15.0),
+            tcp_loser_grace_ms=raw.get("tcp_loser_grace_ms", 1500),
         )

+ 4 - 3
demo-config copy.json

@@ -1,7 +1,8 @@
 {
-  "strategy": "top2",
-  "redundancy": 2,
-  "tcp_warmup_bytes": 262144,
+  "strategy": "top3",
+  "redundancy": 3,
+  "tcp_warmup_bytes": 1048576,
+  "tcp_loser_grace_ms": 1500,
   "probe_interval": 3,
   "relays": []
 }

+ 6 - 0
scheduler.py

@@ -56,6 +56,12 @@ class Scheduler:
             return [item.node for item in ordered[:limit]]
         if self.config.strategy == "backup":
             return [item.node for item in ordered[:1]]
+        if self.config.strategy == "top4":
+            limit = min(len(ordered), max(1, self.config.redundancy, 4))
+            return [item.node for item in ordered[:limit]]
+        if self.config.strategy == "top3":
+            limit = min(len(ordered), max(1, self.config.redundancy, 3))
+            return [item.node for item in ordered[:limit]]
         limit = min(len(ordered), max(1, self.config.redundancy, 2))
         return [item.node for item in ordered[:limit]]
 

BIN
scripts/__pycache__/rotate-log.cpython-313.pyc


+ 52 - 0
scripts/rotate-log.py

@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import os
+import sys
+from pathlib import Path
+
+
+def rotate(path: Path, backups: int) -> None:
+    if backups <= 0:
+        path.write_text("")
+        return
+    oldest = path.with_name(f"{path.name}.{backups}")
+    if oldest.exists():
+        oldest.unlink()
+    for index in range(backups - 1, 0, -1):
+        source = path.with_name(f"{path.name}.{index}")
+        target = path.with_name(f"{path.name}.{index + 1}")
+        if source.exists():
+            source.replace(target)
+    if path.exists():
+        path.replace(path.with_name(f"{path.name}.1"))
+
+
+def main() -> int:
+    if len(sys.argv) != 4:
+        print("usage: rotate-log.py <log_path> <max_bytes> <backups>", file=sys.stderr)
+        return 1
+    log_path = Path(sys.argv[1])
+    max_bytes = int(sys.argv[2])
+    backups = int(sys.argv[3])
+    log_path.parent.mkdir(parents=True, exist_ok=True)
+    log_path.touch(exist_ok=True)
+
+    stream = sys.stdin.buffer
+    while True:
+        chunk = stream.readline()
+        if not chunk:
+            break
+        with log_path.open("ab") as handle:
+            handle.write(chunk)
+        try:
+            if log_path.stat().st_size > max_bytes:
+                rotate(log_path, backups)
+                log_path.touch(exist_ok=True)
+        except FileNotFoundError:
+            log_path.touch(exist_ok=True)
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 7 - 2
scripts/start-relay.sh

@@ -22,6 +22,8 @@ PACKAGE_NAME="$(basename "$INSTALL_DIR")"
 RUNTIME_USER="${MYNETSPEEDER_USER:-mynetspeeder}"
 PID_FILE="/var/run/mynetspeeder-relay.pid"
 LOG_FILE="/var/log/mynetspeeder-relay.log"
+LOG_MAX_MB="${MYNETSPEEDER_LOG_MAX_MB:-50}"
+LOG_BACKUPS="${MYNETSPEEDER_LOG_BACKUPS:-3}"
 
 if [[ $EUID -ne 0 ]]; then
   echo "need root"
@@ -40,12 +42,14 @@ fi
 
 id -u "$RUNTIME_USER" >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin "$RUNTIME_USER"
 mkdir -p /var/log
-: > "$LOG_FILE"
+touch "$LOG_FILE"
 chown "$RUNTIME_USER":"$RUNTIME_USER" "$LOG_FILE"
+if ! [[ "$LOG_MAX_MB" =~ ^[0-9]+$ ]] || ! [[ "$LOG_BACKUPS" =~ ^[0-9]+$ ]]; then echo "log limits must be numeric"; exit 1; fi
+LOG_MAX_BYTES=$((LOG_MAX_MB * 1024 * 1024))
 
 pkill -f 'python3 -m mynetspeeder relay' || true
 
-runuser -u "$RUNTIME_USER" -- bash -lc "export PYTHONUNBUFFERED=1; export PYTHONPATH=${INSTALL_PARENT}; cd ${INSTALL_PARENT} && exec nohup python3 -m ${PACKAGE_NAME} relay --listen-host ${LISTEN_HOST} --listen-port ${LISTEN_PORT} --token ${TOKEN}" >>"$LOG_FILE" 2>&1 &
+runuser -u "$RUNTIME_USER" -- bash -lc "export PYTHONUNBUFFERED=1; export PYTHONPATH=${INSTALL_PARENT}; cd ${INSTALL_PARENT} && exec nohup python3 -m ${PACKAGE_NAME} relay --listen-host ${LISTEN_HOST} --listen-port ${LISTEN_PORT} --token ${TOKEN} 2>&1 | python3 ${INSTALL_DIR}/scripts/rotate-log.py ${LOG_FILE} ${LOG_MAX_BYTES} ${LOG_BACKUPS}" &
 RELAY_PID=$!
 echo "$RELAY_PID" > "$PID_FILE"
 
@@ -59,3 +63,4 @@ fi
 echo "relay started on ${LISTEN_HOST}:${LISTEN_PORT}"
 echo "pid file: $PID_FILE"
 echo "log file: $LOG_FILE"
+echo "log max: ${LOG_MAX_MB}MB x ${LOG_BACKUPS}"

+ 11 - 2
scripts/start-transparent.sh

@@ -40,6 +40,8 @@ LISTEN_PORT="${MYNETSPEEDER_LISTEN_PORT:-19080}"
 RUNTIME_USER="${MYNETSPEEDER_USER:-mynetspeeder}"
 PID_FILE="/var/run/mynetspeeder-edge.pid"
 LOG_FILE="/var/log/mynetspeeder-edge.log"
+LOG_MAX_MB="${MYNETSPEEDER_LOG_MAX_MB:-50}"
+LOG_BACKUPS="${MYNETSPEEDER_LOG_BACKUPS:-3}"
 CHAIN4="MYNETSPEEDER"
 CHAIN6="MYNETSPEEDER6"
 
@@ -50,11 +52,17 @@ if ! [[ "$CAPTURE_UID" =~ ^[0-9]+$ ]]; then echo "capture uid must be numeric";
 
 id -u "$RUNTIME_USER" >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin "$RUNTIME_USER"
 mkdir -p /var/log
-: > "$LOG_FILE"
+touch "$LOG_FILE"
 chown "$RUNTIME_USER":"$RUNTIME_USER" "$LOG_FILE"
+if ! [[ "$LOG_MAX_MB" =~ ^[0-9]+$ ]] || ! [[ "$LOG_BACKUPS" =~ ^[0-9]+$ ]]; then echo "log limits must be numeric"; exit 1; fi
+LOG_MAX_BYTES=$((LOG_MAX_MB * 1024 * 1024))
 
 pkill -f 'python3 -m mynetspeeder edge' || true
-runuser -u "$RUNTIME_USER" -- bash -lc "export PYTHONUNBUFFERED=1; export PYTHONPATH=/home; cd /home && exec nohup python3 -m mynetspeeder edge --listen-host ${LISTEN_HOST} --listen-port ${LISTEN_PORT} --config ${CONFIG_PATH}" >>"$LOG_FILE" 2>&1 &
+EDGE_UDP_FLAG=""
+if [[ "$ENABLE_UDP" == "1" ]]; then
+  EDGE_UDP_FLAG="--enable-udp"
+fi
+runuser -u "$RUNTIME_USER" -- bash -lc "export PYTHONUNBUFFERED=1; export PYTHONPATH=/home; cd /home && exec nohup python3 -m mynetspeeder edge --listen-host ${LISTEN_HOST} --listen-port ${LISTEN_PORT} --config ${CONFIG_PATH} ${EDGE_UDP_FLAG} 2>&1 | python3 /home/mynetspeeder/scripts/rotate-log.py ${LOG_FILE} ${LOG_MAX_BYTES} ${LOG_BACKUPS}" &
 EDGE_PID=$!
 echo "$EDGE_PID" > "$PID_FILE"
 sleep 1
@@ -106,6 +114,7 @@ echo "mynetspeeder transparent mode started on ${LISTEN_HOST}:${LISTEN_PORT}"
 echo "capture uid: $CAPTURE_UID"
 echo "udp capture: $ENABLE_UDP"
 echo "log file: $LOG_FILE"
+echo "log max: ${LOG_MAX_MB}MB x ${LOG_BACKUPS}"
 
 if [[ "$VERBOSE" == "1" ]]; then
   echo "verbose mode: press Ctrl+C to stop viewing logs, service keeps running"

+ 28 - 8
transparent_edge.py

@@ -169,6 +169,7 @@ class TransparentSession:
     writer: asyncio.StreamWriter
     paths: list[BasePath]
     warmup_bytes: int
+    loser_grace_ms: int
     stats: dict[str, int]
     target_stats: dict[tuple[str, int], dict[str, int]]
     opened_count: int = 0
@@ -180,6 +181,7 @@ class TransparentSession:
     winner_event: asyncio.Event = field(default_factory=asyncio.Event)
     closed: bool = False
     pump_task: asyncio.Task | None = None
+    loser_close_task: asyncio.Task | None = None
 
     def _record_win(self, winner: BasePath) -> None:
         self.stats[winner.name] = self.stats.get(winner.name, 0) + 1
@@ -212,7 +214,7 @@ class TransparentSession:
                 active = [path for path in self.paths if path.opened and not path.closed]
                 if not active:
                     break
-                if self.winner is None and self.uplink_bytes <= self.warmup_bytes:
+                if self.uplink_bytes <= self.warmup_bytes:
                     await asyncio.gather(*(path.send(chunk) for path in active), return_exceptions=True)
                 else:
                     if self.winner is None:
@@ -241,7 +243,10 @@ class TransparentSession:
                 self.winner = path
                 self._record_win(path)
                 self.winner_event.set()
-                await self._close_losers(path)
+                if self.loser_grace_ms > 0:
+                    self.loser_close_task = asyncio.create_task(self._close_losers_after_grace(path))
+                else:
+                    await self._close_losers(path)
             if path is self.winner and payload is not None:
                 self.writer.write(payload)
                 await self.writer.drain()
@@ -258,6 +263,11 @@ class TransparentSession:
     async def _close_losers(self, winner: BasePath) -> None:
         await asyncio.gather(*(path.close() for path in self.paths if path is not winner), return_exceptions=True)
 
+    async def _close_losers_after_grace(self, winner: BasePath) -> None:
+        await asyncio.sleep(self.loser_grace_ms / 1000)
+        if not self.closed:
+            await self._close_losers(winner)
+
     async def close(self) -> None:
         if self.closed:
             return
@@ -267,6 +277,10 @@ class TransparentSession:
             self.pump_task.cancel()
             with contextlib.suppress(Exception):
                 await self.pump_task
+        if self.loser_close_task and self.loser_close_task is not asyncio.current_task():
+            self.loser_close_task.cancel()
+            with contextlib.suppress(Exception):
+                await self.loser_close_task
         await asyncio.gather(*(path.close() for path in self.paths), return_exceptions=True)
         self.writer.close()
         with contextlib.suppress(Exception):
@@ -425,8 +439,7 @@ class TransparentUdpListener:
             data, ancdata, _flags, src = self.socket.recvmsg(65535, 512)
         except BlockingIOError:
             return
-        except Exception as exc:
-            print(f"[edge] udp recv failed family={self.family} error={exc!r}")
+        except Exception:
             return
         original = None
         for level, ctype, cdata in ancdata:
@@ -437,12 +450,13 @@ class TransparentUdpListener:
                 original = parse_sockaddr(cdata)
                 break
         if original is None:
-            print(f"[edge] udp missing original dst family={self.family} src={src}")
             return
         if self.family == socket.AF_INET:
             source = PeerAddress(host=src[0], port=src[1], family=socket.AF_INET)
         else:
             source = PeerAddress(host=src[0], port=src[1], family=socket.AF_INET6)
+        if original.port == self.port and (original.host in ("127.0.0.1", "::1") or original.host == self.bind_host):
+            return
         asyncio.create_task(self.edge.handle_udp_datagram(source, original, data, self))
 
     async def send_response(self, source: PeerAddress, payload: bytes) -> None:
@@ -461,10 +475,11 @@ class TransparentUdpListener:
 
 
 class TransparentEdge:
-    def __init__(self, listen_host: str, listen_port: int, config: Config) -> None:
+    def __init__(self, listen_host: str, listen_port: int, config: Config, enable_udp: bool = False) -> None:
         self.listen_host = listen_host
         self.listen_port = listen_port
         self.config = config
+        self.enable_udp = enable_udp
         self.manager = RelayManager(config)
         self.session_ids = itertools.count(1)
         self.stream_ids = itertools.count(1)
@@ -488,7 +503,8 @@ class TransparentEdge:
                 sockets.extend(str(sock.getsockname()) for sock in server6.sockets or [])
             except Exception as exc:
                 print(f"[edge] ipv6 tcp listener skipped: {exc!r}")
-        self._start_udp_listeners()
+        if self.enable_udp:
+            self._start_udp_listeners()
         self.udp_gc_task = asyncio.create_task(self._gc_udp_flows())
         print(f"[edge] transparent tcp listening on {', '.join(sockets)}")
         if server6 is None:
@@ -520,7 +536,7 @@ class TransparentEdge:
         try:
             target = self._get_original_dst(writer)
             session_id = next(self.session_ids)
-            session = TransparentSession(session_id=session_id, target=target, reader=reader, writer=writer, paths=[], warmup_bytes=self.config.tcp_warmup_bytes, stats=self.tcp_win_counts, target_stats=self.tcp_target_wins)
+            session = TransparentSession(session_id=session_id, target=target, reader=reader, writer=writer, paths=[], warmup_bytes=self.config.tcp_warmup_bytes, loser_grace_ms=self.config.tcp_loser_grace_ms, stats=self.tcp_win_counts, target_stats=self.tcp_target_wins)
             paths: list[BasePath] = [DirectTcpPath(name="direct", on_frame=lambda path, event, payload, s=session: self._handle_tcp_session(s, path, event, payload))]
             for connection in self.manager.available():
                 stream_id = next(self.stream_ids)
@@ -551,6 +567,10 @@ class TransparentEdge:
         raise RuntimeError(f"unsupported socket family={family}")
 
     async def handle_udp_datagram(self, source: PeerAddress, target: TargetAddress, payload: bytes, listener: TransparentUdpListener) -> None:
+        if not self.enable_udp:
+            return
+        if target.port == self.listen_port and target.host in ("127.0.0.1", "::1", self.listen_host):
+            return
         key = (source, target)
         flow = self.udp_flows.get(key)
         if flow is None: