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:
@@ -4,5 +4,7 @@ __pycache__/
|
|||||||
.git/
|
.git/
|
||||||
data/
|
data/
|
||||||
config.json
|
config.json
|
||||||
|
config.json.bak.*
|
||||||
*.db
|
*.db
|
||||||
*.log
|
*.log
|
||||||
|
tests/
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
config.json
|
config.json
|
||||||
|
config.json.bak.*
|
||||||
.env
|
.env
|
||||||
*.env
|
*.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user