Fix node id collision, host normalization and blank error text

Three defects made the panel look broken to the operator:

- POST /api/proxies with a Chinese name slugged the id down to its ASCII
  digits ("香港01" -> "01"), collided with an existing node and returned
  HTTP 500 "proxy id already exists". Non-ASCII names now get sequential
  node-N ids; ASCII names keep a readable slug. Both paths de-duplicate.
- A host pasted as a URL ("http://192.168.3.71") went straight to DNS, so
  every check failed with "[Errno -2] Name does not resolve". normalize_host
  accepts host, host:port, scheme://user:pass@host:port and [IPv6]:port, and
  existing configs are healed at startup.
- str(asyncio.TimeoutError()) is empty, so a black-holed proxy showed a blank
  error in the UI and looked like an internal bug. describe_exc always
  produces a reason.

Also:
- Expected validation failures return 400 with an error message instead of
  500 + traceback; the frontend surfaces every API error as a toast.
- PUT/DELETE on an unknown proxy id return 404 instead of silently creating
  or reporting ok=false with 200.
- POST /api/check/<id> forces a check on disabled nodes instead of returning
  ok=true without doing anything.
- Port range is validated (1..65535).
- Store.close() lets tests release SQLite handles.
- 33 unittest cases cover id generation, host normalization, error text,
  startup migration and manual checks.
