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.
956 lines
43 KiB
Python
956 lines
43 KiB
Python
#!/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()
|