From 6f24c45ba7c2be14c50cfdc11e256a64f9d8d63b Mon Sep 17 00:00:00 2001 From: siruoren Date: Mon, 20 Jul 2026 12:37:17 +0800 Subject: [PATCH 01/18] =?UTF-8?q?feat:=20=E5=8F=91=E5=B8=83v2.0.0=E7=89=88?= =?UTF-8?q?=E6=9C=AC=EF=BC=8C=E6=96=B0=E5=A2=9E=E8=8A=82=E7=82=B9=E6=A3=80?= =?UTF-8?q?=E6=B5=8B=E9=87=8D=E8=AF=95=E4=B8=8E=E6=89=B9=E9=87=8F=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 升级项目版本号到v2.0.0 2. 新增节点检测失败重试配置,支持配置重试次数 3. 重构检测逻辑,提取单次检测方法并实现重试机制 4. 优化HTTP检测逻辑,轮询多个检测URL并正确处理状态码 5. 新增一键清除所有节点的API接口 6. 新增check_retries配置项并同步到各模块 --- README.md | 2 +- app/__init__.py | 1 + app/checker.py | 107 +++++++++++++++++++++++++++++---------------- app/config.py | 2 + app/database.py | 6 +++ app/routers/api.py | 8 ++++ app/scheduler.py | 3 ++ config.yaml | 1 + 8 files changed, 92 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index b2e7173..ef661a8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# NetHub v1.0.0 +# NetHub v2.0.0 自动获取、检测、维护节点池,提供 Web 管理界面和订阅链接输出。 diff --git a/app/__init__.py b/app/__init__.py index f213a10..1c8cd70 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -120,6 +120,7 @@ async def startup(): http_port=_config.check.http_port, check_mode=_config.check.check_mode, kernel_path=_config.check.kernel_path, + check_retries=_config.check.check_retries, ) # 初始化调度器 diff --git a/app/checker.py b/app/checker.py index 5968631..2106575 100644 --- a/app/checker.py +++ b/app/checker.py @@ -66,7 +66,8 @@ class ProxyChecker: def __init__(self, check_urls: list[str], timeout: float, max_concurrent: int, socks_port: int = 0, http_port: int = 0, - check_mode: str = "auto", kernel_path: str = "xray"): + check_mode: str = "auto", kernel_path: str = "xray", + check_retries: int = 2): """ Args: check_urls: HTTP 检测目标 URL 列表 @@ -76,6 +77,7 @@ def __init__(self, check_urls: list[str], timeout: float, max_concurrent: int, http_port: 本地 HTTP 转发端口(ConnectivityMonitor 使用) check_mode: 检测模式 "http" / "tcp" / "auto" kernel_path: 内核可执行文件路径 + check_retries: 单次检测失败后重试次数(提升有效性) """ self.check_urls = check_urls or DEFAULT_CHECK_URLS self.timeout = timeout @@ -84,6 +86,7 @@ def __init__(self, check_urls: list[str], timeout: float, max_concurrent: int, self.http_port = http_port if http_port > 0 else DEFAULT_HTTP_PORT self.check_mode = check_mode self.kernel_path = self._resolve_kernel_path(kernel_path) + self.check_retries = max(check_retries, 0) @staticmethod def _resolve_kernel_path(kernel_path: str) -> str: @@ -103,27 +106,50 @@ async def check_proxy(self, link: str) -> float | None: - auto: 优先内核转发,失败则回退 TCP/TLS - http: 仅通过内核转发检测 - tcp: 仅直连 TCP/TLS 检测 + + 支持重试:首次失败后重试 check_retries 次,取最快成功的延迟值 """ async with self.semaphore: - if self.check_mode == "tcp": - conn_info = self._parse_link(link) - if not conn_info: - return None - return await self._check_tcp_tls(conn_info) + best_latency = None - # http / auto 模式:优先内核转发 - if self.kernel_path: - latency = await self._check_via_kernel(link) + for attempt in range(1 + self.check_retries): + latency = await self._check_once(link) if latency is not None: - return latency - - # auto 回退或 http 模式内核不可用时回退 TCP - if self.check_mode == "auto" or not self.kernel_path: - conn_info = self._parse_link(link) - if conn_info: - return await self._check_tcp_tls(conn_info) + # 成功则取最快延迟 + if best_latency is None or latency < best_latency: + best_latency = latency + # 首次成功就不再重试 + if attempt == 0: + break + # 重试成功也直接返回(已有可用延迟) + break + # 首次失败,短暂等待后重试 + if attempt < self.check_retries: + await asyncio.sleep(0.5) + + return best_latency + + async def _check_once(self, link: str) -> float | None: + """单次检测节点延迟(不含重试逻辑)""" + if self.check_mode == "tcp": + conn_info = self._parse_link(link) + if not conn_info: + return None + return await self._check_tcp_tls(conn_info) + + # http / auto 模式:优先内核转发 + if self.kernel_path: + latency = await self._check_via_kernel(link) + if latency is not None: + return latency + + # auto 回退或 http 模式内核不可用时回退 TCP + if self.check_mode == "auto" or not self.kernel_path: + conn_info = self._parse_link(link) + if conn_info: + return await self._check_tcp_tls(conn_info) - return None + return None # ---- 内核转发检测 ---- @@ -200,26 +226,33 @@ async def _check_via_kernel(self, link: str) -> float | None: pass async def _http_request_via_proxy(self, port: int) -> float | None: - """通过本地 HTTP 转发端口发送 HTTP 请求检测延迟""" - check_url = self.check_urls[0] if self.check_urls else DEFAULT_CHECK_URLS[0] - start = time.monotonic() - try: - connector = aiohttp.TCPConnector(limit=1, force_close=True) - proxy_url = f"http://127.0.0.1:{port}" - async with aiohttp.ClientSession(connector=connector) as session: - async with session.head( - check_url, - proxy=proxy_url, - timeout=aiohttp.ClientTimeout(total=self.timeout), - allow_redirects=True, - ssl=False, - ) as resp: - elapsed = (time.monotonic() - start) * 1000 - if resp.status < 500: - return round(elapsed, 1) - return None - except Exception: - return None + """通过本地 HTTP 转发端口发送 HTTP 请求检测延迟 + + 轮询多个检测 URL,任意一个返回 2xx 即判定可用; + 仅 2xx 状态码视为真正可达,3xx/4xx/5xx 均判定失败 + """ + urls = self.check_urls if self.check_urls else DEFAULT_CHECK_URLS + proxy_url = f"http://127.0.0.1:{port}" + + for check_url in urls: + start = time.monotonic() + try: + connector = aiohttp.TCPConnector(limit=1, force_close=True) + async with aiohttp.ClientSession(connector=connector) as session: + async with session.head( + check_url, + proxy=proxy_url, + timeout=aiohttp.ClientTimeout(total=self.timeout), + allow_redirects=True, + ssl=False, + ) as resp: + if 200 <= resp.status < 300: + elapsed = (time.monotonic() - start) * 1000 + return round(elapsed, 1) + # 非 2xx 继续尝试下一个 URL + except Exception: + continue + return None @staticmethod def _find_free_port() -> int: diff --git a/app/config.py b/app/config.py index 9d6f107..30c874e 100644 --- a/app/config.py +++ b/app/config.py @@ -27,6 +27,7 @@ class CheckConfig: socks_port: int = 1080 # 本地转发端口(ConnectivityMonitor 使用) http_port: int = 1081 # 本地 HTTP 转发端口 kernel_path: str = "xray" # 内核可执行文件路径 + check_retries: int = 2 # 单次检测失败后重试次数 @dataclass @@ -83,6 +84,7 @@ def load_config(path: str = "config.yaml") -> AppConfig: "socks_port": 1080, "http_port": 1081, "kernel_path": "xray", + "check_retries": 2, }, "scheduler": { "fetch_interval": 3600, diff --git a/app/database.py b/app/database.py index 766f651..1126e5c 100644 --- a/app/database.py +++ b/app/database.py @@ -234,6 +234,12 @@ async def delete_proxy(self, proxy_id: int) -> None: await self._db.execute("DELETE FROM proxies WHERE id = ?", (proxy_id,)) await self._db.commit() + async def delete_all_proxies(self) -> int: + """删除所有节点,返回删除数量""" + cursor = await self._db.execute("DELETE FROM proxies") + await self._db.commit() + return cursor.rowcount + async def delete_proxies_by_fail_count(self, max_fail: int) -> int: """删除 fail_count >= max_fail 的节点,返回删除数量""" cursor = await self._db.execute( diff --git a/app/routers/api.py b/app/routers/api.py index 7f02f15..8a536c4 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -46,6 +46,14 @@ async def delete_proxy(proxy_id: int): return {"message": "deleted"} +@router.delete("/proxies") +async def delete_all_proxies(): + """一键清除数据库内所有节点""" + db = get_db() + count = await db.delete_all_proxies() + return {"message": "deleted", "count": count} + + @router.get("/subscription/v2ray") async def v2ray_subscription(): """获取纯文本格式订阅(每行一条原始代理 URI) diff --git a/app/scheduler.py b/app/scheduler.py index 40888d0..68bc2aa 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -145,6 +145,7 @@ async def _fetch_single_subscription(self, sub_id: int) -> None: http_port=self.checker.http_port, check_mode=self.checker.check_mode, kernel_path=self.checker.kernel_path, + check_retries=self.config.check.check_retries, ) links = [p.link for p in proxies] results = await sub_checker.check_batch(links) @@ -308,6 +309,7 @@ async def verify_subscription_proxies(self, sub_id: int) -> None: http_port=self.checker.http_port, check_mode=self.checker.check_mode, kernel_path=self.checker.kernel_path, + check_retries=self.config.check.check_retries, ) links = [p.link for p in proxies] results = await sub_checker.check_batch(links) @@ -429,6 +431,7 @@ async def _fetch_single_instance_source(self, source_id: int) -> None: http_port=self.checker.http_port, check_mode=self.checker.check_mode, kernel_path=self.checker.kernel_path, + check_retries=self.config.check.check_retries, ) links = [p.link for p in proxies] results = await sub_checker.check_batch(links) diff --git a/config.yaml b/config.yaml index a51ac21..456a387 100644 --- a/config.yaml +++ b/config.yaml @@ -14,6 +14,7 @@ check: socks_port: 1080 # 本地转发端口(ConnectivityMonitor使用) http_port: 1081 # 本地HTTP转发端口 kernel_path: "xray" # 内核可执行文件路径 + check_retries: 2 # 单次检测失败后重试次数 scheduler: fetch_interval: 3600 From 9296c7ece583529febdd94bc12e6274c4978c227 Mon Sep 17 00:00:00 2001 From: siruoren Date: Mon, 20 Jul 2026 12:40:38 +0800 Subject: [PATCH 02/18] feat(index): add button to clear all proxy nodes add a one-click clear all saved proxy nodes feature with confirmation prompt --- app/templates/index.html | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/templates/index.html b/app/templates/index.html index ecce0fa..bda7103 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -293,6 +293,10 @@
可用节点
验证所有订阅 + + @@ -738,6 +742,20 @@ } catch (e) { alert('删除失败: ' + e.message); } } +async function deleteAllProxies() { + if (!confirm('确定清除数据库内所有已保存节点?此操作不可恢复!')) return; + try { + const resp = await fetch('/api/proxies', { method: 'DELETE' }); + const data = await resp.json(); + if (resp.ok) { + alert('已清除 ' + data.count + ' 个节点'); + location.reload(); + } else { + alert('清除失败'); + } + } catch (e) { alert('请求失败: ' + e.message); } +} + function refreshPage() { location.reload(); } // 表格排序 From 03eda4f893544e984c1e7c1c9891b247fab67c5e Mon Sep 17 00:00:00 2001 From: siruoren Date: Mon, 20 Jul 2026 14:50:16 +0800 Subject: [PATCH 03/18] feat: add config export/import feature and fix timezone display - add export/import config buttons on frontend page - implement backend config export and import API endpoints - change all UTC timestamps to use Asia/Shanghai timezone (UTC+8) --- app/database.py | 16 +++--- app/routers/api.py | 117 +++++++++++++++++++++++++++++++++++++++ app/scheduler.py | 10 ++-- app/templates/index.html | 37 +++++++++++++ 4 files changed, 167 insertions(+), 13 deletions(-) diff --git a/app/database.py b/app/database.py index 1126e5c..5de8ede 100644 --- a/app/database.py +++ b/app/database.py @@ -146,7 +146,7 @@ def _row_to_record(self, row: aiosqlite.Row) -> ProxyDBRecord: async def insert_proxy(self, proxy: ProxyInfo, latency_ms: float, source: str) -> bool: """插入新节点,link 唯一约束,重复则忽略。返回是否插入成功""" - now = datetime.now(timezone.utc).isoformat() + now = datetime.now(timezone(timedelta(hours=8))).isoformat() try: cursor = await self._db.execute( """INSERT OR IGNORE INTO proxies @@ -163,7 +163,7 @@ async def insert_proxy(self, proxy: ProxyInfo, latency_ms: float, source: str) - async def update_latency(self, proxy_id: int, latency_ms: float) -> None: """更新延迟并重置 fail_count""" - now = datetime.now(timezone.utc).isoformat() + now = datetime.now(timezone(timedelta(hours=8))).isoformat() await self._db.execute( """UPDATE proxies SET latency_ms = ?, fail_count = 0, @@ -181,7 +181,7 @@ async def batch_update_latency(self, updates: list[tuple[int, float]]) -> None: """ if not updates: return - now = datetime.now(timezone.utc).isoformat() + now = datetime.now(timezone(timedelta(hours=8))).isoformat() await self._db.executemany( """UPDATE proxies SET latency_ms = ?, fail_count = 0, @@ -195,7 +195,7 @@ async def batch_increment_fail(self, proxy_ids: list[int]) -> None: """批量 fail_count + 1,单次 commit""" if not proxy_ids: return - now = datetime.now(timezone.utc).isoformat() + now = datetime.now(timezone(timedelta(hours=8))).isoformat() await self._db.executemany( """UPDATE proxies SET fail_count = fail_count + 1, last_check_time = ? @@ -206,7 +206,7 @@ async def batch_increment_fail(self, proxy_ids: list[int]) -> None: async def increment_fail(self, proxy_id: int) -> int: """fail_count + 1,返回当前值""" - now = datetime.now(timezone.utc).isoformat() + now = datetime.now(timezone(timedelta(hours=8))).isoformat() await self._db.execute( """UPDATE proxies SET fail_count = fail_count + 1, last_check_time = ? @@ -263,7 +263,7 @@ async def delete_instance_proxies_stale(self, days: int) -> int: 条件:source 以 'instance:' 开头 且 fail_count > 0 且 (last_success_time 为空 或 last_success_time 距今超过 days 天) """ - cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() + cutoff = (datetime.now(timezone(timedelta(hours=8))) - timedelta(days=days)).isoformat() cursor = await self._db.execute( """DELETE FROM proxies WHERE source LIKE 'instance:%' @@ -349,7 +349,7 @@ async def add_subscription(self, url: str, crontab: str = "0 * * * *", max_retries: int = 3, max_concurrent: int = 50, enabled: bool = True) -> SubscriptionRecord | None: """添加订阅源""" - now = datetime.now(timezone.utc).isoformat() + now = datetime.now(timezone(timedelta(hours=8))).isoformat() try: cursor = await self._db.execute( """INSERT INTO subscriptions (url, crontab, latency_threshold, max_retries, max_concurrent, enabled, created_at) @@ -605,7 +605,7 @@ async def add_instance_source(self, base_url: str, username: str, password: str, max_concurrent: int = 50, enabled: bool = True) -> InstanceSourceRecord | None: """添加服务实例源""" - now = datetime.now(timezone.utc).isoformat() + now = datetime.now(timezone(timedelta(hours=8))).isoformat() try: cursor = await self._db.execute( """INSERT INTO instance_sources diff --git a/app/routers/api.py b/app/routers/api.py index 8a536c4..fba0cdb 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -543,3 +543,120 @@ def _instance_source_to_dict(source) -> dict: "total_count": source.total_count, "fetch_status": source.fetch_status, } + + +# ---- 配置导出/导入 ---- + +@router.get("/config/export") +async def export_config(): + """导出订阅源和服务实例源配置为 JSON 文件""" + db = get_db() + subs = await db.get_all_subscriptions() + sources = await db.get_all_instance_sources() + + config = { + "version": 1, + "subscriptions": [ + { + "url": s.url, + "crontab": s.crontab, + "latency_threshold": s.latency_threshold, + "max_retries": s.max_retries, + "max_concurrent": s.max_concurrent, + "enabled": s.enabled, + } + for s in subs + ], + "instance_sources": [ + { + "base_url": s.base_url, + "username": s.username, + "password": s.password, + "crontab": s.crontab, + "latency_threshold": s.latency_threshold, + "max_concurrent": s.max_concurrent, + "enabled": s.enabled, + } + for s in sources + ], + } + + from fastapi.responses import Response + import json + content = json.dumps(config, ensure_ascii=False, indent=2) + return Response( + content=content, + media_type="application/json; charset=utf-8", + headers={ + "Content-Disposition": 'attachment; filename="nethub_config.json"', + }, + ) + + +class ConfigImportRequest(BaseModel): + """配置导入请求体""" + config: dict + + +@router.post("/config/import") +async def import_config(req: ConfigImportRequest): + """从 JSON 导入订阅源和服务实例源配置(去重,不覆盖已有)""" + db = get_db() + scheduler = get_scheduler() + + config = req.config + sub_added = 0 + sub_dup = 0 + inst_added = 0 + inst_dup = 0 + + # 导入订阅源 + for item in config.get("subscriptions", []): + url = item.get("url", "").strip() + if not url: + continue + existing = await db.get_subscription_by_url(url) + if existing: + sub_dup += 1 + continue + sub = await db.add_subscription( + url=url, + crontab=item.get("crontab", "0 * * * *"), + latency_threshold=item.get("latency_threshold", 1500.0), + max_retries=item.get("max_retries", 3), + max_concurrent=item.get("max_concurrent", 50), + enabled=item.get("enabled", True), + ) + if sub: + scheduler._add_subscription_job(sub) + sub_added += 1 + + # 导入服务实例源 + for item in config.get("instance_sources", []): + base_url = item.get("base_url", "").strip() + if not base_url: + continue + existing = await db.get_instance_source_by_url(base_url) + if existing: + inst_dup += 1 + continue + source = await db.add_instance_source( + base_url=base_url, + username=item.get("username", ""), + password=item.get("password", ""), + crontab=item.get("crontab", "*/10 * * * *"), + latency_threshold=item.get("latency_threshold", 1500.0), + max_concurrent=item.get("max_concurrent", 50), + enabled=item.get("enabled", True), + ) + if source: + scheduler._add_instance_source_job(source) + inst_added += 1 + + return { + "message": f"导入完成: 订阅源新增 {sub_added} 个(跳过 {sub_dup} 个重复), 实例源新增 {inst_added} 个(跳过 {inst_dup} 个重复)", + "subscription_added": sub_added, + "subscription_duplicate": sub_dup, + "instance_added": inst_added, + "instance_duplicate": inst_dup, + } diff --git a/app/scheduler.py b/app/scheduler.py index 68bc2aa..e96e4b8 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -3,7 +3,7 @@ import asyncio import logging -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger @@ -209,7 +209,7 @@ async def _fetch_single_subscription(self, sub_id: int) -> None: await self.db.update_total_count(sub_id, len(proxies)) await self.db.update_fetch_status(sub_id, "success") - self._last_fetch_time = datetime.now(timezone.utc).isoformat() + self._last_fetch_time = datetime.now(timezone(timedelta(hours=8))).isoformat() logger.info("订阅 #%d 拉取完成: 新增 %d, 更新 %d, 跳过 %d, 总解析 %d", sub.id, added, updated, skipped, len(proxies)) except Exception as e: @@ -280,7 +280,7 @@ async def verify_stored_proxies(self) -> None: if fail_ids: await self.db.batch_increment_fail(fail_ids) - self._last_verify_time = datetime.now(timezone.utc).isoformat() + self._last_verify_time = datetime.now(timezone(timedelta(hours=8))).isoformat() logger.info("验证完成: 成功 %d, 失败 %d", len(latency_updates), len(fail_ids)) except Exception as e: logger.error("验证任务异常: %s", e, exc_info=True) @@ -329,7 +329,7 @@ async def verify_subscription_proxies(self, sub_id: int) -> None: if fail_ids: await self.db.batch_increment_fail(fail_ids) - self._last_verify_time = datetime.now(timezone.utc).isoformat() + self._last_verify_time = datetime.now(timezone(timedelta(hours=8))).isoformat() logger.info("订阅 #%d 验证完成: 成功 %d, 失败 %d", sub_id, len(latency_updates), len(fail_ids)) # ---- 服务实例源管理 ---- @@ -460,7 +460,7 @@ async def _fetch_single_instance_source(self, source_id: int) -> None: await self.db.update_instance_total_count(source_id, len(proxies)) await self.db.update_instance_fetch_status(source_id, "success") - self._last_fetch_time = datetime.now(timezone.utc).isoformat() + self._last_fetch_time = datetime.now(timezone(timedelta(hours=8))).isoformat() logger.info("实例源 #%d 获取完成: 入库 %d(新增%d), 检测成功 %d, 失败 %d, 总 %d", source.id, added + existed, added, len(latency_updates), len(fail_ids), len(proxies)) diff --git a/app/templates/index.html b/app/templates/index.html index bda7103..e99bbed 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -297,6 +297,14 @@
可用节点
清除所有节点 + + + + @@ -756,6 +764,35 @@ } catch (e) { alert('请求失败: ' + e.message); } } +function exportConfig() { + window.location.href = '/api/config/export'; +} + +async function importConfig(input) { + const file = input.files[0]; + if (!file) return; + try { + const text = await file.text(); + const config = JSON.parse(text); + const resp = await fetch('/api/config/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config: config }), + }); + const data = await resp.json(); + if (resp.ok) { + alert(data.message); + location.reload(); + } else { + alert('导入失败: ' + (data.detail || '未知错误')); + } + } catch (e) { + alert('导入失败: ' + e.message); + } + // 重置 file input 以便重复选择同一文件 + input.value = ''; +} + function refreshPage() { location.reload(); } // 表格排序 From 3e68c86493ede1e18959ad199cbff422d3702139 Mon Sep 17 00:00:00 2001 From: siruoren Date: Mon, 20 Jul 2026 14:51:41 +0800 Subject: [PATCH 04/18] =?UTF-8?q?feat(api):=20=E7=BB=99=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E7=9A=84=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6=E5=90=8D=E5=8A=A0?= =?UTF-8?q?=E4=B8=8A=E6=97=B6=E9=97=B4=E6=88=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修改导出配置文件的下载文件名,添加当前北京时间作为后缀,方便区分不同版本的配置导出文件 --- app/routers/api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/routers/api.py b/app/routers/api.py index fba0cdb..629c20d 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -582,13 +582,15 @@ async def export_config(): } from fastapi.responses import Response + from datetime import datetime, timedelta, timezone import json content = json.dumps(config, ensure_ascii=False, indent=2) + ts = datetime.now(timezone(timedelta(hours=8))).strftime("%Y%m%d_%H%M%S") return Response( content=content, media_type="application/json; charset=utf-8", headers={ - "Content-Disposition": 'attachment; filename="nethub_config.json"', + "Content-Disposition": f'attachment; filename="nethub_config_{ts}.json"', }, ) From 897dff33283f898d2e936d988275a468f2a0246a Mon Sep 17 00:00:00 2001 From: siruoren Date: Mon, 20 Jul 2026 16:14:15 +0800 Subject: [PATCH 05/18] =?UTF-8?q?refactor(checker):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E6=A3=80=E6=B5=8B=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=93=8D=E5=BA=94=E9=AA=8C=E8=AF=81=E5=92=8C?= =?UTF-8?q?=E5=8D=8E=E4=B8=BA=E6=A3=80=E6=B5=8B=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增华为云连通性检测URL到默认检测列表 2. 实现统一的响应验证规则,过滤劫持和认证门户页面 3. 将HEAD请求改为GET请求并限制响应体读取大小 4. 完善TCP/TLS检测的注释和异常处理逻辑 --- app/checker.py | 65 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 53 insertions(+), 12 deletions(-) diff --git a/app/checker.py b/app/checker.py index 2106575..7e9d48d 100644 --- a/app/checker.py +++ b/app/checker.py @@ -39,8 +39,33 @@ "https://www.gstatic.com/generate_204", "https://cp.cloudflare.com/", "https://www.apple.com/library/test/success.html", + "https://connectivitycheck.platform.hicloud.com/generate_204", ] +# 检测 URL 预期响应验证规则 +CHECK_URL_VALIDATORS = { + "generate_204": lambda url, status, body: status == 204 or (status == 200 and len(body) == 0), + "cp.cloudflare.com": lambda url, status, body: status == 200 and len(body) > 0, + "success.html": lambda url, status, body: status == 200 and len(body) > 0, + "connectivitycheck": lambda url, status, body: status == 204 or status == 200, +} + + +def _validate_check_response(url: str, status: int, body: bytes) -> bool: + """验证检测响应是否为有效响应(排除劫持页面/认证门户) + + 默认规则:2xx 状态码即通过 + 特定 URL 有额外验证规则 + """ + if not (200 <= status < 300): + return False + # 逐条匹配验证规则 + for keyword, validator in CHECK_URL_VALIDATORS.items(): + if keyword in url: + return validator(url, status, body) + # 通用规则:2xx + 有响应体或 204 + return status == 204 or len(body) > 0 + # 本地转发端口默认值 DEFAULT_SOCKS_PORT = 1080 DEFAULT_HTTP_PORT = 1081 @@ -228,8 +253,8 @@ async def _check_via_kernel(self, link: str) -> float | None: async def _http_request_via_proxy(self, port: int) -> float | None: """通过本地 HTTP 转发端口发送 HTTP 请求检测延迟 - 轮询多个检测 URL,任意一个返回 2xx 即判定可用; - 仅 2xx 状态码视为真正可达,3xx/4xx/5xx 均判定失败 + 轮询多个检测 URL,使用 GET 请求并验证响应内容; + 确保节点能真正访问目标页面,排除劫持页面和认证门户 """ urls = self.check_urls if self.check_urls else DEFAULT_CHECK_URLS proxy_url = f"http://127.0.0.1:{port}" @@ -239,17 +264,20 @@ async def _http_request_via_proxy(self, port: int) -> float | None: try: connector = aiohttp.TCPConnector(limit=1, force_close=True) async with aiohttp.ClientSession(connector=connector) as session: - async with session.head( + async with session.get( check_url, proxy=proxy_url, timeout=aiohttp.ClientTimeout(total=self.timeout), allow_redirects=True, ssl=False, ) as resp: - if 200 <= resp.status < 300: - elapsed = (time.monotonic() - start) * 1000 + # 读取响应体(限制最大 4KB 防止下载大文件) + body = await resp.content.read(4096) + elapsed = (time.monotonic() - start) * 1000 + + if _validate_check_response(check_url, resp.status, body): return round(elapsed, 1) - # 非 2xx 继续尝试下一个 URL + # 验证不通过,尝试下一个 URL except Exception: continue return None @@ -715,7 +743,12 @@ def _http_to_xray(link: str) -> dict | None: # ---- TCP/TLS 直连检测(回退方案) ---- async def _check_tcp_tls(self, info: ProxyConnInfo) -> float | None: - """直接 TCP/TLS 连接到节点服务器检测延迟(回退方案)""" + """直接 TCP/TLS 连接到节点服务器检测延迟(回退方案) + + 注意:此方法仅验证服务器端口可达和 TLS 握手成功, + 不代表节点能真正转发流量到目标网站。 + 连接被拒绝视为不可用,不返回延迟值。 + """ host = info.host if not self._is_ip(host): resolved = await self._resolve_host(host) @@ -730,14 +763,19 @@ async def _check_tcp_tls(self, info: ProxyConnInfo) -> float | None: ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE - _, writer = await asyncio.wait_for( + reader, writer = await asyncio.wait_for( asyncio.open_connection( - host, info.port, ssl=ctx, server_hostname=host + host, info.port, ssl=ctx, server_hostname=info.host ), timeout=self.timeout, ) + # 验证 TLS 握手完成:检查 SSL 对象状态 + ssl_obj = writer.get_extra_info("ssl_object") + if ssl_obj is None: + writer.close() + return None else: - _, writer = await asyncio.wait_for( + reader, writer = await asyncio.wait_for( asyncio.open_connection(host, info.port), timeout=self.timeout, ) @@ -750,8 +788,11 @@ async def _check_tcp_tls(self, info: ProxyConnInfo) -> float | None: pass return round(elapsed, 1) except ConnectionRefusedError: - elapsed = (time.monotonic() - start) * 1000 - return round(elapsed, 1) + # 连接被拒绝 = 不可用,不返回延迟值 + return None + except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError): + # 连接被重置/中断 = 不可用 + return None except Exception: return None From 5d083c6e5fe2deb70d2e889b21e2d62917689334 Mon Sep 17 00:00:00 2001 From: siruoren Date: Mon, 20 Jul 2026 16:30:07 +0800 Subject: [PATCH 06/18] =?UTF-8?q?refactor:=20=E8=B0=83=E6=95=B4=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E7=AD=96=E7=95=A5=E4=B8=8E=E7=95=8C=E9=9D=A2=E6=96=87?= =?UTF-8?q?=E6=9C=AC=EF=BC=8C=E6=B7=BB=E5=8A=A0=E4=BB=BB=E5=8A=A1=E8=B0=83?= =?UTF-8?q?=E5=BA=A6=E9=9A=8F=E6=9C=BA=E6=8A=96=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 将前端页面的"验证所有订阅"按钮文本改为"验证所有节点" 2. 替换按天归档的日志处理器为单文件日志,不再保留历史日志 3. 为定时任务添加随机抖动参数,避免所有任务同时执行 --- app/__init__.py | 9 ++------- app/scheduler.py | 3 +++ app/templates/index.html | 2 +- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 1c8cd70..0371706 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -63,19 +63,14 @@ def create_app(config_path: str = "config.yaml") -> FastAPI: console_handler.setFormatter(log_fmt) root_logger.addHandler(console_handler) - # 文件:按天归档,保留7天 + # 文件:只保留当前日志,不归档 log_dir = Path("logs") log_dir.mkdir(exist_ok=True) - from logging.handlers import TimedRotatingFileHandler - file_handler = TimedRotatingFileHandler( + file_handler = logging.FileHandler( filename=str(log_dir / "proxy_pool.log"), - when="midnight", - interval=1, - backupCount=7, encoding="utf-8", ) file_handler.setFormatter(log_fmt) - file_handler.suffix = "%Y-%m-%d" root_logger.addHandler(file_handler) # 初始化组件 diff --git a/app/scheduler.py b/app/scheduler.py index e96e4b8..fba6a51 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -3,6 +3,7 @@ import asyncio import logging +import random from datetime import datetime, timedelta, timezone from apscheduler.schedulers.asyncio import AsyncIOScheduler @@ -87,6 +88,7 @@ def _add_subscription_job(self, sub) -> None: trigger = CronTrigger( minute=parts[0], hour=parts[1], day=parts[2], month=parts[3], day_of_week=parts[4], + jitter=random.randint(0, 600), # 随机延迟0~600秒(10分钟内) ) self.scheduler.add_job( self._fetch_single_subscription, @@ -349,6 +351,7 @@ def _add_instance_source_job(self, source) -> None: trigger = CronTrigger( minute=parts[0], hour=parts[1], day=parts[2], month=parts[3], day_of_week=parts[4], + jitter=random.randint(0, 600), # 随机延迟0~600秒(10分钟内) ) self.scheduler.add_job( self._fetch_single_instance_source, diff --git a/app/templates/index.html b/app/templates/index.html index e99bbed..e073b57 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -290,7 +290,7 @@
可用节点
拉取所有订阅 - - {% endfor %} - {% for inst in available_insts %} - {% endfor %}
{% for sub in available_subs %} -
+
{% endfor %} - {% for inst in available_insts %} - {% set inst_source_tag = 'instance:' + inst.base_url %} -
-
- -
-
- - - - - - - - - - - - - - - {% for proxy in grouped.get(inst_source_tag, []) %} - - - - - - - - - - - {% endfor %} - {% if not grouped.get(inst_source_tag) %} - - {% endif %} - -
#名称协议地址端口延迟 入库时间 操作
{{ loop.index }}{{ proxy.name[:30] }}{% if proxy.name|length > 30 %}...{% endif %}{{ proxy.protocol }}{{ proxy.address }}{{ proxy.port }} - - {{ "%.0f"|format(proxy.latency_ms) }} ms - - {{ proxy.created_at[:19] if proxy.created_at else '-' }} - -
暂无可用节点,请先获取此实例源
-
-
- {% endfor %}
{% else %}
From 95388257ae9a597e1ffd61e063ae0435a11ec78f Mon Sep 17 00:00:00 2001 From: siruoren Date: Tue, 21 Jul 2026 09:38:32 +0800 Subject: [PATCH 14/18] =?UTF-8?q?style(index=20template):=20=E7=AE=80?= =?UTF-8?q?=E5=8C=96=E7=BB=9F=E8=AE=A1=E5=8D=A1=E7=89=87=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E6=96=87=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将总订阅条目数简化为订阅条目数,优化页面展示文字简洁度 --- app/templates/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/templates/index.html b/app/templates/index.html index 02c8c25..ac37b2b 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -8,7 +8,7 @@
-
总订阅条目数
+
订阅条目数

{{ stats.total }}

From efd7dba3d7d64ae72ebd1d74f00b88559f448a6b Mon Sep 17 00:00:00 2001 From: siruoren Date: Tue, 21 Jul 2026 09:47:09 +0800 Subject: [PATCH 15/18] =?UTF-8?q?chore:=20=E7=A7=BB=E9=99=A4=E5=AF=B9=20so?= =?UTF-8?q?cks4/socks4a=20=E5=8D=8F=E8=AE=AE=E7=9A=84=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 更新解析器、校验器仅处理 socks5 协议链接 2. 在调度阶段自动清理已有 socks4 节点 3. 调整链接生成与转换逻辑适配协议变更 --- app/checker.py | 12 +++++++----- app/generator.py | 11 ++++++----- app/parser.py | 9 +++++---- app/scheduler.py | 7 +++++++ 4 files changed, 25 insertions(+), 14 deletions(-) diff --git a/app/checker.py b/app/checker.py index 7e9d48d..7898b6e 100644 --- a/app/checker.py +++ b/app/checker.py @@ -326,7 +326,7 @@ def _link_to_xray_outbound(link: str) -> dict | None: return ProxyChecker._ss_to_xray(link) elif link.startswith("hysteria2://") or link.startswith("hy2://"): return ProxyChecker._hysteria2_to_xray(link) - elif link.startswith(("socks5://", "socks4://", "socks4a://")): + elif link.startswith("socks5://"): return ProxyChecker._socks_to_xray(link) elif link.startswith(("http://", "https://")) and "#" in link: return ProxyChecker._http_to_xray(link) @@ -696,7 +696,7 @@ def _hysteria2_to_xray(link: str) -> dict | None: @staticmethod def _socks_to_xray(link: str) -> dict | None: - """socks5:// / socks4:// / socks4a:// 分享链接转内核 outbound""" + """socks5:// 分享链接转内核 outbound""" try: parsed = urlparse(link) address = parsed.hostname or "" @@ -858,7 +858,7 @@ def _parse_link(self, link: str) -> ProxyConnInfo | None: return self._parse_ss(link) elif link.startswith("hysteria2://") or link.startswith("hy2://"): return self._parse_hysteria2(link) - elif link.startswith(("socks5://", "socks4://", "socks4a://")): + elif link.startswith("socks5://"): return self._parse_socks(link) elif link.startswith(("http://", "https://")) and "#" in link: return self._parse_http_proxy(link) @@ -977,15 +977,17 @@ def _parse_hysteria2(self, link: str) -> ProxyConnInfo | None: def _parse_socks(self, link: str) -> ProxyConnInfo | None: try: + # socks4/socks4a 不再支持 + if link.lower().startswith(("socks4://", "socks4a://")): + return None parsed = urlparse(link) address = parsed.hostname or "" port = parsed.port or 0 if not address or not port: return None - protocol = "socks5" if link.lower().startswith("socks5://") else "socks4" return ProxyConnInfo( host=address, port=int(port), - use_tls=False, protocol=protocol, + use_tls=False, protocol="socks5", ) except Exception: return None diff --git a/app/generator.py b/app/generator.py index d8fe35c..dd2e1b3 100644 --- a/app/generator.py +++ b/app/generator.py @@ -26,22 +26,23 @@ def _normalize_link(proxy: ProxyDBRecord) -> str: """规范化分享链接,确保 socks/http 代理带有 #host-port 名称""" link = proxy.link if link.startswith(("socks5://", "socks4://", "socks4a://")): + # socks4/socks4a 不再支持,跳过 + if link.lower().startswith(("socks4://", "socks4a://")): + return link parsed = urlparse(link) host = parsed.hostname or proxy.address port = parsed.port or int(proxy.port) if proxy.port.isdigit() else 0 - protocol = "socks5" if link.lower().startswith("socks5://") else "socks4" if parsed.fragment: name = unquote(parsed.fragment) else: name = f"{host}-{port}" - # 重建: socks4://host:port#name (如有用户名密码也保留) auth = "" if parsed.username: auth = unquote(parsed.username) if parsed.password: auth += f":{unquote(parsed.password)}" auth += "@" - return f"{protocol}://{auth}{host}:{port}#{name}" + return f"socks5://{auth}{host}:{port}#{name}" elif link.startswith(("http://", "https://")) and ("#" in link or proxy.protocol in ("http", "https")): parsed = urlparse(link) host = parsed.hostname or proxy.address @@ -124,7 +125,7 @@ def _link_to_clash_proxy(link: str, fallback_name: str) -> dict | None: return _ss_link_to_clash(link, fallback_name) elif link.startswith("hysteria2://") or link.startswith("hy2://"): return _hysteria2_link_to_clash(link, fallback_name) - elif link.startswith(("socks5://", "socks4://", "socks4a://")): + elif link.startswith("socks5://"): return _socks_link_to_clash(link, fallback_name) elif link.startswith(("http://", "https://")) and "#" in link: return _http_link_to_clash(link, fallback_name) @@ -134,7 +135,7 @@ def _link_to_clash_proxy(link: str, fallback_name: str) -> dict | None: def _socks_link_to_clash(link: str, fallback_name: str) -> dict: - """socks5:// / socks4:// / socks4a:// 转 Clash proxy""" + """socks5:// 转 Clash proxy""" parsed = urlparse(link) name = unquote(parsed.fragment) if parsed.fragment else fallback_name proxy = { diff --git a/app/parser.py b/app/parser.py index 14ce7e5..a7a69cb 100644 --- a/app/parser.py +++ b/app/parser.py @@ -225,21 +225,22 @@ def _parse_hysteria2(line: str) -> ProxyInfo | None: def _parse_socks(line: str) -> ProxyInfo | None: - """解析 socks5:// / socks4:// / socks4a:// 链接 + """解析 socks5:// 链接(socks4/socks4a 不再支持,直接跳过) 格式: socks5://[user:pass@]host:port[#name] """ try: + # socks4/socks4a 协议不再支持,跳过 + if line.lower().startswith(("socks4://", "socks4a://")): + return None parsed = urlparse(line) address = parsed.hostname or "" port = str(parsed.port) if parsed.port else "" if not address or not port: return None name = unquote(parsed.fragment) if parsed.fragment else f"{address}:{port}" - # 统一协议名 - protocol = "socks5" if line.lower().startswith("socks5://") else "socks4" return ProxyInfo( - protocol=protocol, + protocol="socks5", name=name, address=address, port=port, diff --git a/app/scheduler.py b/app/scheduler.py index eee2655..2e1377d 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -143,6 +143,13 @@ async def _fetch_single_subscription(self, sub_id: int) -> None: logger.info("订阅 #%d: 解析到 %d 个节点,并发检测中...", sub.id, len(proxies)) + # 自动移除该订阅下已有的 socks4 节点(不再支持) + existing_proxies = await self.db.get_proxies_by_subscription_id(sub_id) + socks4_ids = [p.id for p in existing_proxies if p.protocol == "socks4"] + if socks4_ids: + await self.db.batch_delete_proxies(socks4_ids) + logger.info("订阅 #%d: 自动移除 %d 个 socks4 节点", sub_id, len(socks4_ids)) + # 并发检测所有节点延迟 sub_checker = ProxyChecker( check_urls=self.checker.check_urls, From a0f16ce55116b4a9becf7bc2d9c56ed0f957aa0f Mon Sep 17 00:00:00 2001 From: siruoren Date: Tue, 21 Jul 2026 09:52:09 +0800 Subject: [PATCH 16/18] =?UTF-8?q?style(app/templates):=20=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E5=B9=B3=E5=9D=87=E5=BB=B6=E8=BF=9F=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E7=9A=84=E6=A0=87=E9=A2=98=E6=96=87=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将标题从"平均延迟"调整为"平均延迟数",让表述更准确清晰 --- app/templates/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/templates/index.html b/app/templates/index.html index ac37b2b..68e8a36 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -24,7 +24,7 @@

{{ stats.available }}

-
平均延迟
+
平均延迟数

{{ "%.0f"|format(stats.avg_latency_ms) }} ms

From 3d98d50b48b026c24b4d381f5eb0a34e58dde60b Mon Sep 17 00:00:00 2001 From: siruoren Date: Tue, 21 Jul 2026 10:08:16 +0800 Subject: [PATCH 17/18] feat: add client-side pagination for proxy tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为每个代理表格添加上客户端分页功能,优化大量数据下的页面加载和浏览体验,同时修复排序后序号错乱的问题,新增分页状态管理和页码渲染逻辑 --- app/templates/index.html | 82 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 77 insertions(+), 5 deletions(-) diff --git a/app/templates/index.html b/app/templates/index.html index 68e8a36..5631331 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -351,7 +351,7 @@
可用节点
{% for proxy in grouped.get(sub.id, []) %} - + {{ loop.index }} {{ proxy.name[:30] }}{% if proxy.name|length > 30 %}...{% endif %} {{ proxy.protocol }} @@ -375,6 +375,9 @@
可用节点
+
{% endfor %}
@@ -720,6 +723,75 @@ function refreshPage() { location.reload(); } +// ---- 分页逻辑 ---- +const PAGE_SIZE = 10; +const pageState = {}; // { subId: currentPage } + +function initPagination() { + document.querySelectorAll('nav[id^="pager-"]').forEach(function(nav) { + var subId = nav.dataset.sub; + pageState[subId] = 1; + applyPagination(subId); + }); +} + +function applyPagination(subId) { + var tbody = document.querySelector('#tbl-' + subId + ' tbody'); + if (!tbody) return; + var rows = Array.from(tbody.querySelectorAll('tr[data-sub="' + subId + '"]')); + var total = rows.length; + var totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + var page = pageState[subId] || 1; + if (page > totalPages) page = totalPages; + pageState[subId] = page; + + // 显示/隐藏行 + rows.forEach(function(row, i) { + row.style.display = (i >= (page - 1) * PAGE_SIZE && i < page * PAGE_SIZE) ? '' : 'none'; + }); + + // 更新序号(仅当前页可见行) + var idx = (page - 1) * PAGE_SIZE; + rows.forEach(function(row) { + if (row.style.display !== 'none') { + row.children[0].textContent = ++idx; + } + }); + + // 渲染分页按钮 + var nav = document.getElementById('pager-' + subId); + if (!nav) return; + var ul = nav.querySelector('ul'); + if (total <= PAGE_SIZE) { ul.innerHTML = ''; return; } + + var html = ''; + html += '
  • ' + + '«
  • '; + + // 显示页码(最多显示5页,中间页码) + var startPage = Math.max(1, page - 2); + var endPage = Math.min(totalPages, startPage + 4); + if (endPage - startPage < 4) startPage = Math.max(1, endPage - 4); + + for (var p = startPage; p <= endPage; p++) { + html += '
  • ' + + '' + p + '
  • '; + } + + html += '
  • ' + + '»
  • '; + + ul.innerHTML = html; +} + +function goPage(subId, page) { + pageState[subId] = page; + applyPagination(subId); +} + +// 页面加载后初始化分页 +document.addEventListener('DOMContentLoaded', initPagination); + // 表格排序 function sortTable(tableId, key, th) { var table = document.getElementById(tableId); @@ -747,10 +819,10 @@ } }); rows.forEach(function(row) { tbody.appendChild(row); }); - // 更新序号 - tbody.querySelectorAll('tr').forEach(function(row, i) { - row.children[0].textContent = i + 1; - }); + // 排序后重置到第1页并重新分页 + var subId = tableId.replace('tbl-', ''); + pageState[subId] = 1; + applyPagination(subId); } // ---- 检测目标 URL ---- From ce7c3ab2de72c96237d65187fc3844723e4b1050 Mon Sep 17 00:00:00 2001 From: siruoren Date: Tue, 21 Jul 2026 10:13:37 +0800 Subject: [PATCH 18/18] chore: release 2.0.0 version with full refactor and new features This major release includes a full project refactor and many new features: - Complete node-subscription association refactor with integer subscription_id - Switch to plain text subscription output instead of base64 - Add Xray kernel forwarding detection with TCP/TLS fallback - Remove fail count accumulation, delete failed nodes directly - Drop socks4 protocol support - Add socks5/http(s) proxy support - Add multiple new detection targets and retry mechanism - Add cron task random jitter to avoid concurrent updates - Unify all time to UTC+8 - Rework web UI with pagination and new statistics - Add config export/import functionality - Add service instance source support - Overhaul all API endpoints and update documentation - Fix Docker build with fixed kernel version and curl retries --- CHANGELOG.md | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++- README.md | 92 +++++++++++++++++++++++++++++++++----------------- 2 files changed, 156 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d1dd02..250a2df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,99 @@ All notable changes to this project will be documented in this file. +## [2.0.0] - 2026-07-20 + +### 核心重构 + +- **节点-订阅关联重构** - 代理节点使用 `subscription_id`(整数)替代 `source`(字符串)绑定所属订阅源,已存在于其他订阅的节点不重复入库 +- **纯文本订阅格式** - 对外订阅输出改为纯文本格式(每行一条原始代理 URI),参照 `subdom.txt` 样式;移除 base64 编码输出 +- **内核转发检测** - 新增 Xray 内核转发检测能力,支持 HTTP/SOCKS 代理转发后检测连通性;TCP/TLS 直接检测作为回退 +- **检测失败直接删除** - 取消 `fail_count` 累积逻辑,检测不通过的节点直接从数据库删除,不再保留 +- **实例源节点不入库** - 服务实例获取的节点仅统计已连接数量,不再写入代理数据库 +- **socks4 协议移除** - 不再支持 socks4/socks4a 协议,拉取订阅时自动移除已有的 socks4 节点 + +### 多协议解析扩展 + +- **socks5/http 代理支持** - 新增 socks5:// 和 http(s):// 格式的解析、检测和 Clash 配置生成 +- **http/https 仅带 #fragment 时视为节点** - 避免 URL 误判为代理节点 +- **链接规范化** - socks5/http 代理对外输出确保格式为 `protocol://host:port#host-port`,保留认证信息 + +### 检测优化 + +- **多目标 URL 轮询** - 新增 5 个检测目标(Google 204、Gstatic 204、Cloudflare、Apple、华为连通性检测) +- **响应体验证** - 新增 `_validate_check_response` 排除劫持页面和空响应 +- **GET + 4KB body 读取** - 替代 HEAD 请求,提升服务器兼容性 +- **检测重试机制** - `check_retries` 参数(默认 2),单次检测失败后自动重试 +- **ConnectionRefusedError/ResetError** - 不再返回延迟值(视为不可用) +- **TLS 回退检测** - SSL 对象状态验证,`server_hostname` 使用原始域名 +- **华为连通性检测** - 新增 `connectivitycheck.platform.hicloud.com/generate_204` + +### 调度优化 + +- **CronTrigger 随机延迟** - 所有 crontab 任务加入 `jitter`(0~600 秒),避免多订阅源同时更新 +- **订阅验证防重入** - `_verifying_subs` 集合跟踪正在验证的订阅 ID,防止并发重复验证 +- **拉取后自动验证** - `_fetch_single_subscription` 完成后自动触发该订阅的已入库节点验证 + +### 时间与日志 + +- **UTC+8 统一** - 所有服务时间统一为东八区(UTC+8),包括数据库时间戳和日志时间 +- **单文件日志** - 日志写入 `logs/proxy_pool.log` 单文件,不归档、不保留历史日志 +- **内核日志合并** - 内核 stdout/stderr 合并写入 `logs/proxy-core.log` + +### Web 界面 + +- **统计面板重构** - "总节点数"→"总订阅条目数"(显示订阅源数量),"可用数"→"可用节点数",移除"不可用"统计卡片 +- **分页显示** - 每个订阅源可用节点列表分页,每页 10 条 +- **操作按钮更新** - "验证所有订阅"→"验证所有节点",新增"清除所有节点"、"导出配置"、"导入配置"按钮 +- **实例源表头** - "总数"→"已连接",显示已连接节点数量而非数据库条目数 + +### 配置管理 + +- **一键导出/导入** - 导出订阅源和实例源配置为 JSON 文件(含时间戳),导入时自动去重 +- **导出文件名时间戳** - 格式 `nethub_config_YYYYMMDD_HHMMSS.json` + +### API 接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/proxies` | 可用节点列表 | +| GET | `/api/proxies/all` | 所有节点 | +| GET | `/api/proxies/grouped` | 按 subscription_id 分组的可用节点 | +| DELETE | `/api/proxies/{id}` | 删除节点 | +| DELETE | `/api/proxies` | 一键清除所有节点 | +| GET | `/api/subscription/plain` | 纯文本格式订阅 | +| GET | `/api/subscription/v2ray` | 纯文本格式订阅(同 plain) | +| GET | `/api/subscription/clash` | Clash 格式订阅(YAML) | +| POST | `/api/fetch` | 拉取所有订阅 | +| POST | `/api/fetch/{sub_id}` | 拉取指定订阅 | +| POST | `/api/verify` | 验证所有节点 | +| POST | `/api/verify/{sub_id}` | 验证指定订阅节点 | +| GET | `/api/stats` | 统计信息 | +| GET | `/api/health` | 健康检查 | +| GET | `/api/subscriptions` | 订阅源列表 | +| POST | `/api/subscriptions` | 添加订阅源 | +| POST | `/api/subscriptions/auto` | 自动添加订阅源(仅 URL 必填) | +| PUT | `/api/subscriptions/{sub_id}` | 更新订阅源 | +| DELETE | `/api/subscriptions/{sub_id}` | 删除订阅源及其下所有节点 | +| GET | `/api/check-urls` | 检测目标 URL 列表 | +| POST | `/api/check-urls` | 添加检测目标 URL | +| DELETE | `/api/check-urls/{url_id}` | 删除检测目标 URL | +| GET | `/api/config/export` | 导出配置(JSON) | +| POST | `/api/config/import` | 导入配置(JSON) | +| GET | `/api/instance-sources` | 服务实例源列表 | +| POST | `/api/instance-sources` | 添加服务实例源 | +| PUT | `/api/instance-sources/{source_id}` | 更新服务实例源 | +| DELETE | `/api/instance-sources/{source_id}` | 删除服务实例源 | +| POST | `/api/instance-sources/{source_id}/fetch` | 获取实例源已连接节点数 | +| POST | `/api/instance-sources/{source_id}/import` | 导入实例源订阅 | + +### Docker 构建 + +- **固定版本下载** - 内核数据文件使用固定版本标签,替代 `latest` 避免超时 +- **curl 重试参数** - `--connect-timeout 30 --max-time 180 --retry 3 --retry-delay 5` + +--- + ## [1.0.0] - 2026-07-18 ### 核心功能 @@ -47,7 +140,7 @@ All notable changes to this project will be documented in this file. | GET | `/api/proxies` | 可用节点列表 | | GET | `/api/proxies/all` | 所有节点 | | DELETE | `/api/proxies/{id}` | 删除节点 | -| GET | `/api/subscription/v2ray` | 核心订阅 | +| GET | `/api/subscription/v2ray` | 核心订阅(base64) | | GET | `/api/subscription/clash` | Clash 订阅 | | POST | `/api/fetch` | 拉取所有订阅 | | POST | `/api/fetch/{sub_id}` | 拉取指定订阅 | diff --git a/README.md b/README.md index ef661a8..dd29a49 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,17 @@ ## 功能特性 -- **订阅源管理** - 数据库驱动的增删改查,每个订阅源独立配置 Crontab、延迟阈值、重试次数、并发数 -- **Crontab 定时拉取** - 每个订阅源支持 5 位 Crontab 表达式精准调度 -- **HTTP 延迟检测** - 模拟通过节点访问目标网站,测量完整请求延迟(DNS + TCP + TLS + HTTP),多目标取最大值 -- **检测目标动态配置** - 检测目标 URL 存入数据库,页面可增删,修改即时生效,无需外部文件 -- **多协议支持** - vmess / vless / trojan / ss / hysteria2 解析与 Clash 配置生成 -- **自动清理** - 连续 3 次验证失败的节点自动移除;连续 30 天无节点的订阅源自动删除 -- **单页面管理** - 订阅源管理 + 可用节点列表在同一页面,按订阅源 Tab 切换 -- **协议分布图** - 饼状图动态展示各协议节点数量和百分比 -- **订阅输出** - 仅输出当前可用节点,支持核心格式(base64)和 Clash(YAML)格式 -- **日志归档** - 按天自动归档,自动清理 7 天前的日志 +- **订阅源管理** - 数据库驱动的增删改查,每个订阅源独立配置 Crontab、延迟阈值、并发数;删除订阅源时自动清除其下所有节点 +- **Crontab 定时拉取** - 每个订阅源支持 5 位 Crontab 表达式,内置随机延迟(0~10 分钟)避免多源同时更新 +- **内核转发检测** - Xray 内核转发后检测连通性,TCP/TLS 直接检测作为回退;多目标 URL 轮询 + 响应体验证 + 检测重试 +- **检测失败直接删除** - 取消失败计数累积,检测不通过的节点直接从数据库删除 +- **节点-订阅绑定** - 每个节点绑定所属 `subscription_id`,已存在于其他订阅的节点不重复入库 +- **纯文本订阅输出** - 每行一条原始代理 URI,参照 `subdom.txt` 格式;同时提供 Clash(YAML)格式 +- **多协议支持** - vmess / vless / trojan / ss / hysteria2 / socks5 / http(s) 解析、检测与 Clash 配置生成 +- **服务实例源** - 获取已连接节点数量统计(不入库),支持手工导入实例源中的订阅地址 +- **配置导出/导入** - 一键导出订阅源和实例源配置为 JSON 文件(含时间戳),导入时自动去重 +- **UTC+8 时区统一** - 所有服务时间统一为东八区 +- **单文件日志** - 不归档、不保留历史日志 - **Docker 部署** - Docker Compose 一键启动 ## 快速开始 @@ -31,7 +32,7 @@ vim config.yaml docker-compose up -d ``` -访问 http://localhost:8080 查看 Web 界面。 +访问 http://localhost:2020 查看 Web 界面。 ### 本地运行 @@ -60,15 +61,19 @@ check: timeout: 5.0 # 检测超时(秒) max_concurrent: 50 # 全局并发检测数 latency_threshold: 1500.0 # 全局延迟阈值(毫秒) + check_mode: "auto" # 检测模式: auto(优先内核转发回退TCP) / http(仅内核转发) / tcp(仅TCP/TLS) + socks_port: 1080 # 本地 SOCKS 转发端口 + http_port: 1081 # 本地 HTTP 转发端口 + kernel_path: "xray" # 内核可执行文件路径 + check_retries: 2 # 单次检测失败后重试次数 scheduler: fetch_interval: 3600 # 拉取订阅间隔(秒) verify_interval: 1800 # 验证节点间隔(秒) cleanup_interval: 7200 # 清理间隔(秒) - max_fail_count: 3 # 最大连续失败次数 ``` -> 检测目标 URL 默认为 `https://www.google.com/generate_204` 和 `https://www.gstatic.com/generate_204`,首次启动自动写入数据库,后续在页面「检测目标」中管理,修改即时生效。 +> 检测目标 URL 默认包含 Google 204、Gstatic 204、Cloudflare、Apple、华为连通性检测等,首次启动自动写入数据库,后续在页面「检测目标」中管理,修改即时生效。 ### 环境变量 @@ -85,15 +90,17 @@ scheduler: |------|------|------| | GET | `/api/proxies` | 可用节点列表 | | GET | `/api/proxies/all` | 所有节点 | -| GET | `/api/proxies/grouped` | 按订阅来源分组的可用节点 | +| GET | `/api/proxies/grouped` | 按 subscription_id 分组的可用节点 | | DELETE | `/api/proxies/{id}` | 删除节点 | +| DELETE | `/api/proxies` | 一键清除所有节点 | ### 订阅输出 | 方法 | 路径 | 说明 | |------|------|------| -| GET | `/api/subscription/v2ray` | 核心格式订阅(base64) | -| GET | `/api/subscription/clash` | Clash 格式订阅(YAML) | +| GET | `/api/subscription/plain` | 纯文本格式(每行一条 URI) | +| GET | `/api/subscription/v2ray` | 纯文本格式(同 plain) | +| GET | `/api/subscription/clash` | Clash 格式(YAML) | ### 订阅源管理 @@ -103,7 +110,7 @@ scheduler: | POST | `/api/subscriptions` | 添加订阅源 | | POST | `/api/subscriptions/auto` | 自动添加(仅 URL 必填,自动拉取验证) | | PUT | `/api/subscriptions/{sub_id}` | 更新订阅源 | -| DELETE | `/api/subscriptions/{sub_id}` | 删除订阅源 | +| DELETE | `/api/subscriptions/{sub_id}` | 删除订阅源及其下所有节点 | ### 拉取与验证 @@ -114,10 +121,28 @@ scheduler: | POST | `/api/verify` | 验证所有节点 | | POST | `/api/verify/{sub_id}` | 验证指定订阅节点 | -### 检测目标 +### 服务实例源 | 方法 | 路径 | 说明 | |------|------|------| +| GET | `/api/instance-sources` | 服务实例源列表 | +| POST | `/api/instance-sources` | 添加服务实例源 | +| PUT | `/api/instance-sources/{source_id}` | 更新服务实例源 | +| DELETE | `/api/instance-sources/{source_id}` | 删除服务实例源 | +| POST | `/api/instance-sources/{source_id}/fetch` | 获取实例源已连接节点数 | +| POST | `/api/instance-sources/{source_id}/import` | 导入实例源订阅 | + +### 配置管理 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/config/export` | 导出配置(JSON) | +| POST | `/api/config/import` | 导入配置(JSON,自动去重) | + +### 检测目标 + +| 方法 | 路径 | 说明 | +|------|------|--------| | GET | `/api/check-urls` | 检测目标 URL 列表 | | POST | `/api/check-urls` | 添加检测目标 URL | | DELETE | `/api/check-urls/{url_id}` | 删除检测目标 URL | @@ -125,16 +150,16 @@ scheduler: ### 其他 | 方法 | 路径 | 说明 | -|------|------|------| -| GET | `/api/stats` | 统计信息 | +|------|------|--------| +| GET | `/api/stats` | 统计信息(总订阅条目数、可用节点数、平均延迟) | | GET | `/api/health` | 健康检查 | ## 订阅链接使用 在节点客户端中添加以下订阅链接: -- **核心**: `http://your-server:8080/api/subscription/v2ray` -- **Clash**: `http://your-server:8080/api/subscription/clash` +- **纯文本**: `http://your-server:2020/api/subscription/plain` +- **Clash**: `http://your-server:2020/api/subscription/clash` > 订阅内容仅包含延迟低于阈值的可用节点,随节点池自动更新。 @@ -147,22 +172,28 @@ scheduler: | Trojan | ✅ | ✅ | | Shadowsocks | ✅ | ✅ | | Hysteria2 | ✅ | ✅ | +| SOCKS5 | ✅ | ✅ | +| HTTP/HTTPS | ✅ | ✅ | + +> socks4/socks4a 协议不再支持,拉取时自动移除。 ## 延迟检测原理 -延迟检测模拟真实上网场景:通过转发服务访问目标检测 URL,测量完整请求延迟。 +延迟检测模拟真实上网场景:通过 Xray 内核转发访问目标检测 URL,测量完整请求延迟。 ``` -客户端 → 转发服务 → 目标网站 +客户端 → Xray 内核转发 → 目标网站 ├── DNS 解析 ├── TCP 连接建立 ├── TLS 握手(HTTPS 目标) └── HTTP 请求/响应 ``` -- 检测目标默认为 `https://www.google.com/generate_204` 和 `https://www.gstatic.com/generate_204` +- 检测目标包含 Google 204、Gstatic 204、Cloudflare、Apple、华为连通性检测等 - 多个目标取最大延迟值,确保所有目标均可达 -- 相比仅测试 TCP/TLS 连通性,HTTP 请求延迟更贴近真实上网体验 +- 响应体验证排除劫持页面和空响应 +- 单次检测失败后自动重试(默认 2 次) +- 检测失败节点直接删除,不保留 ## 项目结构 @@ -174,9 +205,9 @@ proxy_pool/ │ ├── config.py # YAML 配置加载 │ ├── database.py # aiosqlite 异步数据库操作 │ ├── models.py # 数据模型(ProxyInfo / ProxyDBRecord / SubscriptionRecord) -│ ├── parser.py # 订阅拉取 & 解析(5 协议) -│ ├── checker.py # HTTP 延迟检测(多目标取最大值) -│ ├── generator.py # 核心 / Clash 订阅生成 +│ ├── parser.py # 订阅拉取 & 解析(7 协议) +│ ├── checker.py # 内核转发检测 + TCP/TLS 回退检测 +│ ├── generator.py # 纯文本 / Clash 订阅生成 │ ├── scheduler.py # APScheduler 定时任务调度 │ ├── routers/ │ │ ├── api.py # REST API 路由 @@ -185,7 +216,8 @@ proxy_pool/ │ ├── base.html # 基础模板 │ ├── index.html # 主页面(管理 + 节点列表) │ └── subscription.html # 订阅链接页 -├── logs/ # 日志目录(自动归档) +├── logs/ # 日志目录(单文件,不归档) +├── data/ # 数据库目录 ├── config.yaml # 配置文件 ├── docker-compose.yaml # Docker 编排 ├── Dockerfile # Docker 镜像