Files
socks5-monitor/app.py
T
Hermes ce0aa22672 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.
2026-08-23 14:07:37 +08:00

956 lines
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import argparse
import asyncio
import contextlib
import dataclasses
import json
import re
import socket
import sqlite3
import struct
import time
import traceback
import urllib.error
import urllib.parse
import urllib.request
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any
APP_DIR = Path(__file__).resolve().parent
DEFAULT_CONFIG = APP_DIR / "config.json"
DEFAULT_DB = APP_DIR / "data" / "monitor.db"
def now_ts() -> int:
return int(time.time())
def iso(ts: int | None = None) -> str:
return datetime.fromtimestamp(ts or now_ts()).strftime("%Y-%m-%d %H:%M:%S")
def load_json(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
return json.loads(path.read_text(encoding="utf-8"))
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)
text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
tmp = path.with_name(path.name + ".tmp")
try:
tmp.write_text(text, encoding="utf-8")
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:
return json.loads(json.dumps(obj, ensure_ascii=False))
def default_config() -> dict[str, Any]:
return {
"interval_seconds": 300,
"timeout_seconds": 8,
"target_host": "www.baidu.com",
"target_port": 443,
"fail_threshold": 3,
"recover_threshold": 1,
"max_checks_per_proxy": 500,
"db_path": str(DEFAULT_DB),
"proxies": [],
"notifications": {"enabled": True, "timeout_seconds": 10, "channels": []},
}
def merge_defaults(cfg: dict[str, Any]) -> dict[str, Any]:
base = default_config()
base.update(cfg or {})
notifications = base.get("notifications") or {}
base["notifications"] = {"enabled": True, "timeout_seconds": 10, "channels": [], **notifications}
if base.get("target_host") in (None, "", "1.1.1.1", "8.8.8.8", "google.com"):
base["target_host"] = "www.baidu.com"
base["target_port"] = int(base.get("target_port") or 443)
base["interval_seconds"] = int(base.get("interval_seconds") or 300)
base["max_checks_per_proxy"] = max(1, int(base.get("max_checks_per_proxy") or 500))
base.setdefault("proxies", [])
for proxy in base["proxies"]:
if proxy.get("target_host") in (None, "", "1.1.1.1", "8.8.8.8", "google.com"):
proxy["target_host"] = base["target_host"]
proxy["target_port"] = int(proxy.get("target_port") or base["target_port"])
proxy["interval_seconds"] = int(proxy.get("interval_seconds") or base["interval_seconds"])
base["notifications"].setdefault("channels", [])
base["notifications"]["channels"] = [normalize_channel(c) for c in base["notifications"].get("channels", [])]
return base
def public_proxy(proxy: dict[str, Any]) -> dict[str, Any]:
p = deep_copy(proxy)
if p.get("password"):
p["password_masked"] = "********"
p["password"] = ""
return p
def normalize_channel(channel: dict[str, Any]) -> dict[str, Any]:
"""Return canonical notification channel shape: common fields + type-specific config.
Backward compatible with the old flat schema, but output never mixes fields
across Bark / PushPlus / Autman / Webhook.
"""
c = dict(channel or {})
typ = c.get("type") or "webhook"
cfg = dict(c.get("config") or {})
if typ == "bark":
cfg = {
"server": cfg.get("server") or c.get("server") or "https://api.day.app",
"key": cfg.get("key") or c.get("key") or "",
"group": cfg.get("group") or c.get("group") or "SOCKS5监控",
}
elif typ == "pushplus":
cfg = {
"token": cfg.get("token") or c.get("token") or c.get("key") or "",
"template": cfg.get("template") or c.get("template") or "txt",
"topic": cfg.get("topic") or c.get("topic") or c.get("group") or "",
}
elif typ == "autman":
cfg = {
"url": cfg.get("url") or c.get("url") or c.get("webhook") or "",
"access_token": cfg.get("access_token") or cfg.get("token") or c.get("access_token") or c.get("token") or "",
}
elif typ == "webhook":
cfg = {
"url": cfg.get("url") or c.get("url") or "",
"headers": cfg.get("headers") if isinstance(cfg.get("headers"), dict) else (c.get("headers") if isinstance(c.get("headers"), dict) else {}),
"payload": cfg.get("payload") if isinstance(cfg.get("payload"), dict) else (c.get("payload") if isinstance(c.get("payload"), dict) else {}),
}
return {
"id": c.get("id") or f"ch-{uuid.uuid4().hex[:8]}",
"type": typ,
"name": c.get("name") or typ,
"enabled": bool(c.get("enabled", True)),
"config": cfg,
}
class Store:
def __init__(self, path: Path):
self.path = path
self.path.parent.mkdir(parents=True, exist_ok=True)
self.conn = sqlite3.connect(self.path, check_same_thread=False)
self.conn.row_factory = sqlite3.Row
self.lock = asyncio.Lock()
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:
self.conn.executescript(
"""
create table if not exists checks(
id integer primary key autoincrement,
proxy_id text not null,
ts integer not null,
ok integer not null,
latency_ms integer,
error text,
exit_ip text
);
create index if not exists idx_checks_proxy_ts on checks(proxy_id, ts desc);
create table if not exists events(
id integer primary key autoincrement,
proxy_id text not null,
ts integer not null,
old_status text,
new_status text,
message text
);
create index if not exists idx_events_ts on events(ts desc);
create table if not exists notification_logs(
id integer primary key autoincrement,
ts integer not null,
channel_id text,
channel_name text,
ok integer not null,
title text,
message text,
result text
);
create index if not exists idx_notification_logs_ts on notification_logs(ts desc);
"""
)
self.conn.commit()
async def add_check(self, proxy_id: str, ok: bool, latency_ms: int | None, error: str | None, exit_ip: str | None, max_per_proxy: int | None = None) -> None:
async with self.lock:
self.conn.execute(
"insert into checks(proxy_id,ts,ok,latency_ms,error,exit_ip) values(?,?,?,?,?,?)",
(proxy_id, now_ts(), 1 if ok else 0, latency_ms, error, exit_ip),
)
if max_per_proxy:
self.conn.execute(
"""
delete from checks
where proxy_id=? and id not in (
select id from checks where proxy_id=? order by ts desc, id desc limit ?
)
""",
(proxy_id, proxy_id, max(1, int(max_per_proxy))),
)
self.conn.commit()
async def add_event(self, proxy_id: str, old_status: str | None, new_status: str, message: str) -> None:
async with self.lock:
self.conn.execute(
"insert into events(proxy_id,ts,old_status,new_status,message) values(?,?,?,?,?)",
(proxy_id, now_ts(), old_status, new_status, message),
)
self.conn.commit()
async def add_notification(self, channel: dict[str, Any], ok: bool, title: str, message: str, result: str) -> None:
async with self.lock:
self.conn.execute(
"insert into notification_logs(ts,channel_id,channel_name,ok,title,message,result) values(?,?,?,?,?,?,?)",
(now_ts(), channel.get("id"), channel.get("name") or channel.get("type"), 1 if ok else 0, title, message, result),
)
self.conn.commit()
def recent_checks(self, proxy_id: str, limit: int = 50) -> list[dict[str, Any]]:
rows = self.conn.execute("select * from checks where proxy_id=? order by ts desc, id desc limit ?", (proxy_id, limit)).fetchall()
return [dict(r) for r in rows]
def recent_events(self, limit: int = 50) -> list[dict[str, Any]]:
rows = self.conn.execute("select * from events order by ts desc limit ?", (limit,)).fetchall()
return [dict(r) for r in rows]
def recent_notifications(self, limit: int = 50) -> list[dict[str, Any]]:
rows = self.conn.execute("select * from notification_logs order by ts desc limit ?", (limit,)).fetchall()
return [dict(r) for r in rows]
async def clear_logs(self) -> dict[str, int]:
async with self.lock:
removed = {}
for table in ("checks", "events", "notification_logs"):
cur = self.conn.execute(f"delete from {table}")
removed[table] = cur.rowcount if cur.rowcount is not None else 0
self.conn.commit()
return removed
@dataclasses.dataclass
class RuntimeState:
status: str = "unknown"
last_ok: bool | None = None
last_ts: int | None = None
latency_ms: int | None = None
error: str | None = None
exit_ip: str | None = None
fail_count: int = 0
ok_count: int = 0
notified_down: bool = False
class Notifier:
def __init__(self, cfg: dict[str, Any], store: Store | None = None):
self.cfg = cfg or {}
self.store = store
self.timeout = int(self.cfg.get("timeout_seconds", 10))
async def send_all(self, title: str, body: str) -> list[dict[str, Any]]:
if not self.cfg.get("enabled", True):
return [{"channel": "all", "ok": False, "error": "notifications disabled"}]
channels = [c for c in self.cfg.get("channels", []) if c.get("enabled", True)]
return await self.send_channels(channels, title, body)
async def send_channel_id(self, channel_id: str, title: str, body: str) -> dict[str, Any]:
ch = next((c for c in self.cfg.get("channels", []) if c.get("id") == channel_id), None)
if not ch:
return {"channel": channel_id, "ok": False, "error": "channel not found"}
# 单通道测试应可验证配置本身,即使该渠道平时处于停用状态。
test_ch = dict(ch)
test_ch["enabled"] = True
res = await self.send_channels([test_ch], title, body)
return res[0] if res else {"channel": channel_id, "ok": False, "error": "not sent"}
async def send_channels(self, channels: list[dict[str, Any]], title: str, body: str) -> list[dict[str, Any]]:
results = []
for ch in channels:
res = await asyncio.to_thread(self._send_one, ch, title, body)
results.append(res)
if self.store:
await self.store.add_notification(ch, bool(res.get("ok")), title, body, json.dumps(res, ensure_ascii=False))
return results
def _http_get(self, url: str) -> tuple[bool, str]:
try:
with urllib.request.urlopen(url, timeout=self.timeout) as r:
text = r.read(4096).decode("utf-8", "replace")
return 200 <= r.status < 300, f"HTTP {r.status} {text[:300]}"
except Exception as e:
return False, repr(e)
def _http_post_json(self, url: str, payload: dict[str, Any], headers: dict[str, str] | None = None) -> tuple[bool, str]:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json", **(headers or {})}, method="POST")
try:
with urllib.request.urlopen(req, timeout=self.timeout) as r:
text = r.read(4096).decode("utf-8", "replace")
return 200 <= r.status < 300, f"HTTP {r.status} {text[:300]}"
except Exception as e:
return False, repr(e)
def _send_one(self, ch: dict[str, Any], title: str, body: str) -> dict[str, Any]:
ch = normalize_channel(ch)
typ = ch.get("type")
cfg = ch.get("config") or {}
name = ch.get("name") or typ
if not ch.get("enabled", True):
return {"channel": name, "ok": False, "error": "disabled"}
try:
if typ == "bark":
base = (cfg.get("server") or "https://api.day.app").rstrip("/")
key = cfg.get("key") or ""
group = cfg.get("group") or "SOCKS5监控"
if not key:
return {"channel": name, "ok": False, "error": "missing Bark key"}
url = f"{base}/{urllib.parse.quote(key)}/{urllib.parse.quote(title)}/{urllib.parse.quote(body)}?group={urllib.parse.quote(group)}"
ok, msg = self._http_get(url)
elif typ == "pushplus":
token = cfg.get("token") or ""
if not token:
return {"channel": name, "ok": False, "error": "missing PushPlus token"}
payload = {"token": token, "title": title, "content": body, "template": cfg.get("template", "txt")}
if cfg.get("topic"):
payload["topic"] = cfg.get("topic")
ok, msg = self._http_post_json("https://www.pushplus.plus/send", payload)
elif typ == "autman":
url = cfg.get("url") or ""
if not url:
return {"channel": name, "ok": False, "error": "missing autman url"}
message = f"标题:{title}\n内容:{body}"
payload = {"message": message, "access_token": cfg.get("access_token") or ""}
ok, msg = self._http_post_json(url, payload, {})
elif typ == "webhook":
url = cfg.get("url") or ""
if not url:
return {"channel": name, "ok": False, "error": "missing webhook url"}
payload = dict(cfg.get("payload", {}) if isinstance(cfg.get("payload"), dict) else {})
payload.update({"title": title, "content": body, "ts": now_ts()})
ok, msg = self._http_post_json(url, payload, cfg.get("headers") or {})
else:
return {"channel": name, "ok": False, "error": f"unknown type {typ}"}
return {"channel": name, "ok": ok, "message": msg}
except Exception as e:
return {"channel": name, "ok": False, "error": repr(e)}
async def socks5_open_connection(proxy: dict[str, Any], timeout: float, target_host: str | None = None, target_port: int | None = None) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
host = proxy["host"]
port = int(proxy["port"])
username = proxy.get("username") or ""
password = proxy.get("password") or ""
target_host = str(target_host or proxy.get("target_host") or "www.baidu.com")
target_port = int(target_port or proxy.get("target_port", 443))
reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=timeout)
try:
methods = [0x00]
if username or password:
methods.append(0x02)
writer.write(bytes([0x05, len(methods), *methods])); await writer.drain()
resp = await asyncio.wait_for(reader.readexactly(2), timeout=timeout)
if resp[0] != 0x05: raise RuntimeError(f"invalid socks version {resp[0]}")
method = resp[1]
if method == 0xFF: raise RuntimeError("socks auth method rejected")
if method == 0x02:
ub, pb = username.encode(), password.encode()
writer.write(bytes([0x01, len(ub)]) + ub + bytes([len(pb)]) + pb); await writer.drain()
auth = await asyncio.wait_for(reader.readexactly(2), timeout=timeout)
if auth[1] != 0x00: raise RuntimeError("socks username/password auth failed")
elif method != 0x00:
raise RuntimeError(f"unsupported socks method {method}")
try:
addr = socket.inet_aton(target_host); atyp = 0x01; body = addr
except OSError:
hb = target_host.encode(); atyp = 0x03; body = bytes([len(hb)]) + hb
writer.write(bytes([0x05, 0x01, 0x00, atyp]) + body + struct.pack("!H", target_port)); await writer.drain()
head = await asyncio.wait_for(reader.readexactly(4), timeout=timeout)
if head[1] != 0x00: raise RuntimeError(f"socks connect failed rep=0x{head[1]:02x}")
atyp = head[3]
if atyp == 0x01: await reader.readexactly(4)
elif atyp == 0x03: await reader.readexactly((await reader.readexactly(1))[0])
elif atyp == 0x04: await reader.readexactly(16)
await reader.readexactly(2)
return reader, writer
except Exception:
writer.close()
with contextlib.suppress(Exception): await writer.wait_closed()
raise
async def socks5_connect(proxy: dict[str, Any], timeout: float) -> None:
reader, writer = await socks5_open_connection(proxy, timeout)
writer.close()
with contextlib.suppress(Exception): await writer.wait_closed()
async def http_get_via_socks(proxy: dict[str, Any], timeout: float, host: str, path: str = "/") -> str:
reader, writer = await socks5_open_connection(proxy, timeout, host, 80)
try:
req = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {host}\r\n"
"User-Agent: socks5-monitor/1.0\r\n"
"Accept: text/plain, application/json;q=0.9, */*;q=0.1\r\n"
"Connection: close\r\n\r\n"
).encode("ascii")
writer.write(req); await writer.drain()
chunks = []
deadline = asyncio.get_running_loop().time() + timeout
while True:
remaining = max(0.1, deadline - asyncio.get_running_loop().time())
if remaining <= 0.1 and chunks:
break
try:
chunk = await asyncio.wait_for(reader.read(4096), timeout=remaining)
except asyncio.TimeoutError:
break
if not chunk:
break
chunks.append(chunk)
if sum(len(x) for x in chunks) >= 65536:
break
raw = b"".join(chunks)
return raw.decode("utf-8", "replace")
finally:
writer.close()
with contextlib.suppress(Exception): await writer.wait_closed()
def extract_ip(text: str) -> str | None:
body = text.split("\r\n\r\n", 1)[-1].strip()
m = re.search(r"\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b", body)
if m:
return m.group(0)
# Very small IPv6 matcher, enough for plain text IP services.
m = re.search(r"\b(?:[0-9a-fA-F]{1,4}:){2,}[0-9a-fA-F]{0,4}\b", body)
return m.group(0) if m else None
async def get_exit_ip_via_socks(proxy: dict[str, Any], timeout: float) -> str | None:
"""Fetch the public egress IP through the SOCKS5 proxy without failing health checks."""
endpoints = [
("api.ipify.org", "/?format=text"),
("ipv4.icanhazip.com", "/"),
("ifconfig.me", "/ip"),
("ident.me", "/"),
("checkip.amazonaws.com", "/"),
]
errors = []
per_endpoint_timeout = max(2.0, min(float(timeout), 6.0))
for host, path in endpoints:
try:
text = await http_get_via_socks(proxy, per_endpoint_timeout, host, path)
ip = extract_ip(text)
if ip:
return ip
errors.append(f"{host}: no ip in response")
except Exception as e:
errors.append(f"{host}: {e}")
proxy["last_exit_ip_error"] = "; ".join(errors[-3:]) or "exit ip lookup failed: empty response from all providers"
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]:
timeout = float(proxy.get("timeout_seconds", 8))
start = time.perf_counter()
try:
await socks5_connect(proxy, timeout)
latency = int((time.perf_counter() - start) * 1000)
exit_ip = await get_exit_ip_via_socks(proxy, timeout)
err = None if exit_ip else (proxy.get("last_exit_ip_error") or "exit ip lookup failed")
return True, latency, err, exit_ip
except Exception as e:
return False, int((time.perf_counter() - start) * 1000), describe_exc(e), None
class App:
def __init__(self, config_path: Path):
self.config_path = 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)
self.store = Store(Path(self.config.get("db_path") or DEFAULT_DB))
self.notifier = Notifier(self.config.get("notifications") or {}, self.store)
self.states: dict[str, RuntimeState] = {}
self.monitor_tasks: dict[str, asyncio.Task] = {}
self.config_lock = asyncio.Lock()
@property
def proxies(self) -> list[dict[str, Any]]:
return self.config.setdefault("proxies", [])
@property
def channels(self) -> list[dict[str, Any]]:
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:
return self.states.setdefault(proxy_id, RuntimeState())
async def save_config(self) -> None:
async with self.config_lock:
save_json(self.config_path, self.config)
self.notifier = Notifier(self.config.get("notifications") or {}, self.store)
def get_proxy(self, proxy_id: str) -> dict[str, Any] | None:
return next((p for p in self.proxies if p.get("id") == proxy_id), 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)
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]:
p = dict(existing or {})
p.update(data)
if not p.get("id"):
p["id"] = self.slug_id(p.get("name"))
p.setdefault("name", p["id"])
if not p.get("host"): raise ValueError("host required")
# 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_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["interval_seconds"] = max(5, int(p.get("interval_seconds") or self.config.get("interval_seconds") or 300))
p["fail_threshold"] = int(p.get("fail_threshold") or self.config.get("fail_threshold") or 3)
p["recover_threshold"] = int(p.get("recover_threshold") or self.config.get("recover_threshold") or 1)
p["enabled"] = bool(p.get("enabled", True))
return p
def validate_channel(self, data: dict[str, Any], existing: dict[str, Any] | None = None) -> dict[str, Any]:
incoming = normalize_channel(data or {})
if existing:
incoming["id"] = existing.get("id")
typ = incoming.get("type")
if typ not in {"bark", "pushplus", "autman", "webhook"}:
raise ValueError("type must be bark/pushplus/autman/webhook")
incoming.setdefault("name", typ)
incoming["enabled"] = bool(incoming.get("enabled", True))
cfg = incoming["config"]
if typ == "bark" and not cfg.get("server"):
cfg["server"] = "https://api.day.app"
if typ == "pushplus" and not cfg.get("template"):
cfg["template"] = "txt"
return incoming
def validate_settings(self, data: dict[str, Any]) -> dict[str, Any]:
out = {}
if "interval_seconds" in data:
out["interval_seconds"] = max(5, int(data.get("interval_seconds") or 300))
if "timeout_seconds" in data:
out["timeout_seconds"] = max(1, int(data.get("timeout_seconds") or 8))
if "target_host" in data:
host = str(data.get("target_host") or "").strip()
if not host:
raise ValueError("target_host required")
out["target_host"] = host
if "target_port" in data:
port = int(data.get("target_port") or 443)
if port < 1 or port > 65535:
raise ValueError("target_port must be 1..65535")
out["target_port"] = port
if "fail_threshold" in data:
out["fail_threshold"] = max(1, int(data.get("fail_threshold") or 3))
if "recover_threshold" in data:
out["recover_threshold"] = max(1, int(data.get("recover_threshold") or 1))
if "max_checks_per_proxy" in data:
out["max_checks_per_proxy"] = max(1, int(data.get("max_checks_per_proxy") or 500))
return out
async def update_settings(self, data: dict[str, Any]) -> dict[str, Any]:
settings = self.validate_settings(data)
self.config.update(settings)
# Apply changed global defaults to nodes that were still using old built-in defaults.
for p in self.proxies:
if settings.get("target_host") and p.get("target_host") in (None, "", "1.1.1.1", "8.8.8.8", "google.com"):
p["target_host"] = settings["target_host"]
if settings.get("target_port") and not p.get("target_port"):
p["target_port"] = settings["target_port"]
if settings.get("interval_seconds") and not p.get("interval_seconds"):
p["interval_seconds"] = settings["interval_seconds"]
await self.save_config()
for p in list(self.proxies):
await self.restart_proxy_task(p["id"])
return {k: self.config.get(k) for k in ["interval_seconds", "timeout_seconds", "target_host", "target_port", "fail_threshold", "recover_threshold", "max_checks_per_proxy"]}
async def upsert_proxy(self, data: dict[str, Any], proxy_id: str | None = None) -> dict[str, Any]:
existing = self.get_proxy(proxy_id) if proxy_id else None
p = self.validate_proxy(data, existing)
if proxy_id and existing:
idx = self.proxies.index(existing); self.proxies[idx] = p
else:
if self.get_proxy(p["id"]): raise ValueError("proxy id already exists")
self.proxies.append(p)
# 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)
async def delete_proxy(self, proxy_id: str) -> bool:
p = self.get_proxy(proxy_id)
if not p: return False
self.proxies.remove(p)
task = self.monitor_tasks.pop(proxy_id, None)
if task: task.cancel()
self.states.pop(proxy_id, None)
await self.save_config()
return True
async def upsert_channel(self, data: dict[str, Any], channel_id: str | None = None) -> dict[str, Any]:
existing = self.get_channel(channel_id) if channel_id else None
c = self.validate_channel(data, existing)
if channel_id and existing:
idx = self.channels.index(existing); self.channels[idx] = c
else:
if self.get_channel(c["id"]): raise ValueError("channel id already exists")
self.channels.append(c)
await self.save_config()
return c
async def delete_channel(self, channel_id: str) -> bool:
c = self.get_channel(channel_id)
if not c: return False
self.channels.remove(c)
await self.save_config()
return True
async def run_once(self, proxy: dict[str, Any], force: bool = False) -> None:
"""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"]
st = self.state_for(proxy_id)
ok, latency, err, exit_ip = await check_proxy(proxy)
await self.store.add_check(proxy_id, ok, latency, err, exit_ip, int(self.config.get("max_checks_per_proxy") or 500))
st.last_ts = now_ts(); st.last_ok = ok; st.latency_ms = latency; st.error = err; st.exit_ip = exit_ip
fail_threshold = int(proxy.get("fail_threshold", self.config.get("fail_threshold", 3)))
recover_threshold = int(proxy.get("recover_threshold", self.config.get("recover_threshold", 1)))
old = st.status
if ok:
st.ok_count += 1; st.fail_count = 0
if st.status == "down" and st.ok_count >= recover_threshold:
st.status = "up"; st.notified_down = False
msg = f"{proxy.get('name', proxy_id)} 已恢复。延迟 {latency}ms,时间 {iso()}"
await self.store.add_event(proxy_id, old, st.status, msg)
await self.notifier.send_all(f"SOCKS5恢复:{proxy.get('name', proxy_id)}", msg)
elif st.status == "unknown":
st.status = "up"
await self.store.add_event(proxy_id, old, st.status, f"{proxy.get('name', proxy_id)} 首次检测正常")
else:
st.fail_count += 1; st.ok_count = 0
if st.fail_count >= fail_threshold:
st.status = "down"
if not st.notified_down:
msg = f"{proxy.get('name', proxy_id)} 不通,连续失败 {st.fail_count} 次。错误:{err}。时间 {iso()}"
await self.store.add_event(proxy_id, old, st.status, msg)
await self.notifier.send_all(f"SOCKS5故障:{proxy.get('name', proxy_id)}", msg)
st.notified_down = True
async def monitor_loop(self, proxy_id: str) -> None:
try:
while True:
proxy = self.get_proxy(proxy_id)
if not proxy:
return
if proxy.get("enabled", True):
await self.run_once(proxy)
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:
old = self.monitor_tasks.pop(proxy_id, None)
if old:
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))
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:
if self._migrated:
await self.save_config()
for p in self.proxies:
await self.restart_proxy_task(p["id"])
def status_payload(self) -> dict[str, Any]:
items = []
for p in self.proxies:
sid = p["id"]; st = self.state_for(sid)
items.append({"proxy": public_proxy(p), "state": dataclasses.asdict(st), "recent": self.store.recent_checks(sid, 20)})
return {
"ok": True, "now": now_ts(), "now_text": iso(),
"settings": {k: self.config.get(k) for k in ["interval_seconds", "timeout_seconds", "target_host", "target_port", "fail_threshold", "recover_threshold", "max_checks_per_proxy"]},
"proxies": items,
"notifications": self.config.get("notifications", {}),
"events": self.store.recent_events(50),
"notification_logs": self.store.recent_notifications(30),
}
def get_index_html() -> str:
static_index = APP_DIR / "static" / "index.html"
if static_index.exists():
return static_index.read_text(encoding="utf-8")
return "<h1>SOCKS5 Sentinel</h1><p>static/index.html missing</p>"
def json_response(obj: Any, status: int = 200) -> tuple[int, str, bytes]:
return status, "application/json; charset=utf-8", json.dumps(obj, ensure_ascii=False).encode("utf-8")
async def read_request(reader: asyncio.StreamReader) -> tuple[str, str, dict[str, str], bytes]:
head = await reader.readuntil(b"\r\n\r\n")
text = head.decode("iso-8859-1")
lines = text.split("\r\n")
method, path, _ = lines[0].split(" ", 2)
headers = {}
for line in lines[1:]:
if ":" in line:
k, v = line.split(":", 1); headers[k.lower()] = v.strip()
length = int(headers.get("content-length") or "0")
body = await reader.readexactly(length) if length else b""
return method, path, headers, body
async def handle_http(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, app: App) -> None:
try:
method, path, headers, body = await read_request(reader)
parsed = urllib.parse.urlparse(path); p = parsed.path
status, ctype, resp = 200, "text/plain; charset=utf-8", b""
data = json.loads(body.decode("utf-8")) if body else {}
if method == "GET" and p == "/":
ctype = "text/html; charset=utf-8"; resp = get_index_html().encode("utf-8")
elif method == "GET" and p == "/api/status":
status, ctype, resp = json_response(app.status_payload())
elif method == "PUT" and p == "/api/settings":
status, ctype, resp = json_response({"ok": True, "settings": await app.update_settings(data)})
elif method == "DELETE" and p == "/api/logs":
status, ctype, resp = json_response({"ok": True, "removed": await app.store.clear_logs()})
elif method == "POST" and p == "/api/proxies":
status, ctype, resp = json_response({"ok": True, "proxy": await app.upsert_proxy(data)}, 201)
elif method == "PUT" and p.startswith("/api/proxies/"):
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)})
elif method == "DELETE" and p.startswith("/api/proxies/"):
pid = urllib.parse.unquote(p.rsplit("/", 1)[-1])
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/"):
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)
else:
await app.run_once(proxy, force=True)
status, ctype, resp = json_response({"ok": True, "state": dataclasses.asdict(app.state_for(pid))})
elif method == "POST" and p == "/api/channels":
status, ctype, resp = json_response({"ok": True, "channel": await app.upsert_channel(data)}, 201)
elif method == "PUT" and p.startswith("/api/channels/"):
cid = urllib.parse.unquote(p.rsplit("/", 1)[-1])
status, ctype, resp = json_response({"ok": True, "channel": await app.upsert_channel(data, cid)})
elif method == "DELETE" and p.startswith("/api/channels/"):
cid = urllib.parse.unquote(p.rsplit("/", 1)[-1])
status, ctype, resp = json_response({"ok": await app.delete_channel(cid)})
elif method == "POST" and p.startswith("/api/channels/") and p.endswith("/test"):
cid = urllib.parse.unquote(p.split("/")[-2])
res = await app.notifier.send_channel_id(cid, "SOCKS5监控测试", f"测试通知 {iso()}")
status, ctype, resp = json_response({"ok": bool(res.get("ok")), "result": res})
elif method == "POST" and p == "/api/notify/test":
res = await app.notifier.send_all("SOCKS5监控测试", f"测试通知 {iso()}")
status, ctype, resp = json_response({"ok": True, "results": res})
else:
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:
traceback.print_exc()
status, ctype, resp = json_response({"ok": False, "error": describe_exc(e)}, 500)
try:
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)
await writer.drain()
finally:
writer.close()
with contextlib.suppress(Exception): await writer.wait_closed()
async def main_async(args: argparse.Namespace) -> None:
app = App(Path(args.config))
await app.start_tasks()
server = await asyncio.start_server(lambda r,w: handle_http(r,w,app), args.host, args.port)
print(f"SOCKS5 monitor panel: http://{args.host}:{args.port}", flush=True)
print(f"Config: {args.config}", flush=True)
async with server:
await server.serve_forever()
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default=str(DEFAULT_CONFIG))
ap.add_argument("--host", default="0.0.0.0")
ap.add_argument("--port", type=int, default=8787)
args = ap.parse_args()
asyncio.run(main_async(args))
if __name__ == "__main__":
main()