mirror of
https://github.com/isboyjc/GoProxy.git
synced 2026-05-06 20:02:54 +08:00
feat: ✨ implement custom proxy subscription management and enhance configuration
- 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.
This commit is contained in:
12
.env.example
12
.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
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -46,3 +46,6 @@ docker-compose.dokploy.yml
|
||||
|
||||
# Docker volumes
|
||||
.docker-data/
|
||||
|
||||
|
||||
tmp/
|
||||
55
CHANGELOG.md
55
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
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -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)
|
||||
|
||||
15
Dockerfile
15
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"]
|
||||
|
||||
202
POOL_DESIGN.md
Normal file
202
POOL_DESIGN.md
Normal file
@@ -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 |
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
100
config/config.go
100
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
|
||||
|
||||
570
custom/manager.go
Normal file
570
custom/manager.go
Normal file
@@ -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 刷新<E588B7><E696B0>个订阅
|
||||
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
|
||||
}
|
||||
736
custom/parser.go
Normal file
736
custom/parser.go
Normal file
@@ -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 == "<nil>" {
|
||||
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
|
||||
}
|
||||
530
custom/singbox.go
Normal file
530
custom/singbox.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
2
go.mod
2
go.mod
@@ -8,3 +8,5 @@ require (
|
||||
)
|
||||
|
||||
require golang.org/x/time v0.15.0
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
4
go.sum
4
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=
|
||||
|
||||
25
main.go
25
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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
1279
subscriptions/sub_1775301718713.yaml
Normal file
1279
subscriptions/sub_1775301718713.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)}
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<button class="tab guest-only" onclick="openContributeModal()" style="color:var(--yellow)" data-i18n="contribute.nav">贡献订阅</button>
|
||||
<a href="/login" class="tab" id="login-link" style="display: none;" data-i18n="nav.login">登录</a>
|
||||
<a href="/logout" class="tab admin-only" data-i18n="nav.logout">退出</a>
|
||||
<button class="tab admin-only" onclick="openSettings()" title="" data-i18n-title="contribute.settings" style="padding:4px 8px">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="proxy-content">
|
||||
@@ -243,10 +247,25 @@ tr:hover{background:var(--gray-2);box-shadow:inset 0 0 20px rgba(0,255,65,0.05)}
|
||||
<div class="control-ops">
|
||||
<button class="ctrl-btn-primary" onclick="triggerFetch()" data-i18n="actions.fetch">抓取代理</button>
|
||||
<button class="ctrl-btn-secondary" onclick="refreshLatency()" data-i18n="actions.refresh">刷新延迟</button>
|
||||
<button class="ctrl-btn-secondary" onclick="openSettings()" data-i18n="actions.config">配置池子</button>
|
||||
<!-- 配置按钮已移到顶部导航 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 订阅管理面板 -->
|
||||
<div class="control-panel admin-only" style="margin-bottom:20px">
|
||||
<div class="control-header">
|
||||
<div class="control-title">[ SUBSCRIPTIONS ]</div>
|
||||
</div>
|
||||
<div id="sub-list" style="margin-bottom:8px;font-size:11px;max-height:200px;overflow-y:auto"></div>
|
||||
<div class="control-ops">
|
||||
<button class="ctrl-btn-primary" onclick="openSubModal()" data-i18n="sub.add">添加订阅</button>
|
||||
<button class="ctrl-btn-secondary" onclick="refreshAllSubs()" data-i18n="sub.refresh_all">刷新所有订阅</button>
|
||||
</div>
|
||||
<div id="sub-status" style="margin-top:8px;font-size:10px;color:var(--fg-dim)"></div>
|
||||
</div>
|
||||
|
||||
<!-- 免费代理池 -->
|
||||
<div style="font-size:8px;color:var(--fg-dim);letter-spacing:0.1em;text-transform:uppercase;margin-bottom:2px;font-weight:600" data-i18n="health.free_pool">[ FREE_POOL ]</div>
|
||||
<div class="health-grid">
|
||||
<div class="health-card">
|
||||
<div class="health-label" data-i18n="health.status">池子状态</div>
|
||||
@@ -254,7 +273,7 @@ tr:hover{background:var(--gray-2);box-shadow:inset 0 0 20px rgba(0,255,65,0.05)}
|
||||
<div class="health-status" id="pool-status-dot"></div>
|
||||
</div>
|
||||
<div class="health-card">
|
||||
<div class="health-label" data-i18n="health.total">总代理数</div>
|
||||
<div class="health-label" data-i18n="health.total">免费代理</div>
|
||||
<div class="health-value" id="stat-total">0</div>
|
||||
<div class="health-meta"><span id="stat-capacity">0</span> <span data-i18n="health.capacity">容量</span></div>
|
||||
</div>
|
||||
@@ -270,6 +289,26 @@ tr:hover{background:var(--gray-2);box-shadow:inset 0 0 20px rgba(0,255,65,0.05)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 订阅代理池 -->
|
||||
<div style="font-size:8px;color:var(--yellow);letter-spacing:0.1em;text-transform:uppercase;margin-bottom:2px;font-weight:600" data-i18n="health.sub_pool">[ SUBSCRIPTION_POOL ]</div>
|
||||
<div class="health-grid" style="grid-template-columns:repeat(3,1fr)">
|
||||
<div class="health-card">
|
||||
<div class="health-label" data-i18n="health.sub_sources">订阅源</div>
|
||||
<div class="health-value" id="stat-sub-count">0</div>
|
||||
<div class="health-meta" id="stat-sub-meta">—</div>
|
||||
</div>
|
||||
<div class="health-card">
|
||||
<div class="health-label" data-i18n="health.available">可用</div>
|
||||
<div class="health-value" id="stat-custom">0</div>
|
||||
<div class="health-meta" id="custom-meta">—</div>
|
||||
</div>
|
||||
<div class="health-card">
|
||||
<div class="health-label" data-i18n="health.disabled">禁用/待恢复</div>
|
||||
<div class="health-value" id="stat-custom-disabled">0</div>
|
||||
<div class="health-meta" id="custom-disabled-meta" data-i18n="health.awaiting_probe">探测唤醒中</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quality-bar">
|
||||
<div class="quality-bar-title" data-i18n="quality.title">质量分布</div>
|
||||
<div class="quality-visual" id="quality-visual">
|
||||
@@ -301,74 +340,56 @@ tr:hover{background:var(--gray-2);box-shadow:inset 0 0 20px rgba(0,255,65,0.05)}
|
||||
|
||||
<div class="modal-overlay" id="settings-modal" onclick="if(event.target===this) closeSettings()">
|
||||
<div class="modal">
|
||||
<div class="modal-title" data-i18n="config.title">池子配置</div>
|
||||
|
||||
<div class="modal-title" data-i18n="config.system_title">系统设置</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="form-section-title" data-i18n="config.section_capacity">池子容量</div>
|
||||
<div class="form-section-title" data-i18n="config.section_proxy_mode">代理使用模式</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-group" style="grid-column:1/-1">
|
||||
<label data-i18n="config.proxy_strategy">出站代理选择策略</label>
|
||||
<select id="cfg-custom-mode" style="width:100%;padding:10px;background:var(--bg-card);border:1px solid var(--border);color:var(--fg);font-family:var(--mono);font-size:12px">
|
||||
<option value="mixed_custom_priority" data-i18n="config.mode_mixed_custom">混合 · 订阅优先</option>
|
||||
<option value="mixed_free_priority" data-i18n="config.mode_mixed_free">混合 · 免费优先</option>
|
||||
<option value="mixed" data-i18n="config.mode_mixed">混合 · 平等</option>
|
||||
<option value="custom_only" data-i18n="config.mode_custom_only">仅订阅代理</option>
|
||||
<option value="free_only" data-i18n="config.mode_free_only">仅免费代理</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 免费池设置 -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-title" data-i18n="config.section_free_pool">免费代理池</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label data-i18n="config.max_size">最大容量</label>
|
||||
<label data-i18n="config.pool_capacity">池子容量</label>
|
||||
<input type="number" id="cfg-pool-size" min="10" max="500">
|
||||
<div class="form-help" data-i18n="config.max_size_help">代理池总槽位数</div>
|
||||
<div class="form-help" data-i18n="config.pool_capacity_help">免费代理总槽位</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="config.http_ratio">HTTP占比</label>
|
||||
<label data-i18n="config.http_ratio_label">HTTP 占比</label>
|
||||
<input type="number" id="cfg-http-ratio" min="0" max="1" step="0.05">
|
||||
<div class="form-help" data-i18n="config.http_ratio_help">0.5 = 50% HTTP, 50% SOCKS5</div>
|
||||
<div class="form-help" data-i18n="config.http_ratio_help">0.3 = 30% HTTP</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="config.min_per_protocol">每协议最小数</label>
|
||||
<input type="number" id="cfg-min-per-protocol" min="1" max="50">
|
||||
<div class="form-help" data-i18n="config.min_per_protocol_help">最小保证数量</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="form-section-title" data-i18n="config.section_latency">延迟标准 (ms)</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label data-i18n="config.latency_standard">标准模式</label>
|
||||
<label data-i18n="config.latency_standard">标准延迟 (ms)</label>
|
||||
<input type="number" id="cfg-max-latency" min="500" max="5000" step="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="config.latency_healthy">健康模式</label>
|
||||
<label data-i18n="config.latency_healthy">健康延迟 (ms)</label>
|
||||
<input type="number" id="cfg-max-latency-healthy" min="500" max="3000" step="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="config.latency_emergency">紧急模式</label>
|
||||
<label data-i18n="config.latency_emergency">紧急延迟 (ms)</label>
|
||||
<input type="number" id="cfg-max-latency-emergency" min="1000" max="5000" step="100">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="form-section-title" data-i18n="config.section_validation">验证与健康检查</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label data-i18n="config.validate_concurrency">验证并发数</label>
|
||||
<input type="number" id="cfg-concurrency" min="50" max="500" step="50">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="config.validate_timeout">验证超时(秒)</label>
|
||||
<input type="number" id="cfg-timeout" min="3" max="15">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="config.health_interval">检查间隔(分钟)</label>
|
||||
<input type="number" id="cfg-health-interval" min="1" max="60">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="config.health_batch">每批数量</label>
|
||||
<input type="number" id="cfg-health-batch" min="10" max="100" step="10">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="form-section-title" data-i18n="config.section_optimization">优化设置</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label data-i18n="config.optimize_interval">优化间隔(分钟)</label>
|
||||
<label data-i18n="config.optimize_interval">优化间隔 (分钟)</label>
|
||||
<input type="number" id="cfg-optimize-interval" min="10" max="120" step="10">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -379,13 +400,54 @@ tr:hover{background:var(--gray-2);box-shadow:inset 0 0 20px rgba(0,255,65,0.05)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 订阅池设置 -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-title" data-i18n="config.section_sub_pool">订阅代理池</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label data-i18n="config.probe_interval">探测间隔 (分钟)</label>
|
||||
<input type="number" id="cfg-custom-probe" min="5" max="120" step="5">
|
||||
<div class="form-help" data-i18n="config.probe_interval_help">禁用代理的唤醒探测间隔</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="config.refresh_interval">默认刷新间隔 (分钟)</label>
|
||||
<input type="number" id="cfg-custom-refresh" min="10" max="1440" step="10">
|
||||
<div class="form-help" data-i18n="config.refresh_interval_help">新订阅的默认刷新周期</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证与检查 -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-title" data-i18n="config.section_validation">验证与健康检查</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label data-i18n="config.validate_concurrency">验证并发数</label>
|
||||
<input type="number" id="cfg-concurrency" min="50" max="500" step="50">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="config.validate_timeout">验证超时 (秒)</label>
|
||||
<input type="number" id="cfg-timeout" min="3" max="15">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="config.health_interval">检查间隔 (分钟)</label>
|
||||
<input type="number" id="cfg-health-interval" min="1" max="60">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="config.health_batch">每批数量</label>
|
||||
<input type="number" id="cfg-health-batch" min="10" max="100" step="10">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 地理过滤 -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-title" data-i18n="config.section_geo_filter">地理过滤</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label data-i18n="config.allowed_countries">允许国家(白名单)</label>
|
||||
<input type="text" id="cfg-allowed-countries" placeholder="US,JP,KR,SG">
|
||||
<div class="form-help" data-i18n="config.allowed_countries_help">非空时仅允许这些国家入池,忽略黑名单</div>
|
||||
<div class="form-help" data-i18n="config.allowed_countries_help">非空时仅允许这些国家,忽略黑名单</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="config.blocked_countries">屏蔽国家(黑名单)</label>
|
||||
@@ -402,6 +464,99 @@ tr:hover{background:var(--gray-2);box-shadow:inset 0 0 20px rgba(0,255,65,0.05)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加订阅弹窗 -->
|
||||
<div class="modal-overlay" id="sub-modal" onclick="if(event.target===this) closeSubModal()" style="display:none">
|
||||
<div class="modal" style="max-width:500px">
|
||||
<div class="modal-title" data-i18n="sub.add_title">添加订阅</div>
|
||||
<div class="form-section">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label data-i18n="sub.name">名称</label>
|
||||
<input type="text" id="sub-name" placeholder="">
|
||||
</div>
|
||||
<div class="form-group" style="grid-column:1/-1">
|
||||
<label data-i18n="sub.import_mode">导入方式</label>
|
||||
<div style="display:flex;gap:8px;margin-bottom:8px">
|
||||
<button id="tab-url" class="ctrl-btn-primary" onclick="switchSubTab('url')" style="flex:1" data-i18n="sub.tab_url">订阅 URL</button>
|
||||
<button id="tab-file" class="ctrl-btn-secondary" onclick="switchSubTab('file')" style="flex:1" data-i18n="sub.tab_file">上传文件</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="sub-url-group" style="grid-column:1/-1">
|
||||
<label data-i18n="sub.url_label">订阅 URL</label>
|
||||
<input type="text" id="sub-url" placeholder="https://example.com/sub?token=xxx">
|
||||
<div class="form-help" data-i18n="sub.url_help">自动识别格式</div>
|
||||
</div>
|
||||
<div class="form-group" id="sub-file-group" style="grid-column:1/-1;display:none">
|
||||
<label data-i18n="sub.file_label">配置文件</label>
|
||||
<div style="border:1px dashed var(--border);padding:16px;text-align:center;cursor:pointer;transition:all 0.2s"
|
||||
onclick="document.getElementById('sub-file-input').click()"
|
||||
ondragover="event.preventDefault();this.style.borderColor='var(--fg)'"
|
||||
ondragleave="this.style.borderColor='var(--border)'"
|
||||
ondrop="event.preventDefault();this.style.borderColor='var(--border)';handleFileDrop(event)">
|
||||
<div id="sub-file-label" style="color:var(--fg-dim);font-size:11px" data-i18n="sub.file_drop">点击选择或拖拽文件到此处</div>
|
||||
</div>
|
||||
<input type="file" id="sub-file-input" accept=".yaml,.yml,.txt,.conf,.json" style="display:none" onchange="handleFileSelect(this)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="sub.refresh_min">刷新间隔 (分钟)</label>
|
||||
<input type="number" id="sub-refresh" value="60" min="10" max="1440" step="10">
|
||||
<div class="form-help" data-i18n="sub.refresh_min_help">仅 URL 模式有效</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" onclick="closeSubModal()" data-i18n="sub.cancel">取消</button>
|
||||
<button class="btn" onclick="addSubscription()" data-i18n="sub.submit">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 访客贡献订阅弹窗 -->
|
||||
<div class="modal-overlay" id="contribute-modal" onclick="if(event.target===this) closeContributeModal()" style="display:none">
|
||||
<div class="modal" style="max-width:460px">
|
||||
<div class="modal-title" data-i18n="contribute.title">贡献订阅</div>
|
||||
<div style="color:var(--fg-dim);font-size:11px;margin-bottom:16px;line-height:1.6">
|
||||
<span data-i18n="contribute.desc">分享你的代理订阅,帮助丰富代理池。</span><br>
|
||||
<span style="color:var(--gray-5);font-size:10px" data-i18n="contribute.privacy">你的订阅仅用于此代理池,不会被用于其他渠道。连续探测无可用节点将自动移除。</span>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<div class="form-grid">
|
||||
<div class="form-group" style="grid-column:1/-1">
|
||||
<label data-i18n="sub.name">名称</label>
|
||||
<input type="text" id="contribute-name" placeholder="">
|
||||
</div>
|
||||
<div class="form-group" style="grid-column:1/-1">
|
||||
<label data-i18n="sub.import_mode">导入方式</label>
|
||||
<div style="display:flex;gap:8px;margin-bottom:8px">
|
||||
<button id="ctab-url" class="ctrl-btn-primary" onclick="switchContributeTab('url')" style="flex:1" data-i18n="sub.tab_url">订阅 URL</button>
|
||||
<button id="ctab-file" class="ctrl-btn-secondary" onclick="switchContributeTab('file')" style="flex:1" data-i18n="sub.tab_file">上传文件</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="contribute-url-group" style="grid-column:1/-1">
|
||||
<label data-i18n="sub.url_label">订阅 URL</label>
|
||||
<input type="text" id="contribute-url" placeholder="https://example.com/sub?token=xxx">
|
||||
<div class="form-help" data-i18n="sub.url_help">自动识别格式</div>
|
||||
</div>
|
||||
<div class="form-group" id="contribute-file-group" style="grid-column:1/-1;display:none">
|
||||
<label data-i18n="sub.file_label">配置文件</label>
|
||||
<div style="border:1px dashed var(--border);padding:16px;text-align:center;cursor:pointer;transition:all 0.2s"
|
||||
onclick="document.getElementById('contribute-file-input').click()"
|
||||
ondragover="event.preventDefault();this.style.borderColor='var(--fg)'"
|
||||
ondragleave="this.style.borderColor='var(--border)'"
|
||||
ondrop="event.preventDefault();this.style.borderColor='var(--border)';handleContributeFileDrop(event)">
|
||||
<div id="contribute-file-label" style="color:var(--fg-dim);font-size:11px" data-i18n="sub.file_drop">点击选择或拖拽文件到此处</div>
|
||||
</div>
|
||||
<input type="file" id="contribute-file-input" accept=".yaml,.yml,.txt,.conf,.json" style="display:none" onchange="handleContributeFileSelect(this)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" onclick="closeContributeModal()" data-i18n="sub.cancel">取消</button>
|
||||
<button class="btn" id="contribute-submit-btn" onclick="submitContribution()" data-i18n="contribute.submit">提交</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 国际化翻译
|
||||
const i18n = {
|
||||
@@ -483,6 +638,82 @@ const i18n = {
|
||||
'msg.delete_confirm': '确定删除代理',
|
||||
'msg.config_saved': '配置保存成功',
|
||||
'msg.config_failed': '配置保存失败',
|
||||
// 设置弹窗新增
|
||||
'config.system_title': '系统设置',
|
||||
'config.section_proxy_mode': '代理使用模式',
|
||||
'config.proxy_strategy': '出站代理选择策略',
|
||||
'config.mode_mixed_custom': '混合 · 订阅优先(有订阅代理时优先使用,无可用则降级到免费)',
|
||||
'config.mode_mixed_free': '混合 · 免费优先(有免费代理时优先使用,无可用则降级到订阅)',
|
||||
'config.mode_mixed': '混合 · 平等(不区分来源,按延迟/随机选择)',
|
||||
'config.mode_custom_only': '仅订阅代理(只使用订阅导入的代理)',
|
||||
'config.mode_free_only': '仅免费代理(只使用公开抓取的代理)',
|
||||
'config.section_free_pool': '免费代理池',
|
||||
'config.pool_capacity': '池子容量',
|
||||
'config.pool_capacity_help': '免费代理总槽位',
|
||||
'config.http_ratio_label': 'HTTP 占比',
|
||||
'config.latency_standard': '标准延迟 (ms)',
|
||||
'config.latency_healthy': '健康延迟 (ms)',
|
||||
'config.latency_emergency': '紧急延迟 (ms)',
|
||||
'config.section_sub_pool': '订阅代理池',
|
||||
'config.probe_interval': '探测间隔 (分钟)',
|
||||
'config.probe_interval_help': '禁用代理的唤醒探测间隔',
|
||||
'config.refresh_interval': '默认刷新间隔 (分钟)',
|
||||
'config.refresh_interval_help': '新订阅的默<E79A84><E9BB98><EFBFBD>刷新周期',
|
||||
'config.geo_filter_help': '免费代理删除,订阅代理禁用',
|
||||
// 健康面板
|
||||
'health.free_pool': 'FREE_POOL',
|
||||
'health.sub_pool': 'SUBSCRIPTION_POOL',
|
||||
'health.free_proxies': '免费代理',
|
||||
'health.sub_sources': '订阅源',
|
||||
'health.available': '可用',
|
||||
'health.disabled': '禁用/待恢复',
|
||||
'health.awaiting_probe': '等待探测唤醒',
|
||||
'health.no_disabled': '无禁用节点',
|
||||
'health.singbox_running': 'sing-box 运行中',
|
||||
'health.ready': '就绪',
|
||||
'health.not_added': '未添加',
|
||||
'health.total_nodes': '共 {0} 节点',
|
||||
// 订阅面板
|
||||
'sub.title': 'SUBSCRIPTIONS',
|
||||
'sub.add': '添加订阅',
|
||||
'sub.refresh_all': '刷新所有订阅',
|
||||
'sub.empty': '暂无订阅',
|
||||
'sub.nodes': '节点',
|
||||
'sub.available': '可用',
|
||||
'sub.disabled_label': '禁用',
|
||||
'sub.contributed': '贡献',
|
||||
// 添加订阅弹窗
|
||||
'sub.add_title': '添加订阅',
|
||||
'sub.name': '名称',
|
||||
'sub.import_mode': '导入方式',
|
||||
'sub.tab_url': '订阅 URL',
|
||||
'sub.tab_file': '上传文件',
|
||||
'sub.url_label': '订阅 URL',
|
||||
'sub.url_help': '自动识别格式:Clash YAML / V2ray 链接 / Base64 / 纯文本',
|
||||
'sub.file_label': '配置文件',
|
||||
'sub.file_drop': '点击选择或拖拽文件到此处',
|
||||
'sub.file_formats': '支持 Clash YAML / V2ray 订阅 / 纯文本',
|
||||
'sub.refresh_min': '刷新间隔 (分钟)',
|
||||
'sub.refresh_min_help': '仅 URL 模式有效,上传文件不自动刷新',
|
||||
'sub.cancel': '取消',
|
||||
'sub.submit': '添加',
|
||||
// 贡献订阅弹窗
|
||||
'contribute.title': '贡献订阅',
|
||||
'contribute.desc': '分享你的代理订阅,帮助丰富代理池。',
|
||||
'contribute.privacy': '你的订阅仅用于此代理池,不会被用于其他渠道。连续探测无可用节点将自动移除。',
|
||||
'contribute.submit': '提交',
|
||||
'contribute.validating': '验证中...',
|
||||
'contribute.nav': '贡献订阅',
|
||||
'contribute.settings': '系统设置',
|
||||
// 消息
|
||||
'msg.sub_added': '订阅已添加,正在导入节点...',
|
||||
'msg.sub_refreshed': '刷新已启动',
|
||||
'msg.sub_refresh_all': '所有订阅刷新已启动',
|
||||
'msg.sub_delete_confirm': '确定删除此订阅?',
|
||||
'msg.sub_url_required': '请填写订阅 URL',
|
||||
'msg.sub_file_required': '请选择或拖拽配置文件',
|
||||
'msg.contribute_thanks': '感谢贡献!订阅已添加,正在导入节点...',
|
||||
'msg.submit_failed': '提交失败: ',
|
||||
},
|
||||
en: {
|
||||
'nav.config': 'Config',
|
||||
@@ -562,6 +793,76 @@ const i18n = {
|
||||
'msg.delete_confirm': 'Delete proxy',
|
||||
'msg.config_saved': 'Configuration saved successfully',
|
||||
'msg.config_failed': 'Failed to save configuration',
|
||||
'config.system_title': 'System Settings',
|
||||
'config.section_proxy_mode': 'Proxy Mode',
|
||||
'config.proxy_strategy': 'Outbound Proxy Strategy',
|
||||
'config.mode_mixed_custom': 'Mixed · Subscription Priority',
|
||||
'config.mode_mixed_free': 'Mixed · Free Priority',
|
||||
'config.mode_mixed': 'Mixed · Equal (select by latency/random)',
|
||||
'config.mode_custom_only': 'Subscription Only',
|
||||
'config.mode_free_only': 'Free Only',
|
||||
'config.section_free_pool': 'Free Proxy Pool',
|
||||
'config.pool_capacity': 'Pool Capacity',
|
||||
'config.pool_capacity_help': 'Total free proxy slots',
|
||||
'config.http_ratio_label': 'HTTP Ratio',
|
||||
'config.latency_standard': 'Standard Latency (ms)',
|
||||
'config.latency_healthy': 'Healthy Latency (ms)',
|
||||
'config.latency_emergency': 'Emergency Latency (ms)',
|
||||
'config.section_sub_pool': 'Subscription Pool',
|
||||
'config.probe_interval': 'Probe Interval (min)',
|
||||
'config.probe_interval_help': 'Wake-up probe interval for disabled proxies',
|
||||
'config.refresh_interval': 'Default Refresh (min)',
|
||||
'config.refresh_interval_help': 'Default refresh cycle for new subscriptions',
|
||||
'config.geo_filter_help': 'Free: delete, Subscription: disable',
|
||||
'health.free_pool': 'FREE_POOL',
|
||||
'health.sub_pool': 'SUBSCRIPTION_POOL',
|
||||
'health.free_proxies': 'Free Proxies',
|
||||
'health.sub_sources': 'Sources',
|
||||
'health.available': 'Available',
|
||||
'health.disabled': 'Disabled',
|
||||
'health.awaiting_probe': 'Awaiting probe',
|
||||
'health.no_disabled': 'No disabled nodes',
|
||||
'health.singbox_running': 'sing-box running',
|
||||
'health.ready': 'Ready',
|
||||
'health.not_added': 'None',
|
||||
'health.total_nodes': '{0} total nodes',
|
||||
'sub.title': 'SUBSCRIPTIONS',
|
||||
'sub.add': 'Add Subscription',
|
||||
'sub.refresh_all': 'Refresh All',
|
||||
'sub.empty': 'No subscriptions',
|
||||
'sub.nodes': 'nodes',
|
||||
'sub.available': 'available',
|
||||
'sub.disabled_label': 'disabled',
|
||||
'sub.contributed': 'Contributed',
|
||||
'sub.add_title': 'Add Subscription',
|
||||
'sub.name': 'Name',
|
||||
'sub.import_mode': 'Import Mode',
|
||||
'sub.tab_url': 'URL',
|
||||
'sub.tab_file': 'Upload File',
|
||||
'sub.url_label': 'Subscription URL',
|
||||
'sub.url_help': 'Auto-detect: Clash YAML / V2ray / Base64 / Plain text',
|
||||
'sub.file_label': 'Config File',
|
||||
'sub.file_drop': 'Click or drag file here',
|
||||
'sub.file_formats': 'Supports Clash YAML / V2ray / Plain text',
|
||||
'sub.refresh_min': 'Refresh Interval (min)',
|
||||
'sub.refresh_min_help': 'URL mode only; file uploads do not auto-refresh',
|
||||
'sub.cancel': 'Cancel',
|
||||
'sub.submit': 'Add',
|
||||
'contribute.title': 'Contribute Subscription',
|
||||
'contribute.desc': 'Share your proxy subscription to enrich the pool.',
|
||||
'contribute.privacy': 'Your subscription is only used for this proxy pool. Subscriptions with no available nodes for 7 days will be auto-removed.',
|
||||
'contribute.submit': 'Submit',
|
||||
'contribute.validating': 'Validating...',
|
||||
'contribute.nav': 'Contribute',
|
||||
'contribute.settings': 'Settings',
|
||||
'msg.sub_added': 'Subscription added, importing nodes...',
|
||||
'msg.sub_refreshed': 'Refresh started',
|
||||
'msg.sub_refresh_all': 'Refreshing all subscriptions',
|
||||
'msg.sub_delete_confirm': 'Delete this subscription?',
|
||||
'msg.sub_url_required': 'Please enter subscription URL',
|
||||
'msg.sub_file_required': 'Please select or drag a config file',
|
||||
'msg.contribute_thanks': 'Thanks! Subscription added, importing nodes...',
|
||||
'msg.submit_failed': 'Submit failed: ',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -582,9 +883,14 @@ function updateI18n() {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
el.textContent = t(key);
|
||||
});
|
||||
// 更新 title 属性
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-title');
|
||||
el.title = t(key);
|
||||
});
|
||||
document.getElementById('lang-btn').textContent = currentLang === 'zh' ? 'EN' : '中';
|
||||
document.title = currentLang === 'zh' ? 'GoProxy — 智能代理池' : 'GoProxy — Intelligent Pool';
|
||||
|
||||
|
||||
// 更新筛选下拉框标签
|
||||
const protocolLabel = document.getElementById('protocol-filter-label');
|
||||
if (protocolLabel) protocolLabel.textContent = t('proxy.filter_protocol');
|
||||
@@ -600,6 +906,9 @@ function toggleLang() {
|
||||
if (allProxies.length > 0) {
|
||||
filterAndRender();
|
||||
}
|
||||
// 重新渲染包含动态 t() 文字的模块
|
||||
loadSubscriptions();
|
||||
loadPoolStatus();
|
||||
}
|
||||
|
||||
// 页面加载时恢复语言设置
|
||||
@@ -643,15 +952,12 @@ function updateUIByRole() {
|
||||
}
|
||||
});
|
||||
|
||||
// 显示/隐藏登录链接(访客模式下显示)
|
||||
// 显示/隐藏登录链接和访客专属元素
|
||||
const loginLink = document.getElementById('login-link');
|
||||
if (loginLink) {
|
||||
if (isAdmin) {
|
||||
loginLink.style.display = 'none';
|
||||
} else {
|
||||
loginLink.style.display = 'inline-flex';
|
||||
}
|
||||
}
|
||||
if (loginLink) loginLink.style.display = isAdmin ? 'none' : 'inline-flex';
|
||||
document.querySelectorAll('.guest-only').forEach(el => {
|
||||
el.style.display = isAdmin ? 'none' : 'inline-flex';
|
||||
});
|
||||
|
||||
// 更新用户模式标识
|
||||
const modeEl = document.getElementById('user-mode');
|
||||
@@ -702,7 +1008,8 @@ async function loadPoolStatus() {
|
||||
const status = await api('/api/pool/status');
|
||||
if (!status) return;
|
||||
|
||||
document.getElementById('stat-total').textContent = status.Total;
|
||||
const freeTotal = status.Total - (status.CustomCount || 0);
|
||||
document.getElementById('stat-total').textContent = freeTotal;
|
||||
document.getElementById('stat-capacity').textContent = status.HTTPSlots + status.SOCKS5Slots;
|
||||
document.getElementById('stat-http').textContent = status.HTTP;
|
||||
document.getElementById('stat-socks5').textContent = status.SOCKS5;
|
||||
@@ -739,11 +1046,19 @@ async function loadQualityDistribution() {
|
||||
}
|
||||
}
|
||||
|
||||
let subNameMap = {};
|
||||
async function loadProxies() {
|
||||
// 先加载订阅名称映射
|
||||
const subs = await api('/api/subscriptions');
|
||||
if (subs) {
|
||||
subNameMap = {};
|
||||
subs.forEach(s => { subNameMap[s.id] = s.name || t('sub.add_title'); });
|
||||
}
|
||||
|
||||
const path = currentProtocol ? '/api/proxies?protocol=' + currentProtocol : '/api/proxies';
|
||||
const proxies = await api(path);
|
||||
if (!proxies) return;
|
||||
|
||||
|
||||
allProxies = proxies;
|
||||
updateCountryOptions();
|
||||
filterAndRender();
|
||||
@@ -811,10 +1126,16 @@ function renderProxies(proxies) {
|
||||
const grade = (p.quality_grade || 'C').toLowerCase();
|
||||
const latencyClass = 'grade-' + grade;
|
||||
|
||||
html += '<tr>';
|
||||
const rowStyle = p.source === 'custom' ? ' style="border-left:2px solid var(--yellow)"' : '';
|
||||
html += '<tr' + rowStyle + '>';
|
||||
html += '<td class="cell-grade grade-' + grade + '">' + (p.quality_grade || 'C') + '</td>';
|
||||
html += '<td><span class="badge badge-' + p.protocol + '">' + p.protocol.toUpperCase() + '</span></td>';
|
||||
html += '<td class="cell-mono cell-clickable" onclick="copyToClipboard(\'' + p.address + '\')" title="点击复制">' + p.address + '</td>';
|
||||
html += '<td><span class="badge badge-' + p.protocol + '">' + p.protocol.toUpperCase() + '</span>';
|
||||
if (p.source === 'custom') {
|
||||
const subName = subNameMap[p.subscription_id] || t('sub.add_title');
|
||||
html += ' <span style="display:inline-block;background:var(--yellow);color:#000;font-size:8px;font-weight:700;padding:1px 4px;margin-left:4px;letter-spacing:0.05em">' + subName + '</span>';
|
||||
}
|
||||
html += '</td>';
|
||||
html += '<td class="cell-mono cell-clickable" onclick="copyToClipboard(\'' + p.address + '\')" title="Copy">' + p.address + '</td>';
|
||||
html += '<td class="cell-mono">' + (p.exit_ip || '—') + '</td>';
|
||||
html += '<td>' + flag + ' ' + (p.exit_location || '—') + '</td>';
|
||||
html += '<td class="cell-mono ' + latencyClass + '">' + (p.latency > 0 ? p.latency + 'ms' : '—') + '</td>';
|
||||
@@ -905,6 +1226,19 @@ async function openSettings() {
|
||||
document.getElementById('cfg-replace-threshold').value = cfg.replace_threshold;
|
||||
document.getElementById('cfg-blocked-countries').value = (cfg.blocked_countries || []).join(',');
|
||||
document.getElementById('cfg-allowed-countries').value = (cfg.allowed_countries || []).join(',');
|
||||
// 将 mode + priority 映射到5种模式
|
||||
const mode = cfg.custom_proxy_mode || 'mixed';
|
||||
const customPri = cfg.custom_priority === true;
|
||||
const freePri = cfg.custom_free_priority === true;
|
||||
let uiMode = 'mixed';
|
||||
if (mode === 'custom_only') uiMode = 'custom_only';
|
||||
else if (mode === 'free_only') uiMode = 'free_only';
|
||||
else if (mode === 'mixed' && customPri) uiMode = 'mixed_custom_priority';
|
||||
else if (mode === 'mixed' && freePri) uiMode = 'mixed_free_priority';
|
||||
else uiMode = 'mixed';
|
||||
document.getElementById('cfg-custom-mode').value = uiMode;
|
||||
document.getElementById('cfg-custom-probe').value = cfg.custom_probe_interval || 10;
|
||||
document.getElementById('cfg-custom-refresh').value = cfg.custom_refresh_interval || 60;
|
||||
|
||||
document.getElementById('settings-modal').classList.add('show');
|
||||
}
|
||||
@@ -929,6 +1263,21 @@ async function saveConfig() {
|
||||
replace_threshold: parseFloat(document.getElementById('cfg-replace-threshold').value),
|
||||
blocked_countries: document.getElementById('cfg-blocked-countries').value.split(',').map(s => s.trim().toUpperCase()).filter(s => s),
|
||||
allowed_countries: document.getElementById('cfg-allowed-countries').value.split(',').map(s => s.trim().toUpperCase()).filter(s => s),
|
||||
custom_proxy_mode: (() => {
|
||||
const m = document.getElementById('cfg-custom-mode').value;
|
||||
if (m === 'custom_only') return 'custom_only';
|
||||
if (m === 'free_only') return 'free_only';
|
||||
return 'mixed';
|
||||
})(),
|
||||
custom_priority: (() => {
|
||||
const m = document.getElementById('cfg-custom-mode').value;
|
||||
if (m === 'mixed_custom_priority') return true;
|
||||
if (m === 'mixed_free_priority') return false;
|
||||
return false;
|
||||
})(),
|
||||
custom_free_priority: document.getElementById('cfg-custom-mode').value === 'mixed_free_priority',
|
||||
custom_probe_interval: parseInt(document.getElementById('cfg-custom-probe').value),
|
||||
custom_refresh_interval: parseInt(document.getElementById('cfg-custom-refresh').value),
|
||||
};
|
||||
|
||||
const result = await api('/api/config/save', {
|
||||
@@ -954,10 +1303,273 @@ async function loadAll() {
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
// ========== 订阅管理 ==========
|
||||
|
||||
async function loadSubscriptions() {
|
||||
const subs = await api('/api/subscriptions');
|
||||
const el = document.getElementById('sub-list');
|
||||
if (!el || !subs) return;
|
||||
|
||||
if (subs.length === 0) {
|
||||
el.innerHTML = '<div style="color:var(--gray-5);text-align:center;padding:8px">' + t('sub.empty') + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = subs.map(s => {
|
||||
const statusColor = s.status === 'active' ? 'var(--green)' : 'var(--gray-5)';
|
||||
const statusIcon = s.status === 'active' ? '●' : '○';
|
||||
const active = s.active_count || 0;
|
||||
const disabled = s.disabled_count || 0;
|
||||
const total = active + disabled;
|
||||
const statsText = total + ' ' + t('sub.nodes') + ' · ' + active + ' ' + t('sub.available') + (disabled > 0 ? ' · ' + disabled + ' ' + t('sub.disabled_label') : '');
|
||||
const badge = s.contributed ? '<span style="display:inline-block;background:var(--orange);color:#000;font-size:7px;font-weight:700;padding:0 3px;margin-left:4px;vertical-align:middle">' + t('sub.contributed') + '</span>' : '';
|
||||
return '<div style="display:flex;align-items:center;justify-content:space-between;padding:4px 0;border-bottom:1px solid var(--border)">' +
|
||||
'<div style="flex:1;min-width:0">' +
|
||||
'<span style="color:' + statusColor + '">' + statusIcon + '</span> ' +
|
||||
'<span style="font-weight:600">' + (s.name||t('sub.add_title')) + '</span>' + badge +
|
||||
'<span style="color:var(--gray-5);margin-left:8px">' + statsText + '</span>' +
|
||||
'</div>' +
|
||||
'<div style="display:flex;gap:4px;flex-shrink:0">' +
|
||||
'<button onclick="refreshSub(' + s.id + ')" style="background:none;border:1px solid var(--border);color:var(--fg-dim);cursor:pointer;padding:2px 6px;font-size:9px;font-family:var(--mono)">↻</button>' +
|
||||
'<button onclick="toggleSub(' + s.id + ')" style="background:none;border:1px solid var(--border);color:var(--fg-dim);cursor:pointer;padding:2px 6px;font-size:9px;font-family:var(--mono)">' + (s.status === 'active' ? '⏸' : '▶') + '</button>' +
|
||||
'<button onclick="deleteSub(' + s.id + ')" style="background:none;border:1px solid var(--red);color:var(--red);cursor:pointer;padding:2px 6px;font-size:9px;font-family:var(--mono)">✕</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
// 加载状态
|
||||
const status = await api('/api/custom/status');
|
||||
const statusEl = document.getElementById('sub-status');
|
||||
if (status && statusEl) {
|
||||
const parts = [];
|
||||
if (status.singbox_running) parts.push('sing-box ✅ ' + status.singbox_nodes + ' ' + t('sub.nodes'));
|
||||
statusEl.textContent = parts.length > 0 ? parts.join(' · ') : '';
|
||||
}
|
||||
|
||||
// 更新订阅代理统计卡片
|
||||
if (status) {
|
||||
const active = status.custom_count || 0;
|
||||
const disabled = status.disabled_count || 0;
|
||||
const subCount = status.subscription_count || 0;
|
||||
|
||||
const subCountEl = document.getElementById('stat-sub-count');
|
||||
const subMetaEl = document.getElementById('stat-sub-meta');
|
||||
if (subCountEl) subCountEl.textContent = subCount;
|
||||
if (subMetaEl) subMetaEl.textContent = status.singbox_running ? t('health.singbox_running') : (subCount > 0 ? t('health.ready') : t('health.not_added'));
|
||||
|
||||
const customEl = document.getElementById('stat-custom');
|
||||
const customMeta = document.getElementById('custom-meta');
|
||||
if (customEl) customEl.textContent = active;
|
||||
if (customMeta) customMeta.textContent = (active + disabled) > 0 ? t('health.total_nodes').replace('{0}', active + disabled) : '—';
|
||||
|
||||
const disabledEl = document.getElementById('stat-custom-disabled');
|
||||
const disabledMeta = document.getElementById('custom-disabled-meta');
|
||||
if (disabledEl) disabledEl.textContent = disabled;
|
||||
if (disabledMeta) disabledMeta.textContent = disabled > 0 ? t('health.awaiting_probe') : t('health.no_disabled');
|
||||
}
|
||||
}
|
||||
|
||||
let subFileContent = '';
|
||||
let subTab = 'url';
|
||||
|
||||
function switchSubTab(tab) {
|
||||
subTab = tab;
|
||||
document.getElementById('sub-url-group').style.display = tab === 'url' ? '' : 'none';
|
||||
document.getElementById('sub-file-group').style.display = tab === 'file' ? '' : 'none';
|
||||
document.getElementById('tab-url').className = tab === 'url' ? 'ctrl-btn-primary' : 'ctrl-btn-secondary';
|
||||
document.getElementById('tab-file').className = tab === 'file' ? 'ctrl-btn-primary' : 'ctrl-btn-secondary';
|
||||
}
|
||||
|
||||
function handleFileSelect(input) {
|
||||
if (input.files && input.files[0]) readSubFile(input.files[0]);
|
||||
}
|
||||
|
||||
function handleFileDrop(e) {
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) readSubFile(e.dataTransfer.files[0]);
|
||||
}
|
||||
|
||||
function readSubFile(file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
subFileContent = e.target.result;
|
||||
document.getElementById('sub-file-label').innerHTML =
|
||||
'<span style="color:var(--fg)">✅ ' + file.name + '</span><br>' +
|
||||
'<span style="font-size:9px;opacity:0.6">' + (subFileContent.length / 1024).toFixed(1) + ' KB</span>';
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
function openSubModal() {
|
||||
subFileContent = '';
|
||||
subTab = 'url';
|
||||
switchSubTab('url');
|
||||
document.getElementById('sub-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeSubModal() {
|
||||
document.getElementById('sub-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function addSubscription() {
|
||||
const name = document.getElementById('sub-name').value || t('sub.add_title');
|
||||
const url = document.getElementById('sub-url').value;
|
||||
const refreshMin = parseInt(document.getElementById('sub-refresh').value) || 60;
|
||||
|
||||
const data = { name, refresh_min: refreshMin };
|
||||
|
||||
if (subTab === 'url') {
|
||||
if (!url) { alert(t('msg.sub_url_required')); return; }
|
||||
data.url = url;
|
||||
} else {
|
||||
if (!subFileContent) { alert(t('msg.sub_file_required')); return; }
|
||||
data.file_content = subFileContent;
|
||||
}
|
||||
|
||||
const result = await api('/api/subscription/add', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (result && result.error) {
|
||||
alert(t('msg.submit_failed') + result.error);
|
||||
return;
|
||||
}
|
||||
if (result && result.status === 'added') {
|
||||
closeSubModal();
|
||||
showToast(t('msg.sub_added'));
|
||||
document.getElementById('sub-name').value = '';
|
||||
document.getElementById('sub-url').value = '';
|
||||
subFileContent = '';
|
||||
document.getElementById('sub-file-label').innerHTML = '' + t('sub.file_drop') + '';
|
||||
setTimeout(loadSubscriptions, 3000);
|
||||
setTimeout(loadProxies, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSub(id) {
|
||||
await api('/api/subscription/refresh', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({id: id})
|
||||
});
|
||||
showToast(t('msg.sub_refreshed'));
|
||||
setTimeout(loadSubscriptions, 3000);
|
||||
}
|
||||
|
||||
async function refreshAllSubs() {
|
||||
await api('/api/subscription/refresh-all', {method: 'POST'});
|
||||
showToast(t('msg.sub_refresh_all'));
|
||||
setTimeout(loadSubscriptions, 3000);
|
||||
}
|
||||
|
||||
async function toggleSub(id) {
|
||||
await api('/api/subscription/toggle', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({id: id})
|
||||
});
|
||||
loadSubscriptions();
|
||||
}
|
||||
|
||||
async function deleteSub(id) {
|
||||
if (!confirm(t('msg.sub_delete_confirm'))) return;
|
||||
await api('/api/subscription/delete', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({id: id})
|
||||
});
|
||||
loadSubscriptions();
|
||||
}
|
||||
|
||||
// ========== 访客贡献订阅 ==========
|
||||
|
||||
let contributeFileContent = '';
|
||||
let contributeTab = 'url';
|
||||
|
||||
function switchContributeTab(tab) {
|
||||
contributeTab = tab;
|
||||
document.getElementById('contribute-url-group').style.display = tab === 'url' ? '' : 'none';
|
||||
document.getElementById('contribute-file-group').style.display = tab === 'file' ? '' : 'none';
|
||||
document.getElementById('ctab-url').className = tab === 'url' ? 'ctrl-btn-primary' : 'ctrl-btn-secondary';
|
||||
document.getElementById('ctab-file').className = tab === 'file' ? 'ctrl-btn-primary' : 'ctrl-btn-secondary';
|
||||
}
|
||||
|
||||
function handleContributeFileSelect(input) {
|
||||
if (input.files && input.files[0]) readContributeFile(input.files[0]);
|
||||
}
|
||||
function handleContributeFileDrop(e) {
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) readContributeFile(e.dataTransfer.files[0]);
|
||||
}
|
||||
function readContributeFile(file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
contributeFileContent = e.target.result;
|
||||
document.getElementById('contribute-file-label').innerHTML =
|
||||
'<span style="color:var(--fg)">✅ ' + file.name + '</span><br>' +
|
||||
'<span style="font-size:9px;opacity:0.6">' + (contributeFileContent.length / 1024).toFixed(1) + ' KB</span>';
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
function openContributeModal() {
|
||||
contributeFileContent = '';
|
||||
contributeTab = 'url';
|
||||
switchContributeTab('url');
|
||||
document.getElementById('contribute-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeContributeModal() {
|
||||
document.getElementById('contribute-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function submitContribution() {
|
||||
const name = document.getElementById('contribute-name').value || t('contribute.title');
|
||||
const data = { name };
|
||||
|
||||
if (contributeTab === 'url') {
|
||||
const url = document.getElementById('contribute-url').value;
|
||||
if (!url) { alert(t('msg.sub_url_required')); return; }
|
||||
data.url = url;
|
||||
} else {
|
||||
if (!contributeFileContent) { alert(t('msg.sub_file_required')); return; }
|
||||
data.file_content = contributeFileContent;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('contribute-submit-btn');
|
||||
btn.textContent = t('contribute.validating');
|
||||
btn.disabled = true;
|
||||
|
||||
const result = await api('/api/subscription/contribute', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
btn.textContent = t('contribute.submit');
|
||||
btn.disabled = false;
|
||||
|
||||
if (result && result.error) {
|
||||
alert(t('msg.submit_failed') + result.error);
|
||||
return;
|
||||
}
|
||||
if (result && result.status === 'contributed') {
|
||||
closeContributeModal();
|
||||
showToast(t('msg.contribute_thanks'));
|
||||
document.getElementById('contribute-name').value = '';
|
||||
document.getElementById('contribute-url').value = '';
|
||||
contributeFileContent = '';
|
||||
document.getElementById('contribute-file-label').innerHTML = '' + t('sub.file_drop') + '';
|
||||
setTimeout(loadSubscriptions, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
loadAll();
|
||||
loadSubscriptions();
|
||||
setInterval(loadPoolStatus, 5000);
|
||||
setInterval(loadQualityDistribution, 10000);
|
||||
setInterval(loadLogs, 5000);
|
||||
setInterval(loadSubscriptions, 30000);
|
||||
|
||||
// 日志倒计时
|
||||
setInterval(() => {
|
||||
|
||||
383
webui/server.go
383
webui/server.go
@@ -6,10 +6,13 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"goproxy/config"
|
||||
"goproxy/custom"
|
||||
"goproxy/logger"
|
||||
"goproxy/pool"
|
||||
"goproxy/storage"
|
||||
@@ -47,15 +50,17 @@ type Server struct {
|
||||
storage *storage.Storage
|
||||
cfg *config.Config
|
||||
poolMgr *pool.Manager
|
||||
customMgr *custom.Manager
|
||||
fetchTrigger FetchTrigger
|
||||
configChanged chan<- struct{}
|
||||
}
|
||||
|
||||
func New(s *storage.Storage, cfg *config.Config, pm *pool.Manager, ft FetchTrigger, cc chan<- struct{}) *Server {
|
||||
func New(s *storage.Storage, cfg *config.Config, pm *pool.Manager, cm *custom.Manager, ft FetchTrigger, cc chan<- struct{}) *Server {
|
||||
return &Server{
|
||||
storage: s,
|
||||
cfg: cfg,
|
||||
poolMgr: pm,
|
||||
customMgr: cm,
|
||||
fetchTrigger: ft,
|
||||
configChanged: cc,
|
||||
}
|
||||
@@ -91,6 +96,16 @@ func (s *Server) Start() {
|
||||
mux.HandleFunc("/api/refresh-latency", s.authMiddleware(s.apiRefreshLatency))
|
||||
mux.HandleFunc("/api/config/save", s.authMiddleware(s.apiConfigSave))
|
||||
|
||||
// 订阅管理 API
|
||||
mux.HandleFunc("/api/subscriptions", s.readOnlyMiddleware(s.apiSubscriptions))
|
||||
mux.HandleFunc("/api/custom/status", s.readOnlyMiddleware(s.apiCustomStatus))
|
||||
mux.HandleFunc("/api/subscription/contribute", s.apiSubscriptionContribute) // 访客可用
|
||||
mux.HandleFunc("/api/subscription/add", s.authMiddleware(s.apiSubscriptionAdd))
|
||||
mux.HandleFunc("/api/subscription/delete", s.authMiddleware(s.apiSubscriptionDelete))
|
||||
mux.HandleFunc("/api/subscription/refresh", s.authMiddleware(s.apiSubscriptionRefresh))
|
||||
mux.HandleFunc("/api/subscription/refresh-all", s.authMiddleware(s.apiSubscriptionRefreshAll))
|
||||
mux.HandleFunc("/api/subscription/toggle", s.authMiddleware(s.apiSubscriptionToggle))
|
||||
|
||||
log.Printf("WebUI listening on %s", s.cfg.WebUIPort)
|
||||
go func() {
|
||||
if err := http.ListenAndServe(s.cfg.WebUIPort, loggedMux); err != nil {
|
||||
@@ -180,11 +195,13 @@ func (s *Server) apiStats(w http.ResponseWriter, r *http.Request) {
|
||||
total, _ := s.storage.Count()
|
||||
httpCount, _ := s.storage.CountByProtocol("http")
|
||||
socks5Count, _ := s.storage.CountByProtocol("socks5")
|
||||
customCount, _ := s.storage.CountBySource("custom")
|
||||
jsonOK(w, map[string]interface{}{
|
||||
"total": total,
|
||||
"http": httpCount,
|
||||
"socks5": socks5Count,
|
||||
"port": s.cfg.ProxyPort,
|
||||
"total": total,
|
||||
"http": httpCount,
|
||||
"socks5": socks5Count,
|
||||
"custom_count": customCount,
|
||||
"port": s.cfg.ProxyPort,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -266,8 +283,13 @@ func (s *Server) apiRefreshProxy(w http.ResponseWriter, r *http.Request) {
|
||||
s.storage.UpdateExitInfo(req.Address, exitIP, exitLocation, latencyMs)
|
||||
log.Printf("[webui] proxy refreshed: %s latency=%dms grade=%s", req.Address, latencyMs, storage.CalculateQualityGrade(latencyMs))
|
||||
} else {
|
||||
s.storage.Delete(req.Address)
|
||||
log.Printf("[webui] proxy validation failed, removed: %s", req.Address)
|
||||
if targetProxy.Source == "custom" {
|
||||
s.storage.DisableProxy(req.Address)
|
||||
log.Printf("[webui] custom proxy validation failed, disabled: %s", req.Address)
|
||||
} else {
|
||||
s.storage.Delete(req.Address)
|
||||
log.Printf("[webui] proxy validation failed, removed: %s", req.Address)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -311,7 +333,11 @@ func (s *Server) apiRefreshLatency(w http.ResponseWriter, r *http.Request) {
|
||||
s.storage.UpdateExitInfo(r.Proxy.Address, r.ExitIP, r.ExitLocation, latencyMs)
|
||||
updated++
|
||||
} else {
|
||||
s.storage.Delete(r.Proxy.Address)
|
||||
if r.Proxy.Source == "custom" {
|
||||
s.storage.DisableProxy(r.Proxy.Address)
|
||||
} else {
|
||||
s.storage.Delete(r.Proxy.Address)
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Printf("[webui] latency refresh done: updated=%d", updated)
|
||||
@@ -357,6 +383,13 @@ func (s *Server) apiConfig(w http.ResponseWriter, r *http.Request) {
|
||||
// 地理过滤配置
|
||||
"blocked_countries": cfg.BlockedCountries,
|
||||
"allowed_countries": cfg.AllowedCountries,
|
||||
|
||||
// 自定义订阅代理配置
|
||||
"custom_proxy_mode": cfg.CustomProxyMode,
|
||||
"custom_priority": cfg.CustomPriority,
|
||||
"custom_free_priority": cfg.CustomFreePriority,
|
||||
"custom_probe_interval": cfg.CustomProbeInterval,
|
||||
"custom_refresh_interval": cfg.CustomRefreshInterval,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -382,6 +415,11 @@ func (s *Server) apiConfigSave(w http.ResponseWriter, r *http.Request) {
|
||||
ReplaceThreshold float64 `json:"replace_threshold"`
|
||||
BlockedCountries []string `json:"blocked_countries"`
|
||||
AllowedCountries []string `json:"allowed_countries"`
|
||||
CustomProxyMode string `json:"custom_proxy_mode"`
|
||||
CustomPriority *bool `json:"custom_priority"`
|
||||
CustomFreePriority *bool `json:"custom_free_priority"`
|
||||
CustomProbeInterval int `json:"custom_probe_interval"`
|
||||
CustomRefreshInterval int `json:"custom_refresh_interval"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -416,6 +454,27 @@ func (s *Server) apiConfigSave(w http.ResponseWriter, r *http.Request) {
|
||||
newCfg.ReplaceThreshold = req.ReplaceThreshold
|
||||
newCfg.BlockedCountries = req.BlockedCountries
|
||||
newCfg.AllowedCountries = req.AllowedCountries
|
||||
if req.CustomProxyMode != "" {
|
||||
newCfg.CustomProxyMode = req.CustomProxyMode
|
||||
}
|
||||
if req.CustomPriority != nil {
|
||||
newCfg.CustomPriority = *req.CustomPriority
|
||||
if *req.CustomPriority {
|
||||
newCfg.CustomFreePriority = false // 互斥
|
||||
}
|
||||
}
|
||||
if req.CustomFreePriority != nil {
|
||||
newCfg.CustomFreePriority = *req.CustomFreePriority
|
||||
if *req.CustomFreePriority {
|
||||
newCfg.CustomPriority = false // 互斥
|
||||
}
|
||||
}
|
||||
if req.CustomProbeInterval > 0 {
|
||||
newCfg.CustomProbeInterval = req.CustomProbeInterval
|
||||
}
|
||||
if req.CustomRefreshInterval > 0 {
|
||||
newCfg.CustomRefreshInterval = req.CustomRefreshInterval
|
||||
}
|
||||
|
||||
if err := config.Save(&newCfg); err != nil {
|
||||
jsonError(w, "save config error: "+err.Error(), http.StatusInternalServerError)
|
||||
@@ -458,6 +517,314 @@ func (s *Server) apiQualityDistribution(w http.ResponseWriter, r *http.Request)
|
||||
jsonOK(w, dist)
|
||||
}
|
||||
|
||||
// ========== 订阅管理 API ==========
|
||||
|
||||
// apiSubscriptions 获取订阅列表(含每个订阅的可用/不可用代理数)
|
||||
func (s *Server) apiSubscriptions(w http.ResponseWriter, r *http.Request) {
|
||||
subs, err := s.storage.GetSubscriptions()
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if subs == nil {
|
||||
subs = []storage.Subscription{}
|
||||
}
|
||||
|
||||
// 附加每个订阅的代理统计
|
||||
type subWithStats struct {
|
||||
storage.Subscription
|
||||
ActiveCount int `json:"active_count"`
|
||||
DisabledCount int `json:"disabled_count"`
|
||||
}
|
||||
var result []subWithStats
|
||||
for _, sub := range subs {
|
||||
active, disabled := s.storage.CountBySubscriptionID(sub.ID)
|
||||
result = append(result, subWithStats{
|
||||
Subscription: sub,
|
||||
ActiveCount: active,
|
||||
DisabledCount: disabled,
|
||||
})
|
||||
}
|
||||
jsonOK(w, result)
|
||||
}
|
||||
|
||||
// apiCustomStatus 获取订阅代理状态
|
||||
func (s *Server) apiCustomStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if s.customMgr == nil {
|
||||
jsonOK(w, map[string]interface{}{
|
||||
"singbox_running": false,
|
||||
"singbox_nodes": 0,
|
||||
"custom_count": 0,
|
||||
"disabled_count": 0,
|
||||
"subscription_count": 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
jsonOK(w, s.customMgr.GetStatus())
|
||||
}
|
||||
|
||||
// apiSubscriptionContribute 访客贡献订阅(支持 URL 和文件上传,需验证通过才入库)
|
||||
func (s *Server) apiSubscriptionContribute(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
FileContent string `json:"file_content"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.URL == "" && req.FileContent == "" {
|
||||
jsonError(w, "请填写订阅 URL 或上传配置文件", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
req.Name = "贡献订阅"
|
||||
}
|
||||
|
||||
// 如果上传了文件,保存到本地
|
||||
filePath := ""
|
||||
if req.FileContent != "" {
|
||||
dataDir := os.Getenv("DATA_DIR")
|
||||
if dataDir == "" {
|
||||
dataDir = "."
|
||||
}
|
||||
subDir := filepath.Join(dataDir, "subscriptions")
|
||||
os.MkdirAll(subDir, 0755)
|
||||
filePath = filepath.Join(subDir, fmt.Sprintf("contribute_%d.yaml", time.Now().UnixMilli()))
|
||||
if err := os.WriteFile(filePath, []byte(req.FileContent), 0644); err != nil {
|
||||
jsonError(w, "保存文件失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
filePath, _ = filepath.Abs(filePath)
|
||||
}
|
||||
|
||||
// 先验证能解析出节点
|
||||
if s.customMgr != nil {
|
||||
nodeCount, err := s.customMgr.ValidateSubscription(req.URL, filePath)
|
||||
if err != nil {
|
||||
if filePath != "" {
|
||||
os.Remove(filePath)
|
||||
}
|
||||
jsonError(w, "订阅验证失败: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.Printf("[webui] 访客贡献订阅验证通过: %s (%d 个节点)", req.Name, nodeCount)
|
||||
}
|
||||
|
||||
// 入库
|
||||
refreshMin := config.Get().CustomRefreshInterval
|
||||
var id int64
|
||||
var err error
|
||||
if req.URL != "" {
|
||||
id, err = s.storage.AddContributedSubscription(req.Name, req.URL, refreshMin)
|
||||
} else {
|
||||
// 文件上传的贡献,用 AddSubscription + contributed 标记
|
||||
id, err = s.storage.AddSubscription(req.Name, "", filePath, "auto", refreshMin)
|
||||
if err == nil {
|
||||
// 标记为贡献
|
||||
s.storage.GetDB().Exec(`UPDATE subscriptions SET contributed = 1 WHERE id = ?`, id)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if filePath != "" {
|
||||
os.Remove(filePath)
|
||||
}
|
||||
jsonError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 异步刷新入池
|
||||
if s.customMgr != nil {
|
||||
go func() {
|
||||
if err := s.customMgr.RefreshSubscription(id); err != nil {
|
||||
log.Printf("[webui] 贡献订阅刷新失败: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
log.Printf("[webui] 🎁 访客贡献订阅: %s (url=%v file=%v)", req.Name, req.URL != "", filePath != "")
|
||||
jsonOK(w, map[string]interface{}{"status": "contributed", "id": id})
|
||||
}
|
||||
|
||||
// apiSubscriptionAdd 添加订阅
|
||||
func (s *Server) apiSubscriptionAdd(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
FileContent string `json:"file_content"` // 上传的文件内容(Base64 编码)
|
||||
RefreshMin int `json:"refresh_min"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.URL == "" && req.FileContent == "" {
|
||||
jsonError(w, "请填写订阅 URL 或上传配置文件", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.RefreshMin <= 0 {
|
||||
req.RefreshMin = config.Get().CustomRefreshInterval
|
||||
}
|
||||
if req.Name == "" {
|
||||
req.Name = "订阅"
|
||||
}
|
||||
|
||||
// 如果上传了文件内容,保存到本地
|
||||
filePath := ""
|
||||
if req.FileContent != "" {
|
||||
dataDir := os.Getenv("DATA_DIR")
|
||||
if dataDir == "" {
|
||||
dataDir = "."
|
||||
}
|
||||
subDir := filepath.Join(dataDir, "subscriptions")
|
||||
os.MkdirAll(subDir, 0755)
|
||||
filePath = filepath.Join(subDir, fmt.Sprintf("sub_%d.yaml", time.Now().UnixMilli()))
|
||||
if err := os.WriteFile(filePath, []byte(req.FileContent), 0644); err != nil {
|
||||
jsonError(w, "保存文件失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
filePath, _ = filepath.Abs(filePath)
|
||||
}
|
||||
|
||||
// 先验证:拉取并解析,确认能解析出节点后再入库
|
||||
if s.customMgr != nil {
|
||||
nodeCount, err := s.customMgr.ValidateSubscription(req.URL, filePath)
|
||||
if err != nil {
|
||||
// 清理已保存的文件
|
||||
if filePath != "" {
|
||||
os.Remove(filePath)
|
||||
}
|
||||
jsonError(w, "订阅验证失败: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.Printf("[webui] 订阅验证通过: %s (%d 个节点)", req.Name, nodeCount)
|
||||
}
|
||||
|
||||
id, err := s.storage.AddSubscription(req.Name, req.URL, filePath, "auto", req.RefreshMin)
|
||||
if err != nil {
|
||||
jsonError(w, "add subscription error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证已通过,异步执行入池
|
||||
if s.customMgr != nil {
|
||||
go func() {
|
||||
if err := s.customMgr.RefreshSubscription(id); err != nil {
|
||||
log.Printf("[webui] 订阅刷新失败: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
log.Printf("[webui] 添加订阅: %s (url=%v file=%v)", req.Name, req.URL != "", filePath != "")
|
||||
jsonOK(w, map[string]interface{}{"status": "added", "id": id})
|
||||
}
|
||||
|
||||
// apiSubscriptionDelete 删除订阅
|
||||
func (s *Server) apiSubscriptionDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ID <= 0 {
|
||||
jsonError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 先删除该订阅关联的代理
|
||||
if s.customMgr != nil {
|
||||
deleted, _ := s.storage.DeleteBySubscriptionID(req.ID)
|
||||
if deleted > 0 {
|
||||
log.Printf("[webui] 清理订阅 #%d 关联的 %d 个代理", req.ID, deleted)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.storage.DeleteSubscription(req.ID); err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 重建 sing-box 配置(剩余订阅的节点)
|
||||
if s.customMgr != nil {
|
||||
go s.customMgr.RefreshAll()
|
||||
}
|
||||
|
||||
log.Printf("[webui] 删除订阅 #%d", req.ID)
|
||||
jsonOK(w, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
// apiSubscriptionRefresh 刷新单个订阅
|
||||
func (s *Server) apiSubscriptionRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ID <= 0 {
|
||||
jsonError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if s.customMgr != nil {
|
||||
go func() {
|
||||
if err := s.customMgr.RefreshSubscription(req.ID); err != nil {
|
||||
log.Printf("[webui] 订阅 #%d 刷新失败: %v", req.ID, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
jsonOK(w, map[string]string{"status": "refresh started"})
|
||||
}
|
||||
|
||||
// apiSubscriptionRefreshAll 刷新所有订阅
|
||||
func (s *Server) apiSubscriptionRefreshAll(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if s.customMgr != nil {
|
||||
go s.customMgr.RefreshAll()
|
||||
}
|
||||
|
||||
jsonOK(w, map[string]string{"status": "refresh all started"})
|
||||
}
|
||||
|
||||
// apiSubscriptionToggle 切换订阅状态
|
||||
func (s *Server) apiSubscriptionToggle(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ID <= 0 {
|
||||
jsonError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.storage.ToggleSubscription(req.ID); err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
jsonOK(w, map[string]string{"status": "toggled"})
|
||||
}
|
||||
|
||||
func jsonOK(w http.ResponseWriter, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(data)
|
||||
|
||||
Reference in New Issue
Block a user