From f03c3300b4032023b5a6ab260df2018ab8c23cdf Mon Sep 17 00:00:00 2001 From: isboyjc Date: Sat, 4 Apr 2026 22:25:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20implement=20custom=20proxy?= =?UTF-8?q?=20subscription=20management=20and=20enhance=20configuration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added support for importing Clash/V2ray subscriptions, including automatic format detection and integration with sing-box for protocol conversion. - Introduced five proxy usage modes in the configuration, allowing flexible selection between mixed, custom-only, and free-only modes. - Enhanced `.env.example` and `docker-compose.yml` to include new environment variables for custom proxy settings. - Updated `CHANGELOG.md` to document new features and improvements related to subscription management. - Improved WebUI for managing subscriptions and displaying proxy statistics. - Implemented a background process for refreshing subscriptions and probing disabled proxies for reactivation. --- .env.example | 12 + .gitignore | 3 + CHANGELOG.md | 55 + CLAUDE.md | 4 + Dockerfile | 15 +- POOL_DESIGN.md | 202 ++++ README.md | 1462 +++----------------------- checker/health_checker.go | 9 +- config/config.go | 100 +- custom/manager.go | 570 ++++++++++ custom/parser.go | 736 +++++++++++++ custom/singbox.go | 530 ++++++++++ docker-compose.yml | 4 +- go.mod | 2 + go.sum | 4 + main.go | 25 +- pool/manager.go | 26 +- proxy/server.go | 92 +- proxy/socks5_server.go | 49 +- storage/storage.go | 568 +++++++++- subscriptions/sub_1775301718713.yaml | 1279 ++++++++++++++++++++++ webui/dashboard.go | 752 +++++++++++-- webui/server.go | 383 ++++++- 23 files changed, 5394 insertions(+), 1488 deletions(-) create mode 100644 POOL_DESIGN.md create mode 100644 custom/manager.go create mode 100644 custom/parser.go create mode 100644 custom/singbox.go create mode 100644 subscriptions/sub_1775301718713.yaml diff --git a/.env.example b/.env.example index 706def5..ac3ea8f 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,18 @@ PROXY_AUTH_PASSWORD= # 代理认证密码(留空=不启用认证) # WebUI 认证配置 WEBUI_PASSWORD=goproxy # ⚠️ 生产环境请修改为强密码 +# 订阅代理配置 +# 代理使用模式(可在 WebUI 设置中切换,支持 5 种策略): +# mixed(默认) - 混合·平等(不区分来源,按延迟/随机选择) +# mixed + 订阅优先 - 优先使用订阅代理,无可用时降级到免费(WebUI 设置) +# mixed + 免费优先 - 优先使用免费代理,无可用时降级到订阅(WebUI 设置) +# custom_only - 仅订阅代理(只使用订阅导入的代理) +# free_only - 仅免费代理(只使用公开抓取的代理) +CUSTOM_PROXY_MODE=mixed +# sing-box 二进制路径(Docker 内置,本地运行需安装:brew install sing-box) +# 用于 vmess/vless/trojan/ss/hysteria2/anytls 等加密协议节点转换为本地 SOCKS5 +SINGBOX_PATH=sing-box + # 数据存储配置(仅 docker run 本地开发需要) # docker-compose.yml 使用 Named Volume,无需此配置 # DATA_DIR=./data diff --git a/.gitignore b/.gitignore index 48e7360..03f416d 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ docker-compose.dokploy.yml # Docker volumes .docker-data/ + + +tmp/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 45a394a..adeb623 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,61 @@ 格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/), 版本号遵循 [语义化版本 2.0.0](https://semver.org/lang/zh-CN/)。 +## [v0.4.0] - 2026-04-04 + +### 新增 + +- **订阅代理导入** + - 支持通过 WebUI 添加 Clash/V2ray 订阅 URL 或上传配置文件 + - 格式全自动识别:Clash YAML、V2ray 链接(vmess/vless/trojan/ss/hysteria2/anytls)、Base64 编码、纯文本 + - 内置 sing-box 协议转换:加密协议节点自动转为本地 SOCKS5 代理,Docker 镜像自带 sing-box 二进制 + - 订阅定时刷新:可配置刷新间隔,自动拉取最新节点并替换旧节点 + - 添加订阅时先验证(拉取+解析通过后才入库),失败不产生垃圾数据 + +- **订阅代理保护机制** + - 软删除:订阅代理健康检查失败不删除只禁用(`status='disabled'`) + - 探测唤醒:定时探测禁用的订阅代理,恢复可用后自动启用 + - 地理过滤全局化:免费代理删除、订阅代理禁用,探测唤醒时也检查地理规则 + - 自动清理:连续 7 天无可用节点的订阅自动移除 + +- **5 种代理使用模式** + - 混合·订阅优先:优先使用订阅代理,无可用时降级到免费 + - 混合·免费优先:优先使用免费代理,无可用时降级到订阅 + - 混合·平等:不区分来源,按延迟/随机选择 + - 仅订阅代理:只使用订阅导入的代理 + - 仅免费代理:只使用公开抓取的代理 + +- **访客贡献订阅** + - 未登录用户可通过「贡献订阅」入口提交订阅 URL 或上传配置文件 + - 提交前自动验证,通过后才入库 + - 管理员可刷新、暂停、删除贡献的订阅 + - 贡献订阅在列表中有橙色「贡献」标记 + +- **WebUI 增强** + - 免费池 / 订阅池分离展示,各自独立统计 + - 订阅管理面板:订阅列表(名称 + 可用数 + 禁用数)、添加/刷新/暂停/删除 + - 代理列表中订阅代理带黄色标签显示所属订阅名称 + 左侧黄色竖线 + - 系统设置从侧边栏移至顶部齿轮图标,重组为:代理模式 → 免费池 → 订阅池 → 验证检查 → 地理过滤 + - 新增 ~70 个 i18n 翻译 key,覆盖所有新增 UI 元素 + +- **代理使用统计** + - HTTP/SOCKS5 代理服务在请求成功/失败时记录使用次数(`RecordProxyUse`) + +### 变更 + +- `Proxy` 结构体新增 `Source`(free/custom)和 `SubscriptionID` 字段 +- `Count()`/`CountByProtocol()` 仅统计免费代理(slot 计算不受订阅代理影响) +- 批量删除方法(`DeleteInvalid`/`DeleteBlockedCountries`/`DeleteNotAllowedCountries`/`DeleteWithoutExitInfo`)仅作用于免费代理 +- `GetWorstProxies` 排除订阅代理,优化器不替换订阅代理 +- Dockerfile 集成 sing-box 二进制(自动检测 amd64/arm64 架构) + +### 修复 + +- 修复 `AddProxy` 未显式设置 `source='free'` 的问题 +- 修复 WebUI「刷新代理」「刷新延迟」对订阅代理执行硬删除的问题(改为禁用) +- 修复 `validateCustomProxies` 将所有代理硬编码为 socks5 协议导致 HTTP 直连代理验证失败 +- 修复 `CustomPriority` 和 `CustomFreePriority` 可同时为 true 的互斥问题 + ## [v0.3.0] - 2026-04-01 ### 新增 diff --git a/CLAUDE.md b/CLAUDE.md index 8a0e969..4fbb120 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,10 @@ main.go (orchestrator) ├── pool/ — Pool manager (admission control, slot allocation, replacement logic) ├── checker/ — Background health checker (batch-based, skips S-grade when healthy) ├── optimizer/ — Background quality optimizer (replaces slow proxies with faster ones) + ├── custom/ — Custom proxy subscription manager (fetch, parse, validate, periodic refresh) + │ ├── parser.go — Clash YAML / plain / base64 subscription parser + │ ├── singbox.go — sing-box process manager (config generation, start/stop/reload) + │ └── manager.go — Subscription refresh loop + probe-wake loop for disabled proxies ├── proxy/ — Outward-facing proxy servers │ ├── server.go — HTTP proxy (implements http.Handler) │ └── socks5_server.go — SOCKS5 proxy (raw TCP, manual protocol implementation) diff --git a/Dockerfile b/Dockerfile index 4c2d488..9c33d20 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,18 +8,29 @@ RUN go mod download COPY . . RUN CGO_ENABLED=1 GOOS=linux go build -o proxy-pool . +# 下载 sing-box 二进制 +ARG SINGBOX_VERSION=1.11.8 +RUN ARCH=$(case "$(dpkg --print-architecture)" in amd64) echo "amd64";; arm64) echo "arm64";; *) echo "amd64";; esac) && \ + curl -fsSL "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${ARCH}.tar.gz" \ + -o /tmp/sing-box.tar.gz && \ + tar -xzf /tmp/sing-box.tar.gz -C /tmp && \ + cp /tmp/sing-box-${SINGBOX_VERSION}-linux-${ARCH}/sing-box /app/sing-box && \ + chmod +x /app/sing-box && \ + rm -rf /tmp/sing-box* + # 运行阶段(使用轻量 debian-slim) FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates tzdata && \ + ca-certificates tzdata curl && \ rm -rf /var/lib/apt/lists/* ENV TZ=Asia/Shanghai WORKDIR /app COPY --from=builder /app/proxy-pool . +COPY --from=builder /app/sing-box /usr/local/bin/sing-box -EXPOSE 7776 7777 7778 +EXPOSE 7776 7777 7778 7779 7780 CMD ["./proxy-pool"] diff --git a/POOL_DESIGN.md b/POOL_DESIGN.md new file mode 100644 index 0000000..461b08f --- /dev/null +++ b/POOL_DESIGN.md @@ -0,0 +1,202 @@ +# GoProxy 架构设计 + +## 系统概览 + +GoProxy 是一个单二进制的智能代理池系统,由多个协作的 goroutine 组成。核心设计理念:**免费池自动运转 + 订阅池按需导入,两池独立管理但统一对外服务**。 + +``` + ┌─────────────────────────┐ + │ WebUI (:7778) │ + │ 管理面板 / 订阅管理 / 设置 │ + └──────────┬──────────────┘ + │ + ┌──────────────────┐ ┌──────────┴──────────────┐ ┌──────────────────┐ + │ 免费代理源 (20+) │──▶│ SQLite 代理池 │◀──│ 订阅源 (Clash/ │ + │ 公开列表自动抓取 │ │ proxies / subscriptions │ │ V2ray/Base64) │ + └──────────────────┘ └──────────┬──────────────┘ └──────────────────┘ + │ │ + ┌──────────┴──────────────┐ ┌───────┴────────┐ + │ 对外代理服务 │ │ sing-box │ + │ HTTP (:7776 / :7777) │ │ 协议转换进程 │ + │ SOCKS5 (:7779 / :7780) │ │ vmess/trojan │ + └───────────────────────────┘ │ → local socks5│ + └────────────────┘ +``` + +## 模块依赖 + +``` +main.go (orchestrator) + ├── config/ 配置管理(环境变量 + config.json),sync.RWMutex 线程安全 + ├── storage/ SQLite 持久化(proxies + subscriptions + source_status) + ├── fetcher/ 免费代理抓取(20+ 源,断路器保护) + ├── validator/ 代理验证(连接 + 出口 IP + 地理位置 + 延迟 + HTTPS 隧道) + ├── pool/ 池子管理(slot 准入、替换逻辑、状态机) + ├── checker/ 健康检查(批次验证,free 删除 / custom 禁用) + ├── optimizer/ 质量优化(替换慢代理,仅作用于免费池) + ├── custom/ 订阅管理 + │ ├── parser.go 格式自动识别(Clash YAML / V2ray 链接 / Base64 / 纯文本) + │ ├── singbox.go sing-box 进程管理(配置生成 + 启停 + 端口映射) + │ └── manager.go 刷新循环 + 探测唤醒 + 过期清理 + ├── proxy/ 对外代理服务 + │ ├── server.go HTTP 代理(支持 CONNECT 隧道) + │ └── socks5_server.go SOCKS5 代理(原生协议实现) + ├── webui/ 管理面板(嵌入式 HTML + REST API) + └── logger/ 内存日志收集(供 WebUI 展示) +``` + +## 数据模型 + +### proxies 表 + +| 字段 | 类型 | 说明 | +|------|------|------| +| address | TEXT UNIQUE | 代理地址 (host:port) | +| protocol | TEXT | http / socks5 | +| source | TEXT | free / custom | +| subscription_id | INTEGER | 所属订阅 ID(0=免费) | +| exit_ip | TEXT | 出口 IP | +| exit_location | TEXT | "国家代码 城市" | +| latency | INTEGER | 延迟 (ms) | +| quality_grade | TEXT | S(<=500ms) / A(<=1000) / B(<=2000) / C(>2000) | +| status | TEXT | active / degraded / disabled / candidate_replace | +| fail_count | INTEGER | 连续失败次数 | +| use_count / success_count | INTEGER | 使用统计 | + +### subscriptions 表 + +| 字段 | 类型 | 说明 | +|------|------|------| +| url | TEXT | 订阅 URL | +| file_path | TEXT | 本地文件路径 | +| format | TEXT | auto(全自动识别) | +| status | TEXT | active / paused | +| last_fetch | DATETIME | 最后拉取时间 | +| last_success | DATETIME | 最后有可用节点的时间 | +| contributed | INTEGER | 是否为访客贡献 | + +## 免费池状态机 + +``` + 总量>95% + ┌─────────────────────────────────────┐ + │ ▼ +┌──────┐ 总量<95% ┌─────────┐ 协议<20% ┌──────────┐ 缺失协议或<10% ┌───────────┐ +│HEALTHY│──────────▶│ WARNING │───────────▶│ CRITICAL │─────────────────▶│ EMERGENCY │ +└──────┘ └─────────┘ └──────────┘ └───────────┘ +``` + +各状态对应的行为: + +| 状态 | 延迟阈值 | 抓取模式 | 说明 | +|------|---------|---------|------| +| healthy | 2000ms | 不抓取 | 优化器定期替换慢代理 | +| warning | 4000ms | refill (快源) | 补充到 95% | +| critical | 4000ms | refill (快源) | 优先补充缺失协议 | +| emergency | 4000ms | emergency (全源) | 忽略断路器,全力抓取 | + +## 订阅池生命周期 + +``` +添加订阅 → 验证(拉取+解析) → 入库 + │ + ┌───────────────────────┘ + ▼ + 刷新订阅(定时/手动) + │ + ├── 拉取内容(直连 → 代理 fallback) + ├── 自动识别格式 + ├── 删除该订阅旧代理 + ├── 分类节点 + │ ├── HTTP/SOCKS5 → 直接入池 + │ └── vmess/trojan/... → sing-box 转换 → 本地 SOCKS5 入池 + ├── 验证入池代理(含地理过滤) + │ ├── 通过 → status=active, 更新 last_success + │ └── 失败/被过滤 → status=disabled + └── 更新订阅 proxy_count +``` + +### 探测唤醒 + +``` +每 N 分钟(默认 10) + │ + ├── 获取所有 source=custom AND status=disabled 的代理 + ├── 逐个验证 + │ ├── 通过 + 未被地理过滤 → EnableProxy → 更新 last_success + │ └── 失败 或 被地理过滤 → 保持 disabled + └── 日志输出恢复数量 +``` + +### 自动清理 + +每分钟检查:创建超过 7 天且 `last_success` 距今超过 7 天的订阅 → 删除订阅 + 关联代理 + 重建 sing-box。 + +## 代理选择策略 + +5 种模式通过 `CustomProxyMode` + `CustomPriority` + `CustomFreePriority` 三个配置组合: + +| UI 选项 | Mode | Priority | FreePriority | 选择逻辑 | +|---------|------|----------|-------------|---------| +| 混合·订阅优先 | mixed | true | false | 先 custom,无可用 fallback 全部 | +| 混合·免费优先 | mixed | false | true | 先 free,无可用 fallback 全部 | +| 混合·平等 | mixed | false | false | sourceFilter="",不区分来源 | +| 仅订阅 | custom_only | - | - | sourceFilter="custom" | +| 仅免费 | free_only | - | - | sourceFilter="free" | + +HTTP 代理服务可使用 HTTP 或 SOCKS5 上游代理。SOCKS5 代理服务仅使用 SOCKS5 上游代理。 + +## sing-box 集成 + +``` +订阅节点 (vmess/vless/trojan/ss/hysteria2/anytls) + │ + ▼ +生成 sing-box JSON 配置: + inbounds: 每个节点一个本地 SOCKS5 端口 (20001, 20002, ...) + outbounds: 每个节点对应一个加密协议出站 + route: inbound → outbound 一一映射 + │ + ▼ +启动 sing-box 子进程 (sing-box run -c config.json) + │ + ▼ +本地 socks5://127.0.0.1:20001 ... 入池为 source=custom 代理 +``` + +Docker 镜像自带 sing-box 二进制,支持 amd64/arm64。本地运行需手动安装。 + +## 后台 Goroutine + +| Goroutine | 间隔 | 职责 | +|-----------|------|------| +| 状态监控 | 30s | 检查免费池状态,触发 smartFetchAndFill | +| 健康检查 | 5min | 批量验证代理,free 删除 / custom 禁用 | +| 优化轮换 | 30min | 替换免费池中的慢代理 | +| 订阅刷新 | 1min tick | 检查到期订阅,执行刷新 | +| 探测唤醒 | 10min | 探测 disabled 的订阅代理 | +| 配置监听 | event | WebUI 配置变更后调整 slot | + +## 地理过滤 + +全局生效,对两个池子行为不同: + +| 操作 | 免费代理 | 订阅代理 | +|------|---------|---------| +| 启动清理 | DELETE | status → disabled | +| 验证时不通过 | 不入池 | 入池但 disabled | +| 探测唤醒 | - | 被过滤的不启用 | +| 配置变更 | 下次清理生效 | 下次清理生效 | + +白名单(`AllowedCountries`)优先于黑名单(`BlockedCountries`)。 + +## 端口映射 + +| 端口 | 服务 | 模式 | +|------|------|------| +| 7776 | HTTP 代理 | 最低延迟 | +| 7777 | HTTP 代理 | 随机轮换 | +| 7778 | WebUI | 管理面板 | +| 7779 | SOCKS5 代理 | 随机轮换 | +| 7780 | SOCKS5 代理 | 最低延迟 | +| 20001+ | sing-box 本地 | 仅 127.0.0.1 | diff --git a/README.md b/README.md index f3d2dbb..a77a25d 100644 --- a/README.md +++ b/README.md @@ -1,1398 +1,286 @@ # GoProxy -> **智能代理池系统** — 基于 Go 的轻量级、低资源消耗、自适应的代理池服务 +> **智能代理池系统** — 基于 Go 的轻量级、自适应代理池服务,支持免费代理自动抓取 + 付费订阅导入 [![Docker Hub](https://img.shields.io/docker/v/isboyjc/goproxy?label=Docker%20Hub&logo=docker)](https://hub.docker.com/r/isboyjc/goproxy) [![GitHub Container Registry](https://img.shields.io/badge/GHCR-latest-blue?logo=github)](https://github.com/isboyjc/GoProxy/pkgs/container/goproxy) [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) [![Go Version](https://img.shields.io/badge/Go-1.25-00ADD8?logo=go)](https://go.dev/) -GoProxy 从多个公开代理源自动抓取 HTTP/SOCKS5 代理,通过严格验证(出口 IP + 位置 + 延迟)后加入智能代理池,对外提供 **HTTP 和 SOCKS5 双协议**代理服务。系统采用质量分级、智能补充、自动优化等机制,确保代理池始终保持高质量和稳定性。 +GoProxy 从公开代理源自动抓取 HTTP/SOCKS5 代理,同时支持导入 Clash/V2ray 付费订阅,通过出口 IP + 地理位置 + 延迟三重验证后统一入池,对外提供 HTTP 和 SOCKS5 双协议代理服务。 **GitHub**:[github.com/isboyjc/GoProxy](https://github.com/isboyjc/GoProxy) ![](https://cdn.isboyjc.com/img/Xnip2026-03-29_03-35-06.png) -## 📋 快速导航 +## 核心特性 -- [✨ 核心特性](#-核心特性) - 智能池子、按需抓取、健康管理、双协议支持 -- [🔌 HTTP vs SOCKS5 协议对比](#-http-vs-socks5-协议对比) - 协议区别、使用建议 -- [🚀 快速开始](#-快速开始) - 本地运行、端口说明、代理使用 -- [🐳 Docker 部署](#-docker-部署) - 容器化部署、环境配置、安全建议 -- [⚙️ 配置说明](#️-配置说明) - 完整参数详解、性能优化 -- [🛠️ 开发与调试](#️-开发与调试) - 日志查看、数据库操作 -- [🧪 测试代理服务](#-测试代理服务) - 测试脚本、持续测试、认证测试 -- [❓ 常见问题](#-常见问题) - SOCKS5 使用、浏览器配置、故障排查 +### 双池架构 -## ✨ 核心特性 +- **免费代理池** — 自动从 20+ 公开源抓取,质量分级(S/A/B/C),智能补充与替换 +- **订阅代理池** — 导入 Clash/V2ray 订阅,通过 sing-box 自动转换加密协议(vmess/vless/trojan/ss/hysteria2/anytls 等)为本地 SOCKS5 代理 +- **5 种使用模式** — 混合·订阅优先 / 混合·免费优先 / 混合·平等 / 仅订阅 / 仅免费,WebUI 随时切换 -### 🎯 智能池子机制 -- **固定容量管理**:可配置池子大小和 HTTP/SOCKS5 协议比例(默认 3:7) -- **质量分级**:S/A/B/C 四级评分(基于延迟),智能选择高质量代理 -- **动态状态感知**:Healthy → Warning → Critical → Emergency 四级状态自适应 -- **严格准入标准**:必须通过出口 IP、地理位置、延迟三重验证才可入池 -- **HTTPS 可用性验证**:HTTP 协议代理入池前额外验证 HTTPS CONNECT 隧道能力,随机访问真实 HTTPS 网站确认可用(失败自动换站重试),确保入池的 HTTP 代理都能正常访问 HTTPS 网站 -- **智能替换**:新代理必须显著优于现有代理(默认快 30%)才触发替换 +### 智能池子管理 -### 🚀 按需抓取 -- **源分组策略**:快更新源(5-30min)用于紧急补充,慢更新源(每天)用于优化轮换 -- **断路器保护**:连续失败的源自动降级/禁用,冷却后恢复 -- **协议并发验证**:抓取到的候选代理按协议分组,SOCKS5 和 HTTP 各自并发验证入池。SOCKS5 无额外检测,天然更快优先填充;HTTP 带 HTTPS CONNECT 检测较慢但不阻塞 SOCKS5 入池 -- **多模式抓取**: - - **Emergency**:单协议缺失或池子 <10%,使用所有可用源 - - **Refill**:池子 <80%,使用快更新源 - - **Optimize**:池子健康时,随机抽取少量慢源优化替换 +- **固定容量 + 动态状态** — Healthy → Warning → Critical → Emergency 四级自适应 +- **严格准入** — 出口 IP + 地理位置 + 延迟验证,HTTP 代理额外验证 HTTPS CONNECT 隧道 +- **自动优化** — 按需抓取(Emergency/Refill/Optimize 三模式),定时替换慢代理 +- **故障自愈** — 请求失败自动切换代理重试(最多 3 次),用户无感知 -### 🏥 分层健康管理 -- **轻量批次检查**:每次仅检查 20 个代理,避免资源浪费 -- **智能跳过 S 级**:池子健康时跳过 S 级代理检查 -- **定时优化轮换**:健康状态下,定期抓取优质代理替换池中延迟高的 +### 订阅管理 -### 🔄 智能重试机制 -- **自动故障切换**:代理请求失败时立即切换到另一个代理重试(最多 3 次) -- **失败即删除**:连接失败或请求超时的代理立即从池子中移除 -- **用户无感知**:自动重试在服务端完成,用户只会收到成功响应或最终失败提示 -- **防重复尝试**:已尝试过的失败代理不会在同一请求中再次使用 +- **格式自动识别** — Clash YAML / V2ray 链接 / Base64 / 纯文本,无需手动选格式 +- **sing-box 内置** — Docker 镜像自带 sing-box,加密协议节点自动转为本地 SOCKS5 +- **软删除机制** — 订阅代理失败不删除只禁用,定时探测唤醒恢复 +- **访客贡献** — 未登录用户可贡献订阅 URL/文件,管理员统一管理 +- **自动清理** — 连续 7 天无可用节点的订阅自动移除 -### 🚪 多端口多协议支持 -- **双协议支持**:同时提供 HTTP 和 SOCKS5 协议服务,满足不同应用需求 -- **双模式策略**:每种协议都提供随机轮换和最低延迟两种模式 -- **4 个服务端口**: - - `7777` - HTTP 随机轮换(IP 多样性,适合爬虫) - - `7776` - HTTP 最低延迟(稳定连接,适合流媒体) - - `7779` - SOCKS5 随机轮换(IP 多样性,适合浏览器/游戏) - - `7780` - SOCKS5 最低延迟(稳定连接,适合固定应用) -- **自动切换**:所有端口都支持失败自动重试,智能切换备用代理 -- **共享池子**:四个端口使用同一个代理池,统一管理和优化 -- **可选认证**:支持 Basic Auth(HTTP)和用户名/密码认证(SOCKS5),对外开放时可启用 +### 多端口多协议 -### 🎨 黑客风格 WebUI -- **Matrix 美学**:荧光绿 + 纯黑背景,CRT 扫描线效果,JetBrains Mono 等宽字体 -- **双角色权限**:访客模式(只读)+ 管理员模式(完全控制),可安全公网开放 -- **实时仪表盘**:池子状态、质量分布可视化、协议统计,带荧光光晕效果 -- **完整配置界面**:池子容量、延迟标准、验证参数、优化策略均可在线调整(管理员) -- **代理注册表**:详细展示地址、出口 IP、位置、延迟、质量等级、使用统计 -- **中英文切换**:支持中文/英文界面切换,默认中文 -- **交互优化**:点击地址复制、单个代理刷新、实时日志倒计时 +| 端口 | 协议 | 模式 | 适用场景 | +|------|------|------|---------| +| 7777 | HTTP | 随机轮换 | 爬虫、数据采集、IP 多样性 | +| 7776 | HTTP | 最低延迟 | 长连接、流媒体、稳定优先 | +| 7779 | SOCKS5 | 随机轮换 | 浏览器、SSH、游戏 | +| 7780 | SOCKS5 | 最低延迟 | 稳定应用、固定连接 | +| 7778 | HTTP | WebUI | 管理面板(双角色权限) | -### 📊 适用场景 -- **Web 开发测试**:HTTP 代理测试 API、爬虫开发、数据采集 -- **浏览器代理**:SOCKS5 协议配置浏览器代理,访问受限网站 -- **命令行工具**:curl、wget、git 等工具使用 HTTP 代理 -- **应用代理**:需要 SOCKS5 协议的应用(SSH、游戏、聊天工具) -- **小型 VPS**:低资源消耗(固定池子 + 按需抓取 + 限流查询) -- **稳定需求**:自动剔除失败代理,始终保持健康池子 -- **质量优先**:S/A 级代理优先使用,自动优化延迟 +### WebUI 仪表盘 -### 🔌 HTTP vs SOCKS5 协议对比 +- 免费池 / 订阅池分离展示,实时状态监控 +- 订阅管理:添加 URL / 上传文件 / 刷新 / 暂停 / 删除 +- 系统设置:5 种代理模式切换、池子参数、地理过滤 +- 双角色权限:访客只读 + 管理员完全控制 +- 中英文切换 -| 特性 | HTTP 代理 | SOCKS5 代理 | -|------|----------|-------------| -| **协议支持** | 仅 HTTP/HTTPS | 所有 TCP 协议(HTTP/HTTPS/SSH/FTP/游戏等) | -| **工作层级** | 应用层(Layer 7) | 会话层(Layer 5) | -| **浏览器支持** | ✅ 良好 | ✅ 更好(无协议限制) | -| **命令行工具** | ✅ curl/wget 原生支持 | ⚠️ 部分工具需要额外配置 | -| **非 HTTP 应用** | ❌ 不支持 | ✅ 完全支持(SSH/游戏/聊天) | -| **UDP 支持** | ❌ 不支持 | ✅ 支持(SOCKS5 协议特性) | -| **性能开销** | 较低 | 稍高 | -| **认证方式** | Basic Auth | 用户名/密码 | +## 快速开始 -**快速选择建议**: -- 🌐 **HTTP 代理**(端口 7776/7777):适合 Web 开发、API 测试、爬虫、数据采集 -- 🔒 **SOCKS5 代理**(端口 7779/7780):适合浏览器、SSH 隧道、游戏、聊天应用、需要完整协议支持的场景 +### Docker 部署(推荐) -**架构设计**: -- HTTP 代理服务:可使用池中的 HTTP 或 SOCKS5 上游代理 -- SOCKS5 代理服务:**仅使用 SOCKS5 上游代理**(因为许多免费 HTTP 代理不支持 HTTPS CONNECT 方法) +```bash +# 一键启动(自动拉取最新镜像) +docker compose up -d -### 📝 扩展文档 - -- [地理过滤配置指南](GEO_FILTER.md) - 国家代码、使用场景、测试方法 -- [数据目录说明](DATA_DIRECTORY.md) - 数据库、配置文件、备份恢复 -- [测试脚本使用](test/README.md) - HTTP + SOCKS5 测试脚本详细说明 -- [架构设计文档](POOL_DESIGN.md) - 完整的系统设计和实现细节 - -## 📦 项目结构 - -```text -. -├── main.go # 程序入口,协调所有模块 -├── config/ # 配置系统(池子容量、延迟标准、验证参数等) -├── pool/ # 🆕 池子管理器(入池判断、替换逻辑、状态计算) -├── fetcher/ # 🆕 智能抓取器(源分组、断路器、按需抓取) -│ ├── fetcher.go # 多模式抓取逻辑 -│ ├── source_manager.go # 源状态管理和断路器 -│ └── ip_query.go # IP查询限流和多源降级 -├── validator/ # 代理验证(连接测试 + 出口IP检测 + 地理过滤) -├── checker/ # 🆕 分批健康检查器 -├── optimizer/ # 🆕 优化轮换器(定时优化池子质量) -├── storage/ # 🆕 扩展存储层(质量等级、使用统计、源状态表) -├── proxy/ # 🆕 对外代理服务(HTTP + SOCKS5 双协议,4 端口 + 可选认证) -│ ├── server.go # HTTP 代理服务器 -│ └── socks5_server.go # SOCKS5 代理服务器 -├── webui/ # 🆕 黑客风格 WebUI(健康仪表盘、配置界面、RBAC) -├── logger/ # 内存日志收集 -├── test/ # 🧪 测试脚本与文档 -│ ├── test_proxy.sh # HTTP 代理测试脚本(Bash) -│ ├── test_socks5.sh # SOCKS5 代理测试脚本(Bash) -│ ├── test_http_https.sh # HTTP 代理 HTTPS 访问测试脚本(Bash) -│ ├── test_proxy.go # Go 测试脚本 -│ ├── test_proxy.py # Python 测试脚本 -│ └── README.md # 测试脚本使用说明 -├── .github/workflows/ -│ └── docker-image.yml # 🆕 GitHub Actions 自动构建(多平台镜像) -├── .env.example # 🆕 环境变量配置模板 -├── docker-compose.yml # 🆕 Docker Compose 配置(使用 Named Volume) -├── Dockerfile # Docker 构建文件 -├── data/ # 🆕 数据目录(SQLite 数据库、配置文件) -│ └── .gitkeep # 目录占位符和说明 -├── GEO_FILTER.md # 🆕 地理过滤配置指南 -├── DATA_DIRECTORY.md # 🆕 数据目录说明文档 -├── POOL_DESIGN.md # 完整架构设计文档 -└── README.md # 本文件 +# 访问 WebUI +# http://localhost:7778(默认密码:goproxy) ``` -## 🚀 快速开始 +自定义配置: -### 运行要求 -- Go `1.25` -- CGO 编译环境(依赖 `github.com/mattn/go-sqlite3`) +```bash +cp .env.example .env +vim .env # 修改密码、认证、地理过滤等 +docker compose up -d +``` ### 本地运行 ```bash +# 需要 Go 1.25 + CGO(依赖 go-sqlite3) go run . + +# 或编译后运行 +go build -o proxygo . && ./proxygo ``` -或先编译再启动: +> 如需订阅导入功能,本地需安装 [sing-box](https://sing-box.sagernet.org/):`brew install sing-box` + +## 使用代理 + +### HTTP 代理 ```bash -go build -o proxygo . -./proxygo -``` - -程序启动后会: -1. 加载配置(环境变量 + `config.json`) -2. 初始化数据库和限流器 -3. 清理不符合条件的代理(不符合地理过滤规则、无地理信息) -4. 启动 WebUI(`:7778`) -5. 立即执行智能填充(按需抓取 + 严格验证) -6. 启动后台协程: - - 状态监控(每 30 秒) - - 健康检查(默认 5 分钟) - - 优化轮换(默认 30 分钟) -7. 启动四个代理服务(支持可选认证): - - `:7776` - HTTP 最低延迟模式(稳定连接) - - `:7777` - HTTP 随机轮换模式(IP 多样性) - - `:7779` - SOCKS5 随机轮换模式(IP 多样性) - - `:7780` - SOCKS5 最低延迟模式(稳定连接) - -### 默认端口 - -#### HTTP 代理端口 -- **7777 端口(HTTP 随机轮换)**:每次请求随机选择代理,IP 多样性高 -- **7776 端口(HTTP 最低延迟)**:固定使用延迟最低的代理,性能优先 - -#### SOCKS5 代理端口 -- **7779 端口(SOCKS5 随机轮换)**:每次连接随机选择代理,IP 多样性高 -- **7780 端口(SOCKS5 最低延迟)**:固定使用延迟最低的代理,性能优先 - -#### 管理端口 -- **WebUI**:`7778` -- **默认密码**:`goproxy`(可通过 `WEBUI_PASSWORD` 环境变量自定义) -- **访问方式**:本地使用 `localhost`,远程使用服务器 IP 地址 - -### 使用代理 - -GoProxy 提供**四个代理端口**,支持 HTTP 和 SOCKS5 两种协议,满足不同场景需求: - -#### 🌐 HTTP 协议代理 - -##### 🎲 7777 端口 - HTTP 随机轮换模式 - -适合需要 **IP 多样性** 的场景(爬虫、数据采集、负载均衡): - -```bash -# 本地使用 +# 随机轮换(IP 多样性) curl -x http://localhost:7777 https://httpbin.org/ip -# 远程使用 -curl -x http://your-server-ip:7777 https://httpbin.org/ip -``` - -**特点**: -- 每次请求随机选择一个代理 -- 优先使用高质量(S/A 级)代理 -- IP 地址高度分散 - -##### ⚡ 7776 端口 - HTTP 最低延迟模式 - -适合需要 **稳定连接** 的场景(长连接、流媒体、实时通信): - -```bash -# 本地使用 +# 最低延迟(稳定优先) curl -x http://localhost:7776 https://httpbin.org/ip -# 远程使用 -curl -x http://your-server-ip:7776 https://httpbin.org/ip -``` - -**特点**: -- 固定使用池中延迟最低的代理 -- 除非该代理失败,否则不会切换 -- 失败时自动删除并切换到下一个最低延迟代理 -- 性能和稳定性最优 - -#### 🔌 SOCKS5 协议代理 - -##### 🎲 7779 端口 - SOCKS5 随机轮换模式 - -适合需要 **原生 SOCKS5** 和 **IP 多样性** 的场景: - -```bash -# 使用 curl(需要 7.21.7+) -curl --socks5 localhost:7779 https://httpbin.org/ip - -# 远程使用 -curl --socks5 your-server-ip:7779 https://httpbin.org/ip - -# 使用 proxychains -echo "socks5 127.0.0.1 7779" > ~/.proxychains.conf -proxychains4 curl https://httpbin.org/ip -``` - -**特点**: -- 原生 SOCKS5 协议,更广泛的应用支持 -- 每次连接随机选择代理 -- 支持 TCP 和 UDP(如果上游代理支持) - -##### ⚡ 7780 端口 - SOCKS5 最低延迟模式 - -适合需要 **SOCKS5 协议** 和 **稳定连接** 的场景: - -```bash -# 本地使用 -curl --socks5 localhost:7780 https://httpbin.org/ip - -# 远程使用 -curl --socks5 your-server-ip:7780 https://httpbin.org/ip -``` - -**特点**: -- 固定使用延迟最低的代理 -- 适合需要 SOCKS5 协议的应用(如浏览器、游戏客户端) -- 最佳性能和稳定性 - -#### 环境变量配置 - -**HTTP 代理**: -```bash -# 使用随机模式 +# 环境变量方式 export http_proxy=http://localhost:7777 export https_proxy=http://localhost:7777 - -# 或使用稳定模式 -export http_proxy=http://localhost:7776 -export https_proxy=http://localhost:7776 - -# 远程使用(带认证) -export http_proxy=http://proxy:your_password@your-server-ip:7777 -export https_proxy=http://proxy:your_password@your-server-ip:7777 ``` -**SOCKS5 代理**(更多应用支持): +### SOCKS5 代理 + ```bash -# 使用随机模式 +# 随机轮换 +curl --socks5 localhost:7779 https://httpbin.org/ip + +# 最低延迟 +curl --socks5 localhost:7780 https://httpbin.org/ip + +# 环境变量方式 export ALL_PROXY=socks5://localhost:7779 - -# 或使用稳定模式 -export ALL_PROXY=socks5://localhost:7780 - -# 远程使用(带认证) -export ALL_PROXY=socks5://proxy:your_password@your-server-ip:7779 ``` -#### 端口对比 +### 带认证使用 -| 端口 | 协议 | 模式 | IP 多样性 | 稳定性 | 性能 | 适用场景 | -|------|------|------|----------|--------|------|---------| -| **7777** | HTTP | 随机轮换 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | 爬虫、数据采集 | -| **7776** | HTTP | 最低延迟 | ⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 长连接、流媒体 | -| **7779** | SOCKS5 | 随机轮换 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | 浏览器、游戏、SSH | -| **7780** | SOCKS5 | 最低延迟 | ⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 稳定应用连接 | +```bash +# HTTP +curl -x http://user:pass@your-server:7777 https://httpbin.org/ip -#### 协议选择建议 +# SOCKS5 +curl --socks5 user:pass@your-server:7779 https://httpbin.org/ip -**何时使用 HTTP 代理(7776/7777)**: -- 简单的 HTTP/HTTPS 请求 -- curl、wget 等命令行工具 -- 大多数编程语言的 HTTP 客户端 +# 环境变量 +export http_proxy=http://user:pass@your-server:7777 +export ALL_PROXY=socks5://user:pass@your-server:7779 +``` -**何时使用 SOCKS5 代理(7779/7780)**: -- 需要代理非 HTTP 协议(如 SSH、FTP、游戏) -- 浏览器代理设置(SOCKS5 支持更好) -- 需要 UDP 支持的应用 -- 某些应用只支持 SOCKS5 协议 - -#### SOCKS5 使用示例 +### 编程语言示例 **Python**: ```python import requests -proxies = { - 'http': 'socks5://localhost:7779', - 'https': 'socks5://localhost:7779' -} -response = requests.get('https://httpbin.org/ip', proxies=proxies) +# HTTP 代理 +proxies = {'http': 'http://localhost:7777', 'https': 'http://localhost:7777'} +requests.get('https://httpbin.org/ip', proxies=proxies) + +# SOCKS5 代理(需 pip install requests[socks]) +proxies = {'http': 'socks5://localhost:7779', 'https': 'socks5://localhost:7779'} +requests.get('https://httpbin.org/ip', proxies=proxies) ``` **Node.js**: ```javascript -const SocksProxyAgent = require('socks-proxy-agent'); +// SOCKS5(需 npm install socks-proxy-agent node-fetch) +const { SocksProxyAgent } = require('socks-proxy-agent'); const fetch = require('node-fetch'); - const agent = new SocksProxyAgent('socks5://localhost:7779'); -fetch('https://httpbin.org/ip', { agent }).then(res => res.json()); +fetch('https://httpbin.org/ip', { agent }).then(r => r.json()).then(console.log); ``` -**浏览器配置**: -- 类型:SOCKS5 -- 地址:`localhost` 或服务器 IP -- 端口:`7779`(随机)或 `7780`(稳定) - -**SSH 隧道**: +**浏览器 / SSH**: ```bash +# 浏览器:设置 → 代理 → SOCKS5 → localhost:7779 +# SSH 隧道: ssh -o ProxyCommand='nc -X 5 -x localhost:7779 %h %p' user@remote-server ``` -#### 自动重试机制说明 +## 订阅导入 -当你通过 GoProxy 发送请求时,如果上游代理失败,系统会**自动处理**: +通过 WebUI 管理订阅(管理员登录后): -1. **立即删除失败代理**:从池子中移除不可用的代理 -2. **自动切换重试**:随机选择另一个可用代理重新发送请求(最多重试 3 次) -3. **用户完全无感知**:整个过程在服务端完成,你的应用只会收到成功响应或最终失败提示 -4. **防止重复尝试**:同一请求中不会重复使用已失败的代理 +1. **订阅 URL** — 填入 Clash/V2ray 订阅地址,自动识别格式并解析 +2. **上传文件** — 拖拽或选择 Clash YAML / V2ray 配置文件 -**示例流程**: -``` -用户请求 → 代理A失败(删除) → 自动切换代理B → 代理B成功 → 返回响应 -``` +支持的节点协议:vmess、vless、trojan、shadowsocks、hysteria2、anytls、http、socks5 -这意味着即使池子中有部分失效代理,你的应用依然可以正常工作,系统会自动保持池子质量。 +订阅代理与免费代理的区别: +- 健康检查失败 → 禁用(不删除),定时探测唤醒 +- 不受免费池 slot 容量限制 +- 地理过滤 → 禁用(不删除) +- 连续 7 天无可用节点 → 自动移除订阅 -## 🐳 Docker 部署 +访客可通过顶部「贡献订阅」按钮分享自己的订阅 URL 或配置文件。 -> 💡 **自动构建**:GitHub Actions 自动构建多架构镜像(linux/amd64、linux/arm64),默认推送到 GHCR,可选推送到 Docker Hub -> 💾 **数据持久化**:必须挂载 `data/` 目录以保存代理池数据和配置,详见 [`DATA_DIRECTORY.md`](DATA_DIRECTORY.md) +## Docker 部署详解 -### 🔄 GitHub Actions 自动构建 - -项目配置了自动化 CI/CD 流程: - -**触发条件**: -- 推送到 `main` 分支 → 构建 `latest` 标签 -- 推送版本标签(如 `v1.0.0`)→ 构建多个版本标签(`1.0.0`, `1.0`, `1`, `latest`) -- 手动触发(workflow_dispatch) - -**镜像仓库**: -- **GHCR**(默认):`ghcr.io/isboyjc/goproxy` - 零配置,自动推送 -- **Docker Hub**(可选):`docker.io/isboyjc/goproxy` - 需配置 secrets: - - `DOCKERHUB_USERNAME` - Docker Hub 用户名 - - `DOCKERHUB_TOKEN` - Docker Hub Access Token - -**工作流程文件**:[`.github/workflows/docker-image.yml`](.github/workflows/docker-image.yml) - -### 快速启动(推荐) - -使用 docker-compose 一键部署: - -```bash -# 1. 复制环境变量模板(可选,使用默认配置也可直接启动) -cp .env.example .env - -# 2. 编辑 .env 设置密码等(可选) -vim .env - -# 3. 启动服务(自动拉取最新镜像) -docker compose up -d - -# 4. 访问 WebUI -# http://localhost:7778(默认密码:goproxy) -``` - -**数据持久化**: -- ✅ 使用 Docker Named Volume `goproxy-data` -- ✅ 容器重启/更新不会丢失数据 -- ✅ 数据独立存储,不受项目目录影响 - -### docker run 方式部署 +### docker run 方式 ```bash docker run -d --name proxygo \ - -p 7776:7776 \ - -p 7777:7777 \ - -p 7778:7778 \ - -p 7779:7779 \ - -p 7780:7780 \ + -p 7776:7776 -p 7777:7777 -p 7778:7778 -p 7779:7779 -p 7780:7780 \ -e WEBUI_PASSWORD=your_password \ + -e PROXY_AUTH_ENABLED=true \ + -e PROXY_AUTH_USERNAME=myuser \ + -e PROXY_AUTH_PASSWORD=mypass \ -v goproxy-data:/app/data \ ghcr.io/isboyjc/goproxy:latest ``` -> 💡 **数据卷说明**:使用 Named Volume `goproxy-data` 确保数据持久化。如需本地开发调试,可改用 `-v "$(pwd)/data:/app/data"`。 +### 数据持久化 -### 数据备份与恢复 +- docker-compose 使用 Named Volume `goproxy-data`,容器重启/更新不丢数据 +- 数据包含:SQLite 数据库(代理池)、config.json(配置)、sing-box 配置 -**导出数据**: +**备份**: ```bash -# 导出 Named Volume 数据 -docker run --rm \ - -v goproxy-data:/data \ - -v $(pwd):/backup \ +docker run --rm -v goproxy-data:/data -v $(pwd):/backup \ alpine tar czf /backup/goproxy-backup-$(date +%Y%m%d).tar.gz -C /data . ``` -**恢复数据**: +**恢复**: ```bash -# 停止服务 docker compose down - -# 恢复备份 -docker run --rm \ - -v goproxy-data:/data \ - -v $(pwd):/backup \ - alpine sh -c "cd /data && tar xzf /backup/goproxy-backup-20260328.tar.gz" - -# 重启服务 +docker run --rm -v goproxy-data:/data -v $(pwd):/backup \ + alpine sh -c "cd /data && tar xzf /backup/goproxy-backup-*.tar.gz" docker compose up -d ``` -### 环境变量配置 - -**核心配置**(`.env` 文件): - -| 变量 | 默认值 | 说明 | -|------|--------|------| -| `BLOCKED_COUNTRIES` | `CN` | 屏蔽的国家代码(逗号分隔,如 `CN,RU`,留空=不屏蔽) | -| `ALLOWED_COUNTRIES` | 空 | 允许的国家代码白名单(非空时优先于黑名单,如 `US,JP,KR`) | -| `PROXY_AUTH_ENABLED` | `false` | 是否启用代理认证(对外开放时强烈建议启用) | -| `PROXY_AUTH_USERNAME` | `proxy` | 代理认证用户名 | -| `PROXY_AUTH_PASSWORD` | 空 | 代理认证密码 | -| `WEBUI_PASSWORD` | `goproxy` | WebUI 登录密码 | -| `STABLE_PORT` | `7776` | HTTP 最低延迟代理端口 | -| `RANDOM_PORT` | `7777` | HTTP 随机轮换代理端口 | -| `WEBUI_PORT` | `7778` | WebUI 管理端口 | -| `SOCKS5_STABLE_PORT` | `7780` | SOCKS5 最低延迟代理端口 | -| `SOCKS5_RANDOM_PORT` | `7779` | SOCKS5 随机轮换代理端口 | - -完整环境变量列表请查看 `.env.example` 文件。 - -**⚠️ 生产部署注意事项**: -- 如使用 Dokploy、Coolify 等平台部署,确保 `docker-compose.yml` 中配置了平台网络(如 `dokploy-network`) -- WebUI 端口可通过平台的域名功能访问,无需手动配置端口绑定 -- 代理端口(7776/7777/7779/7780)通过 `IP:端口` 直接访问,**强烈建议启用认证** - -**常用配置示例**: - -```bash -# 场景 1:本地使用(默认配置) -# 直接运行 docker compose up -d 即可 - -# 场景 2:启用代理认证(推荐) -cat > .env << EOF -PROXY_AUTH_ENABLED=true -PROXY_AUTH_USERNAME=myuser -PROXY_AUTH_PASSWORD=secure_pass_123 -WEBUI_PASSWORD=admin_pass_456 -BLOCKED_COUNTRIES=CN -EOF -docker compose up -d - -# 场景 3:屏蔽多个国家 -cat > .env << EOF -BLOCKED_COUNTRIES=CN,RU,KP,IR -WEBUI_PASSWORD=admin_pass -EOF -docker compose up -d - -# 场景 4:白名单模式(仅允许指定国家) -cat > .env << EOF -ALLOWED_COUNTRIES=US,JP,KR,SG -WEBUI_PASSWORD=admin_pass -EOF -docker compose up -d - -# 场景 5:不做地理限制 -cat > .env << EOF -BLOCKED_COUNTRIES= -WEBUI_PASSWORD=admin_pass -EOF -docker compose up -d -``` - -### 安全部署配置 - -**默认配置**:代理服务对外开放(端口 7776、7777、7779、7780),WebUI 对外开放(端口 7778)。 - -**⚠️ 重要提示**: - -| 使用场景 | 配置建议 | 安全级别 | -|---------|---------|---------| -| **公网部署** | 启用代理认证 + 防火墙限制 | ⭐⭐⭐⭐⭐ 推荐 | -| **内网部署** | 启用代理认证 或 防火墙白名单 | ⭐⭐⭐⭐ 安全 | -| **本地测试** | 无需认证 | ⭐⭐⭐ 仅测试 | - -**启用代理认证**(编辑 `.env`): - -```bash -PROXY_AUTH_ENABLED=true -PROXY_AUTH_USERNAME=myuser -PROXY_AUTH_PASSWORD=secure_pass_123 -WEBUI_PASSWORD=admin_pass -``` - -**客户端使用(带认证)**: - -```bash -# HTTP 代理 - 环境变量方式 -export http_proxy=http://myuser:secure_pass_123@server-ip:7777 -export https_proxy=http://myuser:secure_pass_123@server-ip:7777 - -# HTTP 代理 - curl 直接指定 -curl -x http://myuser:secure_pass_123@server-ip:7777 https://httpbin.org/ip - -# SOCKS5 代理 - curl 使用 -curl --socks5 myuser:secure_pass_123@server-ip:7779 https://httpbin.org/ip - -# Python - HTTP 代理 -proxies = {'http': 'http://myuser:secure_pass_123@server-ip:7777', 'https': 'http://myuser:secure_pass_123@server-ip:7777'} - -# Python - SOCKS5 代理 -proxies = {'http': 'socks5://myuser:secure_pass_123@server-ip:7779', 'https': 'socks5://myuser:secure_pass_123@server-ip:7779'} -``` - -## ⚙️ 配置说明 - -### 配置文件示例 - -所有配置均可通过 WebUI 的 **Configure Pool** 界面在线调整,也可以手动编辑 `config.json`: - -```json -{ - "pool_max_size": 100, - "pool_http_ratio": 0.3, - "pool_min_per_protocol": 10, - "max_latency_ms": 2000, - "max_latency_healthy": 1500, - "max_latency_emergency": 3000, - "validate_concurrency": 300, - "validate_timeout": 8, - "health_check_interval": 5, - "health_check_batch_size": 20, - "optimize_interval": 30, - "replace_threshold": 0.7 -} -``` - -### 配置参数详解 - -**服务端口配置** - -| 参数 | 默认值 | 说明 | -| --- | --- | --- | -| `proxy_port` | `:7777` | HTTP 随机轮换代理端口 | -| `stable_proxy_port` | `:7776` | HTTP 最低延迟代理端口 | -| `socks5_port` | `:7779` | SOCKS5 随机轮换代理端口 | -| `stable_socks5_port` | `:7780` | SOCKS5 最低延迟代理端口 | -| `webui_port` | `:7778` | WebUI 端口 | - -**代理认证配置** - -| 参数 | 默认值 | 说明 | -| --- | --- | --- | -| `proxy_auth_enabled` | `false` | 是否启用代理认证(对外开放时建议启用) | -| `proxy_auth_username` | `proxy` | 代理认证用户名 | -| `proxy_auth_password_hash` | 空 | 代理认证密码 SHA256 哈希(HTTP Basic Auth) | - -> 💡 **注意**: -> - 代理认证配置通过**环境变量**设置,不在 `config.json` 中 -> - 启动时从 `PROXY_AUTH_ENABLED`、`PROXY_AUTH_USERNAME`、`PROXY_AUTH_PASSWORD` 环境变量读取 -> - **HTTP 代理**使用 Basic Auth(密码哈希),**SOCKS5 代理**使用用户名/密码认证(明文传输,建议内网使用) - -**池子容量配置** - -| 参数 | 默认值 | 说明 | 推荐范围 | -| --- | --- | --- | --- | -| `pool_max_size` | `100` | 代理池总容量 | 50-150 ⚠️ | -| `pool_http_ratio` | `0.3` | HTTP 协议占比 | 0.2-0.5 | -| `pool_min_per_protocol` | `10` | 每协议最少保证数量 | 5-50 | - -> ⚠️ **容量限制说明**:公开代理源质量有限,验证通过率通常只有 1-3%。受地理过滤、延迟标准、出口检测等因素影响,**实际填充率约为 70-90%**。如设置 150 容量,实际可能稳定在 105-135 个。建议根据实际需求设置合理容量。 - -**延迟标准配置** ⚡ - -| 参数 | 默认值 | 说明 | 推荐范围 | -| --- | --- | --- | --- | -| `max_latency_ms` | `2500` | 标准模式最大延迟(毫秒) | 2000-3500 | -| `max_latency_healthy` | `2000` | 健康模式严格延迟(毫秒) | 1500-2500 | -| `max_latency_emergency` | `4000` | 紧急/补充模式放宽延迟(毫秒) | 3000-5000 | - -> 💡 **状态与延迟**:`emergency/critical/warning` 状态下使用 `max_latency_emergency`(4000ms),`healthy` 状态使用 `max_latency_healthy`(2000ms)。这确保在池子容量不足时能快速补充。 - -**验证配置** - -| 参数 | 默认值 | 说明 | 推荐范围 | -| --- | --- | --- | --- | -| `validate_concurrency` | `300` | 验证并发数 | 200-500 | -| `validate_timeout` | `10` | 验证超时(秒) | 8-15 | - -**健康检查配置** - -| 参数 | 默认值 | 说明 | 推荐范围 | -| --- | --- | --- | --- | -| `health_check_interval` | `5` | 检查间隔(分钟) | 3-15 | -| `health_check_batch_size` | `20` | 每批检查数量 | 10-50 | - -**优化配置** - -| 参数 | 默认值 | 说明 | 推荐范围 | -| --- | --- | --- | --- | -| `optimize_interval` | `30` | 优化轮换间隔(分钟) | 15-120 | -| `replace_threshold` | `0.7` | 替换阈值(新代理需快 30%) | 0.5-0.9 | - -### 不同场景配置建议 - -**小型 VPS(1C2G)** -```json -{ - "pool_max_size": 50, - "pool_http_ratio": 0.3, - "validate_concurrency": 100, - "health_check_interval": 10, - "health_check_batch_size": 10, - "optimize_interval": 60 -} -``` - -**中型服务器(2C4G+)** -```json -{ - "pool_max_size": 200, - "pool_http_ratio": 0.6, - "validate_concurrency": 300, - "health_check_interval": 5, - "health_check_batch_size": 30, - "optimize_interval": 30 -} -``` - -**低延迟优先** -```json -{ - "pool_max_size": 100, - "max_latency_ms": 1000, - "max_latency_healthy": 800, - "optimize_interval": 15, - "replace_threshold": 0.8 -} -``` - -**高可用优先(需要更多代理)** -```json -{ - "pool_max_size": 300, - "pool_http_ratio": 0.7, - "pool_min_per_protocol": 20, - "max_latency_ms": 3000 -} -``` - -### 固定配置 - -以下配置在代码中固定或通过环境变量设置,无需在 `config.json` 中调整: - -| 配置项 | 值 | 说明 | -| --- | --- | --- | -| `WebUIPort` | `:7778` | WebUI 端口 | -| `ProxyPort` | `:7777` | 随机轮换代理端口 | -| `StableProxyPort` | `:7776` | 最低延迟代理端口 | -| `ValidateURL` | `http://www.gstatic.com/generate_204` | 验证目标地址 | -| `IPQueryRateLimit` | `10 次/秒` | IP 查询限流 | -| `SourceFailThreshold` | `3` | 源降级阈值(连续失败) | -| `SourceDisableThreshold` | `5` | 源禁用阈值(连续失败) | -| `SourceCooldownMinutes` | `30` | 源禁用冷却时间 | -| `MaxRetry` | `3` | 代理请求失败重试次数 | - -**环境变量配置**(启动时读取): - -| 环境变量 | 默认值 | 说明 | -| --- | --- | --- | -| `PROXY_AUTH_ENABLED` | `false` | 是否启用代理认证 | -| `PROXY_AUTH_USERNAME` | `proxy` | 代理认证用户名 | -| `PROXY_AUTH_PASSWORD` | 空 | 代理认证密码(原始密码,自动哈希) | -| `BLOCKED_COUNTRIES` | `CN` | 屏蔽的国家代码(逗号分隔,如 `CN,RU,KP`,留空=不屏蔽) | -| `ALLOWED_COUNTRIES` | 空 | 允许的国家代码白名单(非空时优先于黑名单,如 `US,JP,KR`) | - -## 🎨 WebUI 使用指南 - -访问地址:`http://localhost:7778`(本地)或 `http://your-server-ip:7778`(远程) - -### 👥 双角色权限系统 - -GoProxy WebUI 支持**访客模式**和**管理员模式**: - -#### 访客模式(只读) - -**无需登录**即可访问,可以查看所有数据但不能操作: - -- ✅ 查看池子状态和质量分布 -- ✅ 查看代理列表和详细信息 -- ✅ 查看系统日志 -- ✅ 点击复制代理地址 -- ❌ 不能抓取代理 -- ❌ 不能刷新延迟 -- ❌ 不能删除代理 -- ❌ 不能修改配置 - -**适用场景**: -- 团队成员监控代理池状态 -- 展示给客户或第三方查看 -- 公网开放访问(只读数据安全) - -#### ⚡ 管理员模式(完全控制) - -**登录后**拥有所有操作权限: - -- ✅ 所有访客模式的查看功能 -- ✅ 手动触发代理抓取 -- ✅ 刷新所有代理延迟 -- ✅ 刷新单个代理信息 -- ✅ 删除指定代理 -- ✅ 修改池子配置(容量、延迟标准、检查间隔等) - -**默认密码**:`goproxy`(通过环境变量 `WEBUI_PASSWORD` 自定义) - -### 健康仪表盘 - -**四宫格指标卡** -- **Pool Status**:当前池子状态(HEALTHY/WARNING/CRITICAL/EMERGENCY) -- **Total Proxies**:总代理数 / 池子容量 -- **HTTP**:HTTP 代理数 / HTTP 槽位数 + 平均延迟 -- **SOCKS5**:SOCKS5 代理数 / SOCKS5 槽位数 + 平均延迟 - -**质量分布可视化** -- 横向条形图展示 S/A/B/C 四级质量分布 -- 实时显示各级别代理数量 - -### 代理注册表 - -**表格字段** -- **Grade**:质量等级(S/A/B/C,基于延迟计算) -- **Protocol**:协议类型(HTTP/SOCKS5) -- **Address**:代理地址(host:port),点击可复制 -- **Exit IP**:代理的出口 IP 地址 -- **Location**:出口地理位置(国旗 emoji + 国家代码 + 城市) -- **Latency**:连接延迟(毫秒,动态颜色编码:绿/黄/橙/红) -- **Usage**:使用统计(使用总次数 / 成功次数,成功率指标) -- **Action**:操作按钮(刷新单个代理、删除代理,管理员可见) - -**操作功能** - -**所有用户可用**: -- **协议筛选**:下拉选择协议类型(全部/HTTP/SOCKS5) -- **国家筛选**:下拉选择出口国家(全部/动态国家列表,带国旗 emoji) -- **点击复制地址**:点击代理地址单元格直接复制到剪贴板 -- **查看数据**:池子状态、质量分布、系统日志 - -**管理员专属**(需登录): -- **Fetch Proxies**:手动触发智能抓取 -- **Refresh Latency**:重新验证所有代理并更新延迟 -- **刷新单个代理**:点击行内刷新按钮验证单个代理 -- **删除代理**:点击行内删除按钮移除指定代理 -- **Configure Pool**:打开配置界面修改池子参数 - -### 配置界面(⚡ 管理员专属) - -点击 **Configure Pool** 打开配置模态框,包含: - -**Pool Capacity 部分** -- Max Size:池子总容量 -- HTTP Ratio:HTTP 协议占比(0.5 = 50%) -- Min Per Protocol:每协议最小保证 - -**Latency Standards 部分** -- Standard:标准模式延迟阈值 -- Healthy:健康模式严格延迟 -- Emergency:紧急模式放宽延迟 - -**Validation & Health Check 部分** -- Validate Concurrency:并发验证数 -- Validate Timeout:验证超时 -- Health Check Interval:健康检查间隔 -- Health Check Batch Size:每批检查数量 - -**Optimization 部分** -- Optimize Interval:优化轮换间隔 -- Replace Threshold:替换阈值(0.7 = 新代理需快 30%) - -保存后立即生效,系统会自动调整池子策略。 - -## 🏗️ 核心架构 - -### 智能池子生命周期 +### 安全建议 + +| 场景 | 建议 | +|------|------| +| 公网部署 | 启用代理认证 + 修改 WebUI 密码 | +| 内网部署 | 启用代理认证 或 防火墙白名单 | +| 本地测试 | 默认配置即可 | + +## 环境变量 + +| 变量 | 默认值 | 必须 | 说明 | +|------|--------|------|------| +| `WEBUI_PASSWORD` | `goproxy` | 是 | WebUI 登录密码,生产环境务必修改 | +| `PROXY_AUTH_ENABLED` | `false` | 否 | 代理认证开关,公网部署建议启用 | +| `PROXY_AUTH_USERNAME` | `proxy` | 否 | 代理认证用户名 | +| `PROXY_AUTH_PASSWORD` | 空 | 否 | 代理认证密码,启用认证时必填 | +| `BLOCKED_COUNTRIES` | `CN` | 否 | 屏蔽国家代码(逗号分隔,留空不屏蔽) | +| `ALLOWED_COUNTRIES` | 空 | 否 | 允许国家白名单(非空时优先于黑名单) | +| `CUSTOM_PROXY_MODE` | `mixed` | 否 | 代理模式:mixed / custom_only / free_only | +| `SINGBOX_PATH` | `sing-box` | 否 | sing-box 路径(Docker 内置,无需修改) | +| `TZ` | `Asia/Shanghai` | 否 | 时区 | + +完整配置见 [.env.example](.env.example),更多池子参数可通过 WebUI 设置面板调整。 + +## 项目结构 ```text -[启动] → 状态监控 → 判断池子健康度 - ↓ - 需要补充? - ↙ ↘ - 是 否 - ↓ ↓ - 智能抓取 保持监控 - (多模式) ↓ - ↓ 优化轮换 - 严格验证 (定时执行) - ↓ ↓ - 智能入池 替换劣质代理 - (替换逻辑) ↓ - ↓ 分批健康检查 - ↓ (剔除失败) - ↓ ↓ - └─────────┘ - ↓ - 持续优化循环 +main.go # 入口,协调所有模块 +├── config/ # 配置(环境变量 + config.json) +├── storage/ # SQLite 持久化(proxies + subscriptions + source_status) +├── fetcher/ # 多源代理抓取 + 断路器 +├── validator/ # 代理验证(连接 + IP + 地理 + 延迟) +├── pool/ # 池子管理(准入 + 替换 + 状态机) +├── checker/ # 健康检查(free 删除 / custom 禁用) +├── optimizer/ # 质量优化(仅免费池) +├── custom/ # 订阅管理 +│ ├── parser.go # 格式自动识别解析 +│ ├── singbox.go # sing-box 进程管理 +│ └── manager.go # 刷新循环 + 探测唤醒 + 过期清理 +├── proxy/ # 代理服务(HTTP + SOCKS5,4 端口) +├── webui/ # 管理面板(嵌入式 HTML + REST API) +└── logger/ # 日志收集 ``` -### 状态转换机制 +## 扩展文档 -```text -Healthy (总数≥95% 且 各协议≥80%槽位) - ↓ 代理失效 -Warning (总数<95% 或 任一协议<80%) - ↓ 继续失效 -Critical (总数<50% 或 任一协议<20%槽位) - ↓ 继续失效 -Emergency (总数<10% 或 单协议缺失) - ↑ - └─ 自动触发紧急抓取 ─┘ -``` +- [架构设计文档](POOL_DESIGN.md) — 状态机、数据模型、选择策略、sing-box 集成 +- [地理过滤配置](GEO_FILTER.md) — 国家代码、白名单/黑名单、测试方法 +- [数据目录说明](DATA_DIRECTORY.md) — 数据库、配置文件、备份恢复 +- [测试脚本](test/README.md) — HTTP + SOCKS5 测试脚本 +- [更新日志](CHANGELOG.md) — 版本历史 -> 💡 **自动补充阈值**:当总数低于 95% 时进入 Warning 状态并触发自动补充,确保池子始终接近满容量运行。 +## 免责声明 -### 抓取模式选择 +本项目仅供学习交流和技术研究使用。 -| 池子状态 | 抓取模式 | 使用源 | 触发条件 | -| --- | --- | --- | --- | -| Emergency | 紧急模式 | 所有可用源 | 单协议缺失或总数<10% | -| Critical/Warning | 补充模式 | 快更新源 | 总数<95%或协议不均 | -| Healthy | 优化模式 | 慢更新源(随机2-3个) | 定时触发(30分钟) | +- 本项目抓取的代理均来自互联网公开资源,不保证其可用性、稳定性和安全性 +- 用户应自行承担使用本项目的一切风险,包括但不限于网络安全风险、法律风险等 +- 请遵守当地法律法规,不得将本项目用于任何违法违规活动 +- 订阅导入功能仅为方便用户管理自有代理资源,用户应确保其订阅来源合法合规 +- 访客贡献的订阅由贡献者自行负责,项目维护者不对其内容承担任何责任 +- 本项目不提供任何形式的代理服务,不对通过本系统传输的内容负责 +- 作者不对因使用本项目造成的任何直接或间接损失承担责任 -### 质量分级标准 +使用本项目即表示您已阅读并同意以上声明。 -| 等级 | 延迟范围 | 说明 | 权重 | -| --- | --- | --- | --- | -| S | ≤500ms | 超快,优先使用,健康状态跳过检查 | 最高 | -| A | 501-1000ms | 良好,稳定可用 | 高 | -| B | 1001-2000ms | 可用,会被优化替换 | 中 | -| C | >2000ms | 淘汰候选,优先替换 | 低 | +## 友情链接 -## 🔧 数据库 Schema +- [LINUX DO](https://linux.do/) — 真诚、友善、团结、专业,共建你我引以为傲的社区 -### proxies 表 +## License -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `id` | INTEGER | 主键 | -| `address` | TEXT | 代理地址(UNIQUE) | -| `protocol` | TEXT | 协议类型(http/socks5) | -| `exit_ip` | TEXT | 出口 IP | -| `exit_location` | TEXT | 出口位置 | -| `latency` | INTEGER | 延迟(毫秒) | -| `quality_grade` | TEXT | 质量等级(S/A/B/C) | -| `use_count` | INTEGER | 使用次数 | -| `success_count` | INTEGER | 成功次数 | -| `fail_count` | INTEGER | 失败次数 | -| `last_used` | DATETIME | 最后使用时间 | -| `last_check` | DATETIME | 最后检查时间 | -| `created_at` | DATETIME | 创建时间 | -| `status` | TEXT | 状态(active/degraded/candidate_replace) | - -### source_status 表 - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `id` | INTEGER | 主键 | -| `url` | TEXT | 源地址(UNIQUE) | -| `success_count` | INTEGER | 成功次数 | -| `fail_count` | INTEGER | 失败次数 | -| `consecutive_fails` | INTEGER | 连续失败次数 | -| `last_success` | DATETIME | 最后成功时间 | -| `last_fail` | DATETIME | 最后失败时间 | -| `status` | TEXT | 状态(active/degraded/disabled) | -| `disabled_until` | DATETIME | 禁用到期时间 | - -## 🔍 代理源 - -系统内置 16 个代理源,分为快更新和慢更新两组: - -**快更新源(5-30分钟更新)** -- proxifly/free-proxy-list (HTTP/SOCKS4/SOCKS5) -- ProxyScraper/ProxyScraper (HTTP/SOCKS4/SOCKS5) -- monosans/proxy-list (HTTP) - -**慢更新源(每天更新)** -- TheSpeedX/SOCKS-List (HTTP/SOCKS4/SOCKS5) -- monosans/proxy-list (SOCKS4/SOCKS5) -- databay-labs/free-proxy-list (HTTP/SOCKS5) - -系统会根据池子状态自动选择合适的源组: -- 紧急/补充模式:使用快更新源,快速填充 -- 优化模式:随机选择慢更新源,精细优化 - -## 🚦 核心机制详解 - -### 1. 智能入池机制 - -每个代理在入池前需通过: -1. **连接验证**:能否成功连接 `http://www.gstatic.com/generate_204` -2. **出口 IP 检测**:获取代理的出口 IP -3. **地理位置查询**:获取出口 IP 的国家/城市 -4. **延迟测试**:测量连接延迟 -5. **质量评估**:根据延迟计算质量等级 -6. **HTTPS 隧道验证**(仅 HTTP 协议):通过代理实际访问随机 HTTPS 网站(Google/OpenAI/GitHub/Cloudflare/httpbin),验证 CONNECT 隧道可用性,首次失败自动换站重试 - -**入池判断逻辑** -- ✅ 协议槽位未满:直接加入 -- ✅ 槽位满但总量允许10%浮动:浮动加入 -- 🔄 池子满且质量更优:替换延迟最高的现有代理(需快30%+) -- ❌ 池子满且质量不足:拒绝 - -### 2. 健康检查机制 - -**批次检查策略** -- 每次检查 20 个代理(可配置) -- 优先检查长时间未检查的 -- 池子健康时跳过 S 级代理(降低资源消耗) - -**检查结果处理** -- ✅ 验证通过:更新延迟、出口 IP、质量等级 -- ❌ 验证失败:失败计数 +1,≥3次自动删除 - -### 3. 优化轮换机制 - -**触发条件** -- 池子状态:Healthy -- 定时触发:默认 30 分钟 - -**优化流程** -1. 从慢更新源随机抽取 2-3 个源 -2. 抓取候选代理并验证 -3. 筛选出延迟 ≤1500ms 的优质代理 -4. 尝试替换池中 B/C 级代理(需快30%+) - -**资源控制** -- 仅在池子健康时执行 -- 抽取少量源,避免浪费 -- 严格质量标准(≤1500ms) - -### 4. 源管理与断路器 - -**状态跟踪** -- 记录每个源的成功/失败次数 -- 连续失败 3 次:降级(Degraded) -- 连续失败 5 次:禁用 30 分钟(Disabled) -- 冷却期结束:自动恢复为 Active - -**好处** -- 避免浪费资源在失效源上 -- 自动恢复,无需人工干预 -- 保护系统免受源故障影响 - -## 📖 常见问题 - -### Q: 为什么池子容量是固定的? -A: 固定容量可以: -- **可预测资源消耗**:内存、CPU、网络带宽均可控 -- **提升代理质量**:通过严格准入和替换保持高质量 -- **简化管理逻辑**:避免无限增长和复杂的淘汰策略 - -### Q: 如何调整池子大小和协议比例? -A: -1. 访问 WebUI → 点击 **Configure Pool** -2. 修改 **Max Size** 和 **HTTP Ratio** -3. 点击 **Save Configuration** -4. 系统会自动调整槽位分配 - -示例: -- 池子大小 200,HTTP 比例 0.7 → HTTP 槽位 140,SOCKS5 槽位 60 -- 池子大小 50,HTTP 比例 0.3 → HTTP 槽位 15,SOCKS5 槽位 35 - -### Q: 池子状态如何计算? -A: -- **Healthy**:总数 ≥95% 且各协议 ≥80% 槽位 -- **Warning**:总数 <95% 或任一协议 <80% 槽位 -- **Critical**:总数 <50% 或任一协议 <20% 槽位 -- **Emergency**:总数 <10% 或单协议缺失 - -### Q: 如何优化延迟? -A: 系统会自动优化,也可以手动调整: -1. 降低 `max_latency_healthy`(严格模式) -2. 增加 `optimize_interval` 频率(更频繁优化) -3. 调高 `replace_threshold`(要求新代理更快) -4. 点击 **Refresh Latency** 立即重新验证 - -### Q: 为什么有的代理没有出口 IP? -A: -- IP 查询有限流(10 次/秒) -- 部分代理可能不支持 IP 查询 -- 系统会在后续健康检查中补全信息 -- 没有出口信息的代理会在启动时被自动清理 - -### Q: 如何配置地理过滤? -A: -支持黑名单和白名单两种模式,白名单优先: - -```bash -# === 黑名单模式(默认) === -BLOCKED_COUNTRIES=CN # 屏蔽中国大陆 -BLOCKED_COUNTRIES=CN,RU,KP # 屏蔽多个国家 -BLOCKED_COUNTRIES= # 不屏蔽任何国家 - -# === 白名单模式(优先于黑名单) === -ALLOWED_COUNTRIES=US,JP,KR,SG # 仅允许这些国家入池 -``` - -也可以通过 WebUI 配置面板的「地理过滤」区域动态修改,保存后立即生效。 - -**工作机制**: -- **白名单优先**:`ALLOWED_COUNTRIES` 非空时,仅允许白名单国家入池,黑名单被忽略 -- **验证阶段**:新代理验证时检查出口国家,不符合条件的直接拒绝 -- **启动清理**:自动删除数据库中不符合过滤规则的代理 -- **精确匹配**:使用 ISO 3166-1 alpha-2 国家代码(CN、HK、US 等) - -**常用国家代码**:`CN`=中国大陆 | `HK`=香港 | `RU`=俄罗斯 | `US`=美国 | `JP`=日本 | `SG`=新加坡 - -> 📖 **详细配置指南**:更多国家代码、使用场景、测试方法,请查看 [`GEO_FILTER.md`](./GEO_FILTER.md) - -### Q: 资源消耗如何? -A: -- **内存**:池子 100 个约 50MB,200 个约 100MB -- **CPU**:空闲时 <1%,验证时 10-30%(取决于并发数) -- **网络**: - - IP 查询限流 10 次/秒 - - 按需抓取,避免无效流量 - - 健康检查批次小(20 个) - -### Q: 代理服务如何启用认证? -A: -1. 编辑 `.env` 文件: - ```bash - PROXY_AUTH_ENABLED=true - PROXY_AUTH_USERNAME=myuser - PROXY_AUTH_PASSWORD=mypass - ``` -2. 重启服务:`docker compose up -d` -3. 客户端使用: - - HTTP:`http://myuser:mypass@server-ip:7777` - - SOCKS5:`socks5://myuser:mypass@server-ip:7779` - -### Q: 代理认证和 WebUI 认证有什么区别? -A: -- **代理认证**:保护代理服务端口(7776/7777/7779/7780),防止代理被滥用 -- **WebUI 认证**:保护 7778 管理后台,区分访客和管理员权限 -- 两者独立配置,互不影响 -- 启用代理认证时,HTTP 和 SOCKS5 代理都需要认证 - -## 🛠️ 开发与调试 - -### 查看日志 - -日志会输出到 stdout,同时在 WebUI 的 **System Log** 部分实时展示。 - -关键日志标识: -- `[pool]`:池子管理器 -- `[fetch]`:抓取器 -- `[source]`:源管理器 -- `[health]`:健康检查器 -- `[optimize]`:优化器 -- `[monitor]`:状态监控器 -- `[socks5]`:SOCKS5 代理服务器(握手、认证、连接建立) - -### 数据库操作 - -```bash -# 查看当前代理 -sqlite3 data/proxy.db "SELECT address, protocol, latency, quality_grade, status FROM proxies LIMIT 10;" - -# 查看质量分布 -sqlite3 data/proxy.db "SELECT quality_grade, COUNT(*) FROM proxies WHERE status='active' GROUP BY quality_grade;" - -# 查看源状态 -sqlite3 data/proxy.db "SELECT url, status, consecutive_fails FROM source_status;" - -# 清空池子(慎用) -sqlite3 data/proxy.db "DELETE FROM proxies;" -``` - -## 🧪 测试代理服务 - -项目提供了多种测试脚本,用于验证 HTTP 和 SOCKS5 代理服务功能和性能(位于 `test/` 目录)。 - -### 快速测试 - -**HTTP 代理测试**: -```bash -# 测试 HTTP 随机轮换模式(7777 端口) -./test/test_proxy.sh - -# 测试 HTTP 最低延迟模式(7776 端口) -./test/test_proxy.sh 7776 - -# 使用 Go/Python 脚本 -go run test/test_proxy.go 7777 -python test/test_proxy.py 7776 -``` - -**HTTP 代理 HTTPS 访问测试**: -```bash -# 持续测试 HTTP 代理访问 HTTPS 网站(随机访问 Google/OpenAI/GitHub 等) -./test/test_http_https.sh - -# 指定端口 -./test/test_http_https.sh 7776 - -# 指定端口 + 测试次数 -./test/test_http_https.sh 7777 20 -``` - -**SOCKS5 代理测试**: -```bash -# 测试 SOCKS5 随机轮换模式(7779 端口) -./test/test_socks5.sh localhost 7779 - -# 测试 SOCKS5 最低延迟模式(7780 端口) -./test/test_socks5.sh localhost 7780 - -# 持续测试 50 次 -./test/test_socks5.sh localhost 7779 50 - -# 按 Ctrl+C 停止测试并查看统计 -``` - -**HTTP 测试脚本**(`test_proxy.sh`)特点: -- **持续运行模式**:类似 `ping` 命令,持续发送请求 -- 实时显示每次请求的出口 IP、国家和延迟 -- 动态更新成功率统计 -- 验证 HTTP 代理轮换机制 -- 按 `Ctrl+C` 停止并显示完整统计报告 - -**SOCKS5 测试脚本**(`test_socks5.sh`)特点: -- 使用 `curl --socks5-hostname -k` 测试 SOCKS5 协议 -- 显示出口 IP、国家、延迟和时间戳 -- 验证 SOCKS5 代理轮换和稳定性 -- 支持指定测试次数或持续测试 -- 实时统计成功率和平均延迟 -- 自动跳过 SSL 证书验证(免费代理常有证书问题) - -### 测试输出示例 - -``` -PROXY localhost:7777 (http://ip-api.com/json/?fields=countryCode,query): continuous mode - -proxy from 🇺🇸 203.0.113.45: seq=1 time=1234ms -proxy from 🇩🇪 198.51.100.78: seq=2 time=987ms -proxy from 🇬🇧 192.0.2.123: seq=3 time=1567ms -proxy #4: request failed (timeout) -proxy from 🇯🇵 198.51.100.12: seq=5 time=890ms -...(持续运行,按 Ctrl+C 停止) - -^C ---- -50 requests transmitted, 47 received, 3 failed, 6.0% packet loss -``` - -**测试认证功能**: -```bash -# HTTP 代理 - 带认证 -curl -x http://myuser:mypass@localhost:7777 https://httpbin.org/ip - -# HTTP 代理 - 无认证(应该返回 407 错误) -curl -x http://localhost:7777 https://httpbin.org/ip - -# SOCKS5 代理 - 带认证 -curl --socks5 myuser:mypass@localhost:7779 https://httpbin.org/ip - -# SOCKS5 代理 - 无认证(应该连接失败) -curl --socks5 localhost:7779 https://httpbin.org/ip -``` - -**测试脚本使用**:[`test/README.md`](./test/README.md) - -## ❓ 常见问题 - -### Q1: HTTP 和 SOCKS5 代理有什么区别? - -**简单理解**: -- **HTTP 代理**:专门为 HTTP/HTTPS 设计,简单易用,命令行工具支持好 -- **SOCKS5 代理**:通用代理协议,支持所有 TCP/UDP 协议,功能更强大 - -**详细对比**见上方 [HTTP vs SOCKS5 协议对比](#-http-vs-socks5-协议对比) 章节。 - -### Q2: 我应该使用哪个端口? - -根据你的需求: - -| 需求 | 推荐端口 | 理由 | -|------|---------|------| -| 🕷️ **爬虫/数据采集** | 7777(HTTP 随机) | IP 高度分散,降低封禁风险 | -| 🎥 **流媒体/长连接** | 7776(HTTP 稳定) | 延迟最低,连接稳定 | -| 🌐 **浏览器代理** | 7779(SOCKS5 随机) | 协议支持好,IP 多样性高 | -| 🎮 **游戏/SSH/聊天** | 7780(SOCKS5 稳定) | 非 HTTP 协议,稳定优先 | - -### Q3: SOCKS5 代理支持认证吗? - -支持。当 `PROXY_AUTH_ENABLED=true` 时,所有代理端口(包括 SOCKS5)都需要认证: - -```bash -# SOCKS5 带认证 -curl --socks5 username:password@server-ip:7779 https://httpbin.org/ip - -# 浏览器配置 -# SOCKS5 Host: server-ip -# SOCKS5 Port: 7779 -# Username: username -# Password: password -``` - -### Q4: 为什么推荐"域名:端口"访问而非域名直接配置代理? - -**技术限制**: -- Cloudflare 等 DNS 托管服务的 HTTP 代理(橙云)**不支持非标准端口**(80/443 以外) -- 即使关闭橙云,DNS 记录也无法直接指向容器内部端口 - -**正确做法**: -```bash -# ✅ 推荐方式:IP:端口 或 域名:端口 -curl -x http://proxy.example.com:7777 https://httpbin.org/ip -curl -x http://123.45.67.89:7777 https://httpbin.org/ip - -# ❌ 不支持:直接域名(除非反向代理到 80/443) -curl -x http://proxy.example.com https://httpbin.org/ip -``` - -### Q5: 如何测试 SOCKS5 代理是否正常工作? - -使用提供的测试脚本: - -```bash -# 测试 SOCKS5 随机端口(7779) -./test/test_socks5.sh localhost 7779 - -# 持续测试 50 次 -./test/test_socks5.sh localhost 7779 50 -``` - -或手动测试: -```bash -# curl 测试(-k 跳过 SSL 证书验证) -curl -k --socks5-hostname localhost:7779 https://httpbin.org/ip - -# 或测试 HTTP(不需要 SSL) -curl --socks5-hostname localhost:7779 http://httpbin.org/ip - -# Python 测试(需要安装 requests[socks]) -python3 -c "import requests; print(requests.get('https://httpbin.org/ip', proxies={'http': 'socks5://localhost:7779', 'https': 'socks5://localhost:7779'}).json())" -``` - -### Q6: SOCKS5 代理失败率是否比 HTTP 高? - -不会。两种服务质量和成功率相近,但**使用不同的上游代理**: -- **HTTP 代理服务**(7776/7777):可使用池中的 HTTP 或 SOCKS5 上游代理 -- **SOCKS5 代理服务**(7779/7780):仅使用 SOCKS5 上游代理(因为许多免费 HTTP 代理不支持 HTTPS CONNECT) -- 两种服务都支持自动重试和故障切换 - -### Q7: 如何在浏览器中配置 SOCKS5 代理? - -**Chrome/Edge**(使用插件如 SwitchyOmega): -1. 安装 Proxy SwitchyOmega 插件 -2. 新建情景模式 → SOCKS5 -3. 代理服务器:`server-ip` 或 `localhost` -4. 代理端口:`7779`(随机)或 `7780`(稳定) -5. 如启用认证,填写用户名和密码 - -**Firefox**: -1. 设置 → 网络设置 → 手动代理配置 -2. SOCKS Host:`server-ip` 或 `localhost` -3. Port:`7779` 或 `7780` -4. 选择:SOCKS v5 -5. 勾选"通过 SOCKS 代理 DNS 查询"(可选) - -### Q8: 如何查看 SOCKS5 服务的运行日志? - -**本地运行**: -```bash -# 直接查看终端输出,SOCKS5 日志前缀为 [socks5] -# 启动日志示例: -# socks5 server listening on :7779 [随机轮换] [需认证 (用户: proxy)] -# socks5 server listening on :7780 [最低延迟] [无认证] - -# 连接日志示例: -# [socks5] google.com:443 via 203.0.113.45:1080 established -``` - -**Docker 部署**: -```bash -# 查看最近 50 行日志 -docker logs proxygo --tail 50 - -# 实时跟踪日志(含 SOCKS5) -docker logs proxygo -f - -# 过滤 SOCKS5 相关日志 -docker logs proxygo -f | grep socks5 - -# 查看 SOCKS5 错误日志 -docker logs proxygo --tail 200 | grep -i "socks5.*failed" -``` - -**WebUI 查看**: -- 访问 WebUI(端口 7778)→ 点击右上角 **Logs** 按钮 -- 实时日志面板会显示所有协议的连接和错误信息 -- 包括 SOCKS5 握手、认证、连接建立等详细日志 - -## 🙏 致谢与声明 - -本项目基于 [jonasen1988/proxygo](https://github.com/jonasen1988/proxygo) 进行魔改和增强。 - -### 原项目 -- **项目地址**:https://github.com/jonasen1988/proxygo -- **作者**:jonasen1988 -- **基础功能**:代理抓取、验证、存储、HTTP代理服务、WebUI管理 - -### 本项目增强功能 -在原项目基础上,我们进行了大量改进和功能增强: - -- 🆕 **智能池子机制**:固定容量管理、质量分级(S/A/B/C)、智能替换逻辑、HTTP/SOCKS5 默认 3:7 比例 -- 🆕 **HTTPS 可用性验证**:HTTP 协议代理入池/刷新时额外验证 HTTPS CONNECT 隧道,随机访问真实网站确认可用 -- 🆕 **按需抓取策略**:源分组、断路器保护、Emergency/Refill/Optimize 多模式 -- 🆕 **分层健康管理**:批次检查、智能跳过 S 级、定时优化轮换 -- 🆕 **智能重试机制**:自动故障切换、失败即删除、防重复尝试 -- 🆕 **双协议支持**:HTTP + SOCKS5 双协议,4 个服务端口(随机/稳定各 2 个) -- 🆕 **双模式策略**:每种协议都支持随机轮换(IP 多样性)和最低延迟(稳定连接) -- 🆕 **代理认证保护**:HTTP Basic Auth + SOCKS5 用户名/密码认证,对外开放时保护服务 -- 🆕 **黑客风格 WebUI**:Matrix 美学、实时仪表盘、完整配置界面、中英文切换 -- 🆕 **双角色权限**:访客模式(只读)+ 管理员模式(完全控制),可安全公网开放 -- 🆕 **扩展存储层**:质量等级、使用统计、源状态管理 -- 🆕 **测试套件**:HTTP + SOCKS5 + HTTPS 访问测试脚本,持续运行模式,显示国旗 emoji -- 🆕 **CI/CD 自动化**:GitHub Actions 自动构建多架构镜像(amd64/arm64),双仓库发布 -- 🆕 **环境变量配置**:docker-compose + .env 文件,灵活配置各种部署场景 - -感谢原作者提供的基础实现,让我们能够在此之上构建更强大的代理池系统。 - -同时感谢 [LINUX DO](https://linux.do/) 社区的支持。 - -## 📝 License - -MIT License \ No newline at end of file +[MIT](LICENSE) diff --git a/checker/health_checker.go b/checker/health_checker.go index c11d362..688ddae 100644 --- a/checker/health_checker.go +++ b/checker/health_checker.go @@ -78,9 +78,14 @@ func (hc *HealthChecker) RunOnce() { } else { // 失败次数+1 hc.storage.IncrementFailCount(result.Proxy.Address) - // 如果失败次数 >= 3,删除 + // 如果失败次数 >= 3 if result.Proxy.FailCount+1 >= 3 { - hc.storage.Delete(result.Proxy.Address) + if result.Proxy.Source == "custom" { + // 订阅代理:禁用而非删除 + hc.storage.DisableProxy(result.Proxy.Address) + } else { + hc.storage.Delete(result.Proxy.Address) + } removeCount++ } } diff --git a/config/config.go b/config/config.go index 4d408f6..48bd6c7 100644 --- a/config/config.go +++ b/config/config.go @@ -87,6 +87,15 @@ type Config struct { SourceDisableThreshold int // 源禁用阈值(默认5) SourceCooldownMinutes int // 源禁用冷却时间(默认30) + // ========== 自定义订阅代理配置 ========== + CustomProxyMode string // 代理使用模式:mixed / custom_only / free_only(默认 mixed) + CustomPriority bool // 混用模式下订阅代理优先(默认 true) + CustomFreePriority bool // 混用模式下免费代理优先(默认 false) + CustomProbeInterval int // 禁用代理探测唤醒间隔(分钟,默认 10) + CustomRefreshInterval int // 默认订阅刷新间隔(分钟,默认 60) + SingBoxPath string // sing-box 二进制路径(默认 "sing-box") + SingBoxBasePort int // sing-box 本地端口起始(默认 20000) + // ========== 兼容旧配置 ========== MaxResponseMs int // 已废弃,使用 MaxLatencyMs 替代 MaxFailCount int // 代理失败次数阈值 @@ -154,6 +163,16 @@ func DefaultConfig() *Config { } } + // 读取订阅代理配置 + customProxyMode := os.Getenv("CUSTOM_PROXY_MODE") + if customProxyMode == "" { + customProxyMode = "mixed" + } + singBoxPath := os.Getenv("SINGBOX_PATH") + if singBoxPath == "" { + singBoxPath = "sing-box" + } + return &Config{ // 基础服务配置 WebUIPort: ":7778", @@ -208,6 +227,14 @@ func DefaultConfig() *Config { SourceDisableThreshold: 5, // 失败5次禁用 SourceCooldownMinutes: 30, // 禁用30分钟 + // 自定义订阅代理配置 + CustomProxyMode: customProxyMode, + CustomPriority: true, + CustomProbeInterval: 10, + CustomRefreshInterval: 60, + SingBoxPath: singBoxPath, + SingBoxBasePort: 20000, + // 兼容旧配置 MaxResponseMs: 5000, MaxFailCount: 3, @@ -287,6 +314,29 @@ func Load() *Config { if saved.AllowedCountries != nil { cfg.AllowedCountries = saved.AllowedCountries } + + // 自定义订阅代理配置 + if saved.CustomProxyMode != "" { + cfg.CustomProxyMode = saved.CustomProxyMode + } + if saved.CustomPriority != nil { + cfg.CustomPriority = *saved.CustomPriority + } + if saved.CustomFreePriority != nil { + cfg.CustomFreePriority = *saved.CustomFreePriority + } + if saved.CustomProbeInterval > 0 { + cfg.CustomProbeInterval = saved.CustomProbeInterval + } + if saved.CustomRefreshInterval > 0 { + cfg.CustomRefreshInterval = saved.CustomRefreshInterval + } + if saved.SingBoxPath != "" { + cfg.SingBoxPath = saved.SingBoxPath + } + if saved.SingBoxBasePort > 0 { + cfg.SingBoxBasePort = saved.SingBoxBasePort + } } } cfgMu.Lock() @@ -330,6 +380,15 @@ type savedConfig struct { BlockedCountries []string `json:"blocked_countries,omitempty"` AllowedCountries []string `json:"allowed_countries,omitempty"` + // 自定义订阅代理配置 + CustomProxyMode string `json:"custom_proxy_mode,omitempty"` + CustomPriority *bool `json:"custom_priority,omitempty"` + CustomFreePriority *bool `json:"custom_free_priority,omitempty"` + CustomProbeInterval int `json:"custom_probe_interval,omitempty"` + CustomRefreshInterval int `json:"custom_refresh_interval,omitempty"` + SingBoxPath string `json:"singbox_path,omitempty"` + SingBoxBasePort int `json:"singbox_base_port,omitempty"` + // 兼容旧配置 FetchInterval int `json:"fetch_interval,omitempty"` CheckInterval int `json:"check_interval,omitempty"` @@ -341,23 +400,32 @@ func Save(cfg *Config) error { *globalCfg = *cfg cfgMu.Unlock() + customPriority := cfg.CustomPriority + customFreePriority := cfg.CustomFreePriority data, err := json.MarshalIndent(savedConfig{ - PoolMaxSize: cfg.PoolMaxSize, - PoolHTTPRatio: cfg.PoolHTTPRatio, - PoolMinPerProtocol: cfg.PoolMinPerProtocol, - MaxLatencyMs: cfg.MaxLatencyMs, - MaxLatencyEmergency: cfg.MaxLatencyEmergency, - MaxLatencyHealthy: cfg.MaxLatencyHealthy, - ValidateConcurrency: cfg.ValidateConcurrency, - ValidateTimeout: cfg.ValidateTimeout, - HealthCheckInterval: cfg.HealthCheckInterval, - HealthCheckBatchSize: cfg.HealthCheckBatchSize, - OptimizeInterval: cfg.OptimizeInterval, - ReplaceThreshold: cfg.ReplaceThreshold, - BlockedCountries: cfg.BlockedCountries, - AllowedCountries: cfg.AllowedCountries, - FetchInterval: cfg.FetchInterval, - CheckInterval: cfg.CheckInterval, + PoolMaxSize: cfg.PoolMaxSize, + PoolHTTPRatio: cfg.PoolHTTPRatio, + PoolMinPerProtocol: cfg.PoolMinPerProtocol, + MaxLatencyMs: cfg.MaxLatencyMs, + MaxLatencyEmergency: cfg.MaxLatencyEmergency, + MaxLatencyHealthy: cfg.MaxLatencyHealthy, + ValidateConcurrency: cfg.ValidateConcurrency, + ValidateTimeout: cfg.ValidateTimeout, + HealthCheckInterval: cfg.HealthCheckInterval, + HealthCheckBatchSize: cfg.HealthCheckBatchSize, + OptimizeInterval: cfg.OptimizeInterval, + ReplaceThreshold: cfg.ReplaceThreshold, + BlockedCountries: cfg.BlockedCountries, + AllowedCountries: cfg.AllowedCountries, + CustomProxyMode: cfg.CustomProxyMode, + CustomPriority: &customPriority, + CustomFreePriority: &customFreePriority, + CustomProbeInterval: cfg.CustomProbeInterval, + CustomRefreshInterval: cfg.CustomRefreshInterval, + SingBoxPath: cfg.SingBoxPath, + SingBoxBasePort: cfg.SingBoxBasePort, + FetchInterval: cfg.FetchInterval, + CheckInterval: cfg.CheckInterval, }, "", " ") if err != nil { return err diff --git a/custom/manager.go b/custom/manager.go new file mode 100644 index 0000000..93f6b0f --- /dev/null +++ b/custom/manager.go @@ -0,0 +1,570 @@ +package custom + +import ( + "crypto/tls" + "fmt" + "io" + "log" + "net" + "net/http" + "net/url" + "os" + "strconv" + "sync" + "time" + + "golang.org/x/net/proxy" + "goproxy/config" + "goproxy/storage" + "goproxy/validator" +) + +// Manager 订阅管理器 +type Manager struct { + storage *storage.Storage + validator *validator.Validator + singbox *SingBoxProcess + stopCh chan struct{} + refreshMu sync.Mutex // 防止并发刷新 +} + +// NewManager 创建订阅管理器 +func NewManager(store *storage.Storage, v *validator.Validator, cfg *config.Config) *Manager { + dataDir := "" + if d := os.Getenv("DATA_DIR"); d != "" { + dataDir = d + } + + return &Manager{ + storage: store, + validator: v, + singbox: NewSingBoxProcess(cfg.SingBoxPath, dataDir, cfg.SingBoxBasePort), + stopCh: make(chan struct{}), + } +} + +// Start 启动后台循环 +func (m *Manager) Start() { + log.Println("[custom] 订阅管理器启动") + + // 启动时立即刷新所有订阅 + go m.initialRefresh() + + // 订阅刷新循环 + go m.refreshLoop() + + // 探测唤醒循环 + go m.probeLoop() +} + +// Stop 停止管理器 +func (m *Manager) Stop() { + close(m.stopCh) + m.singbox.Stop() + log.Println("[custom] 订阅管理器已停止") +} + +// initialRefresh 启动时刷新所有活跃订阅 +func (m *Manager) initialRefresh() { + time.Sleep(3 * time.Second) // 等待其他模块初始化 + subs, err := m.storage.GetSubscriptions() + if err != nil || len(subs) == 0 { + return + } + + activeSubs := 0 + for _, sub := range subs { + if sub.Status == "active" { + activeSubs++ + } + } + if activeSubs == 0 { + return + } + + log.Printf("[custom] 启动刷新,共 %d 个活跃订阅", activeSubs) + m.RefreshAll() +} + +// refreshLoop 订阅刷新循环 +func (m *Manager) refreshLoop() { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-m.stopCh: + return + case <-ticker.C: + m.checkAndRefresh() + } + } +} + +// checkAndRefresh 检查并刷新到期的订阅 + 清理长期无可用节点的订阅 +func (m *Manager) checkAndRefresh() { + // 清理连续 7 天无可用节点的订阅 + m.cleanupStaleSubscriptions() + + subs, err := m.storage.GetSubscriptions() + if err != nil { + log.Printf("[custom] 获取订阅列表失败: %v", err) + return + } + + for _, sub := range subs { + if sub.Status != "active" { + continue + } + // 检查是否到刷新时间 + if !sub.LastFetch.IsZero() && time.Since(sub.LastFetch) < time.Duration(sub.RefreshMin)*time.Minute { + continue + } + log.Printf("[custom] 🔄 订阅 [%s] 到期,开始刷新", sub.Name) + if err := m.RefreshSubscription(sub.ID); err != nil { + log.Printf("[custom] ❌ 订阅 [%s] 刷新失败: %v", sub.Name, err) + } + } +} + +// cleanupStaleSubscriptions 清理连续 7 天无可用节点的订阅 +func (m *Manager) cleanupStaleSubscriptions() { + staleSubs, err := m.storage.GetStaleSubscriptions(7) + if err != nil || len(staleSubs) == 0 { + return + } + + for _, sub := range staleSubs { + deleted, _ := m.storage.DeleteBySubscriptionID(sub.ID) + m.storage.DeleteSubscription(sub.ID) + log.Printf("[custom] 🗑️ 自动移除订阅 [%s]:连续 7 天无可用节点(清理 %d 个代理)", sub.Name, deleted) + } + + // 重建 sing-box 配置 + if len(staleSubs) > 0 { + m.RefreshAll() + } +} + +// probeLoop 探测唤醒循环 +func (m *Manager) probeLoop() { + // 等待初始化完成 + time.Sleep(5 * time.Second) + + for { + cfg := config.Get() + interval := time.Duration(cfg.CustomProbeInterval) * time.Minute + if interval < time.Minute { + interval = 10 * time.Minute + } + + select { + case <-m.stopCh: + return + case <-time.After(interval): + m.probeDisabled() + } + } +} + +// probeDisabled 探测被禁用的订阅代理 +func (m *Manager) probeDisabled() { + disabled, err := m.storage.GetDisabledCustomProxies() + if err != nil || len(disabled) == 0 { + return + } + + log.Printf("[custom] 🔍 探测 %d 个禁用的订阅代理", len(disabled)) + + cfg := config.Get() + recovered := 0 + recoveredSubs := make(map[int64]bool) + for _, proxy := range disabled { + valid, latency, exitIP, exitLocation := m.validator.ValidateOne(proxy) + if valid { + // 检查地理过滤:恢复前确认不在屏蔽列表中 + if exitLocation != "" && isGeoBlocked(exitLocation, cfg) { + log.Printf("[custom] 代理 %s 验证通过但被地理过滤 (%s),保持禁用", proxy.Address, exitLocation) + m.storage.UpdateExitInfo(proxy.Address, exitIP, exitLocation, int(latency.Milliseconds())) + continue + } + m.storage.EnableProxy(proxy.Address) + m.storage.UpdateExitInfo(proxy.Address, exitIP, exitLocation, int(latency.Milliseconds())) + recovered++ + recoveredSubs[proxy.SubscriptionID] = true + log.Printf("[custom] ✅ 代理 %s 恢复可用 (%dms)", proxy.Address, latency.Milliseconds()) + } + } + // 有恢复的代理则更新对应订阅的 last_success + for subID := range recoveredSubs { + if subID > 0 { + m.storage.UpdateSubscriptionSuccess(subID) + } + } + + if recovered > 0 { + log.Printf("[custom] 探测完成:%d/%d 恢复可用", recovered, len(disabled)) + } +} + +// RefreshSubscription 刷新��个订阅 +func (m *Manager) RefreshSubscription(subID int64) error { + m.refreshMu.Lock() + defer m.refreshMu.Unlock() + + sub, err := m.storage.GetSubscription(subID) + if err != nil { + return fmt.Errorf("获取订阅失败: %w", err) + } + + // 获取订阅内容 + data, err := m.fetchSubscriptionData(sub) + if err != nil { + return fmt.Errorf("拉取订阅内容失败: %w", err) + } + + // 解析节点 + nodes, err := Parse(data, sub.Format) + if err != nil { + return fmt.Errorf("解析订阅内容失败: %w", err) + } + + if len(nodes) == 0 { + log.Printf("[custom] ⚠️ 订阅 [%s] 无有效节点", sub.Name) + return nil + } + + log.Printf("[custom] 订阅 [%s] 解析到 %d 个节点", sub.Name, len(nodes)) + + // 先删除该订阅的旧代理 + oldDeleted, _ := m.storage.DeleteBySubscriptionID(subID) + if oldDeleted > 0 { + log.Printf("[custom] 🧹 清理订阅 [%s] 旧代理 %d 个", sub.Name, oldDeleted) + } + + // 分类节点 + var directNodes []ParsedNode + var tunnelNodes []ParsedNode + for _, node := range nodes { + if node.IsDirect() { + directNodes = append(directNodes, node) + } else { + tunnelNodes = append(tunnelNodes, node) + } + } + + // 收集所有入池的代理(带正确的协议信息) + var allProxies []storage.Proxy + + // 处理可直接使用的 HTTP/SOCKS5 节点 + for _, node := range directNodes { + addr := node.DirectAddress() + proto := node.DirectProtocol() + m.storage.AddProxyWithSource(addr, proto, "custom", subID) + allProxies = append(allProxies, storage.Proxy{Address: addr, Protocol: proto, Source: "custom"}) + } + if len(directNodes) > 0 { + log.Printf("[custom] 📥 %d 个 HTTP/SOCKS5 节点直接入池", len(directNodes)) + } + + // 处理需要 sing-box 转换的节点 + if len(tunnelNodes) > 0 { + // 收集所有订阅的 tunnel 节点(需合并) + allTunnelNodes, err := m.collectAllTunnelNodes() + if err != nil { + log.Printf("[custom] ⚠️ 收集 tunnel 节点失败: %v", err) + } + // 将当前订阅的 tunnel 节点也加入,去重 + nodeMap := make(map[string]ParsedNode) + for _, n := range allTunnelNodes { + nodeMap[n.NodeKey()] = n + } + for _, n := range tunnelNodes { + nodeMap[n.NodeKey()] = n + } + var mergedNodes []ParsedNode + for _, n := range nodeMap { + mergedNodes = append(mergedNodes, n) + } + + if err := m.singbox.Reload(mergedNodes); err != nil { + log.Printf("[custom] ❌ sing-box 重载失败: %v", err) + } else { + portMap := m.singbox.GetPortMap() + for _, node := range tunnelNodes { + key := node.NodeKey() + if port, ok := portMap[key]; ok { + addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) + m.storage.AddProxyWithSource(addr, "socks5", "custom", subID) + allProxies = append(allProxies, storage.Proxy{Address: addr, Protocol: "socks5", Source: "custom"}) + } + } + log.Printf("[custom] 📥 %d 个加密节点通过 sing-box 转换入池", len(tunnelNodes)) + } + } + + // 验证新入池的代理 + go m.validateCustomProxies(allProxies, subID) + + // 更新订阅信息(记录实际入池的代理数) + m.storage.UpdateSubscriptionFetch(subID, len(allProxies)) + log.Printf("[custom] ✅ 订阅 [%s] 刷新完成,解析 %d 节点,入池 %d 个", sub.Name, len(nodes), len(allProxies)) + + return nil +} + +// RefreshAll 刷新所有活跃订阅 +func (m *Manager) RefreshAll() { + subs, err := m.storage.GetSubscriptions() + if err != nil { + log.Printf("[custom] 获取订阅列表失败: %v", err) + return + } + + for _, sub := range subs { + if sub.Status != "active" { + continue + } + if err := m.RefreshSubscription(sub.ID); err != nil { + log.Printf("[custom] ❌ 订阅 [%s] 刷新失败: %v", sub.Name, err) + } + } +} + +// collectAllTunnelNodes 收集所有订阅中需要 tunnel 的节点 +func (m *Manager) collectAllTunnelNodes() ([]ParsedNode, error) { + subs, err := m.storage.GetSubscriptions() + if err != nil { + return nil, err + } + + var allNodes []ParsedNode + for _, sub := range subs { + if sub.Status != "active" { + continue + } + data, err := m.fetchSubscriptionData(&sub) + if err != nil { + continue + } + nodes, err := Parse(data, sub.Format) + if err != nil { + continue + } + for _, node := range nodes { + if !node.IsDirect() { + allNodes = append(allNodes, node) + } + } + } + return allNodes, nil +} + +// fetchSubscriptionData 获取订阅数据 +func (m *Manager) fetchSubscriptionData(sub *storage.Subscription) ([]byte, error) { + // 优先使用本地文件 + if sub.FilePath != "" { + data, err := os.ReadFile(sub.FilePath) + if err != nil { + return nil, fmt.Errorf("读取文件 %s 失败: %w", sub.FilePath, err) + } + return data, nil + } + + // 从 URL 拉取 + if sub.URL == "" { + return nil, fmt.Errorf("订阅未配置 URL 或文件路径") + } + + // 尝试拉取(直连 → 代理) + data, err := m.fetchWithRetry(sub.URL) + if err != nil { + return nil, err + } + return data, nil +} + +// fetchWithRetry 尝试拉取 URL(直连 → 代理,多种方式) +func (m *Manager) fetchWithRetry(urlStr string) ([]byte, error) { + // 先尝试直连 + data, err := m.fetchURL(urlStr, nil) + if err == nil { + return data, nil + } + log.Printf("[custom] 直连订阅 URL 失败: %v,尝试通过代理访问...", err) + + // 直连失败,尝试通过池中已有代理访问 + for i := 0; i < 3; i++ { + p, pErr := m.storage.GetRandom() + if pErr != nil { + break + } + data, err = m.fetchURL(urlStr, p) + if err == nil { + log.Printf("[custom] ✅ 通过代理 %s 成功访问订阅 URL", p.Address) + return data, nil + } + log.Printf("[custom] 代理 %s 访问订阅 URL 失败: %v", p.Address, err) + } + + return nil, fmt.Errorf("直连和代理均无法访问订阅 URL: %w", err) +} + +// fetchURL 通过指定代理(或直连)拉取 URL 内容 +func (m *Manager) fetchURL(urlStr string, p *storage.Proxy) ([]byte, error) { + transport := &http.Transport{} + + if p != nil { + // 通过代理访问时跳过 TLS 验证(免费代理可能 MITM) + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + + switch p.Protocol { + case "socks5": + dialer, err := proxy.SOCKS5("tcp", p.Address, nil, proxy.Direct) + if err != nil { + return nil, err + } + transport.Dial = dialer.Dial + default: // http + proxyURL, err := url.Parse(fmt.Sprintf("http://%s", p.Address)) + if err != nil { + return nil, err + } + transport.Proxy = http.ProxyURL(proxyURL) + } + } + + client := &http.Client{Timeout: 30 * time.Second, Transport: transport} + req, err := http.NewRequest("GET", urlStr, nil) + if err != nil { + return nil, err + } + // 用 v2rayN UA,大部分机场都会返回完整的节点信息 + req.Header.Set("User-Agent", "v2rayN") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + return io.ReadAll(resp.Body) +} + +// validateCustomProxies 验证订阅代理,返回可用数 +func (m *Manager) validateCustomProxies(proxies []storage.Proxy, subID int64) int { + if len(proxies) == 0 { + return 0 + } + + log.Printf("[custom] 🔍 开始验证 %d 个订阅代理", len(proxies)) + + cfg := config.Get() + resultCh := m.validator.ValidateStream(proxies) + valid, invalid := 0, 0 + for result := range resultCh { + if result.Valid { + latencyMs := int(result.Latency.Milliseconds()) + m.storage.UpdateExitInfo(result.Proxy.Address, result.ExitIP, result.ExitLocation, latencyMs) + // 检查地理过滤 + if result.ExitLocation != "" && isGeoBlocked(result.ExitLocation, cfg) { + m.storage.DisableProxy(result.Proxy.Address) + invalid++ + } else { + m.storage.EnableProxy(result.Proxy.Address) + valid++ + } + } else { + invalid++ + m.storage.DisableProxy(result.Proxy.Address) + } + } + + // 有可用节点则更新 last_success + if valid > 0 && subID > 0 { + m.storage.UpdateSubscriptionSuccess(subID) + } + + log.Printf("[custom] 验证完成:%d 可用,%d 不可用", valid, invalid) + return valid +} + +// GetStatus 获取订阅管理器状态 +func (m *Manager) GetStatus() map[string]interface{} { + customCount, _ := m.storage.CountBySource("custom") + disabled, _ := m.storage.GetDisabledCustomProxies() + subs, _ := m.storage.GetSubscriptions() + + return map[string]interface{}{ + "singbox_running": m.singbox.IsRunning(), + "singbox_nodes": m.singbox.GetNodeCount(), + "custom_count": customCount, + "disabled_count": len(disabled), + "subscription_count": len(subs), + } +} + +// ValidateSubscription 验证订阅能否解析出节点(不入库,仅检查) +func (m *Manager) ValidateSubscription(url, filePath string) (int, error) { + var data []byte + var err error + + if filePath != "" { + data, err = os.ReadFile(filePath) + if err != nil { + return 0, fmt.Errorf("读取文件失败: %w", err) + } + } else if url != "" { + data, err = m.fetchWithRetry(url) + if err != nil { + return 0, err + } + } else { + return 0, fmt.Errorf("未提供 URL 或文件") + } + + nodes, err := Parse(data, "auto") + if err != nil { + return 0, err + } + if len(nodes) == 0 { + return 0, fmt.Errorf("解析结果为空,未找到有效代理节点") + } + + return len(nodes), nil +} + +// isGeoBlocked 检查代理出口位置是否被地理过滤 +func isGeoBlocked(exitLocation string, cfg *config.Config) bool { + if exitLocation == "" || len(exitLocation) < 2 { + return false + } + countryCode := exitLocation[:2] + + // 白名单模式优先 + if len(cfg.AllowedCountries) > 0 { + for _, allowed := range cfg.AllowedCountries { + if countryCode == allowed { + return false + } + } + return true // 不在白名单中 + } + + // 黑名单模式 + for _, blocked := range cfg.BlockedCountries { + if countryCode == blocked { + return true + } + } + return false +} + +// GetSingBox 获取 sing-box 进程管理器 +func (m *Manager) GetSingBox() *SingBoxProcess { + return m.singbox +} diff --git a/custom/parser.go b/custom/parser.go new file mode 100644 index 0000000..b1c9eaf --- /dev/null +++ b/custom/parser.go @@ -0,0 +1,736 @@ +package custom + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "log" + "net" + "net/url" + "os" + "strconv" + "strings" + + "gopkg.in/yaml.v3" +) + +// ParsedNode 解析后的代理节点 +type ParsedNode struct { + Name string // 节点名称 + Type string // vmess/trojan/ss/vless/hysteria2/http/socks5 等 + Server string // 远程服务器地址 + Port int // 远程服务器端口 + Raw map[string]interface{} // 原始配置字段(用于生成 sing-box 配置) +} + +// NodeKey 节点去重 key +func (n *ParsedNode) NodeKey() string { + return fmt.Sprintf("%s:%s:%d", n.Type, n.Server, n.Port) +} + +// IsDirect 是否可以直接作为代理使用(不需要 sing-box 转换) +func (n *ParsedNode) IsDirect() bool { + return n.Type == "http" || n.Type == "socks5" +} + +// DirectAddress 返回直接代理的地址 +func (n *ParsedNode) DirectAddress() string { + return net.JoinHostPort(n.Server, strconv.Itoa(n.Port)) +} + +// DirectProtocol 返回直接代理的协议名 +func (n *ParsedNode) DirectProtocol() string { + if n.Type == "socks5" { + return "socks5" + } + return "http" +} + +// Parse 解析订阅内容(全自动检测格式) +func Parse(data []byte, format string) ([]ParsedNode, error) { + // 无论用户选择什么格式,都走自动检测 + return parseAutoDetect(data) +} + +// parseAutoDetect 自动检测订阅格式并解析 +func parseAutoDetect(data []byte) ([]ParsedNode, error) { + content := strings.TrimSpace(string(data)) + log.Printf("[custom] 自动检测格式: 内容长度=%d", len(content)) + + // 1. 尝试 Clash YAML + if looksLikeYAML(content) { + log.Println("[custom] 检测到 Clash YAML 格式") + nodes, err := parseClash(data) + if err == nil && len(nodes) > 0 { + return nodes, nil + } + log.Printf("[custom] YAML 解析无有效节点,继续尝试其他格式...") + } + + // 2. 直接包含协议链接(vmess://、vless:// 等) + if looksLikeProxyLinks(content) { + log.Println("[custom] 检测到协议链接格式") + return parseProxyLinks(content) + } + + // 3. 尝试 Base64 解码 + decoded, err := tryBase64Decode(content) + if err != nil { + return nil, fmt.Errorf("无法识别订阅内容格式(非 YAML / 非协议链接 / 非 Base64)") + } + + decodedStr := strings.TrimSpace(string(decoded)) + if decodedStr == "" { + return nil, fmt.Errorf("Base64 解码后内容为空") + } + log.Printf("[custom] Base64 解码成功: %d bytes", len(decoded)) + + // 解码后是 YAML? + if looksLikeYAML(decodedStr) { + log.Println("[custom] Base64 解码后为 Clash YAML 格式") + return parseClash(decoded) + } + + // 解码后是协议链接? + if looksLikeProxyLinks(decodedStr) { + log.Println("[custom] Base64 解码后为协议链接格式") + return parseProxyLinks(decodedStr) + } + + // 解码后尝试纯文本 + nodes, err := parsePlain(decoded) + if err == nil && len(nodes) > 0 { + return nodes, nil + } + + return nil, fmt.Errorf("无法识别订阅内容格式") +} + +func safePreview(s string, n int) string { + if len(s) > n { + return s[:n] + "..." + } + return s +} + +// looksLikeProxyLinks 判断内容是否包含代理协议链接 +func looksLikeProxyLinks(s string) bool { + return strings.Contains(s, "vmess://") || + strings.Contains(s, "vless://") || + strings.Contains(s, "trojan://") || + strings.Contains(s, "ss://") || + strings.Contains(s, "ssr://") || + strings.Contains(s, "hysteria2://") || + strings.Contains(s, "hy2://") || + strings.Contains(s, "tuic://") +} + +// clashConfig Clash YAML 配置结构(兼容新旧格式) +type clashConfig struct { + Proxies []map[string]interface{} `yaml:"proxies"` + ProxyOld []map[string]interface{} `yaml:"Proxy"` // 旧版 Clash 格式 +} + +// getProxies 兼容获取代理列表 +func (c *clashConfig) getProxies() []map[string]interface{} { + if len(c.Proxies) > 0 { + return c.Proxies + } + return c.ProxyOld +} + +// parseClash 解析 Clash YAML 格式 +func parseClash(data []byte) ([]ParsedNode, error) { + content := strings.TrimSpace(string(data)) + + // 打印前 100 字符帮助调试 + preview := content + if len(preview) > 100 { + preview = preview[:100] + } + log.Printf("[custom] 订阅内容预览: %s...", preview) + + // 自动检测:如果内容不像 YAML(不以常见 YAML 字段开头),尝试 base64 解码 + if !looksLikeYAML(content) { + log.Println("[custom] 内容不像 YAML,尝试 Base64 解码...") + decoded, err := tryBase64Decode(content) + if err == nil && looksLikeYAML(string(decoded)) { + log.Println("[custom] Base64 解码成功,使用解码后的 YAML") + data = decoded + } else { + log.Println("[custom] Base64 解码后仍不是 YAML,按原始内容解析") + } + } + + // 使用 yaml.Node 解析,精确提取 proxies 列表 + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return nil, fmt.Errorf("解析 Clash YAML 失败: %w", err) + } + + var proxies []map[string]interface{} + proxies = extractProxiesFromNode(&doc) + + if len(proxies) == 0 { + log.Printf("[custom] ⚠️ YAML 中未找到有效的代理节点(内容长度: %d 字节)", len(data)) + } else { + log.Printf("[custom] 从 YAML 中提取到 %d 个代理节点", len(proxies)) + } + + var nodes []ParsedNode + for _, proxy := range proxies { + node, err := parseClashProxy(proxy) + if err != nil { + log.Printf("[custom] 跳过无效节点: %v", err) + continue + } + nodes = append(nodes, *node) + } + + log.Printf("[custom] Clash YAML 解析完成,共 %d 个节点", len(nodes)) + return nodes, nil +} + +// extractProxiesFromNode 从 yaml.Node 树中提取 proxies 列表 +func extractProxiesFromNode(doc *yaml.Node) []map[string]interface{} { + if doc == nil { + return nil + } + + // doc 是 DocumentNode,内容在 Content[0](MappingNode) + var root *yaml.Node + if doc.Kind == yaml.DocumentNode && len(doc.Content) > 0 { + root = doc.Content[0] + } else if doc.Kind == yaml.MappingNode { + root = doc + } else { + return nil + } + + // 在 MappingNode 中找 proxies 或 Proxy key + for i := 0; i < len(root.Content)-1; i += 2 { + keyNode := root.Content[i] + valNode := root.Content[i+1] + if keyNode.Value == "proxies" || keyNode.Value == "Proxy" { + log.Printf("[custom] 找到 %s 字段: kind=%d tag=%s 子节点数=%d", + keyNode.Value, valNode.Kind, valNode.Tag, len(valNode.Content)) + + // 把 proxies 段的原始 YAML 写到临时文件方便调试 + debugData, _ := yaml.Marshal(valNode) + os.WriteFile("/tmp/goproxy_debug_proxies.yaml", debugData, 0644) + log.Printf("[custom] 调试: proxies 原始数据已写入 /tmp/goproxy_debug_proxies.yaml (%d bytes)", len(debugData)) + + if valNode.Kind != yaml.SequenceNode { + log.Printf("[custom] proxies 字段不是列表(kind=%d tag=%s)", valNode.Kind, valNode.Tag) + return nil + } + // 每个 item 是一个 MappingNode,解码为 map + var proxies []map[string]interface{} + for idx, itemNode := range valNode.Content { + var m map[string]interface{} + if err := itemNode.Decode(&m); err != nil { + log.Printf("[custom] 解码代理节点 #%d 失败: %v (kind=%d tag=%s)", idx, err, itemNode.Kind, itemNode.Tag) + continue + } + proxies = append(proxies, m) + } + log.Printf("[custom] 成功解码 %d/%d 个代理节点", len(proxies), len(valNode.Content)) + return proxies + } + } + log.Printf("[custom] 遍历 %d 个顶级 key 未找到 proxies", len(root.Content)/2) + return nil +} + +// looksLikeYAML 判断内容是否看起来像 YAML/Clash 配置 +func looksLikeYAML(s string) bool { + s = strings.TrimSpace(s) + // Clash YAML 通常包含 proxies: 或 port: 或以 # 注释开头 + return strings.Contains(s, "proxies:") || + strings.Contains(s, "proxy-groups:") || + strings.HasPrefix(s, "port:") || + strings.HasPrefix(s, "mixed-port:") || + strings.HasPrefix(s, "#") || + strings.HasPrefix(s, "---") +} + +// tryBase64Decode 尝试多种 Base64 变体解码 +func tryBase64Decode(s string) ([]byte, error) { + // 去掉所有空白字符(换行、回车、空格)再解码 + s = strings.TrimSpace(s) + s = strings.ReplaceAll(s, "\r", "") + s = strings.ReplaceAll(s, "\n", "") + s = strings.ReplaceAll(s, " ", "") + // 标准 Base64 + if decoded, err := base64.StdEncoding.DecodeString(s); err == nil { + return decoded, nil + } + // URL-safe Base64 + if decoded, err := base64.URLEncoding.DecodeString(s); err == nil { + return decoded, nil + } + // 无填充 Base64 + if decoded, err := base64.RawStdEncoding.DecodeString(s); err == nil { + return decoded, nil + } + // 无填充 URL-safe + if decoded, err := base64.RawURLEncoding.DecodeString(s); err == nil { + return decoded, nil + } + return nil, fmt.Errorf("Base64 解码失败") +} + +// parseClashProxy 解析单个 Clash 代理节点 +func parseClashProxy(proxy map[string]interface{}) (*ParsedNode, error) { + name, _ := proxy["name"].(string) + typ, _ := proxy["type"].(string) + server, _ := proxy["server"].(string) + + if typ == "" || server == "" { + return nil, fmt.Errorf("缺少 type 或 server 字段") + } + + port := 0 + switch v := proxy["port"].(type) { + case int: + port = v + case float64: + port = int(v) + case string: + p, err := strconv.Atoi(v) + if err != nil { + return nil, fmt.Errorf("无效端口: %s", v) + } + port = p + default: + return nil, fmt.Errorf("缺少 port 字段") + } + + // 标准化类型名 + typ = strings.ToLower(typ) + switch typ { + case "ss": + typ = "shadowsocks" + case "ssr": + typ = "shadowsocksr" + } + + // 支持的类型 + supported := map[string]bool{ + "vmess": true, "vless": true, "trojan": true, + "shadowsocks": true, "shadowsocksr": true, + "hysteria": true, "hysteria2": true, "tuic": true, + "anytls": true, + "http": true, "socks5": true, + } + if !supported[typ] { + return nil, fmt.Errorf("不支持的代理类型: %s", typ) + } + + return &ParsedNode{ + Name: name, + Type: typ, + Server: server, + Port: port, + Raw: proxy, + }, nil +} + +// parseBase64 解析 Base64 编码的纯文本 +func parseBase64(data []byte) ([]ParsedNode, error) { + // 尝试标准 Base64 解码 + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(data))) + if err != nil { + // 尝试 URL-safe Base64 + decoded, err = base64.URLEncoding.DecodeString(strings.TrimSpace(string(data))) + if err != nil { + // 尝试无填充的 Base64 + decoded, err = base64.RawStdEncoding.DecodeString(strings.TrimSpace(string(data))) + if err != nil { + return nil, fmt.Errorf("Base64 解码失败: %w", err) + } + } + } + return parsePlain(decoded) +} + +// parsePlain 解析纯文本格式(每行一个 IP:PORT) +func parsePlain(data []byte) ([]ParsedNode, error) { + lines := strings.Split(string(data), "\n") + var nodes []ParsedNode + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + protocol := "http" + addr := line + + // 解析协议前缀 + if strings.HasPrefix(line, "socks5://") { + protocol = "socks5" + addr = strings.TrimPrefix(line, "socks5://") + } else if strings.HasPrefix(line, "socks4://") { + protocol = "socks5" // socks4 当 socks5 处理 + addr = strings.TrimPrefix(line, "socks4://") + } else if strings.HasPrefix(line, "http://") { + protocol = "http" + addr = strings.TrimPrefix(line, "http://") + } else if strings.HasPrefix(line, "https://") { + protocol = "http" + addr = strings.TrimPrefix(line, "https://") + } + + host, portStr, err := net.SplitHostPort(addr) + if err != nil { + continue + } + port, err := strconv.Atoi(portStr) + if err != nil { + continue + } + + nodes = append(nodes, ParsedNode{ + Name: addr, + Type: protocol, + Server: host, + Port: port, + Raw: map[string]interface{}{"type": protocol, "server": host, "port": port}, + }) + } + + log.Printf("[custom] 纯文本解析完成,共 %d 个节点", len(nodes)) + return nodes, nil +} + +// parseProxyLinks 解析协议链接格式(vmess://, trojan://, ss://, vless:// 等) +func parseProxyLinks(content string) ([]ParsedNode, error) { + lines := strings.Split(content, "\n") + var nodes []ParsedNode + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + node, err := parseProxyLink(line) + if err != nil { + continue + } + nodes = append(nodes, *node) + } + + log.Printf("[custom] 协议链接解析完成,共 %d 个节点", len(nodes)) + return nodes, nil +} + +// parseProxyLink 解析单个协议链接 +func parseProxyLink(link string) (*ParsedNode, error) { + link = strings.TrimSpace(link) + + switch { + case strings.HasPrefix(link, "vmess://"): + return parseVmessLink(link) + case strings.HasPrefix(link, "vless://"): + return parseStandardLink(link, "vless") + case strings.HasPrefix(link, "trojan://"): + return parseStandardLink(link, "trojan") + case strings.HasPrefix(link, "ss://"): + return parseShadowsocksLink(link) + case strings.HasPrefix(link, "hysteria2://"), strings.HasPrefix(link, "hy2://"): + return parseStandardLink(link, "hysteria2") + case strings.HasPrefix(link, "tuic://"): + return parseStandardLink(link, "tuic") + default: + return nil, fmt.Errorf("不支持的协议链接: %s", link[:min(20, len(link))]) + } +} + +// parseVmessLink 解析 vmess:// 链接(V2rayN JSON base64 格式) +func parseVmessLink(link string) (*ParsedNode, error) { + encoded := strings.TrimPrefix(link, "vmess://") + decoded, err := tryBase64Decode(encoded) + if err != nil { + return nil, fmt.Errorf("vmess base64 解码失败: %w", err) + } + + var info map[string]interface{} + if err := json.Unmarshal(decoded, &info); err != nil { + return nil, fmt.Errorf("vmess JSON 解析失败: %w", err) + } + + server := fmt.Sprintf("%v", info["add"]) + portStr := fmt.Sprintf("%v", info["port"]) + port, _ := strconv.Atoi(portStr) + name := fmt.Sprintf("%v", info["ps"]) + if name == "" || name == "" { + name = server + } + + // 构建 Clash 兼容的 raw 配置 + raw := map[string]interface{}{ + "type": "vmess", + "name": name, + "server": server, + "port": port, + "uuid": fmt.Sprintf("%v", info["id"]), + "alterId": getInt(info, "aid"), + "cipher": getStrDefault(info, "scy", "auto"), + } + + // TLS + if fmt.Sprintf("%v", info["tls"]) == "tls" { + raw["tls"] = true + if sni, ok := info["sni"]; ok { + raw["sni"] = sni + } + } + + // 传输层 + net := getStrDefault(info, "net", "tcp") + raw["network"] = net + if net == "ws" { + wsOpts := map[string]interface{}{} + if path := getStr(info, "path"); path != "" { + wsOpts["path"] = path + } + if host := getStr(info, "host"); host != "" { + wsOpts["headers"] = map[string]interface{}{"Host": host} + } + raw["ws-opts"] = wsOpts + } else if net == "grpc" { + grpcOpts := map[string]interface{}{} + if path := getStr(info, "path"); path != "" { + grpcOpts["grpc-service-name"] = path + } + raw["grpc-opts"] = grpcOpts + } + + return &ParsedNode{ + Name: name, + Type: "vmess", + Server: server, + Port: port, + Raw: raw, + }, nil +} + +// parseStandardLink 解析标准 URI 格式链接(vless://, trojan://, hysteria2://, tuic://) +// 格式: protocol://userinfo@host:port?params#fragment +func parseStandardLink(link string, typ string) (*ParsedNode, error) { + // 去除协议前缀,统一处理 + u, err := url.Parse(link) + if err != nil { + return nil, fmt.Errorf("链接解析失败: %w", err) + } + + host := u.Hostname() + port, _ := strconv.Atoi(u.Port()) + if port == 0 { + port = 443 + } + name := u.Fragment + if name == "" { + name = host + } + + raw := map[string]interface{}{ + "type": typ, + "name": name, + "server": host, + "port": port, + } + + // 用户信息(password/uuid) + if u.User != nil { + password := u.User.Username() + if typ == "trojan" || typ == "hysteria2" { + raw["password"] = password + } else if typ == "vless" || typ == "tuic" { + raw["uuid"] = password + if p, ok := u.User.Password(); ok { + raw["password"] = p // tuic 的 password + } + } + } + + // 查询参数 + params := u.Query() + + // TLS + security := params.Get("security") + if security == "" { + security = params.Get("type") // 有些链接用 type 表示 + } + if security != "none" && security != "" || typ == "trojan" || typ == "hysteria2" { + raw["tls"] = true + if sni := params.Get("sni"); sni != "" { + raw["sni"] = sni + } + if fp := params.Get("fp"); fp != "" { + raw["client-fingerprint"] = fp + } + if alpn := params.Get("alpn"); alpn != "" { + raw["alpn"] = strings.Split(alpn, ",") + } + if params.Get("allowInsecure") == "1" || params.Get("insecure") == "1" { + raw["skip-cert-verify"] = true + } + } + + // Reality + if security == "reality" { + raw["tls"] = true + realityOpts := map[string]interface{}{} + if pbk := params.Get("pbk"); pbk != "" { + realityOpts["public-key"] = pbk + } + if sid := params.Get("sid"); sid != "" { + realityOpts["short-id"] = sid + } + raw["reality-opts"] = realityOpts + } + + // 传输层 + netType := params.Get("type") + if netType == "" { + netType = "tcp" + } + raw["network"] = netType + + if netType == "ws" { + wsOpts := map[string]interface{}{} + if path := params.Get("path"); path != "" { + wsOpts["path"] = path + } + if host := params.Get("host"); host != "" { + wsOpts["headers"] = map[string]interface{}{"Host": host} + } + raw["ws-opts"] = wsOpts + } else if netType == "grpc" { + grpcOpts := map[string]interface{}{} + if sn := params.Get("serviceName"); sn != "" { + grpcOpts["grpc-service-name"] = sn + } + raw["grpc-opts"] = grpcOpts + } + + // Hysteria2 特有 + if typ == "hysteria2" { + if obfs := params.Get("obfs"); obfs != "" { + raw["obfs"] = obfs + raw["obfs-password"] = params.Get("obfs-password") + } + } + + // VLESS flow + if typ == "vless" { + if flow := params.Get("flow"); flow != "" { + raw["flow"] = flow + } + } + + return &ParsedNode{ + Name: name, + Type: typ, + Server: host, + Port: port, + Raw: raw, + }, nil +} + +// parseShadowsocksLink 解析 ss:// 链接 +// 格式1: ss://base64(method:password)@host:port#name +// 格式2: ss://base64(method:password@host:port)#name +func parseShadowsocksLink(link string) (*ParsedNode, error) { + link = strings.TrimPrefix(link, "ss://") + + // 分离 fragment (节点名) + name := "" + if idx := strings.Index(link, "#"); idx >= 0 { + name, _ = url.QueryUnescape(link[idx+1:]) + link = link[:idx] + } + + var server, method, password string + var port int + + // 尝试格式1: base64(method:password)@host:port + if idx := strings.LastIndex(link, "@"); idx >= 0 { + userInfo := link[:idx] + hostPort := link[idx+1:] + + // 解码 userInfo + decoded, err := tryBase64Decode(userInfo) + if err != nil { + // 可能未编码 + decoded = []byte(userInfo) + } + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) == 2 { + method = parts[0] + password = parts[1] + } + + // 分离 host:port(去掉查询参数) + if qIdx := strings.Index(hostPort, "?"); qIdx >= 0 { + hostPort = hostPort[:qIdx] + } + h, p, err := net.SplitHostPort(hostPort) + if err != nil { + return nil, fmt.Errorf("ss 地址解析失败: %w", err) + } + server = h + port, _ = strconv.Atoi(p) + } else { + // 格式2: 整个 base64 编码 + if qIdx := strings.Index(link, "?"); qIdx >= 0 { + link = link[:qIdx] + } + decoded, err := tryBase64Decode(link) + if err != nil { + return nil, fmt.Errorf("ss base64 解码失败: %w", err) + } + // method:password@host:port + s := string(decoded) + atIdx := strings.LastIndex(s, "@") + if atIdx < 0 { + return nil, fmt.Errorf("ss 格式无效") + } + parts := strings.SplitN(s[:atIdx], ":", 2) + if len(parts) == 2 { + method = parts[0] + password = parts[1] + } + h, p, err := net.SplitHostPort(s[atIdx+1:]) + if err != nil { + return nil, fmt.Errorf("ss 地址解析失败: %w", err) + } + server = h + port, _ = strconv.Atoi(p) + } + + if name == "" { + name = server + } + + raw := map[string]interface{}{ + "type": "ss", + "name": name, + "server": server, + "port": port, + "cipher": method, + "password": password, + } + + return &ParsedNode{ + Name: name, + Type: "shadowsocks", + Server: server, + Port: port, + Raw: raw, + }, nil +} diff --git a/custom/singbox.go b/custom/singbox.go new file mode 100644 index 0000000..c79490b --- /dev/null +++ b/custom/singbox.go @@ -0,0 +1,530 @@ +package custom + +import ( + "encoding/json" + "fmt" + "log" + "net" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +// SingBoxProcess 管理 sing-box 子进程 +type SingBoxProcess struct { + cmd *exec.Cmd + binPath string + configDir string + configFile string + basePort int + portMap map[string]int // nodeKey → 本地端口 + nodes []ParsedNode + mu sync.Mutex + running bool +} + +// NewSingBoxProcess 创建 sing-box 进程管理器 +func NewSingBoxProcess(binPath, dataDir string, basePort int) *SingBoxProcess { + if dataDir == "" { + // 没设置 DATA_DIR 时,使用当前工作目录下的 singbox/ + wd, _ := os.Getwd() + dataDir = wd + } + configDir, _ := filepath.Abs(filepath.Join(dataDir, "singbox")) + os.MkdirAll(configDir, 0755) + + return &SingBoxProcess{ + binPath: binPath, + configDir: configDir, + configFile: filepath.Join(configDir, "config.json"), + basePort: basePort, + portMap: make(map[string]int), + } +} + +// Reload 重新加载节点配置并重启 sing-box +func (s *SingBoxProcess) Reload(nodes []ParsedNode) error { + s.mu.Lock() + defer s.mu.Unlock() + + // 过滤出需要 sing-box 转换的节点 + var tunnelNodes []ParsedNode + for _, n := range nodes { + if !n.IsDirect() { + tunnelNodes = append(tunnelNodes, n) + } + } + + if len(tunnelNodes) == 0 { + log.Println("[custom] 无需 sing-box 转换的节点,停止进程") + s.stopLocked() + s.nodes = nil + s.portMap = make(map[string]int) + return nil + } + + // 生成配置 + if err := s.generateConfig(tunnelNodes); err != nil { + return fmt.Errorf("生成 sing-box 配置失败: %w", err) + } + + // 重启进程 + s.stopLocked() + if err := s.startLocked(); err != nil { + return fmt.Errorf("启动 sing-box 失败: %w", err) + } + + s.nodes = tunnelNodes + return nil +} + +// generateConfig 生成 sing-box JSON 配置 +func (s *SingBoxProcess) generateConfig(nodes []ParsedNode) error { + s.portMap = make(map[string]int) + port := s.basePort + + var inbounds []map[string]interface{} + var outbounds []map[string]interface{} + var rules []map[string]interface{} + + for i, node := range nodes { + port++ + key := node.NodeKey() + s.portMap[key] = port + tag := fmt.Sprintf("node-%d", i) + + // 入站:本地 SOCKS5 监听 + inbounds = append(inbounds, map[string]interface{}{ + "type": "socks", + "tag": fmt.Sprintf("in-%s", tag), + "listen": "127.0.0.1", + "listen_port": port, + }) + + // 出站:根据节点类型生成 + outbound := buildOutbound(node, tag) + if outbound == nil { + log.Printf("[custom] 跳过不支持的节点类型: %s (%s)", node.Name, node.Type) + delete(s.portMap, key) + continue + } + outbounds = append(outbounds, outbound) + + // 路由规则:入站 → 出站 + rules = append(rules, map[string]interface{}{ + "inbound": []string{fmt.Sprintf("in-%s", tag)}, + "outbound": fmt.Sprintf("out-%s", tag), + }) + } + + // 添加 direct 出站作为默认 + outbounds = append(outbounds, map[string]interface{}{ + "type": "direct", + "tag": "direct", + }) + + config := map[string]interface{}{ + "log": map[string]interface{}{ + "level": "warn", + }, + "inbounds": inbounds, + "outbounds": outbounds, + "route": map[string]interface{}{ + "rules": rules, + "final": "direct", + }, + } + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + + return os.WriteFile(s.configFile, data, 0644) +} + +// buildOutbound 根据节点类型构建 sing-box 出站配置 +func buildOutbound(node ParsedNode, tag string) map[string]interface{} { + raw := node.Raw + out := map[string]interface{}{ + "tag": fmt.Sprintf("out-%s", tag), + "server": node.Server, + } + + // sing-box 使用 server_port 而不是 port + out["server_port"] = node.Port + + switch node.Type { + case "vmess": + out["type"] = "vmess" + out["uuid"] = getStr(raw, "uuid") + out["alter_id"] = getInt(raw, "alterId") + out["security"] = getStrDefault(raw, "cipher", "auto") + applyTLS(raw, out) + applyTransport(raw, out) + + case "vless": + out["type"] = "vless" + out["uuid"] = getStr(raw, "uuid") + out["flow"] = getStr(raw, "flow") + applyTLS(raw, out) + applyTransport(raw, out) + + case "trojan": + out["type"] = "trojan" + out["password"] = getStr(raw, "password") + applyTLS(raw, out) + applyTransport(raw, out) + + case "shadowsocks": + out["type"] = "shadowsocks" + out["method"] = getStr(raw, "cipher") + out["password"] = getStr(raw, "password") + if plugin := getStr(raw, "plugin"); plugin != "" { + out["plugin"] = plugin + if pluginOpts, ok := raw["plugin-opts"].(map[string]interface{}); ok { + out["plugin_opts"] = convertPluginOpts(plugin, pluginOpts) + } + } + + case "hysteria2": + out["type"] = "hysteria2" + out["password"] = getStr(raw, "password") + applyTLS(raw, out) + + case "hysteria": + out["type"] = "hysteria" + out["auth_str"] = getStr(raw, "auth-str") + if up := getStr(raw, "up"); up != "" { + out["up_mbps"] = parseSpeed(up) + } + if down := getStr(raw, "down"); down != "" { + out["down_mbps"] = parseSpeed(down) + } + applyTLS(raw, out) + + case "tuic": + out["type"] = "tuic" + out["uuid"] = getStr(raw, "uuid") + out["password"] = getStr(raw, "password") + out["congestion_control"] = getStrDefault(raw, "congestion-controller", "bbr") + applyTLS(raw, out) + + case "anytls": + out["type"] = "anytls" + out["password"] = getStr(raw, "password") + // anytls 强制启用 TLS + forceTLS(raw, out) + + default: + return nil + } + + return out +} + +// forceTLS 强制应用 TLS 配置(用于 anytls 等必须 TLS 的协议) +func forceTLS(raw map[string]interface{}, out map[string]interface{}) { + raw["tls"] = true + applyTLS(raw, out) +} + +// applyTLS 应用 TLS 配置 +func applyTLS(raw map[string]interface{}, out map[string]interface{}) { + tls := getBool(raw, "tls") + // 如果有 sni/alpn/client-fingerprint 也视为需要 TLS + if !tls && getStr(raw, "sni") == "" && getStr(raw, "client-fingerprint") == "" { + return + } + + tlsConfig := map[string]interface{}{ + "enabled": true, + } + + if sni := getStr(raw, "sni"); sni != "" { + tlsConfig["server_name"] = sni + } else if servername := getStr(raw, "servername"); servername != "" { + tlsConfig["server_name"] = servername + } + + if getBool(raw, "skip-cert-verify") { + tlsConfig["insecure"] = true + } + + if alpn, ok := raw["alpn"].([]interface{}); ok { + var alpnStrs []string + for _, a := range alpn { + if s, ok := a.(string); ok { + alpnStrs = append(alpnStrs, s) + } + } + if len(alpnStrs) > 0 { + tlsConfig["alpn"] = alpnStrs + } + } + + if fp := getStr(raw, "client-fingerprint"); fp != "" { + tlsConfig["utls"] = map[string]interface{}{ + "enabled": true, + "fingerprint": fp, + } + } + + // reality 配置 + if realityOpts, ok := raw["reality-opts"].(map[string]interface{}); ok { + tlsConfig["reality"] = map[string]interface{}{ + "enabled": true, + "public_key": getStr(realityOpts, "public-key"), + "short_id": getStr(realityOpts, "short-id"), + } + } + + out["tls"] = tlsConfig +} + +// applyTransport 应用传输层配置 +func applyTransport(raw map[string]interface{}, out map[string]interface{}) { + network := getStrDefault(raw, "network", "tcp") + + switch network { + case "ws": + transport := map[string]interface{}{ + "type": "ws", + } + if wsOpts, ok := raw["ws-opts"].(map[string]interface{}); ok { + if path := getStr(wsOpts, "path"); path != "" { + transport["path"] = path + } + if headers, ok := wsOpts["headers"].(map[string]interface{}); ok { + transport["headers"] = headers + } + } + out["transport"] = transport + + case "grpc": + transport := map[string]interface{}{ + "type": "grpc", + } + if grpcOpts, ok := raw["grpc-opts"].(map[string]interface{}); ok { + if sn := getStr(grpcOpts, "grpc-service-name"); sn != "" { + transport["service_name"] = sn + } + } + out["transport"] = transport + + case "h2": + transport := map[string]interface{}{ + "type": "http", + } + if h2Opts, ok := raw["h2-opts"].(map[string]interface{}); ok { + if path := getStr(h2Opts, "path"); path != "" { + transport["path"] = path + } + if host, ok := h2Opts["host"].([]interface{}); ok && len(host) > 0 { + if h, ok := host[0].(string); ok { + transport["host"] = []string{h} + } + } + } + out["transport"] = transport + + case "httpupgrade": + transport := map[string]interface{}{ + "type": "httpupgrade", + } + if wsOpts, ok := raw["ws-opts"].(map[string]interface{}); ok { + if path := getStr(wsOpts, "path"); path != "" { + transport["path"] = path + } + if headers, ok := wsOpts["headers"].(map[string]interface{}); ok { + if host, ok := headers["Host"].(string); ok { + transport["host"] = host + } + } + } + out["transport"] = transport + } +} + +// convertPluginOpts 转换 shadowsocks 插件选项 +func convertPluginOpts(plugin string, opts map[string]interface{}) string { + var parts []string + for k, v := range opts { + parts = append(parts, fmt.Sprintf("%s=%v", k, v)) + } + return strings.Join(parts, ";") +} + +// startLocked 启动 sing-box(需持有锁) +func (s *SingBoxProcess) startLocked() error { + if _, err := exec.LookPath(s.binPath); err != nil { + return fmt.Errorf("sing-box 未找到: %s(请安装 sing-box 或设置 SINGBOX_PATH)", s.binPath) + } + + s.cmd = exec.Command(s.binPath, "run", "-c", s.configFile, "-D", s.configDir) + s.cmd.Stdout = os.Stdout + s.cmd.Stderr = os.Stderr + + if err := s.cmd.Start(); err != nil { + return err + } + s.running = true + + // 监控进程退出 + go func() { + if s.cmd != nil && s.cmd.Process != nil { + s.cmd.Wait() + s.mu.Lock() + s.running = false + s.mu.Unlock() + } + }() + + // 等待端口就绪(最多 10 秒) + log.Printf("[custom] sing-box 启动中,等待端口就绪...") + ready := false + for i := 0; i < 20; i++ { + time.Sleep(500 * time.Millisecond) + // 检查第一个端口是否可连 + for _, port := range s.portMap { + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Second) + if err == nil { + conn.Close() + ready = true + break + } + } + if ready { + break + } + } + + if !ready { + log.Println("[custom] ⚠️ sing-box 端口未就绪,部分节点可能不可用") + } else { + log.Printf("[custom] ✅ sing-box 启动成功,管理 %d 个节点", len(s.portMap)) + } + + return nil +} + +// stopLocked 停止 sing-box(需持有锁) +func (s *SingBoxProcess) stopLocked() { + if s.cmd != nil && s.cmd.Process != nil && s.running { + log.Println("[custom] 停止 sing-box 进程...") + s.cmd.Process.Signal(os.Interrupt) + done := make(chan struct{}) + go func() { + s.cmd.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + s.cmd.Process.Kill() + } + s.running = false + } +} + +// Stop 停止 sing-box +func (s *SingBoxProcess) Stop() { + s.mu.Lock() + defer s.mu.Unlock() + s.stopLocked() +} + +// IsRunning 检查进程是否运行中 +func (s *SingBoxProcess) IsRunning() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.running +} + +// GetLocalAddress 获取节点的本地 SOCKS5 地址 +func (s *SingBoxProcess) GetLocalAddress(nodeKey string) string { + s.mu.Lock() + defer s.mu.Unlock() + if port, ok := s.portMap[nodeKey]; ok { + return net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) + } + return "" +} + +// GetPortMap 获取所有端口映射 +func (s *SingBoxProcess) GetPortMap() map[string]int { + s.mu.Lock() + defer s.mu.Unlock() + result := make(map[string]int, len(s.portMap)) + for k, v := range s.portMap { + result[k] = v + } + return result +} + +// GetNodeCount 获取管理的节点数 +func (s *SingBoxProcess) GetNodeCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.portMap) +} + +// 辅助函数 + +func getStr(m map[string]interface{}, key string) string { + if v, ok := m[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +func getStrDefault(m map[string]interface{}, key, def string) string { + if s := getStr(m, key); s != "" { + return s + } + return def +} + +func getInt(m map[string]interface{}, key string) int { + if v, ok := m[key]; ok { + switch val := v.(type) { + case int: + return val + case float64: + return int(val) + case string: + n, _ := strconv.Atoi(val) + return n + } + } + return 0 +} + +func getBool(m map[string]interface{}, key string) bool { + if v, ok := m[key]; ok { + switch val := v.(type) { + case bool: + return val + case string: + return val == "true" + } + } + return false +} + +func parseSpeed(s string) int { + s = strings.TrimSpace(s) + s = strings.TrimSuffix(s, " Mbps") + s = strings.TrimSuffix(s, "Mbps") + n, _ := strconv.Atoi(s) + if n == 0 { + n = 100 // 默认 100 Mbps + } + return n +} diff --git a/docker-compose.yml b/docker-compose.yml index 6a9cb5a..f0ce48a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,8 +26,10 @@ services: - PROXY_AUTH_PASSWORD=${PROXY_AUTH_PASSWORD} - BLOCKED_COUNTRIES=${BLOCKED_COUNTRIES:-CN} - ALLOWED_COUNTRIES=${ALLOWED_COUNTRIES} + - CUSTOM_PROXY_MODE=${CUSTOM_PROXY_MODE:-mixed} + - SINGBOX_PATH=sing-box healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:7778/"] + test: ["CMD-SHELL", "curl -sf http://localhost:7778/ || exit 1"] interval: 30s timeout: 5s retries: 3 diff --git a/go.mod b/go.mod index 3153a8c..36ff249 100644 --- a/go.mod +++ b/go.mod @@ -8,3 +8,5 @@ require ( ) require golang.org/x/time v0.15.0 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 576faf2..be58c5f 100644 --- a/go.sum +++ b/go.sum @@ -4,3 +4,7 @@ golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index f6b387d..adfa366 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "goproxy/checker" "goproxy/config" + "goproxy/custom" "goproxy/fetcher" "goproxy/logger" "goproxy/optimizer" @@ -57,20 +58,24 @@ func main() { healthChecker := checker.NewHealthChecker(store, validate, cfg, poolMgr) opt := optimizer.NewOptimizer(store, fetch, validate, poolMgr, cfg) - // 清理无效代理 + // 清理无效代理(免费代理删除,订阅代理禁用) totalDeleted := 0 if len(cfg.AllowedCountries) > 0 { - // 白名单模式:清理不在白名单中的代理 if deleted, err := store.DeleteNotAllowedCountries(cfg.AllowedCountries); err == nil && deleted > 0 { - log.Printf("[main] 🧹 已清理 %d 个非白名单国家出口代理 (允许: %v)", deleted, cfg.AllowedCountries) + log.Printf("[main] 🧹 已清理 %d 个非白名单免费代理 (允许: %v)", deleted, cfg.AllowedCountries) totalDeleted += int(deleted) } + if disabled, err := store.DisableNotAllowedCountries(cfg.AllowedCountries); err == nil && disabled > 0 { + log.Printf("[main] 🔒 已禁用 %d 个非白名单订阅代理", disabled) + } } else if len(cfg.BlockedCountries) > 0 { - // 黑名单模式:清理屏蔽国家的代理 if deleted, err := store.DeleteBlockedCountries(cfg.BlockedCountries); err == nil && deleted > 0 { - log.Printf("[main] 🧹 已清理 %d 个屏蔽国家出口代理 (屏蔽: %v)", deleted, cfg.BlockedCountries) + log.Printf("[main] 🧹 已清理 %d 个屏蔽国家免费代理 (屏蔽: %v)", deleted, cfg.BlockedCountries) totalDeleted += int(deleted) } + if disabled, err := store.DisableBlockedCountries(cfg.BlockedCountries); err == nil && disabled > 0 { + log.Printf("[main] 🔒 已禁用 %d 个屏蔽国家订阅代理", disabled) + } } if deleted, err := store.DeleteWithoutExitInfo(); err == nil && deleted > 0 { log.Printf("[main] 🧹 已清理 %d 个无出口信息的代理", deleted) @@ -85,11 +90,14 @@ func main() { socks5RandomServer := proxy.NewSOCKS5(store, cfg, "random", cfg.SOCKS5Port) socks5StableServer := proxy.NewSOCKS5(store, cfg, "lowest-latency", cfg.StableSOCKS5Port) + // 初始化订阅管理器 + customMgr := custom.NewManager(store, validate, cfg) + // 配置变更通知 channel configChanged := make(chan struct{}, 1) - // 启动 WebUI(传递池子管理器) - ui := webui.New(store, cfg, poolMgr, func() { + // 启动 WebUI(传递池子管理器和订阅管理器) + ui := webui.New(store, cfg, poolMgr, customMgr, func() { go smartFetchAndFill(fetch, validate, store, poolMgr) }, configChanged) ui.Start() @@ -113,6 +121,9 @@ func main() { // 启动优化轮换器 opt.StartBackground() + // 启动订阅管理器 + go customMgr.Start() + // 监听配置变更 go watchConfigChanges(configChanged, poolMgr) diff --git a/pool/manager.go b/pool/manager.go index bf974a2..3ecba0c 100644 --- a/pool/manager.go +++ b/pool/manager.go @@ -22,14 +22,15 @@ func NewManager(s *storage.Storage, cfg *config.Config) *Manager { // PoolStatus 池子状态 type PoolStatus struct { - Total int - HTTP int - SOCKS5 int - HTTPSlots int - SOCKS5Slots int - State string // healthy/warning/critical/emergency + Total int + HTTP int + SOCKS5 int + HTTPSlots int + SOCKS5Slots int + State string // healthy/warning/critical/emergency AvgLatencyHTTP int AvgLatencySocks5 int + CustomCount int // 订阅代理数量 } // GetStatus 获取当前池子状态 @@ -47,6 +48,8 @@ func (m *Manager) GetStatus() (*PoolStatus, error) { // 判断状态 state := m.determineState(total, httpCount, socks5Count) + customCount, _ := m.storage.CountBySource("custom") + return &PoolStatus{ Total: total, HTTP: httpCount, @@ -56,6 +59,7 @@ func (m *Manager) GetStatus() (*PoolStatus, error) { State: state, AvgLatencyHTTP: avgHTTP, AvgLatencySocks5: avgSOCKS5, + CustomCount: customCount, }, nil } @@ -136,6 +140,16 @@ func (m *Manager) NeedsFetchQuick(status *PoolStatus) bool { // TryAddProxy 尝试将代理加入池子 func (m *Manager) TryAddProxy(p storage.Proxy) (bool, string) { + // 订阅代理直接入池,不受 slot 限制 + if p.Source == "custom" { + if err := m.storage.AddProxyWithSource(p.Address, p.Protocol, "custom"); err != nil { + return false, "db_error" + } + m.storage.UpdateExitInfo(p.Address, p.ExitIP, p.ExitLocation, p.Latency) + log.Printf("[pool] ✅ 订阅代理入池: %s (%s) %dms %s", p.Address, p.Protocol, p.Latency, p.ExitLocation) + return true, "added_custom" + } + httpSlots, socks5Slots := m.cfg.CalculateSlots() httpCount, _ := m.storage.CountByProtocol("http") socks5Count, _ := m.storage.CountByProtocol("socks5") diff --git a/proxy/server.go b/proxy/server.go index 0076927..b10f1ad 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -98,30 +98,76 @@ func (s *Server) checkAuth(r *http.Request) bool { return usernameMatch && passwordMatch } +// selectProxy 根据使用模式和选择策略获取代理 +func (s *Server) selectProxy(tried []string, lowestLatency bool) (*storage.Proxy, error) { + cfg := config.Get() + sourceFilter := sourceFilterFromMode(cfg.CustomProxyMode) + + // 混用 + 优先模式:先尝试优先源,无可用则 fallback 全部 + if cfg.CustomProxyMode == "mixed" && (cfg.CustomPriority || cfg.CustomFreePriority) { + preferSource := "custom" + if cfg.CustomFreePriority { + preferSource = "free" + } + var p *storage.Proxy + var err error + if lowestLatency { + p, err = s.storage.GetLowestLatencyExcludeFiltered(tried, preferSource) + } else { + p, err = s.storage.GetRandomExcludeFiltered(tried, preferSource) + } + if err == nil { + return p, nil + } + // fallback 到全部 + if lowestLatency { + return s.storage.GetLowestLatencyExcludeFiltered(tried, "") + } + return s.storage.GetRandomExcludeFiltered(tried, "") + } + + if lowestLatency { + return s.storage.GetLowestLatencyExcludeFiltered(tried, sourceFilter) + } + return s.storage.GetRandomExcludeFiltered(tried, sourceFilter) +} + +// removeOrDisableProxy 根据代理来源决定删除或禁用 +func removeOrDisableProxy(store *storage.Storage, p *storage.Proxy) { + if p.Source == "custom" { + store.DisableProxy(p.Address) + } else { + store.Delete(p.Address) + } +} + +// sourceFilterFromMode 根据使用模式返回来源过滤值 +func sourceFilterFromMode(mode string) string { + switch mode { + case "custom_only": + return "custom" + case "free_only": + return "free" + default: + return "" // mixed + } +} + // handleHTTP 处理普通 HTTP 请求(带自动重试) func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { var tried []string for attempt := 0; attempt <= s.cfg.MaxRetry; attempt++ { - var p *storage.Proxy - var err error - - // 根据模式选择代理 - if s.mode == "lowest-latency" { - p, err = s.storage.GetLowestLatencyExclude(tried) - } else { - p, err = s.storage.GetRandomExclude(tried) - } - + p, err := s.selectProxy(tried, s.mode == "lowest-latency") if err != nil { http.Error(w, "no available proxy", http.StatusServiceUnavailable) return } - + tried = append(tried, p.Address) client, err := s.buildClient(p) if err != nil { - s.storage.Delete(p.Address) + removeOrDisableProxy(s.storage, p) continue } @@ -136,7 +182,8 @@ func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { resp, err := client.Do(req) if err != nil { log.Printf("[proxy] %s via %s failed, removing", r.RequestURI, p.Address) - s.storage.Delete(p.Address) + s.storage.RecordProxyUse(p.Address, false) + removeOrDisableProxy(s.storage, p) continue } defer resp.Body.Close() @@ -149,6 +196,7 @@ func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(resp.StatusCode) io.Copy(w, resp.Body) + s.storage.RecordProxyUse(p.Address, true) if resp.StatusCode == 429 { log.Printf("[proxy] ⚠️ 429 %s via %s (protocol=%s)", r.RequestURI, p.Address, p.Protocol) } else { @@ -164,30 +212,24 @@ func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTunnel(w http.ResponseWriter, r *http.Request) { var tried []string for attempt := 0; attempt <= s.cfg.MaxRetry; attempt++ { - var p *storage.Proxy - var err error - - // 根据模式选择代理 - if s.mode == "lowest-latency" { - p, err = s.storage.GetLowestLatencyExclude(tried) - } else { - p, err = s.storage.GetRandomExclude(tried) - } - + p, err := s.selectProxy(tried, s.mode == "lowest-latency") if err != nil { http.Error(w, "no available proxy", http.StatusServiceUnavailable) return } - + tried = append(tried, p.Address) conn, err := s.dialViaProxy(p, r.Host) if err != nil { log.Printf("[tunnel] dial %s via %s failed, removing", r.Host, p.Address) - s.storage.Delete(p.Address) + s.storage.RecordProxyUse(p.Address, false) + removeOrDisableProxy(s.storage, p) continue } + s.storage.RecordProxyUse(p.Address, true) + // 告知客户端隧道建立 hijacker, ok := w.(http.Hijacker) if !ok { diff --git a/proxy/socks5_server.go b/proxy/socks5_server.go index 3dc74e8..2c46291 100644 --- a/proxy/socks5_server.go +++ b/proxy/socks5_server.go @@ -80,16 +80,7 @@ func (s *SOCKS5Server) handleConnection(clientConn net.Conn) { maxRetries := s.cfg.MaxRetry + 2 // 增加重试次数以应对质量差的代理 for attempt := 0; attempt <= maxRetries; attempt++ { - var p *storage.Proxy - var err error - - // SOCKS5 服务只使用 SOCKS5 上游代理 - if s.mode == "lowest-latency" { - p, err = s.storage.GetLowestLatencyByProtocolExclude("socks5", tried) - } else { - p, err = s.storage.GetRandomByProtocolExclude("socks5", tried) - } - + p, err := s.selectSOCKS5Proxy(tried) if err != nil { log.Printf("[socks5] no available socks5 upstream proxy: %v", err) s.sendSOCKS5Reply(clientConn, 0x01) // General failure @@ -102,7 +93,8 @@ func (s *SOCKS5Server) handleConnection(clientConn net.Conn) { upstreamConn, err := s.dialViaProxy(p, target) if err != nil { log.Printf("[socks5] dial %s via %s (%s) failed: %v, removing", target, p.Address, p.Protocol, err) - s.storage.Delete(p.Address) + s.storage.RecordProxyUse(p.Address, false) + removeOrDisableProxy(s.storage, p) continue } @@ -112,6 +104,7 @@ func (s *SOCKS5Server) handleConnection(clientConn net.Conn) { return } + s.storage.RecordProxyUse(p.Address, true) log.Printf("[socks5] %s via %s established", target, p.Address) // 双向转发数据 @@ -128,6 +121,40 @@ func (s *SOCKS5Server) handleConnection(clientConn net.Conn) { log.Printf("[socks5] all proxies failed for %s", target) } +// selectSOCKS5Proxy 根据使用模式选择 SOCKS5 上游代理 +func (s *SOCKS5Server) selectSOCKS5Proxy(tried []string) (*storage.Proxy, error) { + cfg := config.Get() + sourceFilter := sourceFilterFromMode(cfg.CustomProxyMode) + + // 混用 + 优先模式 + if cfg.CustomProxyMode == "mixed" && (cfg.CustomPriority || cfg.CustomFreePriority) { + preferSource := "custom" + if cfg.CustomFreePriority { + preferSource = "free" + } + var p *storage.Proxy + var err error + if s.mode == "lowest-latency" { + p, err = s.storage.GetLowestLatencyByProtocolExcludeFiltered("socks5", tried, preferSource) + } else { + p, err = s.storage.GetRandomByProtocolExcludeFiltered("socks5", tried, preferSource) + } + if err == nil { + return p, nil + } + // fallback + if s.mode == "lowest-latency" { + return s.storage.GetLowestLatencyByProtocolExcludeFiltered("socks5", tried, "") + } + return s.storage.GetRandomByProtocolExcludeFiltered("socks5", tried, "") + } + + if s.mode == "lowest-latency" { + return s.storage.GetLowestLatencyByProtocolExcludeFiltered("socks5", tried, sourceFilter) + } + return s.storage.GetRandomByProtocolExcludeFiltered("socks5", tried, sourceFilter) +} + // socks5Handshake 处理 SOCKS5 握手 func (s *SOCKS5Server) socks5Handshake(conn net.Conn) error { buf := make([]byte, 257) diff --git a/storage/storage.go b/storage/storage.go index 8ab822d..7e7b550 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -25,7 +25,25 @@ type Proxy struct { LastUsed time.Time `json:"last_used"` LastCheck time.Time `json:"last_check"` CreatedAt time.Time `json:"created_at"` - Status string `json:"status"` + Status string `json:"status"` + Source string `json:"source"` // "free" 或 "custom" + SubscriptionID int64 `json:"subscription_id"` // 所属订阅ID(0=免费代理) +} + +// Subscription 订阅信息 +type Subscription struct { + ID int64 `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + FilePath string `json:"file_path"` + Format string `json:"format"` // clash / plain / base64 / auto + RefreshMin int `json:"refresh_min"` + LastFetch time.Time `json:"last_fetch"` + LastSuccess time.Time `json:"last_success"` // 最后一次有可用节点的时间 + Status string `json:"status"` // active / paused + ProxyCount int `json:"proxy_count"` + CreatedAt time.Time `json:"created_at"` + Contributed bool `json:"contributed"` // 是否为访客贡献 } // SourceStatus 代理源状态 @@ -173,13 +191,61 @@ func (s *Storage) initSchema() error { s.db.Exec(`ALTER TABLE proxies ADD COLUMN status TEXT NOT NULL DEFAULT 'active'`) } + // 迁移:添加 source 字段(区分免费代理和订阅代理) + var hasSource int + s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('proxies') WHERE name='source'`).Scan(&hasSource) + if hasSource == 0 { + log.Println("[storage] migrating: adding source column") + s.db.Exec(`ALTER TABLE proxies ADD COLUMN source TEXT NOT NULL DEFAULT 'free'`) + } + s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_source ON proxies(source, status)`) + + // 迁移:添加 subscription_id 字段 + var hasSubID int + s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('proxies') WHERE name='subscription_id'`).Scan(&hasSubID) + if hasSubID == 0 { + log.Println("[storage] migrating: adding subscription_id column") + s.db.Exec(`ALTER TABLE proxies ADD COLUMN subscription_id INTEGER NOT NULL DEFAULT 0`) + } + + // 创建订阅表 + _, err = s.db.Exec(` + CREATE TABLE IF NOT EXISTS subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL DEFAULT '', + format TEXT NOT NULL DEFAULT 'clash', + refresh_min INTEGER NOT NULL DEFAULT 60, + last_fetch DATETIME, + status TEXT NOT NULL DEFAULT 'active', + proxy_count INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `) + if err != nil { + return err + } + + // 迁移:订阅表添加 contributed 和 last_success 字段 + var hasContributed int + s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('subscriptions') WHERE name='contributed'`).Scan(&hasContributed) + if hasContributed == 0 { + s.db.Exec(`ALTER TABLE subscriptions ADD COLUMN contributed INTEGER NOT NULL DEFAULT 0`) + } + var hasLastSuccess int + s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('subscriptions') WHERE name='last_success'`).Scan(&hasLastSuccess) + if hasLastSuccess == 0 { + s.db.Exec(`ALTER TABLE subscriptions ADD COLUMN last_success DATETIME`) + } + return nil } -// AddProxy 新增代理,已存在则忽略 +// AddProxy 新增免费代理,已存在则忽略 func (s *Storage) AddProxy(address, protocol string) error { result, err := s.db.Exec( - `INSERT OR IGNORE INTO proxies (address, protocol) VALUES (?, ?)`, + `INSERT OR IGNORE INTO proxies (address, protocol, source) VALUES (?, ?, 'free')`, address, protocol, ) if err != nil { @@ -218,20 +284,18 @@ func (s *Storage) AddProxies(proxies []Proxy) error { // GetRandom 随机取一个可用代理(优先选择质量高的) func (s *Storage) GetRandom() (*Proxy, error) { - // 优先从 S/A 级代理中随机选择 rows, err := s.db.Query( - `SELECT id, address, protocol, exit_ip, exit_location, latency, quality_grade, - use_count, success_count, fail_count, last_used, last_check, created_at, status - FROM proxies + `SELECT `+proxyColumns+` + FROM proxies WHERE status = 'active' AND fail_count < 3 - ORDER BY - CASE quality_grade - WHEN 'S' THEN 1 - WHEN 'A' THEN 2 - WHEN 'B' THEN 3 - ELSE 4 + ORDER BY + CASE quality_grade + WHEN 'S' THEN 1 + WHEN 'A' THEN 2 + WHEN 'B' THEN 3 + ELSE 4 END, - RANDOM() + RANDOM() LIMIT 1`, ) if err != nil { @@ -245,13 +309,19 @@ func (s *Storage) GetRandom() (*Proxy, error) { return nil, fmt.Errorf("no available proxy") } +// proxyColumns 代理表查询的标准列列表 +const proxyColumns = `id, address, protocol, exit_ip, exit_location, latency, quality_grade, + use_count, success_count, fail_count, last_used, last_check, created_at, status, source, subscription_id` + // scanProxy 扫描代理行数据 func scanProxy(rows *sql.Rows) (*Proxy, error) { p := &Proxy{} var lastUsed, lastCheck sql.NullTime + var source sql.NullString + var subID sql.NullInt64 if err := rows.Scan(&p.ID, &p.Address, &p.Protocol, &p.ExitIP, &p.ExitLocation, &p.Latency, &p.QualityGrade, &p.UseCount, &p.SuccessCount, &p.FailCount, - &lastUsed, &lastCheck, &p.CreatedAt, &p.Status); err != nil { + &lastUsed, &lastCheck, &p.CreatedAt, &p.Status, &source, &subID); err != nil { return nil, err } if lastUsed.Valid { @@ -260,18 +330,36 @@ func scanProxy(rows *sql.Rows) (*Proxy, error) { if lastCheck.Valid { p.LastCheck = lastCheck.Time } + if source.Valid { + p.Source = source.String + } else { + p.Source = "free" + } + if subID.Valid { + p.SubscriptionID = subID.Int64 + } return p, nil } // GetAll 获取所有可用代理 func (s *Storage) GetAll() ([]Proxy, error) { - rows, err := s.db.Query( - `SELECT id, address, protocol, exit_ip, exit_location, latency, quality_grade, - use_count, success_count, fail_count, last_used, last_check, created_at, status - FROM proxies - WHERE status IN ('active', 'degraded') AND fail_count < 3 - ORDER BY latency ASC`, - ) + return s.GetAllFiltered("") +} + +// GetAllFiltered 获取可用代理(可按来源过滤) +// sourceFilter: "" = 全部, "free" = 仅免费, "custom" = 仅订阅 +func (s *Storage) GetAllFiltered(sourceFilter string) ([]Proxy, error) { + query := `SELECT ` + proxyColumns + ` + FROM proxies + WHERE status IN ('active', 'degraded') AND fail_count < 3` + var args []interface{} + if sourceFilter != "" { + query += ` AND source = ?` + args = append(args, sourceFilter) + } + query += ` ORDER BY latency ASC` + + rows, err := s.db.Query(query, args...) if err != nil { return nil, err } @@ -290,7 +378,12 @@ func (s *Storage) GetAll() ([]Proxy, error) { // GetRandomExclude 排除指定地址随机取一个 func (s *Storage) GetRandomExclude(excludes []string) (*Proxy, error) { - proxies, err := s.GetAll() + return s.GetRandomExcludeFiltered(excludes, "") +} + +// GetRandomExcludeFiltered 排除指定地址随机取一个(可按来源过滤) +func (s *Storage) GetRandomExcludeFiltered(excludes []string, sourceFilter string) (*Proxy, error) { + proxies, err := s.GetAllFiltered(sourceFilter) if err != nil { return nil, err } @@ -308,7 +401,9 @@ func (s *Storage) GetRandomExclude(excludes []string) (*Proxy, error) { } if len(available) == 0 { - // 没有可排除的了,随机取任意一个 + if sourceFilter != "" { + return nil, fmt.Errorf("no available %s proxy", sourceFilter) + } return s.GetRandom() } @@ -318,7 +413,12 @@ func (s *Storage) GetRandomExclude(excludes []string) (*Proxy, error) { // GetLowestLatencyExclude 排除指定地址后获取延迟最低的代理 func (s *Storage) GetLowestLatencyExclude(excludes []string) (*Proxy, error) { - proxies, err := s.GetAll() + return s.GetLowestLatencyExcludeFiltered(excludes, "") +} + +// GetLowestLatencyExcludeFiltered 排除指定地址后获取延迟最低的代理(可按来源过滤) +func (s *Storage) GetLowestLatencyExcludeFiltered(excludes []string, sourceFilter string) (*Proxy, error) { + proxies, err := s.GetAllFiltered(sourceFilter) if err != nil { return nil, err } @@ -328,7 +428,6 @@ func (s *Storage) GetLowestLatencyExclude(excludes []string) (*Proxy, error) { excludeMap[e] = true } - // GetAll() 已经按 latency ASC 排序,找到第一个不在排除列表中的 for _, p := range proxies { if !excludeMap[p.Address] { proxy := p @@ -341,7 +440,12 @@ func (s *Storage) GetLowestLatencyExclude(excludes []string) (*Proxy, error) { // GetRandomByProtocolExclude 按协议获取随机代理(排除已尝试的) func (s *Storage) GetRandomByProtocolExclude(protocol string, excludes []string) (*Proxy, error) { - proxies, err := s.GetAll() + return s.GetRandomByProtocolExcludeFiltered(protocol, excludes, "") +} + +// GetRandomByProtocolExcludeFiltered 按协议获取随机代理(可按来源过滤) +func (s *Storage) GetRandomByProtocolExcludeFiltered(protocol string, excludes []string, sourceFilter string) (*Proxy, error) { + proxies, err := s.GetAllFiltered(sourceFilter) if err != nil { return nil, err } @@ -368,7 +472,12 @@ func (s *Storage) GetRandomByProtocolExclude(protocol string, excludes []string) // GetLowestLatencyByProtocolExclude 按协议获取最低延迟代理(排除已尝试的) func (s *Storage) GetLowestLatencyByProtocolExclude(protocol string, excludes []string) (*Proxy, error) { - proxies, err := s.GetAll() + return s.GetLowestLatencyByProtocolExcludeFiltered(protocol, excludes, "") +} + +// GetLowestLatencyByProtocolExcludeFiltered 按协议获取最低延迟代理(可按来源过滤) +func (s *Storage) GetLowestLatencyByProtocolExcludeFiltered(protocol string, excludes []string, sourceFilter string) (*Proxy, error) { + proxies, err := s.GetAllFiltered(sourceFilter) if err != nil { return nil, err } @@ -378,7 +487,6 @@ func (s *Storage) GetLowestLatencyByProtocolExclude(protocol string, excludes [] excludeMap[e] = true } - // GetAll() 已经按 latency ASC 排序,找到第一个匹配协议且不在排除列表中的 for _, p := range proxies { if p.Protocol == protocol && !excludeMap[p.Address] { proxy := p @@ -450,13 +558,12 @@ func (s *Storage) RecordProxyUse(address string, success bool) error { return err } -// GetWorstProxies 获取指定协议中延迟最高的N个代理 +// GetWorstProxies 获取指定协议中延迟最高的N个代理(仅免费代理) func (s *Storage) GetWorstProxies(protocol string, limit int) ([]Proxy, error) { rows, err := s.db.Query( - `SELECT id, address, protocol, exit_ip, exit_location, latency, quality_grade, - use_count, success_count, fail_count, last_used, last_check, created_at, status - FROM proxies - WHERE protocol = ? AND status = 'active' + `SELECT `+proxyColumns+` + FROM proxies + WHERE protocol = ? AND status = 'active' AND source = 'free' AND quality_grade != 'S' AND (JULIANDAY('now') - JULIANDAY(created_at)) * 1440 > 60 ORDER BY latency DESC, fail_count DESC @@ -494,10 +601,14 @@ func (s *Storage) ReplaceProxy(oldAddress string, newProxy Proxy) error { // 添加新代理(带完整信息) grade := CalculateQualityGrade(newProxy.Latency) + source := newProxy.Source + if source == "" { + source = "free" + } _, err = tx.Exec( - `INSERT INTO proxies (address, protocol, exit_ip, exit_location, latency, quality_grade, status) - VALUES (?, ?, ?, ?, ?, ?, 'active')`, - newProxy.Address, newProxy.Protocol, newProxy.ExitIP, newProxy.ExitLocation, newProxy.Latency, grade, + `INSERT INTO proxies (address, protocol, exit_ip, exit_location, latency, quality_grade, status, source) + VALUES (?, ?, ?, ?, ?, ?, 'active', ?)`, + newProxy.Address, newProxy.Protocol, newProxy.ExitIP, newProxy.ExitLocation, newProxy.Latency, grade, source, ) if err != nil { return err @@ -563,9 +674,8 @@ func (s *Storage) GetQualityDistribution() (map[string]int, error) { // GetBatchForHealthCheck 获取一批需要健康检查的代理 func (s *Storage) GetBatchForHealthCheck(batchSize int, skipSGrade bool) ([]Proxy, error) { - query := `SELECT id, address, protocol, exit_ip, exit_location, latency, quality_grade, - use_count, success_count, fail_count, last_used, last_check, created_at, status - FROM proxies + query := `SELECT ` + proxyColumns + ` + FROM proxies WHERE status IN ('active', 'degraded') AND fail_count < 3` if skipSGrade { @@ -608,9 +718,9 @@ func CalculateQualityGrade(latencyMs int) string { } } -// DeleteInvalid 删除失败次数超过阈值的代理 +// DeleteInvalid 删除失败次数超过阈值的代理(仅免费代理) func (s *Storage) DeleteInvalid(maxFailCount int) (int64, error) { - res, err := s.db.Exec(`DELETE FROM proxies WHERE fail_count >= ?`, maxFailCount) + res, err := s.db.Exec(`DELETE FROM proxies WHERE fail_count >= ? AND source = 'free'`, maxFailCount) if err != nil { return 0, err } @@ -626,8 +736,8 @@ func (s *Storage) DeleteBlockedCountries(countryCodes []string) (int64, error) { var totalDeleted int64 for _, code := range countryCodes { // exit_location 格式:如 "CN Beijing" 或 "CN"(仅国家代码) - // 同时匹配 "CODE" 和 "CODE ..." 两种情况 - res, err := s.db.Exec(`DELETE FROM proxies WHERE exit_location = ? OR exit_location LIKE ?`, code, code+" %") + // 同时匹配 "CODE" 和 "CODE ..." 两种情况(仅删除免费代理) + res, err := s.db.Exec(`DELETE FROM proxies WHERE source = 'free' AND (exit_location = ? OR exit_location LIKE ?)`, code, code+" %") if err != nil { return totalDeleted, err } @@ -652,7 +762,7 @@ func (s *Storage) DeleteNotAllowedCountries(allowedCodes []string) (int64, error args = append(args, code, code+" %") } - query := `DELETE FROM proxies WHERE exit_location != '' AND NOT (` + strings.Join(conditions, " OR ") + `)` + query := `DELETE FROM proxies WHERE source = 'free' AND exit_location != '' AND NOT (` + strings.Join(conditions, " OR ") + `)` res, err := s.db.Exec(query, args...) if err != nil { return 0, err @@ -660,17 +770,65 @@ func (s *Storage) DeleteNotAllowedCountries(allowedCodes []string) (int64, error return res.RowsAffected() } -// DeleteWithoutExitInfo 删除没有出口信息的代理 +// DeleteWithoutExitInfo 删除没有出口信息的代理(仅免费代理) func (s *Storage) DeleteWithoutExitInfo() (int64, error) { - res, err := s.db.Exec(`DELETE FROM proxies WHERE exit_ip = '' OR exit_location = ''`) + res, err := s.db.Exec(`DELETE FROM proxies WHERE source = 'free' AND (exit_ip = '' OR exit_location = '')`) if err != nil { return 0, err } return res.RowsAffected() } -// Count 返回可用代理数量 +// DisableBlockedCountries 禁用订阅代理中属于被屏蔽国家的(不删除) +func (s *Storage) DisableBlockedCountries(countryCodes []string) (int64, error) { + if len(countryCodes) == 0 { + return 0, nil + } + var total int64 + for _, code := range countryCodes { + res, err := s.db.Exec( + `UPDATE proxies SET status = 'disabled' WHERE source = 'custom' AND status = 'active' AND (exit_location = ? OR exit_location LIKE ?)`, + code, code+" %", + ) + if err != nil { + return total, err + } + affected, _ := res.RowsAffected() + total += affected + } + return total, nil +} + +// DisableNotAllowedCountries 禁用订阅代理中不在白名单的(不删除) +func (s *Storage) DisableNotAllowedCountries(allowedCodes []string) (int64, error) { + if len(allowedCodes) == 0 { + return 0, nil + } + conditions := make([]string, 0, len(allowedCodes)*2) + args := make([]interface{}, 0, len(allowedCodes)*2) + for _, code := range allowedCodes { + conditions = append(conditions, "exit_location = ?", "exit_location LIKE ?") + args = append(args, code, code+" %") + } + query := `UPDATE proxies SET status = 'disabled' WHERE source = 'custom' AND status = 'active' AND exit_location != '' AND NOT (` + strings.Join(conditions, " OR ") + `)` + res, err := s.db.Exec(query, args...) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// Count 返回可用代理数量(仅免费代理,用于 slot 计算) func (s *Storage) Count() (int, error) { + var count int + err := s.db.QueryRow( + `SELECT COUNT(*) FROM proxies WHERE status IN ('active', 'degraded') AND fail_count < 3 AND source = 'free'`, + ).Scan(&count) + return count, err +} + +// CountAll 返回所有可用代理数量(免费+订阅) +func (s *Storage) CountAll() (int, error) { var count int err := s.db.QueryRow( `SELECT COUNT(*) FROM proxies WHERE status IN ('active', 'degraded') AND fail_count < 3`, @@ -678,11 +836,11 @@ func (s *Storage) Count() (int, error) { return count, err } -// CountByProtocol 按协议统计数量 +// CountByProtocol 按协议统计数量(仅免费代理,用于 slot 计算) func (s *Storage) CountByProtocol(protocol string) (int, error) { var count int err := s.db.QueryRow( - `SELECT COUNT(*) FROM proxies WHERE status IN ('active', 'degraded') AND fail_count < 3 AND protocol = ?`, + `SELECT COUNT(*) FROM proxies WHERE status IN ('active', 'degraded') AND fail_count < 3 AND source = 'free' AND protocol = ?`, protocol, ).Scan(&count) return count, err @@ -700,9 +858,8 @@ func (s *Storage) IncrementFailCount(address string) error { // GetByProtocol 按协议获取代理列表 func (s *Storage) GetByProtocol(protocol string) ([]Proxy, error) { rows, err := s.db.Query( - `SELECT id, address, protocol, exit_ip, exit_location, latency, quality_grade, - use_count, success_count, fail_count, last_used, last_check, created_at, status - FROM proxies + `SELECT `+proxyColumns+` + FROM proxies WHERE status IN ('active', 'degraded') AND fail_count < 3 AND protocol = ? ORDER BY latency ASC`, protocol, ) @@ -722,6 +879,311 @@ func (s *Storage) GetByProtocol(protocol string) ([]Proxy, error) { return proxies, nil } +// ========== 订阅代理相关方法 ========== + +// AddProxyWithSource 新增代理并指定来源和订阅ID +func (s *Storage) AddProxyWithSource(address, protocol, source string, subscriptionID ...int64) error { + subID := int64(0) + if len(subscriptionID) > 0 { + subID = subscriptionID[0] + } + result, err := s.db.Exec( + `INSERT OR IGNORE INTO proxies (address, protocol, source, subscription_id) VALUES (?, ?, ?, ?)`, + address, protocol, source, subID, + ) + if err != nil { + log.Printf("[storage] AddProxyWithSource %s error: %v", address, err) + return err + } + affected, _ := result.RowsAffected() + if affected == 0 { + // 已存在,更新 source 和 subscription_id + _, err = s.db.Exec(`UPDATE proxies SET source = ?, subscription_id = ? WHERE address = ?`, source, subID, address) + } + return err +} + +// DeleteBySubscriptionID 删除指定订阅的所有代理 +func (s *Storage) DeleteBySubscriptionID(subscriptionID int64) (int64, error) { + res, err := s.db.Exec(`DELETE FROM proxies WHERE subscription_id = ?`, subscriptionID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// DisableProxy 禁用代理(软删除,用于订阅代理) +func (s *Storage) DisableProxy(address string) error { + _, err := s.db.Exec( + `UPDATE proxies SET status = 'disabled' WHERE address = ?`, + address, + ) + return err +} + +// EnableProxy 启用代理(从禁用状态恢复) +func (s *Storage) EnableProxy(address string) error { + _, err := s.db.Exec( + `UPDATE proxies SET status = 'active', fail_count = 0 WHERE address = ?`, + address, + ) + return err +} + +// GetDisabledCustomProxies 获取所有被禁用的订阅代理 +func (s *Storage) GetDisabledCustomProxies() ([]Proxy, error) { + rows, err := s.db.Query( + `SELECT `+proxyColumns+` + FROM proxies + WHERE source = 'custom' AND status = 'disabled'`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var proxies []Proxy + for rows.Next() { + p, err := scanProxy(rows) + if err != nil { + return nil, err + } + proxies = append(proxies, *p) + } + return proxies, nil +} + +// CountBySource 按来源统计可用代理数量 +func (s *Storage) CountBySource(source string) (int, error) { + var count int + err := s.db.QueryRow( + `SELECT COUNT(*) FROM proxies WHERE source = ? AND status IN ('active', 'degraded') AND fail_count < 3`, + source, + ).Scan(&count) + return count, err +} + +// DeleteBySource 删除指定来源的所有代理 +func (s *Storage) DeleteBySource(source string) (int64, error) { + res, err := s.db.Exec(`DELETE FROM proxies WHERE source = ?`, source) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// DeleteCustomProxiesNotIn 删除不在给定地址列表中的订阅代理 +func (s *Storage) DeleteCustomProxiesNotIn(addresses []string) (int64, error) { + if len(addresses) == 0 { + return s.DeleteBySource("custom") + } + placeholders := make([]string, len(addresses)) + args := make([]interface{}, len(addresses)) + for i, addr := range addresses { + placeholders[i] = "?" + args[i] = addr + } + query := `DELETE FROM proxies WHERE source = 'custom' AND address NOT IN (` + strings.Join(placeholders, ",") + `)` + res, err := s.db.Exec(query, args...) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// ========== 订阅 CRUD ========== + +// AddSubscription 添加订阅(自动去重:相同 URL 或 file_path 不重复添加) +func (s *Storage) AddSubscription(name, url, filePath, format string, refreshMin int) (int64, error) { + // 去重检查 + if url != "" { + var existID int64 + err := s.db.QueryRow(`SELECT id FROM subscriptions WHERE url = ? AND url != ''`, url).Scan(&existID) + if err == nil { + return 0, fmt.Errorf("该订阅 URL 已存在") + } + } + if filePath != "" { + var existID int64 + err := s.db.QueryRow(`SELECT id FROM subscriptions WHERE file_path = ? AND file_path != ''`, filePath).Scan(&existID) + if err == nil { + return 0, fmt.Errorf("该订阅文件已存在") + } + } + + res, err := s.db.Exec( + `INSERT INTO subscriptions (name, url, file_path, format, refresh_min) VALUES (?, ?, ?, ?, ?)`, + name, url, filePath, format, refreshMin, + ) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// CountBySubscriptionID 统计指定订阅的可用/禁用代理数 +func (s *Storage) CountBySubscriptionID(subID int64) (active int, disabled int) { + s.db.QueryRow( + `SELECT COUNT(*) FROM proxies WHERE subscription_id = ? AND status IN ('active', 'degraded') AND fail_count < 3`, + subID, + ).Scan(&active) + s.db.QueryRow( + `SELECT COUNT(*) FROM proxies WHERE subscription_id = ? AND status = 'disabled'`, + subID, + ).Scan(&disabled) + return +} + +// AddContributedSubscription 添加访客贡献的订阅 +func (s *Storage) AddContributedSubscription(name, url string, refreshMin int) (int64, error) { + if url == "" { + return 0, fmt.Errorf("URL 不能为空") + } + // 去重 + var existID int64 + err := s.db.QueryRow(`SELECT id FROM subscriptions WHERE url = ? AND url != ''`, url).Scan(&existID) + if err == nil { + return 0, fmt.Errorf("该订阅 URL 已存在") + } + + res, err := s.db.Exec( + `INSERT INTO subscriptions (name, url, format, refresh_min, contributed) VALUES (?, ?, 'auto', ?, 1)`, + name, url, refreshMin, + ) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// UpdateSubscription 更新订阅 +func (s *Storage) UpdateSubscription(id int64, name, url, filePath, format string, refreshMin int) error { + _, err := s.db.Exec( + `UPDATE subscriptions SET name = ?, url = ?, file_path = ?, format = ?, refresh_min = ? WHERE id = ?`, + name, url, filePath, format, refreshMin, id, + ) + return err +} + +// DeleteSubscription 删除订阅 +func (s *Storage) DeleteSubscription(id int64) error { + _, err := s.db.Exec(`DELETE FROM subscriptions WHERE id = ?`, id) + return err +} + +// GetSubscriptions 获取所有订阅 +func (s *Storage) GetSubscriptions() ([]Subscription, error) { + rows, err := s.db.Query( + `SELECT ` + subColumns + ` + FROM subscriptions ORDER BY created_at DESC`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var subs []Subscription + for rows.Next() { + sub, err := scanSubscription(rows) + if err != nil { + return nil, err + } + subs = append(subs, *sub) + } + return subs, nil +} + +// GetSubscription 获取单个订阅 +func (s *Storage) GetSubscription(id int64) (*Subscription, error) { + rows, err := s.db.Query( + `SELECT ` + subColumns + ` + FROM subscriptions WHERE id = ?`, id, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + if rows.Next() { + return scanSubscription(rows) + } + return nil, fmt.Errorf("subscription %d not found", id) +} + +// UpdateSubscriptionFetch 更新订阅的最后拉取时间和代理数 +func (s *Storage) UpdateSubscriptionFetch(id int64, proxyCount int) error { + _, err := s.db.Exec( + `UPDATE subscriptions SET last_fetch = CURRENT_TIMESTAMP, proxy_count = ? WHERE id = ?`, + proxyCount, id, + ) + return err +} + +// UpdateSubscriptionSuccess 记录订阅最后一次有可用节点的时间 +func (s *Storage) UpdateSubscriptionSuccess(id int64) error { + _, err := s.db.Exec( + `UPDATE subscriptions SET last_success = CURRENT_TIMESTAMP WHERE id = ?`, id, + ) + return err +} + +// GetStaleSubscriptions 获取连续 N 天无可用节点的订阅 +func (s *Storage) GetStaleSubscriptions(staleDays int) ([]Subscription, error) { + rows, err := s.db.Query( + `SELECT `+subColumns+` + FROM subscriptions + WHERE status = 'active' + AND (last_success IS NULL OR JULIANDAY('now') - JULIANDAY(last_success) > ?) + AND JULIANDAY('now') - JULIANDAY(created_at) > ?`, + staleDays, staleDays, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var subs []Subscription + for rows.Next() { + sub, err := scanSubscription(rows) + if err != nil { + return nil, err + } + subs = append(subs, *sub) + } + return subs, nil +} + +// ToggleSubscription 切换订阅状态 +func (s *Storage) ToggleSubscription(id int64) error { + _, err := s.db.Exec( + `UPDATE subscriptions SET status = CASE WHEN status = 'active' THEN 'paused' ELSE 'active' END WHERE id = ?`, + id, + ) + return err +} + +// scanSubscription 扫描订阅行数据 +// subColumns 订阅表查询列 +const subColumns = `id, name, url, file_path, format, refresh_min, last_fetch, last_success, status, proxy_count, created_at, contributed` + +func scanSubscription(rows *sql.Rows) (*Subscription, error) { + sub := &Subscription{} + var lastFetch, lastSuccess sql.NullTime + var contributed int + if err := rows.Scan(&sub.ID, &sub.Name, &sub.URL, &sub.FilePath, &sub.Format, + &sub.RefreshMin, &lastFetch, &lastSuccess, &sub.Status, &sub.ProxyCount, &sub.CreatedAt, &contributed); err != nil { + return nil, err + } + if lastFetch.Valid { + sub.LastFetch = lastFetch.Time + } + if lastSuccess.Valid { + sub.LastSuccess = lastSuccess.Time + } + sub.Contributed = contributed == 1 + return sub, nil +} + // Close 关闭数据库 func (s *Storage) Close() error { return s.db.Close() diff --git a/subscriptions/sub_1775301718713.yaml b/subscriptions/sub_1775301718713.yaml new file mode 100644 index 0000000..289f62c --- /dev/null +++ b/subscriptions/sub_1775301718713.yaml @@ -0,0 +1,1279 @@ +port: 0 +socks-port: 0 +redir-port: 0 +tproxy-port: 0 +mixed-port: 7890 +ss-config: "" +vmess-config: "" +inbound-tfo: false +inbound-mptcp: false +authentication: [] +skip-auth-prefixes: null +lan-allowed-ips: + - "0.0.0.0/0" + - "::/0" +lan-disallowed-ips: null +allow-lan: true +bind-address: "*" +mode: "rule" +unified-delay: true +log-level: "error" +ipv6: true +external-controller: "" +external-controller-pipe: "" +external-controller-unix: "" +external-controller-tls: "" +external-controller-cors: + allow-origins: + - "*" + allow-private-network: true +external-ui: "" +external-ui-url: "" +external-ui-name: "" +external-doh-server: "" +secret: "" +interface-name: "" +routing-mark: 0 +tunnels: null +geo-auto-update: false +geo-update-interval: 24 +geodata-mode: false +geodata-loader: "memconservative" +geosite-matcher: "" +tcp-concurrent: true +find-process-mode: "always" +global-client-fingerprint: "" +global-ua: "FlClash/v0.8.92 clash-verge Platform/macos" +etag-support: true +keep-alive-idle: 0 +keep-alive-interval: 30 +disable-keep-alive: false +proxy-providers: null +rule-providers: null +proxies: + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "美国01[免费]0.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 18888 + server: "us01.shanhai.click" + skip-cert-verify: false + sni: "us01.shanhai.click" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "美国02[免费]0.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 18888 + server: "us02.shanhai.click" + skip-cert-verify: false + sni: "us02.shanhai.click" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "台湾01[优化]0.1x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 18888 + server: "tw01.shanhai.click" + skip-cert-verify: false + sni: "tw01.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "台湾02[优化]0.1x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 18888 + server: "tw02.shanhai.click" + skip-cert-verify: false + sni: "tw02.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "日本01[软银]0.5x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 18888 + server: "jp01.shanhai.click" + skip-cert-verify: false + sni: "jp01.shanhai.click" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "日本02[软银]0.5x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 18888 + server: "jp02.shanhai.click" + skip-cert-verify: false + sni: "jp02.shanhai.click" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "日本03[软银]0.5x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 18888 + server: "jp03.shanhai.click" + skip-cert-verify: false + sni: "jp03.shanhai.click" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "香港01[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 11101 + server: "hk01.shanhai.cfd" + skip-cert-verify: false + sni: "hk01.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "香港02[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 21101 + server: "hk02.shanhai.cfd" + skip-cert-verify: false + sni: "hk02.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "台湾01[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 11102 + server: "tw01.shanhai.cfd" + skip-cert-verify: false + sni: "tw01.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "台湾02[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 21102 + server: "tw02.shanhai.cfd" + skip-cert-verify: false + sni: "tw02.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "日本01[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 11103 + server: "jp01.shanhai.cfd" + skip-cert-verify: false + sni: "jp01.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "日本02[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 21103 + server: "jp02.shanhai.cfd" + skip-cert-verify: false + sni: "jp02.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "美国01[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 11104 + server: "us01.shanhai.cfd" + skip-cert-verify: false + sni: "us01.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "美国02[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 21104 + server: "us02.shanhai.cfd" + skip-cert-verify: false + sni: "us02.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "美国03[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 31104 + server: "us03.shanhai.cfd" + skip-cert-verify: false + sni: "us03.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "英国01[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 11105 + server: "gb01.shanhai.cfd" + skip-cert-verify: false + sni: "gb01.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "越南01[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 11106 + server: "vn01.shanhai.cfd" + skip-cert-verify: false + sni: "vn01.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "澳门01[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 11107 + server: "mo01.shanhai.cfd" + skip-cert-verify: false + sni: "mo01.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "德国01[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 11108 + server: "de01.shanhai.cfd" + skip-cert-verify: false + sni: "de01.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "泰国01[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 11109 + server: "th01.shanhai.cfd" + skip-cert-verify: false + sni: "th01.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "韩国01[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 11110 + server: "kr01.shanhai.cfd" + skip-cert-verify: false + sni: "kr01.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "印度01[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 11111 + server: "in01.shanhai.cfd" + skip-cert-verify: false + sni: "in01.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "埃及01[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 11113 + server: "eg01.shanhai.cfd" + skip-cert-verify: false + sni: "eg01.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "蒙古01[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 11114 + server: "mn01.shanhai.cfd" + skip-cert-verify: false + sni: "mn01.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "老挝01[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 11115 + server: "la01.shanhai.cfd" + skip-cert-verify: false + sni: "la01.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "缅甸01[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 11116 + server: "mm01.shanhai.cfd" + skip-cert-verify: false + sni: "mm01.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "文莱01[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 11117 + server: "bn01.shanhai.cfd" + skip-cert-verify: false + sni: "bn01.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "新加坡01[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 12201 + server: "sg01.shanhai.cfd" + skip-cert-verify: false + sni: "sg01.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "新加坡02[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 22201 + server: "sg02.shanhai.cfd" + skip-cert-verify: false + sni: "sg02.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "菲律宾01[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 12202 + server: "ph01.shanhai.cfd" + skip-cert-verify: false + sni: "ph01.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "加拿大01[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 12203 + server: "ca01.shanhai.cfd" + skip-cert-verify: false + sni: "ca01.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "土耳其01[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 12204 + server: "tr01.shanhai.cfd" + skip-cert-verify: false + sni: "tr01.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "柬埔寨01[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 12205 + server: "kh01.shanhai.cfd" + skip-cert-verify: false + sni: "kh01.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "澳大利亚01[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 13301 + server: "au01.shanhai.cfd" + skip-cert-verify: false + sni: "au01.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "巴基斯坦01[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 13302 + server: "pk01.shanhai.cfd" + skip-cert-verify: false + sni: "pk01.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "马来西亚01[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 13303 + server: "my01.shanhai.cfd" + skip-cert-verify: false + sni: "my01.shanhai.sbs" + type: "anytls" + udp: true + - alpn: + - "h2" + - "http/1.1" + client-fingerprint: "chrome" + name: "印度尼西亚01[专线]1.0x" + password: "848f43e4-6e11-4efa-9f96-84c0e16a03e5" + port: 14401 + server: "id01.shanhai.cfd" + skip-cert-verify: false + sni: "id01.shanhai.sbs" + type: "anytls" + udp: true +proxy-groups: + - name: "山海ShanHai" + proxies: + - "自动选择" + - "故障转移" + - "美国01[免费]0.0x" + - "美国02[免费]0.0x" + - "台湾01[优化]0.1x" + - "台湾02[优化]0.1x" + - "日本01[软银]0.5x" + - "日本02[软银]0.5x" + - "日本03[软银]0.5x" + - "香港01[专线]1.0x" + - "香港02[专线]1.0x" + - "台湾01[专线]1.0x" + - "台湾02[专线]1.0x" + - "日本01[专线]1.0x" + - "日本02[专线]1.0x" + - "美国01[专线]1.0x" + - "美国02[专线]1.0x" + - "美国03[专线]1.0x" + - "英国01[专线]1.0x" + - "越南01[专线]1.0x" + - "澳门01[专线]1.0x" + - "德国01[专线]1.0x" + - "泰国01[专线]1.0x" + - "韩国01[专线]1.0x" + - "印度01[专线]1.0x" + - "埃及01[专线]1.0x" + - "蒙古01[专线]1.0x" + - "老挝01[专线]1.0x" + - "缅甸01[专线]1.0x" + - "文莱01[专线]1.0x" + - "新加坡01[专线]1.0x" + - "新加坡02[专线]1.0x" + - "菲律宾01[专线]1.0x" + - "加拿大01[专线]1.0x" + - "土耳其01[专线]1.0x" + - "柬埔寨01[专线]1.0x" + - "澳大利亚01[专线]1.0x" + - "巴基斯坦01[专线]1.0x" + - "马来西亚01[专线]1.0x" + - "印度尼西亚01[专线]1.0x" + type: "select" + - interval: 86400 + name: "自动选择" + proxies: + - "美国01[免费]0.0x" + - "美国02[免费]0.0x" + - "台湾01[优化]0.1x" + - "台湾02[优化]0.1x" + - "日本01[软银]0.5x" + - "日本02[软银]0.5x" + - "日本03[软银]0.5x" + - "香港01[专线]1.0x" + - "香港02[专线]1.0x" + - "台湾01[专线]1.0x" + - "台湾02[专线]1.0x" + - "日本01[专线]1.0x" + - "日本02[专线]1.0x" + - "美国01[专线]1.0x" + - "美国02[专线]1.0x" + - "美国03[专线]1.0x" + - "英国01[专线]1.0x" + - "越南01[专线]1.0x" + - "澳门01[专线]1.0x" + - "德国01[专线]1.0x" + - "泰国01[专线]1.0x" + - "韩国01[专线]1.0x" + - "印度01[专线]1.0x" + - "埃及01[专线]1.0x" + - "蒙古01[专线]1.0x" + - "老挝01[专线]1.0x" + - "缅甸01[专线]1.0x" + - "文莱01[专线]1.0x" + - "新加坡01[专线]1.0x" + - "新加坡02[专线]1.0x" + - "菲律宾01[专线]1.0x" + - "加拿大01[专线]1.0x" + - "土耳其01[专线]1.0x" + - "柬埔寨01[专线]1.0x" + - "澳大利亚01[专线]1.0x" + - "巴基斯坦01[专线]1.0x" + - "马来西亚01[专线]1.0x" + - "印度尼西亚01[专线]1.0x" + type: "url-test" + url: "http://www.gstatic.com/generate_204" + - interval: 7200 + name: "故障转移" + proxies: + - "美国01[免费]0.0x" + - "美国02[免费]0.0x" + - "台湾01[优化]0.1x" + - "台湾02[优化]0.1x" + - "日本01[软银]0.5x" + - "日本02[软银]0.5x" + - "日本03[软银]0.5x" + - "香港01[专线]1.0x" + - "香港02[专线]1.0x" + - "台湾01[专线]1.0x" + - "台湾02[专线]1.0x" + - "日本01[专线]1.0x" + - "日本02[专线]1.0x" + - "美国01[专线]1.0x" + - "美国02[专线]1.0x" + - "美国03[专线]1.0x" + - "英国01[专线]1.0x" + - "越南01[专线]1.0x" + - "澳门01[专线]1.0x" + - "德国01[专线]1.0x" + - "泰国01[专线]1.0x" + - "韩国01[专线]1.0x" + - "印度01[专线]1.0x" + - "埃及01[专线]1.0x" + - "蒙古01[专线]1.0x" + - "老挝01[专线]1.0x" + - "缅甸01[专线]1.0x" + - "文莱01[专线]1.0x" + - "新加坡01[专线]1.0x" + - "新加坡02[专线]1.0x" + - "菲律宾01[专线]1.0x" + - "加拿大01[专线]1.0x" + - "土耳其01[专线]1.0x" + - "柬埔寨01[专线]1.0x" + - "澳大利亚01[专线]1.0x" + - "巴基斯坦01[专线]1.0x" + - "马来西亚01[专线]1.0x" + - "印度尼西亚01[专线]1.0x" + type: "fallback" + url: "http://www.gstatic.com/generate_204" +sub-rules: null +listeners: null +hosts: {} +dns: + enable: true + prefer-h3: false + ipv6: false + ipv6-timeout: 100 + use-hosts: true + use-system-hosts: true + respect-rules: true + nameserver: + - "223.5.5.5" + - "119.29.29.29" + - "114.114.114.114" + fallback: + - "1.1.1.1" + - "8.8.8.8" + fallback-filter: + geoip: true + geoip-code: "CN" + ipcidr: + - "240.0.0.0/4" + domain: + - "+.google.com" + - "+.facebook.com" + - "+.youtube.com" + geosite: + - "gfw" + listen: "" + enhanced-mode: "fake-ip" + fake-ip-range: "198.18.0.1/16" + fake-ip-range6: "" + fake-ip-filter: + - "dns.msftnsci.com" + - "www.msftnsci.com" + - "www.msftconnecttest.com" + fake-ip-filter-mode: "blacklist" + fake-ip-ttl: 1 + default-nameserver: + - "223.5.5.5" + - "119.29.29.29" + - "114.114.114.114" + cache-algorithm: "" + cache-max-size: 0 + nameserver-policy: null + proxy-server-nameserver: + - "223.5.5.5" + - "119.29.29.29" + - "114.114.114.114" + direct-nameserver: null + direct-nameserver-follow-policy: false +ntp: + enable: false + server: "time.apple.com" + port: 123 + interval: 30 + dialer-proxy: "" + write-to-system: false +tun: + enable: true + device: "FlClash" + stack: "mixed" + dns-hijack: + - "any:53" + auto-route: true + AutoDetectInterface: true + inet6-address: + - "fdfe:dcba:9876::1/126" + file-descriptor: 0 + recvmsgx: true + route-address: [] +tuic-server: + enable: false + listen: "" + token: null + certificate: "" + private-key: "" + max-idle-time: 15000 + authentication-timeout: 1000 + alpn: + - "h3" + max-udp-relay-packet-size: 1500 +iptables: + enable: false + inbound-interface: "lo" + bypass: [] + dns-redirect: true +experimental: + Fingerprints: null + QUICGoDisableGSO: false + QUICGoDisableECN: true + IP4PEnable: false +profile: + store-selected: false + store-fake-ip: false +geox-url: + mmdb: "https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.metadb" + asn: "https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/GeoLite2-ASN.mmdb" + geoip: "https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.dat" + geosite: "https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat" +sniffer: + enable: false + override-destination: true + sniffing: null + force-domain: [] + skip-src-address: null + skip-dst-address: null + skip-domain: [] + port-whitelist: [] + force-dns-mapping: true + parse-pure-ip: true + sniff: {} +tls: + certificate: "" + private-key: "" + client-auth-type: "" + client-auth-cert: "" + ech-key: "" + custom-certifactes: null +clash-for-android: + append-system-dns: false + ui-subtitle-pattern: "" +rules: + - "IP-CIDR,1.1.1.1/32,山海ShanHai,no-resolve" + - "IP-CIDR,8.8.8.8/32,山海ShanHai,no-resolve" + - "DOMAIN-SUFFIX,services.googleapis.cn,山海ShanHai" + - "DOMAIN-SUFFIX,xn--ngstr-lra8j.com,山海ShanHai" + - "DOMAIN,safebrowsing.urlsec.qq.com,DIRECT" + - "DOMAIN,safebrowsing.googleapis.com,DIRECT" + - "DOMAIN,developer.apple.com,山海ShanHai" + - "DOMAIN-SUFFIX,digicert.com,山海ShanHai" + - "DOMAIN,ocsp.apple.com,山海ShanHai" + - "DOMAIN,ocsp.comodoca.com,山海ShanHai" + - "DOMAIN,ocsp.usertrust.com,山海ShanHai" + - "DOMAIN,ocsp.sectigo.com,山海ShanHai" + - "DOMAIN,ocsp.verisign.net,山海ShanHai" + - "DOMAIN-SUFFIX,apple-dns.net,山海ShanHai" + - "DOMAIN,testflight.apple.com,山海ShanHai" + - "DOMAIN,sandbox.itunes.apple.com,山海ShanHai" + - "DOMAIN,itunes.apple.com,山海ShanHai" + - "DOMAIN-SUFFIX,apps.apple.com,山海ShanHai" + - "DOMAIN-SUFFIX,blobstore.apple.com,山海ShanHai" + - "DOMAIN,cvws.icloud-content.com,山海ShanHai" + - "DOMAIN-SUFFIX,mzstatic.com,DIRECT" + - "DOMAIN-SUFFIX,itunes.apple.com,DIRECT" + - "DOMAIN-SUFFIX,icloud.com,DIRECT" + - "DOMAIN-SUFFIX,icloud-content.com,DIRECT" + - "DOMAIN-SUFFIX,me.com,DIRECT" + - "DOMAIN-SUFFIX,aaplimg.com,DIRECT" + - "DOMAIN-SUFFIX,cdn20.com,DIRECT" + - "DOMAIN-SUFFIX,cdn-apple.com,DIRECT" + - "DOMAIN-SUFFIX,akadns.net,DIRECT" + - "DOMAIN-SUFFIX,akamaiedge.net,DIRECT" + - "DOMAIN-SUFFIX,edgekey.net,DIRECT" + - "DOMAIN-SUFFIX,mwcloudcdn.com,DIRECT" + - "DOMAIN-SUFFIX,mwcname.com,DIRECT" + - "DOMAIN-SUFFIX,apple.com,DIRECT" + - "DOMAIN-SUFFIX,apple-cloudkit.com,DIRECT" + - "DOMAIN-SUFFIX,apple-mapkit.com,DIRECT" + - "DOMAIN,cn.bing.com,DIRECT" + - "DOMAIN-SUFFIX,126.com,DIRECT" + - "DOMAIN-SUFFIX,126.net,DIRECT" + - "DOMAIN-SUFFIX,127.net,DIRECT" + - "DOMAIN-SUFFIX,163.com,DIRECT" + - "DOMAIN-SUFFIX,360buyimg.com,DIRECT" + - "DOMAIN-SUFFIX,36kr.com,DIRECT" + - "DOMAIN-SUFFIX,acfun.tv,DIRECT" + - "DOMAIN-SUFFIX,air-matters.com,DIRECT" + - "DOMAIN-SUFFIX,aixifan.com,DIRECT" + - "DOMAIN-KEYWORD,alicdn,DIRECT" + - "DOMAIN-KEYWORD,alipay,DIRECT" + - "DOMAIN-KEYWORD,taobao,DIRECT" + - "DOMAIN-SUFFIX,amap.com,DIRECT" + - "DOMAIN-SUFFIX,autonavi.com,DIRECT" + - "DOMAIN-KEYWORD,baidu,DIRECT" + - "DOMAIN-SUFFIX,bdimg.com,DIRECT" + - "DOMAIN-SUFFIX,bdstatic.com,DIRECT" + - "DOMAIN-SUFFIX,bilibili.com,DIRECT" + - "DOMAIN-SUFFIX,bilivideo.com,DIRECT" + - "DOMAIN-SUFFIX,caiyunapp.com,DIRECT" + - "DOMAIN-SUFFIX,clouddn.com,DIRECT" + - "DOMAIN-SUFFIX,cnbeta.com,DIRECT" + - "DOMAIN-SUFFIX,cnbetacdn.com,DIRECT" + - "DOMAIN-SUFFIX,cootekservice.com,DIRECT" + - "DOMAIN-SUFFIX,csdn.net,DIRECT" + - "DOMAIN-SUFFIX,ctrip.com,DIRECT" + - "DOMAIN-SUFFIX,dgtle.com,DIRECT" + - "DOMAIN-SUFFIX,dianping.com,DIRECT" + - "DOMAIN-SUFFIX,douban.com,DIRECT" + - "DOMAIN-SUFFIX,doubanio.com,DIRECT" + - "DOMAIN-SUFFIX,duokan.com,DIRECT" + - "DOMAIN-SUFFIX,easou.com,DIRECT" + - "DOMAIN-SUFFIX,ele.me,DIRECT" + - "DOMAIN-SUFFIX,feng.com,DIRECT" + - "DOMAIN-SUFFIX,fir.im,DIRECT" + - "DOMAIN-SUFFIX,frdic.com,DIRECT" + - "DOMAIN-SUFFIX,g-cores.com,DIRECT" + - "DOMAIN-SUFFIX,godic.net,DIRECT" + - "DOMAIN-SUFFIX,gtimg.com,DIRECT" + - "DOMAIN,cdn.hockeyapp.net,DIRECT" + - "DOMAIN-SUFFIX,hongxiu.com,DIRECT" + - "DOMAIN-SUFFIX,hxcdn.net,DIRECT" + - "DOMAIN-SUFFIX,iciba.com,DIRECT" + - "DOMAIN-SUFFIX,ifeng.com,DIRECT" + - "DOMAIN-SUFFIX,ifengimg.com,DIRECT" + - "DOMAIN-SUFFIX,ipip.net,DIRECT" + - "DOMAIN-SUFFIX,iqiyi.com,DIRECT" + - "DOMAIN-SUFFIX,jd.com,DIRECT" + - "DOMAIN-SUFFIX,jianshu.com,DIRECT" + - "DOMAIN-SUFFIX,knewone.com,DIRECT" + - "DOMAIN-SUFFIX,le.com,DIRECT" + - "DOMAIN-SUFFIX,lecloud.com,DIRECT" + - "DOMAIN-SUFFIX,lemicp.com,DIRECT" + - "DOMAIN-SUFFIX,licdn.com,DIRECT" + - "DOMAIN-SUFFIX,luoo.net,DIRECT" + - "DOMAIN-SUFFIX,meituan.com,DIRECT" + - "DOMAIN-SUFFIX,meituan.net,DIRECT" + - "DOMAIN-SUFFIX,mi.com,DIRECT" + - "DOMAIN-SUFFIX,miaopai.com,DIRECT" + - "DOMAIN-SUFFIX,microsoft.com,DIRECT" + - "DOMAIN-SUFFIX,microsoftonline.com,DIRECT" + - "DOMAIN-SUFFIX,miui.com,DIRECT" + - "DOMAIN-SUFFIX,miwifi.com,DIRECT" + - "DOMAIN-SUFFIX,mob.com,DIRECT" + - "DOMAIN-SUFFIX,netease.com,DIRECT" + - "DOMAIN-SUFFIX,office.com,DIRECT" + - "DOMAIN-SUFFIX,office365.com,DIRECT" + - "DOMAIN-KEYWORD,officecdn,DIRECT" + - "DOMAIN-SUFFIX,oschina.net,DIRECT" + - "DOMAIN-SUFFIX,ppsimg.com,DIRECT" + - "DOMAIN-SUFFIX,pstatp.com,DIRECT" + - "DOMAIN-SUFFIX,qcloud.com,DIRECT" + - "DOMAIN-SUFFIX,qdaily.com,DIRECT" + - "DOMAIN-SUFFIX,qdmm.com,DIRECT" + - "DOMAIN-SUFFIX,qhimg.com,DIRECT" + - "DOMAIN-SUFFIX,qhres.com,DIRECT" + - "DOMAIN-SUFFIX,qidian.com,DIRECT" + - "DOMAIN-SUFFIX,qihucdn.com,DIRECT" + - "DOMAIN-SUFFIX,qiniu.com,DIRECT" + - "DOMAIN-SUFFIX,qiniucdn.com,DIRECT" + - "DOMAIN-SUFFIX,qiyipic.com,DIRECT" + - "DOMAIN-SUFFIX,qq.com,DIRECT" + - "DOMAIN-SUFFIX,qqurl.com,DIRECT" + - "DOMAIN-SUFFIX,rarbg.to,DIRECT" + - "DOMAIN-SUFFIX,ruguoapp.com,DIRECT" + - "DOMAIN-SUFFIX,segmentfault.com,DIRECT" + - "DOMAIN-SUFFIX,sinaapp.com,DIRECT" + - "DOMAIN-SUFFIX,smzdm.com,DIRECT" + - "DOMAIN-SUFFIX,snapdrop.net,DIRECT" + - "DOMAIN-SUFFIX,sogou.com,DIRECT" + - "DOMAIN-SUFFIX,sogoucdn.com,DIRECT" + - "DOMAIN-SUFFIX,sohu.com,DIRECT" + - "DOMAIN-SUFFIX,soku.com,DIRECT" + - "DOMAIN-SUFFIX,speedtest.net,DIRECT" + - "DOMAIN-SUFFIX,sspai.com,DIRECT" + - "DOMAIN-SUFFIX,suning.com,DIRECT" + - "DOMAIN-SUFFIX,taobao.com,DIRECT" + - "DOMAIN-SUFFIX,tencent.com,DIRECT" + - "DOMAIN-SUFFIX,tenpay.com,DIRECT" + - "DOMAIN-SUFFIX,tianyancha.com,DIRECT" + - "DOMAIN-SUFFIX,tmall.com,DIRECT" + - "DOMAIN-SUFFIX,tudou.com,DIRECT" + - "DOMAIN-SUFFIX,umetrip.com,DIRECT" + - "DOMAIN-SUFFIX,upaiyun.com,DIRECT" + - "DOMAIN-SUFFIX,upyun.com,DIRECT" + - "DOMAIN-SUFFIX,veryzhun.com,DIRECT" + - "DOMAIN-SUFFIX,weather.com,DIRECT" + - "DOMAIN-SUFFIX,weibo.com,DIRECT" + - "DOMAIN-SUFFIX,xiami.com,DIRECT" + - "DOMAIN-SUFFIX,xiami.net,DIRECT" + - "DOMAIN-SUFFIX,xiaomicp.com,DIRECT" + - "DOMAIN-SUFFIX,ximalaya.com,DIRECT" + - "DOMAIN-SUFFIX,xmcdn.com,DIRECT" + - "DOMAIN-SUFFIX,xunlei.com,DIRECT" + - "DOMAIN-SUFFIX,yhd.com,DIRECT" + - "DOMAIN-SUFFIX,yihaodianimg.com,DIRECT" + - "DOMAIN-SUFFIX,yinxiang.com,DIRECT" + - "DOMAIN-SUFFIX,ykimg.com,DIRECT" + - "DOMAIN-SUFFIX,youdao.com,DIRECT" + - "DOMAIN-SUFFIX,youku.com,DIRECT" + - "DOMAIN-SUFFIX,zealer.com,DIRECT" + - "DOMAIN-SUFFIX,zhihu.com,DIRECT" + - "DOMAIN-SUFFIX,zhimg.com,DIRECT" + - "DOMAIN-SUFFIX,zimuzu.tv,DIRECT" + - "DOMAIN-SUFFIX,zoho.com,DIRECT" + - "DOMAIN-KEYWORD,amazon,山海ShanHai" + - "DOMAIN-KEYWORD,google,山海ShanHai" + - "DOMAIN-KEYWORD,gmail,山海ShanHai" + - "DOMAIN-KEYWORD,youtube,山海ShanHai" + - "DOMAIN-KEYWORD,facebook,山海ShanHai" + - "DOMAIN-SUFFIX,fb.me,山海ShanHai" + - "DOMAIN-SUFFIX,fbcdn.net,山海ShanHai" + - "DOMAIN-KEYWORD,twitter,山海ShanHai" + - "DOMAIN-KEYWORD,instagram,山海ShanHai" + - "DOMAIN-KEYWORD,dropbox,山海ShanHai" + - "DOMAIN-SUFFIX,twimg.com,山海ShanHai" + - "DOMAIN-KEYWORD,blogspot,山海ShanHai" + - "DOMAIN-SUFFIX,youtu.be,山海ShanHai" + - "DOMAIN-KEYWORD,whatsapp,山海ShanHai" + - "DOMAIN-KEYWORD,admarvel,REJECT" + - "DOMAIN-KEYWORD,admaster,REJECT" + - "DOMAIN-KEYWORD,adsage,REJECT" + - "DOMAIN-KEYWORD,adsmogo,REJECT" + - "DOMAIN-KEYWORD,adsrvmedia,REJECT" + - "DOMAIN-KEYWORD,adwords,REJECT" + - "DOMAIN-KEYWORD,adservice,REJECT" + - "DOMAIN-SUFFIX,appsflyer.com,REJECT" + - "DOMAIN-KEYWORD,domob,REJECT" + - "DOMAIN-SUFFIX,doubleclick.net,REJECT" + - "DOMAIN-KEYWORD,duomeng,REJECT" + - "DOMAIN-KEYWORD,dwtrack,REJECT" + - "DOMAIN-KEYWORD,guanggao,REJECT" + - "DOMAIN-KEYWORD,lianmeng,REJECT" + - "DOMAIN-SUFFIX,mmstat.com,REJECT" + - "DOMAIN-KEYWORD,mopub,REJECT" + - "DOMAIN-KEYWORD,omgmta,REJECT" + - "DOMAIN-KEYWORD,openx,REJECT" + - "DOMAIN-KEYWORD,partnerad,REJECT" + - "DOMAIN-KEYWORD,pingfore,REJECT" + - "DOMAIN-KEYWORD,supersonicads,REJECT" + - "DOMAIN-KEYWORD,uedas,REJECT" + - "DOMAIN-KEYWORD,umeng,REJECT" + - "DOMAIN-KEYWORD,usage,REJECT" + - "DOMAIN-SUFFIX,vungle.com,REJECT" + - "DOMAIN-KEYWORD,wlmonitor,REJECT" + - "DOMAIN-KEYWORD,zjtoolbar,REJECT" + - "DOMAIN-SUFFIX,9to5mac.com,山海ShanHai" + - "DOMAIN-SUFFIX,abpchina.org,山海ShanHai" + - "DOMAIN-SUFFIX,adblockplus.org,山海ShanHai" + - "DOMAIN-SUFFIX,adobe.com,山海ShanHai" + - "DOMAIN-SUFFIX,akamaized.net,山海ShanHai" + - "DOMAIN-SUFFIX,alfredapp.com,山海ShanHai" + - "DOMAIN-SUFFIX,amplitude.com,山海ShanHai" + - "DOMAIN-SUFFIX,ampproject.org,山海ShanHai" + - "DOMAIN-SUFFIX,android.com,山海ShanHai" + - "DOMAIN-SUFFIX,angularjs.org,山海ShanHai" + - "DOMAIN-SUFFIX,aolcdn.com,山海ShanHai" + - "DOMAIN-SUFFIX,apkpure.com,山海ShanHai" + - "DOMAIN-SUFFIX,appledaily.com,山海ShanHai" + - "DOMAIN-SUFFIX,appshopper.com,山海ShanHai" + - "DOMAIN-SUFFIX,appspot.com,山海ShanHai" + - "DOMAIN-SUFFIX,arcgis.com,山海ShanHai" + - "DOMAIN-SUFFIX,archive.org,山海ShanHai" + - "DOMAIN-SUFFIX,armorgames.com,山海ShanHai" + - "DOMAIN-SUFFIX,aspnetcdn.com,山海ShanHai" + - "DOMAIN-SUFFIX,att.com,山海ShanHai" + - "DOMAIN-SUFFIX,awsstatic.com,山海ShanHai" + - "DOMAIN-SUFFIX,azureedge.net,山海ShanHai" + - "DOMAIN-SUFFIX,azurewebsites.net,山海ShanHai" + - "DOMAIN-SUFFIX,bing.com,山海ShanHai" + - "DOMAIN-SUFFIX,bintray.com,山海ShanHai" + - "DOMAIN-SUFFIX,bit.com,山海ShanHai" + - "DOMAIN-SUFFIX,bit.ly,山海ShanHai" + - "DOMAIN-SUFFIX,bitbucket.org,山海ShanHai" + - "DOMAIN-SUFFIX,bjango.com,山海ShanHai" + - "DOMAIN-SUFFIX,bkrtx.com,山海ShanHai" + - "DOMAIN-SUFFIX,blog.com,山海ShanHai" + - "DOMAIN-SUFFIX,blogcdn.com,山海ShanHai" + - "DOMAIN-SUFFIX,blogger.com,山海ShanHai" + - "DOMAIN-SUFFIX,blogsmithmedia.com,山海ShanHai" + - "DOMAIN-SUFFIX,blogspot.com,山海ShanHai" + - "DOMAIN-SUFFIX,blogspot.hk,山海ShanHai" + - "DOMAIN-SUFFIX,bloomberg.com,山海ShanHai" + - "DOMAIN-SUFFIX,box.com,山海ShanHai" + - "DOMAIN-SUFFIX,box.net,山海ShanHai" + - "DOMAIN-SUFFIX,cachefly.net,山海ShanHai" + - "DOMAIN-SUFFIX,chromium.org,山海ShanHai" + - "DOMAIN-SUFFIX,cl.ly,山海ShanHai" + - "DOMAIN-SUFFIX,cloudflare.com,山海ShanHai" + - "DOMAIN-SUFFIX,cloudfront.net,山海ShanHai" + - "DOMAIN-SUFFIX,cloudmagic.com,山海ShanHai" + - "DOMAIN-SUFFIX,cmail19.com,山海ShanHai" + - "DOMAIN-SUFFIX,cnet.com,山海ShanHai" + - "DOMAIN-SUFFIX,cocoapods.org,山海ShanHai" + - "DOMAIN-SUFFIX,comodoca.com,山海ShanHai" + - "DOMAIN-SUFFIX,crashlytics.com,山海ShanHai" + - "DOMAIN-SUFFIX,culturedcode.com,山海ShanHai" + - "DOMAIN-SUFFIX,d.pr,山海ShanHai" + - "DOMAIN-SUFFIX,danilo.to,山海ShanHai" + - "DOMAIN-SUFFIX,dayone.me,山海ShanHai" + - "DOMAIN-SUFFIX,db.tt,山海ShanHai" + - "DOMAIN-SUFFIX,deskconnect.com,山海ShanHai" + - "DOMAIN-SUFFIX,disq.us,山海ShanHai" + - "DOMAIN-SUFFIX,disqus.com,山海ShanHai" + - "DOMAIN-SUFFIX,disquscdn.com,山海ShanHai" + - "DOMAIN-SUFFIX,dnsimple.com,山海ShanHai" + - "DOMAIN-SUFFIX,docker.com,山海ShanHai" + - "DOMAIN-SUFFIX,dribbble.com,山海ShanHai" + - "DOMAIN-SUFFIX,droplr.com,山海ShanHai" + - "DOMAIN-SUFFIX,duckduckgo.com,山海ShanHai" + - "DOMAIN-SUFFIX,dueapp.com,山海ShanHai" + - "DOMAIN-SUFFIX,dytt8.net,山海ShanHai" + - "DOMAIN-SUFFIX,edgecastcdn.net,山海ShanHai" + - "DOMAIN-SUFFIX,edgekey.net,山海ShanHai" + - "DOMAIN-SUFFIX,edgesuite.net,山海ShanHai" + - "DOMAIN-SUFFIX,engadget.com,山海ShanHai" + - "DOMAIN-SUFFIX,entrust.net,山海ShanHai" + - "DOMAIN-SUFFIX,eurekavpt.com,山海ShanHai" + - "DOMAIN-SUFFIX,evernote.com,山海ShanHai" + - "DOMAIN-SUFFIX,fabric.io,山海ShanHai" + - "DOMAIN-SUFFIX,fast.com,山海ShanHai" + - "DOMAIN-SUFFIX,fastly.net,山海ShanHai" + - "DOMAIN-SUFFIX,fc2.com,山海ShanHai" + - "DOMAIN-SUFFIX,feedburner.com,山海ShanHai" + - "DOMAIN-SUFFIX,feedly.com,山海ShanHai" + - "DOMAIN-SUFFIX,feedsportal.com,山海ShanHai" + - "DOMAIN-SUFFIX,fiftythree.com,山海ShanHai" + - "DOMAIN-SUFFIX,firebaseio.com,山海ShanHai" + - "DOMAIN-SUFFIX,flexibits.com,山海ShanHai" + - "DOMAIN-SUFFIX,flickr.com,山海ShanHai" + - "DOMAIN-SUFFIX,flipboard.com,山海ShanHai" + - "DOMAIN-SUFFIX,g.co,山海ShanHai" + - "DOMAIN-SUFFIX,gabia.net,山海ShanHai" + - "DOMAIN-SUFFIX,geni.us,山海ShanHai" + - "DOMAIN-SUFFIX,gfx.ms,山海ShanHai" + - "DOMAIN-SUFFIX,ggpht.com,山海ShanHai" + - "DOMAIN-SUFFIX,ghostnoteapp.com,山海ShanHai" + - "DOMAIN-SUFFIX,git.io,山海ShanHai" + - "DOMAIN-KEYWORD,github,山海ShanHai" + - "DOMAIN-SUFFIX,globalsign.com,山海ShanHai" + - "DOMAIN-SUFFIX,gmodules.com,山海ShanHai" + - "DOMAIN-SUFFIX,godaddy.com,山海ShanHai" + - "DOMAIN-SUFFIX,golang.org,山海ShanHai" + - "DOMAIN-SUFFIX,gongm.in,山海ShanHai" + - "DOMAIN-SUFFIX,goo.gl,山海ShanHai" + - "DOMAIN-SUFFIX,goodreaders.com,山海ShanHai" + - "DOMAIN-SUFFIX,goodreads.com,山海ShanHai" + - "DOMAIN-SUFFIX,gravatar.com,山海ShanHai" + - "DOMAIN-SUFFIX,gstatic.com,山海ShanHai" + - "DOMAIN-SUFFIX,gvt0.com,山海ShanHai" + - "DOMAIN-SUFFIX,hockeyapp.net,山海ShanHai" + - "DOMAIN-SUFFIX,hotmail.com,山海ShanHai" + - "DOMAIN-SUFFIX,icons8.com,山海ShanHai" + - "DOMAIN-SUFFIX,ifixit.com,山海ShanHai" + - "DOMAIN-SUFFIX,ift.tt,山海ShanHai" + - "DOMAIN-SUFFIX,ifttt.com,山海ShanHai" + - "DOMAIN-SUFFIX,iherb.com,山海ShanHai" + - "DOMAIN-SUFFIX,imageshack.us,山海ShanHai" + - "DOMAIN-SUFFIX,img.ly,山海ShanHai" + - "DOMAIN-SUFFIX,imgur.com,山海ShanHai" + - "DOMAIN-SUFFIX,imore.com,山海ShanHai" + - "DOMAIN-SUFFIX,instapaper.com,山海ShanHai" + - "DOMAIN-SUFFIX,ipn.li,山海ShanHai" + - "DOMAIN-SUFFIX,is.gd,山海ShanHai" + - "DOMAIN-SUFFIX,issuu.com,山海ShanHai" + - "DOMAIN-SUFFIX,itgonglun.com,山海ShanHai" + - "DOMAIN-SUFFIX,itun.es,山海ShanHai" + - "DOMAIN-SUFFIX,ixquick.com,山海ShanHai" + - "DOMAIN-SUFFIX,j.mp,山海ShanHai" + - "DOMAIN-SUFFIX,js.revsci.net,山海ShanHai" + - "DOMAIN-SUFFIX,jshint.com,山海ShanHai" + - "DOMAIN-SUFFIX,jtvnw.net,山海ShanHai" + - "DOMAIN-SUFFIX,justgetflux.com,山海ShanHai" + - "DOMAIN-SUFFIX,kat.cr,山海ShanHai" + - "DOMAIN-SUFFIX,klip.me,山海ShanHai" + - "DOMAIN-SUFFIX,libsyn.com,山海ShanHai" + - "DOMAIN-SUFFIX,linkedin.com,山海ShanHai" + - "DOMAIN-SUFFIX,line-apps.com,山海ShanHai" + - "DOMAIN-SUFFIX,linode.com,山海ShanHai" + - "DOMAIN-SUFFIX,lithium.com,山海ShanHai" + - "DOMAIN-SUFFIX,littlehj.com,山海ShanHai" + - "DOMAIN-SUFFIX,live.com,山海ShanHai" + - "DOMAIN-SUFFIX,live.net,山海ShanHai" + - "DOMAIN-SUFFIX,livefilestore.com,山海ShanHai" + - "DOMAIN-SUFFIX,llnwd.net,山海ShanHai" + - "DOMAIN-SUFFIX,macid.co,山海ShanHai" + - "DOMAIN-SUFFIX,macromedia.com,山海ShanHai" + - "DOMAIN-SUFFIX,macrumors.com,山海ShanHai" + - "DOMAIN-SUFFIX,mashable.com,山海ShanHai" + - "DOMAIN-SUFFIX,mathjax.org,山海ShanHai" + - "DOMAIN-SUFFIX,medium.com,山海ShanHai" + - "DOMAIN-SUFFIX,mega.co.nz,山海ShanHai" + - "DOMAIN-SUFFIX,mega.nz,山海ShanHai" + - "DOMAIN-SUFFIX,megaupload.com,山海ShanHai" + - "DOMAIN-SUFFIX,microsofttranslator.com,山海ShanHai" + - "DOMAIN-SUFFIX,mindnode.com,山海ShanHai" + - "DOMAIN-SUFFIX,mobile01.com,山海ShanHai" + - "DOMAIN-SUFFIX,modmyi.com,山海ShanHai" + - "DOMAIN-SUFFIX,msedge.net,山海ShanHai" + - "DOMAIN-SUFFIX,myfontastic.com,山海ShanHai" + - "DOMAIN-SUFFIX,name.com,山海ShanHai" + - "DOMAIN-SUFFIX,nextmedia.com,山海ShanHai" + - "DOMAIN-SUFFIX,nsstatic.net,山海ShanHai" + - "DOMAIN-SUFFIX,nssurge.com,山海ShanHai" + - "DOMAIN-SUFFIX,nyt.com,山海ShanHai" + - "DOMAIN-SUFFIX,nytimes.com,山海ShanHai" + - "DOMAIN-SUFFIX,omnigroup.com,山海ShanHai" + - "DOMAIN-SUFFIX,onedrive.com,山海ShanHai" + - "DOMAIN-SUFFIX,onenote.com,山海ShanHai" + - "DOMAIN-SUFFIX,ooyala.com,山海ShanHai" + - "DOMAIN-SUFFIX,openvpn.net,山海ShanHai" + - "DOMAIN-SUFFIX,openwrt.org,山海ShanHai" + - "DOMAIN-SUFFIX,orkut.com,山海ShanHai" + - "DOMAIN-SUFFIX,osxdaily.com,山海ShanHai" + - "DOMAIN-SUFFIX,outlook.com,山海ShanHai" + - "DOMAIN-SUFFIX,ow.ly,山海ShanHai" + - "DOMAIN-SUFFIX,paddleapi.com,山海ShanHai" + - "DOMAIN-SUFFIX,parallels.com,山海ShanHai" + - "DOMAIN-SUFFIX,parse.com,山海ShanHai" + - "DOMAIN-SUFFIX,pdfexpert.com,山海ShanHai" + - "DOMAIN-SUFFIX,periscope.tv,山海ShanHai" + - "DOMAIN-SUFFIX,pinboard.in,山海ShanHai" + - "DOMAIN-SUFFIX,pinterest.com,山海ShanHai" + - "DOMAIN-SUFFIX,pixelmator.com,山海ShanHai" + - "DOMAIN-SUFFIX,pixiv.net,山海ShanHai" + - "DOMAIN-SUFFIX,playpcesor.com,山海ShanHai" + - "DOMAIN-SUFFIX,playstation.com,山海ShanHai" + - "DOMAIN-SUFFIX,playstation.com.hk,山海ShanHai" + - "DOMAIN-SUFFIX,playstation.net,山海ShanHai" + - "DOMAIN-SUFFIX,playstationnetwork.com,山海ShanHai" + - "DOMAIN-SUFFIX,pushwoosh.com,山海ShanHai" + - "DOMAIN-SUFFIX,rime.im,山海ShanHai" + - "DOMAIN-SUFFIX,servebom.com,山海ShanHai" + - "DOMAIN-SUFFIX,sfx.ms,山海ShanHai" + - "DOMAIN-SUFFIX,shadowsocks.org,山海ShanHai" + - "DOMAIN-SUFFIX,sharethis.com,山海ShanHai" + - "DOMAIN-SUFFIX,shazam.com,山海ShanHai" + - "DOMAIN-SUFFIX,skype.com,山海ShanHai" + - "DOMAIN-SUFFIX,smartdns山海ShanHai.com,山海ShanHai" + - "DOMAIN-SUFFIX,smartmailcloud.com,山海ShanHai" + - "DOMAIN-SUFFIX,sndcdn.com,山海ShanHai" + - "DOMAIN-SUFFIX,sony.com,山海ShanHai" + - "DOMAIN-SUFFIX,soundcloud.com,山海ShanHai" + - "DOMAIN-SUFFIX,sourceforge.net,山海ShanHai" + - "DOMAIN-SUFFIX,spotify.com,山海ShanHai" + - "DOMAIN-SUFFIX,squarespace.com,山海ShanHai" + - "DOMAIN-SUFFIX,sstatic.net,山海ShanHai" + - "DOMAIN-SUFFIX,st.luluku.pw,山海ShanHai" + - "DOMAIN-SUFFIX,stackoverflow.com,山海ShanHai" + - "DOMAIN-SUFFIX,startpage.com,山海ShanHai" + - "DOMAIN-SUFFIX,staticflickr.com,山海ShanHai" + - "DOMAIN-SUFFIX,steamcommunity.com,山海ShanHai" + - "DOMAIN-SUFFIX,symauth.com,山海ShanHai" + - "DOMAIN-SUFFIX,symcb.com,山海ShanHai" + - "DOMAIN-SUFFIX,symcd.com,山海ShanHai" + - "DOMAIN-SUFFIX,tapbots.com,山海ShanHai" + - "DOMAIN-SUFFIX,tapbots.net,山海ShanHai" + - "DOMAIN-SUFFIX,tdesktop.com,山海ShanHai" + - "DOMAIN-SUFFIX,techcrunch.com,山海ShanHai" + - "DOMAIN-SUFFIX,techsmith.com,山海ShanHai" + - "DOMAIN-SUFFIX,thepiratebay.org,山海ShanHai" + - "DOMAIN-SUFFIX,theverge.com,山海ShanHai" + - "DOMAIN-SUFFIX,time.com,山海ShanHai" + - "DOMAIN-SUFFIX,timeinc.net,山海ShanHai" + - "DOMAIN-SUFFIX,tiny.cc,山海ShanHai" + - "DOMAIN-SUFFIX,tinypic.com,山海ShanHai" + - "DOMAIN-SUFFIX,tmblr.co,山海ShanHai" + - "DOMAIN-SUFFIX,todoist.com,山海ShanHai" + - "DOMAIN-SUFFIX,trello.com,山海ShanHai" + - "DOMAIN-SUFFIX,trustasiassl.com,山海ShanHai" + - "DOMAIN-SUFFIX,tumblr.co,山海ShanHai" + - "DOMAIN-SUFFIX,tumblr.com,山海ShanHai" + - "DOMAIN-SUFFIX,tweetdeck.com,山海ShanHai" + - "DOMAIN-SUFFIX,tweetmarker.net,山海ShanHai" + - "DOMAIN-SUFFIX,twitch.tv,山海ShanHai" + - "DOMAIN-SUFFIX,txmblr.com,山海ShanHai" + - "DOMAIN-SUFFIX,typekit.net,山海ShanHai" + - "DOMAIN-SUFFIX,ubertags.com,山海ShanHai" + - "DOMAIN-SUFFIX,ublock.org,山海ShanHai" + - "DOMAIN-SUFFIX,ubnt.com,山海ShanHai" + - "DOMAIN-SUFFIX,ulyssesapp.com,山海ShanHai" + - "DOMAIN-SUFFIX,urchin.com,山海ShanHai" + - "DOMAIN-SUFFIX,usertrust.com,山海ShanHai" + - "DOMAIN-SUFFIX,v.gd,山海ShanHai" + - "DOMAIN-SUFFIX,v2ex.com,山海ShanHai" + - "DOMAIN-SUFFIX,vimeo.com,山海ShanHai" + - "DOMAIN-SUFFIX,vimeocdn.com,山海ShanHai" + - "DOMAIN-SUFFIX,vine.co,山海ShanHai" + - "DOMAIN-SUFFIX,vivaldi.com,山海ShanHai" + - "DOMAIN-SUFFIX,vox-cdn.com,山海ShanHai" + - "DOMAIN-SUFFIX,vsco.co,山海ShanHai" + - "DOMAIN-SUFFIX,vultr.com,山海ShanHai" + - "DOMAIN-SUFFIX,w.org,山海ShanHai" + - "DOMAIN-SUFFIX,w3schools.com,山海ShanHai" + - "DOMAIN-SUFFIX,webtype.com,山海ShanHai" + - "DOMAIN-SUFFIX,wikiwand.com,山海ShanHai" + - "DOMAIN-SUFFIX,wikileaks.org,山海ShanHai" + - "DOMAIN-SUFFIX,wikimedia.org,山海ShanHai" + - "DOMAIN-SUFFIX,wikipedia.com,山海ShanHai" + - "DOMAIN-SUFFIX,wikipedia.org,山海ShanHai" + - "DOMAIN-SUFFIX,windows.com,山海ShanHai" + - "DOMAIN-SUFFIX,windows.net,山海ShanHai" + - "DOMAIN-SUFFIX,wire.com,山海ShanHai" + - "DOMAIN-SUFFIX,wordpress.com,山海ShanHai" + - "DOMAIN-SUFFIX,workflowy.com,山海ShanHai" + - "DOMAIN-SUFFIX,wp.com,山海ShanHai" + - "DOMAIN-SUFFIX,wsj.com,山海ShanHai" + - "DOMAIN-SUFFIX,wsj.net,山海ShanHai" + - "DOMAIN-SUFFIX,xda-developers.com,山海ShanHai" + - "DOMAIN-SUFFIX,xeeno.com,山海ShanHai" + - "DOMAIN-SUFFIX,xiti.com,山海ShanHai" + - "DOMAIN-SUFFIX,yahoo.com,山海ShanHai" + - "DOMAIN-SUFFIX,yimg.com,山海ShanHai" + - "DOMAIN-SUFFIX,ying.com,山海ShanHai" + - "DOMAIN-SUFFIX,yoyo.org,山海ShanHai" + - "DOMAIN-SUFFIX,ytimg.com,山海ShanHai" + - "DOMAIN-SUFFIX,telegra.ph,山海ShanHai" + - "DOMAIN-SUFFIX,telegram.org,山海ShanHai" + - "IP-CIDR,91.108.4.0/22,山海ShanHai,no-resolve" + - "IP-CIDR,91.108.8.0/21,山海ShanHai,no-resolve" + - "IP-CIDR,91.108.16.0/22,山海ShanHai,no-resolve" + - "IP-CIDR,91.108.56.0/22,山海ShanHai,no-resolve" + - "IP-CIDR,149.154.160.0/20,山海ShanHai,no-resolve" + - "IP-CIDR6,2001:67c:4e8::/48,山海ShanHai,no-resolve" + - "IP-CIDR6,2001:b28:f23d::/48,山海ShanHai,no-resolve" + - "IP-CIDR6,2001:b28:f23f::/48,山海ShanHai,no-resolve" + - "IP-CIDR,120.232.181.162/32,山海ShanHai,no-resolve" + - "IP-CIDR,120.241.147.226/32,山海ShanHai,no-resolve" + - "IP-CIDR,120.253.253.226/32,山海ShanHai,no-resolve" + - "IP-CIDR,120.253.255.162/32,山海ShanHai,no-resolve" + - "IP-CIDR,120.253.255.34/32,山海ShanHai,no-resolve" + - "IP-CIDR,120.253.255.98/32,山海ShanHai,no-resolve" + - "IP-CIDR,180.163.150.162/32,山海ShanHai,no-resolve" + - "IP-CIDR,180.163.150.34/32,山海ShanHai,no-resolve" + - "IP-CIDR,180.163.151.162/32,山海ShanHai,no-resolve" + - "IP-CIDR,180.163.151.34/32,山海ShanHai,no-resolve" + - "IP-CIDR,203.208.39.0/24,山海ShanHai,no-resolve" + - "IP-CIDR,203.208.40.0/24,山海ShanHai,no-resolve" + - "IP-CIDR,203.208.41.0/24,山海ShanHai,no-resolve" + - "IP-CIDR,203.208.43.0/24,山海ShanHai,no-resolve" + - "IP-CIDR,203.208.50.0/24,山海ShanHai,no-resolve" + - "IP-CIDR,220.181.174.162/32,山海ShanHai,no-resolve" + - "IP-CIDR,220.181.174.226/32,山海ShanHai,no-resolve" + - "IP-CIDR,220.181.174.34/32,山海ShanHai,no-resolve" + - "DOMAIN,injections.adguard.org,DIRECT" + - "DOMAIN,local.adguard.org,DIRECT" + - "DOMAIN-SUFFIX,local,DIRECT" + - "IP-CIDR,127.0.0.0/8,DIRECT" + - "IP-CIDR,172.16.0.0/12,DIRECT" + - "IP-CIDR,192.168.0.0/16,DIRECT" + - "IP-CIDR,10.0.0.0/8,DIRECT" + - "IP-CIDR,17.0.0.0/8,DIRECT" + - "IP-CIDR,100.64.0.0/10,DIRECT" + - "IP-CIDR,224.0.0.0/4,DIRECT" + - "IP-CIDR6,fe80::/10,DIRECT" + - "DOMAIN-SUFFIX,cn,DIRECT" + - "DOMAIN-KEYWORD,-cn,DIRECT" + - "GEOIP,CN,DIRECT" + - "MATCH,山海ShanHai" diff --git a/webui/dashboard.go b/webui/dashboard.go index 2c70e9b..b2320c0 100644 --- a/webui/dashboard.go +++ b/webui/dashboard.go @@ -52,7 +52,7 @@ body::after{content:'';position:fixed;top:0;left:0;width:100%;height:100%;backgr .control-panel{background:var(--bg-card);border:1px solid var(--border-heavy);padding:20px;margin-bottom:20px;box-shadow:0 0 20px rgba(0,255,65,0.15)} .control-header{display:flex;align-items:center;justify-content:center;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid var(--border)} .control-title{font-size:14px;font-weight:700;letter-spacing:0.12em;font-family:var(--mono);text-transform:uppercase;color:var(--fg);text-shadow:0 0 10px var(--fg)} -.control-ops{display:flex;flex-direction:column;gap:8px} +.control-ops{display:flex;flex-direction:row;gap:8px} .ctrl-btn-primary{width:100%;padding:10px;font-size:10px;font-weight:600;cursor:pointer;border:1px solid var(--border-heavy);background:var(--bg-card);color:var(--fg);font-family:var(--mono);text-transform:uppercase;letter-spacing:0.08em;transition:all 0.2s} .ctrl-btn-primary:hover{background:var(--border);box-shadow:0 0 15px var(--border-heavy);color:var(--fg);text-shadow:0 0 5px var(--fg)} .ctrl-btn-secondary{width:100%;padding:8px;font-size:9px;font-weight:600;cursor:pointer;border:1px solid var(--border);background:var(--bg-card);color:var(--fg-dim);font-family:var(--mono);text-transform:uppercase;letter-spacing:0.08em;transition:all 0.2s} @@ -78,16 +78,16 @@ body::after{content:'';position:fixed;top:0;left:0;width:100%;height:100%;backgr } /* Health Grid - 侧边栏紧凑布局 */ -.health-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:2px;background:var(--bg);border:1px solid var(--border);margin-bottom:16px;box-shadow:0 0 20px rgba(0,255,65,0.1)} -.health-card{background:var(--bg-card);padding:16px;position:relative;border:1px solid var(--border)} -.health-label{font-size:8px;text-transform:uppercase;letter-spacing:0.15em;color:var(--fg-dim);margin-bottom:8px;font-weight:600;font-family:var(--mono)} -.health-value{font-size:24px;font-weight:700;font-family:var(--mono);line-height:1;letter-spacing:0.05em;color:var(--fg);text-shadow:0 0 10px var(--fg)} +.health-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:1px;background:var(--bg);border:1px solid var(--border);margin-bottom:10px;box-shadow:0 0 20px rgba(0,255,65,0.1)} +.health-card{background:var(--bg-card);padding:8px 10px;position:relative;border:1px solid var(--border)} +.health-label{font-size:8px;text-transform:uppercase;letter-spacing:0.15em;color:var(--fg-dim);margin-bottom:4px;font-weight:600;font-family:var(--mono)} +.health-value{font-size:18px;font-weight:700;font-family:var(--mono);line-height:1;letter-spacing:0.05em;color:var(--fg);text-shadow:0 0 10px var(--fg)} .health-status{position:absolute;top:16px;right:16px;width:8px;height:8px;border-radius:50%} .health-status.healthy{background:var(--green);box-shadow:0 0 8px var(--green)} .health-status.warning{background:var(--orange);box-shadow:0 0 8px var(--orange)} .health-status.critical{background:var(--red);box-shadow:0 0 8px var(--red)} .health-status.emergency{background:var(--red);box-shadow:0 0 15px var(--red),0 0 0 3px rgba(255,0,51,0.3);animation:pulse 1s infinite} -.health-meta{font-size:9px;color:var(--gray-5);margin-top:6px;font-family:var(--mono)} +.health-meta{font-size:8px;color:var(--gray-5);margin-top:3px;font-family:var(--mono)} @keyframes pulse{0%,100%{opacity:1}50%{opacity:0.6}} @@ -184,7 +184,7 @@ tr:hover{background:var(--gray-2);box-shadow:inset 0 0 20px rgba(0,255,65,0.05)} .content-grid{grid-template-columns:1fr} .sidebar{position:static} .health-grid{grid-template-columns:repeat(4,1fr)} - .health-card{padding:20px} + .health-card{padding:10px 12px} .health-value{font-size:32px} .log-box{height:400px} .sidebar .section{border:1px solid var(--border)} @@ -225,8 +225,12 @@ tr:hover{background:var(--gray-2);box-shadow:inset 0 0 20px rgba(0,255,65,0.05)} + 退出 +
@@ -243,10 +247,25 @@ tr:hover{background:var(--gray-2);box-shadow:inset 0 0 20px rgba(0,255,65,0.05)}
- +
+ +
+
+
[ SUBSCRIPTIONS ]
+
+
+
+ + +
+
+
+ + +
[ FREE_POOL ]
池子状态
@@ -254,7 +273,7 @@ tr:hover{background:var(--gray-2);box-shadow:inset 0 0 20px rgba(0,255,65,0.05)}
-
总代理数
+
免费代理
0
0 容量
@@ -270,6 +289,26 @@ tr:hover{background:var(--gray-2);box-shadow:inset 0 0 20px rgba(0,255,65,0.05)}
+ +
[ SUBSCRIPTION_POOL ]
+
+
+
订阅源
+
0
+
+
+
+
可用
+
0
+
+
+
+
禁用/待恢复
+
0
+
探测唤醒中
+
+
+
质量分布
@@ -301,74 +340,56 @@ tr:hover{background:var(--gray-2);box-shadow:inset 0 0 20px rgba(0,255,65,0.05)}