ui: simplify configuration forms

This commit is contained in:
2026-05-28 20:26:44 +08:00
commit fdd3f9cade
8 changed files with 960 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.pyc
.DS_Store
.git/
data/
config.json
*.db
*.log
+12
View File
@@ -0,0 +1,12 @@
config.json
.env
*.env
.env.*
data/
*.db
*.sqlite
*.sqlite3
*.log
__pycache__/
*.pyc
.DS_Store
+19
View File
@@ -0,0 +1,19 @@
FROM python:3.11-alpine
ENV PYTHONUNBUFFERED=1 \
SOCKS5_MONITOR_CONFIG=/app/config.json
WORKDIR /app
COPY app.py config.example.json README.md ./
COPY static ./static
RUN mkdir -p /app/data && \
cp /app/config.example.json /app/config.json
EXPOSE 8787
VOLUME ["/app/data"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/api/status', timeout=3).read()" || exit 1
CMD ["python3", "/app/app.py", "--host", "0.0.0.0", "--port", "8787", "--config", "/app/config.json"]
+128
View File
@@ -0,0 +1,128 @@
# SOCKS5 Monitor
一个轻量的 SOCKS5 代理健康监控面板,支持节点检测、状态记录和通知告警。
## 功能
- SOCKS5 连通性检测:协商、认证并 CONNECT 到目标地址
- 全局默认检测目标与节点级覆盖
- 检测间隔、超时、失败/恢复阈值可配置
- 通知渠道:Bark、PushPlus、Autman、Webhook
- SQLite 保存检测记录、事件和通知日志
- Docker / Docker Compose 部署
## 快速启动
```bash
cp config.example.json config.json
docker compose up -d
```
访问:
```text
http://127.0.0.1:8787
```
## 本地运行
```bash
python3 app.py --host 0.0.0.0 --port 8787 --config config.json
```
## 配置
默认配置见 `config.example.json`。实际运行配置写入 `config.json`,不要提交真实账号、密码、Token 或私有地址。
### 全局检测设置
```json
{
"target_host": "www.baidu.com",
"target_port": 443,
"interval_seconds": 300,
"timeout_seconds": 8,
"fail_threshold": 3,
"recover_threshold": 1
}
```
### 节点配置
```json
{
"id": "proxy-1",
"name": "Example Proxy",
"host": "127.0.0.1",
"port": 1080,
"username": "",
"password": "",
"target_host": "www.baidu.com",
"target_port": 443,
"timeout_seconds": 8,
"interval_seconds": 300,
"fail_threshold": 3,
"recover_threshold": 1,
"enabled": false
}
```
### Autman 通知
Autman 通道适配 `/m/push1` 消息推送插件,发送 JSON
```json
{
"message": "标题:...\n内容:...",
"access_token": "..."
}
```
配置示例:
```json
{
"type": "autman",
"enabled": false,
"config": {
"url": "http://example.com/m/push1",
"access_token": ""
}
}
```
## API
```text
GET /api/status
PUT /api/settings
POST /api/check/<id>
POST /api/notify/test
GET /api/proxies
POST /api/proxies
PUT /api/proxies/<id>
DELETE /api/proxies/<id>
GET /api/channels
POST /api/channels
PUT /api/channels/<id>
DELETE /api/channels/<id>
POST /api/channels/<id>/test
```
## Docker 镜像
```text
192.168.2.66:80/hermes/socks5-monitor:latest
```
## 数据目录
```text
data/monitor.db
```
## 安全
- `config.json``data/`、日志和缓存不应提交到源码仓库
- 镜像构建只包含 `config.example.json`,不包含真实运行配置
- 公开仓库中只保留占位配置
+684
View File
@@ -0,0 +1,684 @@
#!/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:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp.replace(path)
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,
"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.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 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) -> 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),
)
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 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]
@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_connect(proxy: dict[str, Any], timeout: float) -> None:
host = proxy["host"]
port = int(proxy["port"])
username = proxy.get("username") or ""
password = proxy.get("password") or ""
target_host = proxy.get("target_host", "1.1.1.1")
target_port = int(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)
finally:
writer.close()
with contextlib.suppress(Exception): await writer.wait_closed()
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)
return True, int((time.perf_counter() - start) * 1000), None, None
except Exception as e:
return False, int((time.perf_counter() - start) * 1000), str(e), None
class App:
def __init__(self, config_path: Path):
self.config_path = config_path
self.config = merge_defaults(load_json(config_path))
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 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 validate_proxy(self, data: dict[str, Any], existing: dict[str, Any] | None = None) -> dict[str, Any]:
p = dict(existing or {})
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]}")
p.setdefault("name", p["id"])
if not p.get("host"): raise ValueError("host required")
p["port"] = int(p.get("port") or 1080)
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))
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"]}
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)
await self.save_config(); await self.restart_proxy_task(p["id"])
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]) -> None:
if 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)
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:
while True:
proxy = self.get_proxy(proxy_id)
if not proxy: return
if proxy.get("enabled", True):
await self.run_once(proxy)
await asyncio.sleep(int(proxy.get("interval_seconds", self.config.get("interval_seconds", 60))))
async def restart_proxy_task(self, proxy_id: str) -> None:
old = self.monitor_tasks.pop(proxy_id, None)
if old: old.cancel()
if self.get_proxy(proxy_id):
self.monitor_tasks[proxy_id] = asyncio.create_task(self.monitor_loop(proxy_id))
async def start_tasks(self) -> None:
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"]},
"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 == "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])
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])
status, ctype, resp = json_response({"ok": await app.delete_proxy(pid)})
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)
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 Exception as e:
traceback.print_exc()
status, ctype, resp = json_response({"ok": False, "error": str(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()
+64
View File
@@ -0,0 +1,64 @@
{
"interval_seconds": 300,
"timeout_seconds": 8,
"target_host": "www.baidu.com",
"target_port": 443,
"fail_threshold": 3,
"recover_threshold": 1,
"db_path": "/app/data/monitor.db",
"proxies": [
{
"id": "example-local",
"name": "示例 SOCKS5(默认停用)",
"host": "127.0.0.1",
"port": 1080,
"username": "",
"password": "",
"target_host": "www.baidu.com",
"target_port": 443,
"timeout_seconds": 8,
"interval_seconds": 300,
"fail_threshold": 3,
"recover_threshold": 1,
"enabled": false
}
],
"notifications": {
"enabled": true,
"timeout_seconds": 10,
"channels": [
{
"id": "ch-bark",
"type": "bark",
"name": "Bark",
"enabled": false,
"config": {
"server": "https://api.day.app",
"key": "填你的BarkKey",
"group": "SOCKS5监控"
}
},
{
"id": "ch-pushplus",
"type": "pushplus",
"name": "PushPlus",
"enabled": false,
"config": {
"token": "填你的PushPlusToken",
"template": "txt",
"topic": ""
}
},
{
"id": "ch-autman",
"type": "autman",
"name": "Autman",
"enabled": false,
"config": {
"url": "http://ip:port/m/push1",
"access_token": "填你的Autman鉴权token"
}
}
]
}
}
+10
View File
@@ -0,0 +1,10 @@
services:
socks5-monitor:
image: 192.168.2.66:80/hermes/socks5-monitor:latest
container_name: socks5-monitor
restart: unless-stopped
ports:
- "8787:8787"
volumes:
- ./config.json:/app/config.json
- ./data:/app/data
+35
View File
File diff suppressed because one or more lines are too long