This commit is contained in:
Hermes
2026-08-23 14:07:37 +08:00
parent 68ad064f80
commit ce0aa22672
8 changed files with 468 additions and 26 deletions
+2
View File
@@ -4,5 +4,7 @@ __pycache__/
.git/ .git/
data/ data/
config.json config.json
config.json.bak.*
*.db *.db
*.log *.log
tests/
+1
View File
@@ -1,4 +1,5 @@
config.json config.json
config.json.bak.*
.env .env
*.env *.env
.env.* .env.*
+14
View File
@@ -0,0 +1,14 @@
# Incremental rebuild on top of the previously published image.
# Used when the upstream python:3.11-alpine base cannot be pulled
# (mirror rate limits). Only ships the updated app source.
#
# docker build -f Dockerfile.patch -t 192.168.2.66:80/hermes/socks5-monitor:latest .
#
# This is an escape hatch, not the canonical build. Prefer the plain
# `Dockerfile` whenever the base image is reachable, otherwise layers keep
# stacking on top of each other with every patch.
FROM 192.168.2.66:80/hermes/socks5-monitor:latest
WORKDIR /app
COPY app.py README.md ./
COPY static ./static
+41 -1
View File
@@ -101,7 +101,6 @@ PUT /api/settings
DELETE /api/logs DELETE /api/logs
POST /api/check/<id> POST /api/check/<id>
POST /api/notify/test POST /api/notify/test
GET /api/proxies
POST /api/proxies POST /api/proxies
PUT /api/proxies/<id> PUT /api/proxies/<id>
DELETE /api/proxies/<id> DELETE /api/proxies/<id>
@@ -112,12 +111,53 @@ DELETE /api/channels/<id>
POST /api/channels/<id>/test POST /api/channels/<id>/test
``` ```
节点列表通过 `GET /api/status` 返回(含运行状态和最近记录),没有单独的
`GET /api/proxies`。参数校验失败返回 `400` 并带 `error` 说明,仅未预期的内部
错误返回 `500`
## 节点字段说明
### Host 会被自动归一化
`host` 支持直接粘贴以下形式,服务端会拆成主机名 + 端口:
```text
192.168.1.10
192.168.1.10:7890
http://192.168.1.10:7890
socks5://user:pass@192.168.1.10:1080
[2001:db8::1]:1080
```
未显式指定 `port`(或仍为默认 `1080`)时采用 URL 中的端口。启动时也会自动修正
历史配置里残留的带协议前缀的 `host`,避免出现
`[Errno -2] Name does not resolve`
### 节点 id 生成规则
未显式提供 `id` 时由 `name` 推导:纯 ASCII 名字保留可读 slug(`HK Node 01`
`HK-Node-01`),含中文/emoji 的名字分配递增 `node-N`。两种路径都会自动去重,
不会因重名报错。`PUT` 编辑不会改变已有 id。
## 测试
```bash
python3 -m unittest discover -s tests
```
## Docker 镜像 ## Docker 镜像
```text ```text
192.168.2.66:80/hermes/socks5-monitor:latest 192.168.2.66:80/hermes/socks5-monitor:latest
``` ```
上游基础镜像(`python:3.11-alpine`)拉取受限时,可用 `Dockerfile.patch` 在上一版
镜像之上只叠加应用源码做增量构建:
```bash
docker build -f Dockerfile.patch -t 192.168.2.66:80/hermes/socks5-monitor:latest .
```
## 数据目录 ## 数据目录
```text ```text
+183 -15
View File
@@ -41,10 +41,26 @@ def load_json(path: Path) -> dict[str, Any]:
def save_json(path: Path, data: dict[str, Any]) -> None: def save_json(path: Path, data: dict[str, Any]) -> None:
"""Persist JSON config.
Prefer atomic replace, but fall back to in-place write when the target is a
Docker/macOS bind-mounted file. Those mounts commonly reject ``os.replace``
with ``OSError: [Errno 16] Resource busy``, which previously aborted
``upsert_proxy`` before ``restart_proxy_task`` and left new nodes without a
monitor loop.
"""
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp") text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") tmp = path.with_name(path.name + ".tmp")
try:
tmp.write_text(text, encoding="utf-8")
tmp.replace(path) tmp.replace(path)
except OSError:
# Bind-mounted single files cannot always be renamed into place.
path.write_text(text, encoding="utf-8")
with contextlib.suppress(Exception):
if tmp.exists():
tmp.unlink()
def deep_copy(obj: Any) -> Any: def deep_copy(obj: Any) -> Any:
@@ -145,6 +161,11 @@ class Store:
self.lock = asyncio.Lock() self.lock = asyncio.Lock()
self.init() self.init()
def close(self) -> None:
"""Release the SQLite handle (used by tests to avoid ResourceWarning)."""
with contextlib.suppress(Exception):
self.conn.close()
def init(self) -> None: def init(self) -> None:
self.conn.executescript( self.conn.executescript(
""" """
@@ -461,6 +482,57 @@ async def get_exit_ip_via_socks(proxy: dict[str, Any], timeout: float) -> str |
return None return None
def normalize_host(raw: str) -> tuple[str, int | None]:
"""Split a user-entered host field into (hostname, optional port).
Accepts bare hosts, ``host:port``, and full URLs such as
``socks5://user@1.2.3.4:1080`` / ``http://192.168.3.71:7890``. Without this,
a pasted URL is handed straight to DNS and every check fails with
``[Errno -2] Name does not resolve``, which looks like a panel bug.
"""
s = (raw or "").strip().strip("/")
if not s:
return "", None
if "://" in s:
s = s.split("://", 1)[1]
s = s.split("/", 1)[0]
if "@" in s:
s = s.rsplit("@", 1)[1]
port: int | None = None
if s.startswith("["): # bracketed IPv6, optionally with :port
host, _, rest = s[1:].partition("]")
if rest.startswith(":") and rest[1:].isdigit():
port = int(rest[1:])
return host, port
if s.count(":") == 1:
host, _, pstr = s.partition(":")
if pstr.isdigit():
return host, int(pstr)
return host, None
return s, None
def describe_exc(e: BaseException) -> str:
"""Human-readable failure reason.
``str(e)`` is empty for several exceptions that matter here -- most notably
``asyncio.TimeoutError``/``TimeoutError``, which is what a firewalled or
black-holed proxy endpoint produces. An empty error string in the panel
looked like an internal bug, so always fall back to the class name and
annotate the common cases.
"""
msg = str(e).strip()
if isinstance(e, (asyncio.TimeoutError, TimeoutError)):
return f"超时无响应:TCP 已连接但代理未回应握手({msg or 'timeout'}"
if isinstance(e, asyncio.IncompleteReadError):
return f"握手被中断:{msg or 'connection closed during handshake'}"
if isinstance(e, ConnectionRefusedError):
return "连接被拒绝:端口未监听"
if isinstance(e, socket.gaierror):
return f"域名解析失败:{msg or e}"
return msg or e.__class__.__name__
async def check_proxy(proxy: dict[str, Any]) -> tuple[bool, int | None, str | None, str | None]: async def check_proxy(proxy: dict[str, Any]) -> tuple[bool, int | None, str | None, str | None]:
timeout = float(proxy.get("timeout_seconds", 8)) timeout = float(proxy.get("timeout_seconds", 8))
start = time.perf_counter() start = time.perf_counter()
@@ -471,13 +543,14 @@ async def check_proxy(proxy: dict[str, Any]) -> tuple[bool, int | None, str | No
err = None if exit_ip else (proxy.get("last_exit_ip_error") or "exit ip lookup failed") err = None if exit_ip else (proxy.get("last_exit_ip_error") or "exit ip lookup failed")
return True, latency, err, exit_ip return True, latency, err, exit_ip
except Exception as e: except Exception as e:
return False, int((time.perf_counter() - start) * 1000), str(e), None return False, int((time.perf_counter() - start) * 1000), describe_exc(e), None
class App: class App:
def __init__(self, config_path: Path): def __init__(self, config_path: Path):
self.config_path = config_path self.config_path = config_path
self.config = merge_defaults(load_json(config_path)) self.config = merge_defaults(load_json(config_path))
self.migrate_proxy_hosts()
if not config_path.exists(): save_json(config_path, self.config) if not config_path.exists(): save_json(config_path, self.config)
self.store = Store(Path(self.config.get("db_path") or DEFAULT_DB)) self.store = Store(Path(self.config.get("db_path") or DEFAULT_DB))
self.notifier = Notifier(self.config.get("notifications") or {}, self.store) self.notifier = Notifier(self.config.get("notifications") or {}, self.store)
@@ -493,6 +566,25 @@ class App:
def channels(self) -> list[dict[str, Any]]: def channels(self) -> list[dict[str, Any]]:
return self.config.setdefault("notifications", {}).setdefault("channels", []) return self.config.setdefault("notifications", {}).setdefault("channels", [])
def migrate_proxy_hosts(self) -> None:
"""Clean up hosts stored before ``normalize_host`` existed.
Nodes saved with a pasted URL (e.g. ``http://192.168.3.71``) fail every
check with ``[Errno -2] Name does not resolve``. Rewrite them in place at
startup so existing configs heal themselves; ``start_tasks`` persists the
result.
"""
self._migrated = False
for p in self.config.get("proxies") or []:
raw = str(p.get("host") or "")
host, url_port = normalize_host(raw)
if host and host != raw:
p["host"] = host
if url_port and int(p.get("port") or 0) in (0, 1080):
p["port"] = url_port
self._migrated = True
print(f"[migrate] proxy {p.get('id')}: host {raw!r} -> {host!r}:{p.get('port')}", flush=True)
def state_for(self, proxy_id: str) -> RuntimeState: def state_for(self, proxy_id: str) -> RuntimeState:
return self.states.setdefault(proxy_id, RuntimeState()) return self.states.setdefault(proxy_id, RuntimeState())
@@ -507,13 +599,53 @@ class App:
def get_channel(self, channel_id: str) -> dict[str, Any] | None: def get_channel(self, channel_id: str) -> dict[str, Any] | None:
return next((c for c in self.channels if c.get("id") == channel_id), None) return next((c for c in self.channels if c.get("id") == channel_id), None)
def slug_id(self, name: str | None) -> str:
"""Build a URL-safe unique node id from a (possibly non-ASCII) name.
Non-ASCII names (Chinese, emoji flags, ...) collapse to an empty or
misleading slug -- "香港01" used to become "01", colliding with an
existing node and making the POST fail with 500 "proxy id already
exists". Keep the readable slug only for plain ASCII names; anything
containing non-ASCII characters gets a sequential ``node-N`` id.
Both paths are de-duplicated instead of raising.
"""
raw = (name or "").strip()
base = ""
if raw and re.fullmatch(r"[\x20-\x7e]+", raw):
base = re.sub(r"[^a-zA-Z0-9_-]+", "-", raw).strip("-")
if not base:
for n in range(1, 10000):
cand = f"node-{n}"
if not self.get_proxy(cand):
return cand
return f"node-{uuid.uuid4().hex[:8]}"
if not self.get_proxy(base):
return base
for n in range(2, 1000):
cand = f"{base}-{n}"
if not self.get_proxy(cand):
return cand
return f"{base}-{uuid.uuid4().hex[:8]}"
def validate_proxy(self, data: dict[str, Any], existing: dict[str, Any] | None = None) -> dict[str, Any]: def validate_proxy(self, data: dict[str, Any], existing: dict[str, Any] | None = None) -> dict[str, Any]:
p = dict(existing or {}) p = dict(existing or {})
p.update(data) p.update(data)
p.setdefault("id", re.sub(r"[^a-zA-Z0-9_-]+", "-", (p.get("name") or "proxy").strip()).strip("-") or f"proxy-{uuid.uuid4().hex[:8]}") if not p.get("id"):
p["id"] = self.slug_id(p.get("name"))
p.setdefault("name", p["id"]) p.setdefault("name", p["id"])
if not p.get("host"): raise ValueError("host required") if not p.get("host"): raise ValueError("host required")
p["port"] = int(p.get("port") or 1080) # Users paste "http://1.2.3.4:7890" or "1.2.3.4:7890" into the Host
# field; the raw string then fails DNS with "Name does not resolve".
host, url_port = normalize_host(str(p["host"]))
if not host:
raise ValueError("host required")
p["host"] = host
# A port embedded in the pasted host wins over an untouched default.
if url_port and int(p.get("port") or 0) in (0, 1080):
p["port"] = url_port
p["port"] = int(p.get("port") or url_port or 1080)
if not 1 <= p["port"] <= 65535:
raise ValueError("port must be 1..65535")
p["target_host"] = p.get("target_host") or self.config.get("target_host") or "www.baidu.com" p["target_host"] = p.get("target_host") or self.config.get("target_host") or "www.baidu.com"
p["target_port"] = int(p.get("target_port") or self.config.get("target_port") or 443) p["target_port"] = int(p.get("target_port") or self.config.get("target_port") or 443)
p["timeout_seconds"] = int(p.get("timeout_seconds") or self.config.get("timeout_seconds") or 8) p["timeout_seconds"] = int(p.get("timeout_seconds") or self.config.get("timeout_seconds") or 8)
@@ -587,7 +719,16 @@ class App:
else: else:
if self.get_proxy(p["id"]): raise ValueError("proxy id already exists") if self.get_proxy(p["id"]): raise ValueError("proxy id already exists")
self.proxies.append(p) self.proxies.append(p)
await self.save_config(); await self.restart_proxy_task(p["id"]) # Always (re)start the monitor loop after the in-memory update so a
# transient config-write failure cannot leave a node without polling.
save_error: Exception | None = None
try:
await self.save_config()
except Exception as exc: # noqa: BLE001 - re-raise after starting monitor
save_error = exc
await self.restart_proxy_task(p["id"])
if save_error is not None:
raise save_error
return public_proxy(p) return public_proxy(p)
async def delete_proxy(self, proxy_id: str) -> bool: async def delete_proxy(self, proxy_id: str) -> bool:
@@ -618,8 +759,13 @@ class App:
await self.save_config() await self.save_config()
return True return True
async def run_once(self, proxy: dict[str, Any]) -> None: async def run_once(self, proxy: dict[str, Any], force: bool = False) -> None:
if not proxy.get("enabled", True): return """Run one check. ``force`` bypasses the enabled flag for manual checks.
Without ``force`` a manual "check now" on a disabled node returned
``{"ok": true}`` while silently doing nothing.
"""
if not force and not proxy.get("enabled", True): return
proxy_id = proxy["id"] proxy_id = proxy["id"]
st = self.state_for(proxy_id) st = self.state_for(proxy_id)
ok, latency, err, exit_ip = await check_proxy(proxy) ok, latency, err, exit_ip = await check_proxy(proxy)
@@ -649,20 +795,34 @@ class App:
st.notified_down = True st.notified_down = True
async def monitor_loop(self, proxy_id: str) -> None: async def monitor_loop(self, proxy_id: str) -> None:
try:
while True: while True:
proxy = self.get_proxy(proxy_id) proxy = self.get_proxy(proxy_id)
if not proxy: return if not proxy:
return
if proxy.get("enabled", True): if proxy.get("enabled", True):
await self.run_once(proxy) await self.run_once(proxy)
await asyncio.sleep(int(proxy.get("interval_seconds", self.config.get("interval_seconds", 60)))) interval = int(proxy.get("interval_seconds", self.config.get("interval_seconds", 60)))
await asyncio.sleep(max(5, interval))
except asyncio.CancelledError:
raise
async def restart_proxy_task(self, proxy_id: str) -> None: async def restart_proxy_task(self, proxy_id: str) -> None:
old = self.monitor_tasks.pop(proxy_id, None) old = self.monitor_tasks.pop(proxy_id, None)
if old: old.cancel() if old:
if self.get_proxy(proxy_id): old.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await old
proxy = self.get_proxy(proxy_id)
if proxy and proxy.get("enabled", True):
self.monitor_tasks[proxy_id] = asyncio.create_task(self.monitor_loop(proxy_id)) self.monitor_tasks[proxy_id] = asyncio.create_task(self.monitor_loop(proxy_id))
elif proxy and not proxy.get("enabled", True):
# Keep disabled nodes registered without an active loop.
self.monitor_tasks.pop(proxy_id, None)
async def start_tasks(self) -> None: async def start_tasks(self) -> None:
if self._migrated:
await self.save_config()
for p in self.proxies: for p in self.proxies:
await self.restart_proxy_task(p["id"]) await self.restart_proxy_task(p["id"])
@@ -725,15 +885,21 @@ async def handle_http(reader: asyncio.StreamReader, writer: asyncio.StreamWriter
status, ctype, resp = json_response({"ok": True, "proxy": await app.upsert_proxy(data)}, 201) status, ctype, resp = json_response({"ok": True, "proxy": await app.upsert_proxy(data)}, 201)
elif method == "PUT" and p.startswith("/api/proxies/"): elif method == "PUT" and p.startswith("/api/proxies/"):
pid = urllib.parse.unquote(p.rsplit("/", 1)[-1]) pid = urllib.parse.unquote(p.rsplit("/", 1)[-1])
if not app.get_proxy(pid):
status, ctype, resp = json_response({"ok": False, "error": "proxy not found"}, 404)
else:
status, ctype, resp = json_response({"ok": True, "proxy": await app.upsert_proxy(data, pid)}) status, ctype, resp = json_response({"ok": True, "proxy": await app.upsert_proxy(data, pid)})
elif method == "DELETE" and p.startswith("/api/proxies/"): elif method == "DELETE" and p.startswith("/api/proxies/"):
pid = urllib.parse.unquote(p.rsplit("/", 1)[-1]) pid = urllib.parse.unquote(p.rsplit("/", 1)[-1])
status, ctype, resp = json_response({"ok": await app.delete_proxy(pid)}) if await app.delete_proxy(pid):
status, ctype, resp = json_response({"ok": True})
else:
status, ctype, resp = json_response({"ok": False, "error": "proxy not found"}, 404)
elif method == "POST" and p.startswith("/api/check/"): elif method == "POST" and p.startswith("/api/check/"):
pid = urllib.parse.unquote(p.rsplit("/", 1)[-1]); proxy = app.get_proxy(pid) pid = urllib.parse.unquote(p.rsplit("/", 1)[-1]); proxy = app.get_proxy(pid)
if not proxy: status, ctype, resp = json_response({"ok": False, "error": "proxy not found"}, 404) if not proxy: status, ctype, resp = json_response({"ok": False, "error": "proxy not found"}, 404)
else: else:
await app.run_once(proxy) await app.run_once(proxy, force=True)
status, ctype, resp = json_response({"ok": True, "state": dataclasses.asdict(app.state_for(pid))}) status, ctype, resp = json_response({"ok": True, "state": dataclasses.asdict(app.state_for(pid))})
elif method == "POST" and p == "/api/channels": elif method == "POST" and p == "/api/channels":
status, ctype, resp = json_response({"ok": True, "channel": await app.upsert_channel(data)}, 201) status, ctype, resp = json_response({"ok": True, "channel": await app.upsert_channel(data)}, 201)
@@ -752,9 +918,11 @@ async def handle_http(reader: asyncio.StreamReader, writer: asyncio.StreamWriter
status, ctype, resp = json_response({"ok": True, "results": res}) status, ctype, resp = json_response({"ok": True, "results": res})
else: else:
status, ctype, resp = json_response({"ok": False, "error": "not found"}, 404) status, ctype, resp = json_response({"ok": False, "error": "not found"}, 404)
except ValueError as e:
status, ctype, resp = json_response({"ok": False, "error": str(e)}, 400)
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
status, ctype, resp = json_response({"ok": False, "error": str(e)}, 500) status, ctype, resp = json_response({"ok": False, "error": describe_exc(e)}, 500)
try: try:
reason = {200:"OK",201:"Created",400:"Bad Request",404:"Not Found",500:"Internal Server Error"}.get(status,"OK") reason = {200:"OK",201:"Created",400:"Bad Request",404:"Not Found",500:"Internal Server Error"}.get(status,"OK")
writer.write(f"HTTP/1.1 {status} {reason}\r\nContent-Type: {ctype}\r\nContent-Length: {len(resp)}\r\nConnection: close\r\n\r\n".encode()+resp) writer.write(f"HTTP/1.1 {status} {reason}\r\nContent-Type: {ctype}\r\nContent-Length: {len(resp)}\r\nConnection: close\r\n\r\n".encode()+resp)
+2 -2
View File
File diff suppressed because one or more lines are too long
+8 -2
View File
@@ -17,9 +17,14 @@ spec.loader.exec_module(app)
class LogRetentionTest(unittest.TestCase): class LogRetentionTest(unittest.TestCase):
def make_store(self, path: Path):
store = app.Store(path)
self.addCleanup(store.close)
return store
def test_check_records_are_pruned_per_proxy(self): def test_check_records_are_pruned_per_proxy(self):
with tempfile.TemporaryDirectory() as td: with tempfile.TemporaryDirectory() as td:
store = app.Store(Path(td) / "monitor.db") store = self.make_store(Path(td) / "monitor.db")
async def run(): async def run():
for i in range(5): for i in range(5):
@@ -35,7 +40,7 @@ class LogRetentionTest(unittest.TestCase):
def test_clear_logs_removes_checks_events_and_notification_logs(self): def test_clear_logs_removes_checks_events_and_notification_logs(self):
with tempfile.TemporaryDirectory() as td: with tempfile.TemporaryDirectory() as td:
store = app.Store(Path(td) / "monitor.db") store = self.make_store(Path(td) / "monitor.db")
async def seed_and_clear(): async def seed_and_clear():
await store.add_check("node-a", True, 1, None, "203.0.113.1") await store.add_check("node-a", True, 1, None, "203.0.113.1")
@@ -57,6 +62,7 @@ class LogRetentionTest(unittest.TestCase):
cfg = Path(td) / "config.json" cfg = Path(td) / "config.json"
cfg.write_text('{"proxies": [], "notifications": {"channels": []}}', encoding="utf-8") cfg.write_text('{"proxies": [], "notifications": {"channels": []}}', encoding="utf-8")
application = app.App(cfg) application = app.App(cfg)
self.addCleanup(application.store.close)
settings = asyncio.run(application.update_settings({"max_checks_per_proxy": 7})) settings = asyncio.run(application.update_settings({"max_checks_per_proxy": 7}))
self.assertEqual(settings["max_checks_per_proxy"], 7) self.assertEqual(settings["max_checks_per_proxy"], 7)
saved = app.load_json(cfg) saved = app.load_json(cfg)
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""Regression tests for node id generation, host normalization and error text.
These cover the three defects that made the panel look broken:
1. Chinese node names slugged into ids that collided with existing nodes.
2. A pasted URL in the Host field went straight to DNS and always failed.
3. asyncio.TimeoutError has an empty str(), so the UI showed a blank error.
"""
from __future__ import annotations
import asyncio
import importlib.util
import json
import socket
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location("socks5_app", ROOT / "app.py")
assert spec is not None and spec.loader is not None
app = importlib.util.module_from_spec(spec)
sys.modules["socks5_app"] = app
spec.loader.exec_module(app)
def make_app(proxies: list[dict] | None = None) -> tuple[object, Path, tempfile.TemporaryDirectory]:
"""Build an App on a throwaway config; caller must use the TemporaryDirectory."""
td = tempfile.TemporaryDirectory()
cfg = Path(td.name) / "config.json"
cfg.write_text(json.dumps({
"proxies": proxies or [],
"notifications": {"channels": []},
"db_path": str(Path(td.name) / "monitor.db"),
}), encoding="utf-8")
return app.App(cfg), cfg, td
class AppCase(unittest.TestCase):
"""Base class that builds an App and always closes its SQLite handle."""
def build(self, proxies: list[dict] | None = None):
application, cfg, td = make_app(proxies)
self.addCleanup(td.cleanup)
self.addCleanup(application.store.close)
return application, cfg
class NormalizeHostTest(unittest.TestCase):
def test_bare_host_has_no_port(self):
self.assertEqual(app.normalize_host("192.168.3.71"), ("192.168.3.71", None))
def test_host_colon_port(self):
self.assertEqual(app.normalize_host("192.168.3.71:7890"), ("192.168.3.71", 7890))
def test_full_url_is_stripped(self):
self.assertEqual(app.normalize_host("http://192.168.3.71:7890"), ("192.168.3.71", 7890))
def test_scheme_and_credentials_are_stripped(self):
self.assertEqual(app.normalize_host("socks5://user:pw@1.2.3.4:1080"), ("1.2.3.4", 1080))
def test_trailing_path_is_dropped(self):
self.assertEqual(app.normalize_host("socks5h://host.example.com:1080/"), ("host.example.com", 1080))
def test_bracketed_ipv6(self):
self.assertEqual(app.normalize_host("[2001:db8::1]:1080"), ("2001:db8::1", 1080))
def test_bare_ipv6_is_not_split_on_colons(self):
self.assertEqual(app.normalize_host("2001:db8::1"), ("2001:db8::1", None))
def test_whitespace_and_empty(self):
self.assertEqual(app.normalize_host(" chickliu.fun "), ("chickliu.fun", None))
self.assertEqual(app.normalize_host(""), ("", None))
self.assertEqual(app.normalize_host(None), ("", None))
def test_non_numeric_port_is_kept_as_host(self):
self.assertEqual(app.normalize_host("host:notaport"), ("host", None))
class DescribeExcTest(unittest.TestCase):
def test_timeout_is_never_blank(self):
for exc in (asyncio.TimeoutError(), TimeoutError()):
msg = app.describe_exc(exc)
self.assertTrue(msg.strip())
self.assertIn("超时", msg)
def test_incomplete_read_is_labelled_handshake(self):
self.assertIn("握手", app.describe_exc(asyncio.IncompleteReadError(b"", 2)))
def test_refused_and_dns(self):
self.assertIn("拒绝", app.describe_exc(ConnectionRefusedError()))
self.assertIn("解析", app.describe_exc(socket.gaierror(-2, "Name does not resolve")))
def test_message_is_preserved_for_other_errors(self):
self.assertEqual(app.describe_exc(RuntimeError("rep=0x05")), "rep=0x05")
def test_bare_exception_falls_back_to_class_name(self):
self.assertEqual(app.describe_exc(RuntimeError()), "RuntimeError")
class SlugIdTest(AppCase):
def test_chinese_name_does_not_collide_with_existing_id(self):
application, _ = self.build([{"id": "01", "name": "15690", "host": "h", "port": 1}])
self.assertEqual(application.slug_id("香港01"), "node-1")
def test_repeated_non_ascii_names_get_distinct_ids(self):
application, _ = self.build()
first = application.validate_proxy({"name": "香港01", "host": "1.1.1.1"})
application.config["proxies"].append(first)
second = application.validate_proxy({"name": "香港01", "host": "1.1.1.1"})
self.assertNotEqual(first["id"], second["id"])
self.assertEqual([first["id"], second["id"]], ["node-1", "node-2"])
def test_emoji_only_name_is_handled(self):
application, _ = self.build()
self.assertEqual(application.slug_id("🇯🇵日本"), "node-1")
def test_ascii_name_keeps_readable_slug(self):
application, _ = self.build()
self.assertEqual(application.slug_id("HK Node 01"), "HK-Node-01")
def test_ascii_collision_is_suffixed(self):
application, _ = self.build([{"id": "hk", "name": "hk", "host": "h", "port": 1}])
self.assertEqual(application.slug_id("hk"), "hk-2")
class ValidateProxyTest(AppCase):
def test_pasted_url_is_normalized_into_host_and_port(self):
application, _ = self.build()
p = application.validate_proxy({"name": "u", "host": "http://192.168.3.71:7890"})
self.assertEqual((p["host"], p["port"]), ("192.168.3.71", 7890))
def test_explicit_port_wins_over_url_port(self):
application, _ = self.build()
p = application.validate_proxy({"name": "u", "host": "http://1.2.3.4:7890", "port": 9050})
self.assertEqual(p["port"], 9050)
def test_missing_host_raises_value_error(self):
application, _ = self.build()
with self.assertRaises(ValueError):
application.validate_proxy({"name": "no-host"})
with self.assertRaises(ValueError):
application.validate_proxy({"name": "blank", "host": " "})
def test_out_of_range_port_raises_value_error(self):
application, _ = self.build()
with self.assertRaises(ValueError):
application.validate_proxy({"name": "p", "host": "1.1.1.1", "port": 70000})
def test_edit_preserves_existing_id(self):
existing = {"id": "410-5585", "name": "old", "host": "1.1.1.1", "port": 1080}
application, _ = self.build([existing])
p = application.validate_proxy({"name": "renamed", "host": "2.2.2.2"}, existing=existing)
self.assertEqual(p["id"], "410-5585")
self.assertEqual(p["name"], "renamed")
class MigrateProxyHostsTest(AppCase):
def test_url_host_is_healed_at_startup(self):
application, _ = self.build([
{"id": "a", "name": "a", "host": "http://192.168.3.71", "port": 7890},
{"id": "b", "name": "b", "host": "192.168.3.46", "port": 7890},
])
self.assertTrue(application._migrated)
self.assertEqual(application.get_proxy("a")["host"], "192.168.3.71")
self.assertEqual(application.get_proxy("a")["port"], 7890)
self.assertEqual(application.get_proxy("b")["host"], "192.168.3.46")
def test_url_port_adopted_when_port_is_default(self):
application, _ = self.build([
{"id": "a", "name": "a", "host": "http://10.0.0.5:1088", "port": 1080},
])
self.assertEqual(application.get_proxy("a")["port"], 1088)
def test_clean_config_is_not_marked_migrated(self):
application, _ = self.build([{"id": "a", "name": "a", "host": "1.1.1.1", "port": 1080}])
self.assertFalse(application._migrated)
def test_migration_is_persisted_by_start_tasks(self):
application, cfg = self.build([
{"id": "a", "name": "a", "host": "http://192.168.3.71", "port": 7890, "enabled": False},
])
asyncio.run(application.start_tasks())
saved = json.loads(cfg.read_text(encoding="utf-8"))
self.assertEqual(saved["proxies"][0]["host"], "192.168.3.71")
class ManualCheckTest(AppCase):
def test_force_runs_on_disabled_node_and_records_error(self):
application, _ = self.build([
{"id": "dead", "name": "dead", "host": "127.0.0.1", "port": 19999,
"enabled": False, "timeout_seconds": 2},
])
proxy = application.get_proxy("dead")
asyncio.run(application.run_once(proxy, force=True))
st = application.state_for("dead")
self.assertIs(st.last_ok, False)
self.assertTrue((st.error or "").strip(), "manual check must report a reason")
def test_without_force_a_disabled_node_is_skipped(self):
application, _ = self.build([
{"id": "dead", "name": "dead", "host": "127.0.0.1", "port": 19999,
"enabled": False, "timeout_seconds": 2},
])
asyncio.run(application.run_once(application.get_proxy("dead")))
self.assertIsNone(application.state_for("dead").last_ok)
if __name__ == "__main__":
unittest.main()