chore: 准备发布 v1.0.0 版本
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
.git
|
||||||
|
.github
|
||||||
|
.kilocode
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
start.sh
|
||||||
|
config.yaml
|
||||||
|
config*.yml
|
||||||
|
*.md
|
||||||
|
data
|
||||||
|
bin
|
||||||
|
docs
|
||||||
|
plans
|
||||||
|
**/node_modules
|
||||||
|
frontend/node_modules
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
name: Build and Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- 'v*' # 当推送形如 v1.0.0 的 tag 时触发
|
||||||
|
workflow_dispatch: # 允许在 GitHub Actions 页面手动触发
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-binaries:
|
||||||
|
name: Build Go Binaries
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Build Frontend
|
||||||
|
working-directory: ./frontend
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.24'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Build Linux Binary
|
||||||
|
env:
|
||||||
|
GOOS: linux
|
||||||
|
GOARCH: amd64
|
||||||
|
CGO_ENABLED: 0
|
||||||
|
run: |
|
||||||
|
go build -trimpath -ldflags "-s -w" -tags "with_utls with_quic with_grpc with_wireguard with_gvisor" -o easy-proxies-linux-amd64 ./cmd/easy_proxies
|
||||||
|
|
||||||
|
- name: Build Windows Binary
|
||||||
|
env:
|
||||||
|
GOOS: windows
|
||||||
|
GOARCH: amd64
|
||||||
|
CGO_ENABLED: 0
|
||||||
|
run: |
|
||||||
|
go build -trimpath -ldflags "-s -w" -tags "with_utls with_quic with_grpc with_wireguard with_gvisor" -o easy-proxies-windows-amd64.exe ./cmd/easy_proxies
|
||||||
|
|
||||||
|
- name: Upload Artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: binaries
|
||||||
|
path: |
|
||||||
|
easy-proxies-linux-amd64
|
||||||
|
easy-proxies-windows-amd64.exe
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v') # 只有打 tag 的时候才创建 Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
easy-proxies-linux-amd64
|
||||||
|
easy-proxies-windows-amd64.exe
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
# Binaries
|
||||||
|
easy-proxies
|
||||||
|
easy-proxies-*
|
||||||
|
easy_proxies
|
||||||
|
easy_proxies-*
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
# Configuration (contains sensitive data)
|
||||||
|
config.yaml
|
||||||
|
nodes.txt
|
||||||
|
|
||||||
|
# Data directory (SQLite database, etc.)
|
||||||
|
data/
|
||||||
|
plans/
|
||||||
|
docs/
|
||||||
|
.kilocode/
|
||||||
|
|
||||||
|
# GeoIP database (auto-downloaded)
|
||||||
|
*.mmdb
|
||||||
|
GeoLite2-Country.mmdb
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# IDE and editor
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.serena/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
|
||||||
|
# Go
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
coverage.out
|
||||||
|
coverage.html
|
||||||
|
|
||||||
|
# Qodo
|
||||||
|
.qodo/
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
# ── Stage 1: Build frontend assets ──────────────────────────────
|
||||||
|
FROM --platform=$BUILDPLATFORM node:22-slim AS frontend
|
||||||
|
WORKDIR /frontend
|
||||||
|
# 使用淘宝 npm 镜像
|
||||||
|
ARG NPM_REGISTRY=https://registry.npmmirror.com
|
||||||
|
RUN npm config set registry ${NPM_REGISTRY}
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ .
|
||||||
|
RUN npx vite build --outDir /frontend-dist
|
||||||
|
|
||||||
|
# ── Stage 2: Build Go binary ───────────────────────────────────
|
||||||
|
FROM --platform=$BUILDPLATFORM golang:1.24 AS builder
|
||||||
|
ARG TARGETARCH
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
# 默认使用国内 Go 代理
|
||||||
|
ARG GOPROXY=https://goproxy.cn,https://goproxy.io,direct
|
||||||
|
RUN go env -w GOPROXY=${GOPROXY} && go mod download
|
||||||
|
COPY . .
|
||||||
|
# Copy frontend build output into the embed directory
|
||||||
|
COPY --from=frontend /frontend-dist/ ./internal/monitor/assets/
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -tags "with_utls with_quic with_grpc with_wireguard with_gvisor" -o easy-proxies ./cmd/easy_proxies
|
||||||
|
|
||||||
|
# ── Stage 3: Runtime ───────────────────────────────────────────
|
||||||
|
FROM debian:bookworm-slim AS runtime
|
||||||
|
# 使用阿里云 Debian 镜像源
|
||||||
|
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates gosu \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& useradd -r -u 10001 easy \
|
||||||
|
&& mkdir -p /etc/easy-proxies/data \
|
||||||
|
&& chown -R easy:easy /etc/easy-proxies
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /src/easy-proxies /usr/local/bin/easy-proxies
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
COPY --chown=easy:easy config.example.yaml /etc/easy-proxies/config.yaml
|
||||||
|
# Pool/Hybrid mode: 2323, Management: 9091, Multi-port/Hybrid mode: 24000-24200
|
||||||
|
EXPOSE 2323 9091 24000-24200
|
||||||
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
|
CMD ["--config", "/etc/easy-proxies/config.yaml"]
|
||||||
@@ -1,2 +1,71 @@
|
|||||||
# EasyProxiesV2
|
# EasyProxiesV2
|
||||||
二开项目,基于jasonwong1991佬的[easy_proxies](https://github.com/jasonwong1991/easy_proxies)项目
|
|
||||||
|
这是一个轻量级、高性能的代理池与订阅管理工具,底层基于强大的 [sing-box](https://github.com/SagerNet/sing-box) 核心构建。自带现代化的 Web 管理控制台,支持自动化的节点健康检查、多协议代理分发、订阅管理以及可视化的流量与延迟监控。
|
||||||
|
|
||||||
|
> **二开声明**:本项目基于大佬的 [jasonwong1991/easy_proxies](https://github.com/jasonwong1991/easy_proxies) 项目进行二次开发。V2 版本主要将前端重构为现代化的 React 单页应用,优化了全栈的交互体验,并改进了项目的工程化构建流程。
|
||||||
|
|
||||||
|
## ✨ 核心特性
|
||||||
|
|
||||||
|
- 🚀 **现代化的 Web UI**:采用 React 19 + Vite + TailwindCSS + DaisyUI 构建的响应式控制台,支持丰富的数据图表展示(节点延迟排行、流量排行、区域分布等)。
|
||||||
|
- 📦 **极简部署 (All-in-One)**:前端静态资源通过 `go:embed` 直接打包入 Go 二进制文件中,只需运行一个文件即可同时提供代理服务与 Web 面板,无需额外部署 Web 服务器。
|
||||||
|
- 🔄 **智能代理池与订阅**:支持多种协议节点的导入、订阅链接的定时拉取与解析;自动进行连通性测试、延迟检测以及故障节点自动隔离。
|
||||||
|
- 🛡️ **灵活的路由与分发**:支持单端口智能分发、多端口映射,内置 GeoIP 分流规则支持。
|
||||||
|
- 📊 **持久化数据监控**:使用内置 SQLite 持久化存储节点统计数据、连通性日志和流量消耗。
|
||||||
|
- 🐳 **容器化与安全**:提供多阶段优化的 Docker 镜像,默认以非 root (`easy`) 用户安全运行。
|
||||||
|
|
||||||
|
## 🛠️ 构建与开发
|
||||||
|
|
||||||
|
本项目由 Go (1.24+) 后端和 Node (22+) 前端组成。
|
||||||
|
|
||||||
|
### 1. 本地手动编译
|
||||||
|
|
||||||
|
**编译前端:**
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
# 构建产物会自动输出到 ../internal/monitor/assets 目录
|
||||||
|
```
|
||||||
|
|
||||||
|
**编译后端 (包含打包好的前端):**
|
||||||
|
```bash
|
||||||
|
# 回到项目根目录
|
||||||
|
go mod download
|
||||||
|
go build -tags "with_utls with_quic with_grpc with_wireguard with_gvisor" -o easy-proxies ./cmd/easy_proxies
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Docker 构建
|
||||||
|
|
||||||
|
项目已提供开箱即用的 `Dockerfile` 和 `docker-compose.yml`,推荐使用 Docker 进行部署:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t easy-proxies:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 部署指南
|
||||||
|
|
||||||
|
使用 Docker Compose 部署是最简单的方式:
|
||||||
|
|
||||||
|
1. 准备配置文件:
|
||||||
|
将项目中的 `config.example.yaml` 复制为 `config.yaml`,并根据实际需求修改(如设置管理面板密码、修改端口等)。
|
||||||
|
|
||||||
|
2. 启动服务:
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 访问 Web 面板:
|
||||||
|
打开浏览器访问 `http://<你的IP>:9091` (默认管理端口,可在 config.yaml 中修改)。
|
||||||
|
|
||||||
|
## 📁 目录结构简述
|
||||||
|
|
||||||
|
- `cmd/easy_proxies/`:Go 程序主入口。
|
||||||
|
- `frontend/`:React 源码,包含仪表盘、设置、节点管理等 UI 组件。
|
||||||
|
- `internal/`:后端的各个核心模块(如代理池、监控、配置、GeoIP、订阅管理等)。
|
||||||
|
- `internal/monitor/assets/`:存放前端 Vite 构建后的静态资源文件,供 Go 嵌入。
|
||||||
|
- `Dockerfile` / `docker-compose.yml`:容器化部署文件。
|
||||||
|
|
||||||
|
## 🙏 鸣谢
|
||||||
|
|
||||||
|
- 原作者 [jasonwong1991/easy_proxies](https://github.com/jasonwong1991/easy_proxies)
|
||||||
|
- 核心代理引擎 [sing-box](https://github.com/SagerNet/sing-box)
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Easy Proxies 完整配置文件
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────────────
|
||||||
|
# 运行模式
|
||||||
|
# ───────────────────────────────────────────────────────────────
|
||||||
|
# pool: 单端口代理池,所有节点共享一个端口,自动负载均衡
|
||||||
|
# multi-port: 多端口模式,每个节点独立端口
|
||||||
|
# hybrid: 混合模式,同时启用 pool + multi-port
|
||||||
|
mode: pool
|
||||||
|
|
||||||
|
# 日志级别: debug, info, warn, error
|
||||||
|
log_level: info
|
||||||
|
|
||||||
|
# 全局跳过 SSL 证书验证(默认 false,不建议在生产环境启用)
|
||||||
|
skip_cert_verify: false
|
||||||
|
|
||||||
|
# 外部 IP 地址,用于导出配置时替换 0.0.0.0(可选)
|
||||||
|
# external_ip: "your.public.ip"
|
||||||
|
|
||||||
|
# SQLite 数据库文件路径(默认 data/data.db,相对于配置文件目录)
|
||||||
|
# 用于存储节点数据、运行时统计、会话等
|
||||||
|
# database_path: "data/data.db"
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────────────
|
||||||
|
# 监听配置(Pool 模式入口)
|
||||||
|
# ───────────────────────────────────────────────────────────────
|
||||||
|
listener:
|
||||||
|
address: 0.0.0.0 # 监听地址
|
||||||
|
port: 2323 # 监听端口
|
||||||
|
username: username # 代理认证用户名
|
||||||
|
password: password # 代理认证密码
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────────────
|
||||||
|
# 代理池配置
|
||||||
|
# ───────────────────────────────────────────────────────────────
|
||||||
|
pool:
|
||||||
|
# 调度模式: sequential(顺序轮询), random(随机选择), balance(最小连接数负载均衡)
|
||||||
|
mode: sequential
|
||||||
|
# 连续失败多少次后加入黑名单
|
||||||
|
failure_threshold: 3
|
||||||
|
# 黑名单持续时间
|
||||||
|
blacklist_duration: 24h
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────────────
|
||||||
|
# 多端口模式配置(multi-port / hybrid 模式使用)
|
||||||
|
# ───────────────────────────────────────────────────────────────
|
||||||
|
multi_port:
|
||||||
|
address: 0.0.0.0 # 监听地址
|
||||||
|
base_port: 24000 # 起始端口号
|
||||||
|
username: mpuser # 默认认证用户名
|
||||||
|
password: mppass # 默认认证密码
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────────────
|
||||||
|
# 管理面板配置
|
||||||
|
# ───────────────────────────────────────────────────────────────
|
||||||
|
management:
|
||||||
|
enabled: true # 是否启用管理面板
|
||||||
|
listen: 0.0.0.0:9091 # 监听地址
|
||||||
|
probe_target: http://cp.cloudflare.com/generate_204 # 健康检查目标
|
||||||
|
password: "" # WebUI 访问密码,为空则不需要密码
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────────────
|
||||||
|
# GeoIP 地域分区配置(可选)
|
||||||
|
# ───────────────────────────────────────────────────────────────
|
||||||
|
# 启用后,节点会按地域自动分组,可通过路径访问特定地域的代理池:
|
||||||
|
# http://127.0.0.1:2323/jp - 日本节点
|
||||||
|
# http://127.0.0.1:2323/kr - 韩国节点
|
||||||
|
# http://127.0.0.1:2323/us - 美国节点
|
||||||
|
# http://127.0.0.1:2323/hk - 香港节点
|
||||||
|
# http://127.0.0.1:2323/tw - 台湾节点
|
||||||
|
# http://127.0.0.1:2323/sg - 新加坡节点
|
||||||
|
# http://127.0.0.1:2323/other - 其他地域
|
||||||
|
# http://127.0.0.1:2323/ - 全部节点(默认)
|
||||||
|
geoip:
|
||||||
|
enabled: false # 是否启用 GeoIP 地域分区
|
||||||
|
database_path: "./GeoLite2-Country.mmdb" # GeoIP 数据库路径(首次启动时自动下载)
|
||||||
|
auto_update_enabled: true # 是否启用自动更新数据库(推荐)
|
||||||
|
auto_update_interval: 24h # 自动更新间隔(默认 24 小时)
|
||||||
|
# listen: 0.0.0.0 # GeoIP 路由监听地址(默认使用 listener 配置)
|
||||||
|
# port: 2323 # GeoIP 路由监听端口(默认 2323)
|
||||||
|
# 注意:
|
||||||
|
# - 首次启动会自动从 GitHub 下载 GeoIP 数据库(约 9MB)
|
||||||
|
# - 启用 auto_update_enabled 后,数据库会定期自动更新,无需重启容器
|
||||||
|
# - 更新过程采用热重载,不会中断现有连接
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────────────
|
||||||
|
# 订阅自动刷新配置(可选)
|
||||||
|
# ───────────────────────────────────────────────────────────────
|
||||||
|
# subscription_refresh:
|
||||||
|
# enabled: false # 是否启用定时刷新
|
||||||
|
# interval: 1h # 刷新间隔(默认 1 小时)
|
||||||
|
# timeout: 30s # 获取订阅的超时时间
|
||||||
|
# health_check_timeout: 60s # 新节点健康检查超时
|
||||||
|
# drain_timeout: 30s # 旧实例排空超时时间
|
||||||
|
# min_available_nodes: 1 # 最少可用节点数,低于此值不切换
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# 节点配置方式(三选一或混合使用)
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────────────
|
||||||
|
# 方式 1: 订阅链接(推荐,自动获取并写入 nodes.txt)
|
||||||
|
# ───────────────────────────────────────────────────────────────
|
||||||
|
# subscriptions:
|
||||||
|
# - "https://example.com/subscribe?token=xxx"
|
||||||
|
# - "https://another.com/api/v1/client/subscribe?token=yyy"
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────────────
|
||||||
|
# 方式 2: 节点文件(每行一个代理 URI)
|
||||||
|
# ───────────────────────────────────────────────────────────────
|
||||||
|
# nodes_file: nodes.txt
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────────────
|
||||||
|
# 方式 3: 直接在配置文件中配置节点
|
||||||
|
# ───────────────────────────────────────────────────────────────
|
||||||
|
nodes:
|
||||||
|
# 最简配置:只需 URI,名称从 # 后自动提取
|
||||||
|
- uri: "vless://uuid@server:443?security=tls&type=ws&path=/path#节点名称"
|
||||||
|
|
||||||
|
# 完整配置:手动指定名称和端口
|
||||||
|
# - name: "自定义名称"
|
||||||
|
# uri: "ss://base64@server:8388"
|
||||||
|
# port: 24001 # 手动指定端口(multi-port/hybrid 模式)
|
||||||
|
# username: "custom_user" # 覆盖默认认证(可选)
|
||||||
|
# password: "custom_pass"
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────────────
|
||||||
|
# 支持的代理协议
|
||||||
|
# ───────────────────────────────────────────────────────────────
|
||||||
|
# - VMess: vmess://base64_encoded_json
|
||||||
|
# - VLESS: vless://uuid@server:port?params#name
|
||||||
|
# - Trojan: trojan://password@server:port?params#name
|
||||||
|
# - SS: ss://method:password@server:port#name
|
||||||
|
# ss://base64(method:password)@server:port#name
|
||||||
|
# - SSR: ssr://base64_encoded
|
||||||
|
# - Hysteria2: hy2://password@server:port?params#name
|
||||||
|
# - HTTP: http://user:pass@server:port
|
||||||
|
# - SOCKS5: socks5://user:pass@server:port
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
easy-proxies:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: ghcr.io/jasonwong1991/easy_proxies:latest
|
||||||
|
container_name: easy-proxies
|
||||||
|
restart: unless-stopped
|
||||||
|
# 方式一:使用主机网络模式(推荐,支持端口自动重分配)
|
||||||
|
network_mode: host
|
||||||
|
# 方式二:手动映射端口(注释掉 network_mode,启用下面的 ports)
|
||||||
|
# ports:
|
||||||
|
# # Pool 模式入口(pool/hybrid 模式使用)
|
||||||
|
# - "2323:2323"
|
||||||
|
# # Web 监控面板
|
||||||
|
# - "9091:9091"
|
||||||
|
# # Multi-port 端口范围(multi-port/hybrid 模式使用,根据节点数量调整)
|
||||||
|
# - "24000-24200:24000-24200"
|
||||||
|
#
|
||||||
|
# 支持的运行模式(在 config.yaml 中配置):
|
||||||
|
# - pool: 单端口代理池,所有节点共享 2323 端口
|
||||||
|
# - multi-port: 多端口模式,每个节点独立端口
|
||||||
|
# - hybrid: 混合模式,同时启用 pool (2323) + multi-port (24000+)
|
||||||
|
#
|
||||||
|
# hybrid 模式说明:
|
||||||
|
# - 代理池入口:http://0.0.0.0:2323(自动负载均衡)
|
||||||
|
# - 独立节点入口:http://0.0.0.0:24000, 24001, ...(直连特定节点)
|
||||||
|
# - 节点状态在两种入口间共享(黑名单同步)
|
||||||
|
# - 端口冲突时自动重分配到下一个可用端口
|
||||||
|
volumes:
|
||||||
|
# 配置文件需要可写权限以支持 WebUI 设置保存
|
||||||
|
# 如遇权限问题,请在宿主机执行: chmod 666 config.yaml nodes.txt
|
||||||
|
- ./config.yaml:/etc/easy-proxies/config.yaml
|
||||||
|
- ./nodes.txt:/etc/easy-proxies/nodes.txt
|
||||||
|
# data 目录(存储 SQLite 数据库、节点数据、运行时统计、会话等)
|
||||||
|
- ./data:/etc/easy-proxies/data
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Ensure data directory exists and is writable by the easy user
|
||||||
|
# When Docker bind-mounts a host directory, it may be owned by root,
|
||||||
|
# making it inaccessible to the non-root 'easy' user (UID 10001).
|
||||||
|
if [ "$(id -u)" = "0" ]; then
|
||||||
|
mkdir -p /etc/easy-proxies/data
|
||||||
|
chown -R easy:easy /etc/easy-proxies/data
|
||||||
|
|
||||||
|
# Also ensure config files are readable
|
||||||
|
chown easy:easy /etc/easy-proxies/config.yaml 2>/dev/null || true
|
||||||
|
chown easy:easy /etc/easy-proxies/nodes.txt 2>/dev/null || true
|
||||||
|
|
||||||
|
# Drop privileges and exec as 'easy' user
|
||||||
|
exec gosu easy /usr/local/bin/easy-proxies "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Already running as non-root (e.g. Kubernetes securityContext)
|
||||||
|
exec /usr/local/bin/easy-proxies "$@"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Easy Proxies</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+3904
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"daisyui": "^5.5.19",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"tailwindcss": "^4.2.1",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.48.0",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,398 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
|
import MonitorPanel from './components/MonitorPanel'
|
||||||
|
import ManagePanel from './components/ManagePanel'
|
||||||
|
import DebugPanel from './components/DebugPanel'
|
||||||
|
import SettingsPanel from './components/SettingsPanel'
|
||||||
|
import LoginPage from './components/LoginPage'
|
||||||
|
import { checkAuth, getToken, logout } from './api/client'
|
||||||
|
|
||||||
|
type AuthState = 'loading' | 'need_login' | 'authenticated'
|
||||||
|
type TabId = 'monitor' | 'manage' | 'debug' | 'settings'
|
||||||
|
|
||||||
|
const ALL_THEMES = [
|
||||||
|
'light', 'dark', 'cupcake', 'bumblebee', 'emerald', 'corporate',
|
||||||
|
'synthwave', 'retro', 'cyberpunk', 'valentine', 'halloween', 'garden',
|
||||||
|
'forest', 'aqua', 'lofi', 'pastel', 'fantasy', 'wireframe', 'black',
|
||||||
|
'luxury', 'dracula', 'cmyk', 'autumn', 'business', 'acid', 'lemonade',
|
||||||
|
'night', 'coffee', 'winter', 'dim', 'nord', 'sunset',
|
||||||
|
'caramellatte', 'abyss', 'silk',
|
||||||
|
]
|
||||||
|
|
||||||
|
const MENU_ITEMS: { id: TabId; label: string; icon: React.ReactNode; desc: string }[] = [
|
||||||
|
{
|
||||||
|
id: 'monitor',
|
||||||
|
label: '节点监控',
|
||||||
|
desc: '数据仪表盘',
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'manage',
|
||||||
|
label: '节点管理',
|
||||||
|
desc: '操作与配置',
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'debug',
|
||||||
|
label: '调试面板',
|
||||||
|
desc: '运行时信息',
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'settings',
|
||||||
|
label: '系统设置',
|
||||||
|
desc: '配置与订阅',
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const VALID_TABS: TabId[] = ['monitor', 'manage', 'debug', 'settings']
|
||||||
|
const THEME_STORAGE_KEY = 'ep-theme'
|
||||||
|
|
||||||
|
const DARK_THEMES = new Set([
|
||||||
|
'dark', 'synthwave', 'halloween', 'forest', 'black', 'luxury',
|
||||||
|
'dracula', 'business', 'night', 'coffee', 'dim', 'sunset', 'abyss',
|
||||||
|
])
|
||||||
|
|
||||||
|
const VIBRANT_THEMES = new Set([
|
||||||
|
'cupcake', 'bumblebee', 'retro', 'cyberpunk', 'valentine', 'aqua',
|
||||||
|
'pastel', 'fantasy', 'cmyk', 'acid', 'lemonade', 'caramellatte', 'silk',
|
||||||
|
])
|
||||||
|
|
||||||
|
function getThemeProfile(themeName: string): { tag: string; icon: string; desc: string } {
|
||||||
|
if (DARK_THEMES.has(themeName)) {
|
||||||
|
return { tag: '深色', icon: '🌙', desc: '低亮度 · 护眼' }
|
||||||
|
}
|
||||||
|
if (VIBRANT_THEMES.has(themeName)) {
|
||||||
|
return { tag: '鲜艳', icon: '🎨', desc: '高对比 · 活力' }
|
||||||
|
}
|
||||||
|
return { tag: '清爽', icon: '☀️', desc: '中性 · 易读' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSystemTheme(): string {
|
||||||
|
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||||
|
return 'dark'
|
||||||
|
}
|
||||||
|
return 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialTheme(): string {
|
||||||
|
const saved = localStorage.getItem(THEME_STORAGE_KEY)
|
||||||
|
if (saved && ALL_THEMES.includes(saved)) {
|
||||||
|
return saved
|
||||||
|
}
|
||||||
|
return getSystemTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTabFromHash(): TabId {
|
||||||
|
const hash = window.location.hash.replace('#', '')
|
||||||
|
if (VALID_TABS.includes(hash as TabId)) {
|
||||||
|
return hash as TabId
|
||||||
|
}
|
||||||
|
return 'monitor'
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [activeTab, setActiveTab] = useState<TabId>(getTabFromHash)
|
||||||
|
const [authState, setAuthState] = useState<AuthState>('loading')
|
||||||
|
const [theme, setTheme] = useState(getInitialTheme)
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
|
|
||||||
|
// Sync hash → state on browser back/forward
|
||||||
|
useEffect(() => {
|
||||||
|
const onHashChange = () => setActiveTab(getTabFromHash())
|
||||||
|
window.addEventListener('hashchange', onHashChange)
|
||||||
|
return () => window.removeEventListener('hashchange', onHashChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme)
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEY, theme)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const doCheck = async () => {
|
||||||
|
try {
|
||||||
|
const res = await checkAuth()
|
||||||
|
if (res.no_password) {
|
||||||
|
setAuthState('authenticated')
|
||||||
|
} else if (getToken()) {
|
||||||
|
setAuthState('authenticated')
|
||||||
|
} else {
|
||||||
|
setAuthState('need_login')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (getToken()) {
|
||||||
|
setAuthState('authenticated')
|
||||||
|
} else {
|
||||||
|
setAuthState('need_login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doCheck()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setAuthState('need_login')
|
||||||
|
window.addEventListener('auth:unauthorized', handler)
|
||||||
|
return () => window.removeEventListener('auth:unauthorized', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleLogin = useCallback(() => setAuthState('authenticated'), [])
|
||||||
|
const handleLogout = useCallback(() => {
|
||||||
|
logout()
|
||||||
|
setAuthState('need_login')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleTabClick = useCallback((tab: TabId) => {
|
||||||
|
setActiveTab(tab)
|
||||||
|
window.location.hash = tab
|
||||||
|
setSidebarOpen(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'monitor': return <MonitorPanel />
|
||||||
|
case 'manage': return <ManagePanel />
|
||||||
|
case 'debug': return <DebugPanel />
|
||||||
|
case 'settings': return <SettingsPanel />
|
||||||
|
default: return <MonitorPanel />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authState === 'loading') {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center bg-base-200 gap-4">
|
||||||
|
<span className="text-2xl font-black text-primary">Easy Proxies</span>
|
||||||
|
<span className="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authState === 'need_login') {
|
||||||
|
return <LoginPage onLogin={handleLogin} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen overflow-hidden bg-base-100">
|
||||||
|
{/* ===== Top Navbar ===== */}
|
||||||
|
<div className="navbar bg-base-100/95 backdrop-blur-xl z-50 min-h-[4rem] h-16 px-4 lg:px-6 shrink-0 border-b border-base-300/40 shadow-sm sticky top-0">
|
||||||
|
<div className="flex-none lg:hidden mr-2">
|
||||||
|
<button
|
||||||
|
className="btn btn-square btn-ghost btn-sm rounded-lg"
|
||||||
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
|
aria-label="toggle sidebar"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-5 h-5 stroke-current">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logo Area */}
|
||||||
|
<div className="flex-1 lg:flex-none lg:w-64 px-2 flex items-center gap-3.5 border-r-0 lg:border-r border-base-300/30">
|
||||||
|
<div className="w-10 h-10 rounded-2xl bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center shadow-sm border border-primary/10">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-black text-lg text-base-content tracking-tight leading-none mb-1">
|
||||||
|
Easy Proxies
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] font-medium text-base-content/40 tracking-wider uppercase">
|
||||||
|
Dashboard
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Right Actions */}
|
||||||
|
<div className="flex-none flex items-center gap-2 lg:gap-3 ml-auto">
|
||||||
|
{/* Theme Selector */}
|
||||||
|
<div className="dropdown dropdown-end">
|
||||||
|
<div tabIndex={0} role="button" className="btn btn-ghost btn-sm h-9 px-3 gap-2 normal-case rounded-lg border border-base-300/50 hover:border-primary/30 hover:bg-primary/5 transition-all">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||||
|
</svg>
|
||||||
|
<div className="hidden sm:flex items-center gap-1.5">
|
||||||
|
<span className="text-xs capitalize font-medium">{theme}</span>
|
||||||
|
<span className="badge badge-ghost badge-xs">{getThemeProfile(theme).tag}</span>
|
||||||
|
</div>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<ul tabIndex={0} className="dropdown-content bg-base-200 border border-base-300/50 rounded-box shadow-2xl mt-2 p-2.5 w-80 max-h-96 overflow-y-auto z-[100] space-y-1">
|
||||||
|
{ALL_THEMES.map((t) => {
|
||||||
|
const profile = getThemeProfile(t)
|
||||||
|
const isActive = theme === t
|
||||||
|
return (
|
||||||
|
<li key={t}>
|
||||||
|
<button
|
||||||
|
className={`w-full rounded-xl px-2 py-2 border transition-all duration-150 ${
|
||||||
|
isActive
|
||||||
|
? 'bg-primary/10 border-primary/30 shadow-sm'
|
||||||
|
: 'border-transparent hover:border-base-300/70 hover:bg-base-300/40'
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
setTheme(t)
|
||||||
|
const el = document.activeElement as HTMLElement
|
||||||
|
el?.blur()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="shrink-0 rounded-lg border border-base-300/60 p-1 bg-base-100/50" data-theme={t}>
|
||||||
|
<div className="w-16 h-10 rounded-md bg-base-100 border border-base-300/70 p-1.5 flex flex-col justify-between">
|
||||||
|
<div className="h-1.5 w-9 rounded bg-primary"></div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="w-2.5 h-2.5 rounded bg-secondary"></span>
|
||||||
|
<span className="w-2.5 h-2.5 rounded bg-accent"></span>
|
||||||
|
<span className="w-2.5 h-2.5 rounded bg-neutral"></span>
|
||||||
|
<span className="w-2.5 h-2.5 rounded bg-info"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1 text-left">
|
||||||
|
<div className={`text-sm font-semibold capitalize leading-tight ${isActive ? 'text-primary' : ''}`}>{t}</div>
|
||||||
|
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-base-content/55">
|
||||||
|
<span>{profile.icon}</span>
|
||||||
|
<span>{profile.tag}</span>
|
||||||
|
<span className="opacity-40">·</span>
|
||||||
|
<span className="truncate">{profile.desc}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isActive && (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-primary shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-base-300/50 mx-1"></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm h-9 w-9 p-0 rounded-lg hover:bg-error/10 hover:text-error transition-colors"
|
||||||
|
onClick={handleLogout}
|
||||||
|
title="退出登录"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== Body: Sidebar + Content ===== */}
|
||||||
|
<div className="flex flex-1 overflow-hidden relative">
|
||||||
|
{/* Mobile overlay */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-30 lg:hidden backdrop-blur-sm"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside
|
||||||
|
className={`
|
||||||
|
fixed lg:static inset-y-0 left-0 z-40
|
||||||
|
w-64 bg-base-100/95 backdrop-blur-xl border-r border-base-300/40
|
||||||
|
transform transition-transform duration-300 ease-in-out
|
||||||
|
${sidebarOpen ? 'translate-x-0 shadow-2xl' : '-translate-x-full'}
|
||||||
|
lg:translate-x-0 lg:shrink-0 lg:shadow-none
|
||||||
|
flex flex-col
|
||||||
|
pt-16 lg:pt-0
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 p-4 lg:p-5 overflow-y-auto">
|
||||||
|
<div className="text-[11px] font-bold text-base-content/40 uppercase tracking-widest mb-3 px-2">主要导航</div>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{MENU_ITEMS.map((item) => {
|
||||||
|
const isActive = activeTab === item.id;
|
||||||
|
return (
|
||||||
|
<li key={item.id}>
|
||||||
|
<button
|
||||||
|
className={`w-full flex items-center gap-3.5 px-3 py-3 rounded-xl transition-all duration-200 group
|
||||||
|
${isActive
|
||||||
|
? 'bg-primary/10 shadow-sm border border-primary/20'
|
||||||
|
: 'hover:bg-base-200/50 border border-transparent'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
onClick={() => handleTabClick(item.id)}
|
||||||
|
>
|
||||||
|
<div className={`p-2 rounded-lg transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-primary text-primary-content shadow-md shadow-primary/20'
|
||||||
|
: 'bg-base-200 text-base-content/50 group-hover:bg-base-300 group-hover:text-base-content'
|
||||||
|
}`}>
|
||||||
|
{item.icon}
|
||||||
|
</div>
|
||||||
|
<div className="text-left flex-1 min-w-0">
|
||||||
|
<div className={`text-sm truncate ${isActive ? 'font-bold text-primary' : 'font-medium text-base-content/80 group-hover:text-base-content'}`}>
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
<div className={`text-xs truncate ${isActive ? 'text-primary/70' : 'text-base-content/40'}`}>
|
||||||
|
{item.desc}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isActive && (
|
||||||
|
<div className="w-1.5 h-6 rounded-full bg-primary shrink-0 opacity-80"></div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Sidebar Footer */}
|
||||||
|
<div className="p-5 border-t border-base-300/30 bg-base-200/20 m-2 rounded-2xl mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative flex h-3 w-3 shrink-0">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-success opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-3 w-3 bg-success"></span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-semibold text-base-content/80">系统运行正常</span>
|
||||||
|
<span className="text-xs text-base-content/40">v1.0.0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<main className="flex-1 overflow-y-auto bg-base-200/40 relative">
|
||||||
|
{renderContent()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import type {
|
||||||
|
AuthResponse,
|
||||||
|
NodesResponse,
|
||||||
|
DebugResponse,
|
||||||
|
SettingsData,
|
||||||
|
SettingsUpdateResponse,
|
||||||
|
ConfigNodesResponse,
|
||||||
|
ConfigNodePayload,
|
||||||
|
ConfigNodeMutationResponse,
|
||||||
|
SubscriptionStatus,
|
||||||
|
ProbeSSEEvent,
|
||||||
|
TrafficStreamEvent,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
// ---- Token management ----
|
||||||
|
|
||||||
|
let authToken: string | null = localStorage.getItem('auth_token')
|
||||||
|
|
||||||
|
export function getToken(): string | null {
|
||||||
|
return authToken
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(token: string | null) {
|
||||||
|
authToken = token
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem('auth_token', token)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('auth_token')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearToken() {
|
||||||
|
setToken(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Base request helper ----
|
||||||
|
|
||||||
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
...(options.headers as Record<string, string> || {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add auth header if we have a token
|
||||||
|
if (authToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${authToken}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set JSON content type for non-GET requests with body
|
||||||
|
if (options.body && typeof options.body === 'string') {
|
||||||
|
headers['Content-Type'] = 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
credentials: 'include', // send cookies
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
// Dispatch a custom event so App can react
|
||||||
|
window.dispatchEvent(new CustomEvent('auth:unauthorized'))
|
||||||
|
throw new ApiError('未授权,请重新登录', 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = `HTTP ${res.status}`
|
||||||
|
try {
|
||||||
|
const body = await res.json()
|
||||||
|
if (body.error) msg = body.error
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
throw new ApiError(msg, res.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle empty responses
|
||||||
|
const text = await res.text()
|
||||||
|
if (!text) return {} as T
|
||||||
|
return JSON.parse(text) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
constructor(message: string, status: number) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Auth API ----
|
||||||
|
|
||||||
|
/** Check if password is required & login */
|
||||||
|
export async function checkAuth(): Promise<AuthResponse> {
|
||||||
|
// Use GET-like behavior: /api/auth without POST returns password status
|
||||||
|
const res = await fetch('/api/auth', { credentials: 'include' })
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(password: string): Promise<AuthResponse> {
|
||||||
|
const res = await fetch('/api/auth', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json()
|
||||||
|
throw new ApiError(body.error || '登录失败', res.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: AuthResponse = await res.json()
|
||||||
|
if (data.token) {
|
||||||
|
setToken(data.token)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout() {
|
||||||
|
clearToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Nodes API ----
|
||||||
|
|
||||||
|
export async function fetchNodes(): Promise<NodesResponse> {
|
||||||
|
return request<NodesResponse>('/api/nodes')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeNode(tag: string): Promise<{ message: string; latency_ms: number }> {
|
||||||
|
return request(`/api/nodes/${encodeURIComponent(tag)}/probe`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function releaseNode(tag: string): Promise<{ message: string }> {
|
||||||
|
return request(`/api/nodes/${encodeURIComponent(tag)}/release`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Probe all nodes with SSE progress updates */
|
||||||
|
export function probeAllNodes(
|
||||||
|
onEvent: (event: ProbeSSEEvent) => void,
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
): AbortController {
|
||||||
|
const controller = new AbortController()
|
||||||
|
|
||||||
|
const doFetch = async () => {
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (authToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${authToken}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/nodes/probe-all', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(`探测失败: HTTP ${res.status}`, res.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body?.getReader()
|
||||||
|
if (!reader) throw new Error('No response body')
|
||||||
|
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (trimmed.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(trimmed.slice(6)) as ProbeSSEEvent
|
||||||
|
onEvent(data)
|
||||||
|
} catch { /* skip malformed events */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name !== 'AbortError') {
|
||||||
|
onError?.(err as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doFetch()
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Traffic Stream API ----
|
||||||
|
|
||||||
|
/** Subscribe real-time traffic speeds via SSE */
|
||||||
|
export function streamTraffic(
|
||||||
|
onEvent: (event: TrafficStreamEvent) => void,
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
): AbortController {
|
||||||
|
const controller = new AbortController()
|
||||||
|
|
||||||
|
const doFetch = async () => {
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (authToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${authToken}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/nodes/traffic/stream', {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(`流量流订阅失败: HTTP ${res.status}`, res.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body?.getReader()
|
||||||
|
if (!reader) throw new Error('No response body')
|
||||||
|
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (trimmed.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(trimmed.slice(6)) as TrafficStreamEvent
|
||||||
|
if (data.type === 'traffic') {
|
||||||
|
onEvent(data)
|
||||||
|
}
|
||||||
|
} catch { /* skip malformed events */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name !== 'AbortError') {
|
||||||
|
onError?.(err as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doFetch()
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Debug API ----
|
||||||
|
|
||||||
|
export async function fetchDebug(): Promise<DebugResponse> {
|
||||||
|
return request<DebugResponse>('/api/debug')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Settings API ----
|
||||||
|
|
||||||
|
export async function fetchSettings(): Promise<SettingsData> {
|
||||||
|
return request<SettingsData>('/api/settings')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSettings(settings: SettingsData): Promise<SettingsUpdateResponse> {
|
||||||
|
return request<SettingsUpdateResponse>('/api/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(settings),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Config Nodes CRUD API ----
|
||||||
|
|
||||||
|
export async function fetchConfigNodes(): Promise<ConfigNodesResponse> {
|
||||||
|
return request<ConfigNodesResponse>('/api/nodes/config')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createConfigNode(payload: ConfigNodePayload): Promise<ConfigNodeMutationResponse> {
|
||||||
|
return request<ConfigNodeMutationResponse>('/api/nodes/config', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateConfigNode(name: string, payload: ConfigNodePayload): Promise<ConfigNodeMutationResponse> {
|
||||||
|
return request<ConfigNodeMutationResponse>(`/api/nodes/config/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteConfigNode(name: string): Promise<ConfigNodeMutationResponse> {
|
||||||
|
return request<ConfigNodeMutationResponse>(`/api/nodes/config/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleConfigNode(name: string, enabled: boolean): Promise<ConfigNodeMutationResponse> {
|
||||||
|
return request<ConfigNodeMutationResponse>(`/api/nodes/config/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchToggleConfigNodes(names: string[], enabled: boolean): Promise<{ message: string; success: number; total: number; errors?: string[] }> {
|
||||||
|
return request('/api/nodes/config/batch-toggle', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ names, enabled }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchDeleteConfigNodes(names: string[]): Promise<{ message: string; success: number; total: number; errors?: string[] }> {
|
||||||
|
return request('/api/nodes/config/batch-delete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ names }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Reload API ----
|
||||||
|
|
||||||
|
export async function triggerReload(): Promise<{ message: string }> {
|
||||||
|
return request('/api/reload', { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Subscription API ----
|
||||||
|
|
||||||
|
export async function fetchSubscriptionStatus(): Promise<SubscriptionStatus> {
|
||||||
|
return request<SubscriptionStatus>('/api/subscription/status')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshSubscription(): Promise<{ message: string; node_count: number }> {
|
||||||
|
return request('/api/subscription/refresh', { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Export API ----
|
||||||
|
|
||||||
|
export async function exportProxies(): Promise<string> {
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (authToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${authToken}`
|
||||||
|
}
|
||||||
|
const res = await fetch('/api/export', {
|
||||||
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new ApiError('导出失败', res.status)
|
||||||
|
return res.text()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Import API ----
|
||||||
|
|
||||||
|
export async function importNodes(content: string): Promise<{ message: string; imported: number; errors?: string[] }> {
|
||||||
|
return request('/api/import', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,350 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import type { DebugResponse, DebugNode } from '../types'
|
||||||
|
import { fetchDebug } from '../api/client'
|
||||||
|
import { formatBytes } from '../utils/format'
|
||||||
|
|
||||||
|
function formatTime(isoStr: string): string {
|
||||||
|
if (!isoStr || isoStr === '0001-01-01T00:00:00Z') return '-'
|
||||||
|
try {
|
||||||
|
return new Date(isoStr).toLocaleString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return isoStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLogTime(isoStr: string): string {
|
||||||
|
if (!isoStr || isoStr === '0001-01-01T00:00:00Z') return '--:--:--'
|
||||||
|
try {
|
||||||
|
const d = new Date(isoStr)
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, '0')
|
||||||
|
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||||
|
} catch {
|
||||||
|
return isoStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DebugPanel() {
|
||||||
|
const [data, setData] = useState<DebugResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||||
|
const [filter, setFilter] = useState('')
|
||||||
|
const [expandedNode, setExpandedNode] = useState<string | null>(null)
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setError('')
|
||||||
|
const res = await fetchDebug()
|
||||||
|
setData(res)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
// Auto refresh
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoRefresh) {
|
||||||
|
intervalRef.current = setInterval(loadData, 5000)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current)
|
||||||
|
intervalRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [autoRefresh, loadData])
|
||||||
|
|
||||||
|
const filteredNodes = (data?.nodes || []).filter((node: DebugNode) => {
|
||||||
|
if (!filter) return true
|
||||||
|
return (
|
||||||
|
node.name.toLowerCase().includes(filter.toLowerCase()) ||
|
||||||
|
node.tag.toLowerCase().includes(filter.toLowerCase()) ||
|
||||||
|
(node.last_error || '').toLowerCase().includes(filter.toLowerCase())
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort: nodes with errors first, then by failure count desc
|
||||||
|
const sortedNodes = [...filteredNodes].sort((a, b) => {
|
||||||
|
if (a.blacklisted !== b.blacklisted) return a.blacklisted ? -1 : 1
|
||||||
|
if (a.failure_count !== b.failure_count) return b.failure_count - a.failure_count
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (loading && !data) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<span className="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-full animate-in fade-in duration-500">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="sticky top-0 z-30 bg-base-100/80 backdrop-blur-xl px-4 lg:px-8 py-4 border-b border-base-300/60 shadow-sm">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 max-w-[1600px] mx-auto w-full">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary shrink-0 border border-primary/20">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
调试面板
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-base-content/50 mt-1.5 ml-[3.25rem]">运行时内部状态及错误信息分析</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer bg-base-200/50 px-3 py-1.5 rounded-lg border border-base-300/50 hover:border-base-300 transition-colors">
|
||||||
|
<span className="text-sm font-medium">自动刷新 (5s)</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="toggle toggle-primary toggle-sm"
|
||||||
|
checked={autoRefresh}
|
||||||
|
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm lg:btn-md btn-primary shadow-sm gap-2"
|
||||||
|
onClick={loadData}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="loading loading-spinner loading-sm"></span>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
手动刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 lg:p-8 space-y-6 flex-1 pb-10 max-w-[1600px] mx-auto w-full">
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div role="alert" className="alert alert-error alert-soft text-sm">
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overview Stats */}
|
||||||
|
{data && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div className="rounded-xl bg-base-200/50 border border-base-300/30 p-4 hover:border-primary/30 transition-colors">
|
||||||
|
<div className="text-xs text-base-content/50 mb-1">总调用数</div>
|
||||||
|
<div className="text-2xl font-bold tabular-nums">{data.total_calls.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-base-200/50 border border-base-300/30 p-4 hover:border-primary/30 transition-colors">
|
||||||
|
<div className="text-xs text-base-content/50 mb-1">成功数</div>
|
||||||
|
<div className="text-2xl font-bold tabular-nums text-success">{data.total_success.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-base-200/50 border border-base-300/30 p-4 hover:border-primary/30 transition-colors">
|
||||||
|
<div className="text-xs text-base-content/50 mb-1">失败数</div>
|
||||||
|
<div className="text-2xl font-bold tabular-nums text-error">{(data.total_calls - data.total_success).toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-base-200/50 border border-base-300/30 p-4 hover:border-primary/30 transition-colors">
|
||||||
|
<div className="text-xs text-base-content/50 mb-1">成功率</div>
|
||||||
|
<div className={`text-2xl font-bold tabular-nums ${data.success_rate >= 90 ? 'text-success' : data.success_rate >= 70 ? 'text-warning' : 'text-error'}`}>
|
||||||
|
{data.success_rate.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filter */}
|
||||||
|
<label className="input input-sm w-full">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索节点名称、标签或错误信息..."
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Node Debug List */}
|
||||||
|
<div className="space-y-2 max-h-[60vh] overflow-y-auto pr-1">
|
||||||
|
{sortedNodes.length === 0 ? (
|
||||||
|
<div className="text-center text-base-content/50 py-8">
|
||||||
|
{filter ? '没有匹配的节点' : '暂无调试数据'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sortedNodes.map((node: DebugNode) => (
|
||||||
|
<div
|
||||||
|
key={node.tag}
|
||||||
|
className={`collapse collapse-arrow bg-base-200/60 border rounded-xl transition-colors ${node.blacklisted ? 'border-error/30' : 'border-base-300/40 hover:border-base-300/70'}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={expandedNode === node.tag}
|
||||||
|
onChange={() => setExpandedNode(expandedNode === node.tag ? null : node.tag)}
|
||||||
|
/>
|
||||||
|
<div className="collapse-title flex items-center gap-3 pr-12">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm truncate">{node.name || node.tag}</span>
|
||||||
|
{node.blacklisted && <span className="badge badge-error badge-xs">黑名单</span>}
|
||||||
|
<span className="badge badge-ghost badge-xs">{node.mode}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 text-xs text-base-content/60 mt-0.5 flex-wrap">
|
||||||
|
<span>成功: <span className="text-success">{node.success_count}</span></span>
|
||||||
|
<span>失败: <span className="text-error">{node.failure_count}</span></span>
|
||||||
|
<span>活跃: {node.active_connections}</span>
|
||||||
|
{node.last_latency_ms > 0 && (
|
||||||
|
<span>延迟: {node.last_latency_ms}ms</span>
|
||||||
|
)}
|
||||||
|
{((node.total_upload || 0) + (node.total_download || 0)) > 0 && (
|
||||||
|
<span className="text-blue-400/70">↑{formatBytes(node.total_upload || 0)}</span>
|
||||||
|
)}
|
||||||
|
{((node.total_upload || 0) + (node.total_download || 0)) > 0 && (
|
||||||
|
<span className="text-green-400/70">↓{formatBytes(node.total_download || 0)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="collapse-content">
|
||||||
|
<div className="pt-2 space-y-3">
|
||||||
|
{/* Detail Info */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="text-base-content/50">端口:</span>{' '}
|
||||||
|
<span className="font-mono">{node.port || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-base-content/50">最后成功:</span>{' '}
|
||||||
|
<span>{formatTime(node.last_success)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-base-content/50">最后失败:</span>{' '}
|
||||||
|
<span>{formatTime(node.last_failure)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-base-content/50">延迟:</span>{' '}
|
||||||
|
<span className="font-mono">{node.last_latency_ms > 0 ? `${node.last_latency_ms}ms` : '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-base-content/50">上传:</span>{' '}
|
||||||
|
<span className="font-mono text-blue-400">{formatBytes(node.total_upload || 0)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-base-content/50">下载:</span>{' '}
|
||||||
|
<span className="font-mono text-green-400">{formatBytes(node.total_download || 0)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-base-content/50">总流量:</span>{' '}
|
||||||
|
<span className="font-mono">{formatBytes((node.total_upload || 0) + (node.total_download || 0))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last Error */}
|
||||||
|
{node.last_error && (
|
||||||
|
<div className="bg-error/5 border border-error/20 rounded-lg p-2.5">
|
||||||
|
<span className="text-xs font-semibold text-error">最后错误: </span>
|
||||||
|
<span className="text-xs text-error/70 font-mono break-all">{node.last_error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
{node.timeline && node.timeline.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-medium mb-2 text-base-content/70">
|
||||||
|
运行日志 (最近 {node.timeline.length} 条)
|
||||||
|
</h4>
|
||||||
|
<div className="bg-[#1a1b26] rounded-lg p-3 overflow-x-auto max-h-56 overflow-y-auto font-mono text-xs leading-5 space-y-px">
|
||||||
|
{node.timeline.map((evt, i) => {
|
||||||
|
const ts = formatLogTime(evt.time)
|
||||||
|
const isOk = evt.success
|
||||||
|
const isTraffic = !!evt.destination
|
||||||
|
return (
|
||||||
|
<div key={i} className="whitespace-nowrap">
|
||||||
|
<span className="text-gray-500">{ts}</span>
|
||||||
|
{' '}
|
||||||
|
{isOk ? (
|
||||||
|
<span className="text-green-400">INFO </span>
|
||||||
|
) : (
|
||||||
|
<span className="text-red-400">ERROR</span>
|
||||||
|
)}
|
||||||
|
{' '}
|
||||||
|
<span className="text-blue-300">[{node.name || node.tag}]</span>
|
||||||
|
{' '}
|
||||||
|
{isTraffic ? (
|
||||||
|
/* 流量事件:显示目标地址 */
|
||||||
|
isOk ? (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-300">→ </span>
|
||||||
|
<span className="text-cyan-300">{evt.destination}</span>
|
||||||
|
<span className="text-green-300 ml-1">✓</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-300">→ </span>
|
||||||
|
<span className="text-cyan-300">{evt.destination}</span>
|
||||||
|
<span className="text-red-300 ml-1">✗</span>
|
||||||
|
{evt.error && (
|
||||||
|
<span className="text-red-400/70 ml-1">— {evt.error}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
/* 探测事件:显示延迟 */
|
||||||
|
isOk ? (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-300">probe </span>
|
||||||
|
<span className="text-green-300">✓ success</span>
|
||||||
|
{evt.latency_ms > 0 && (
|
||||||
|
<span className={`ml-1 ${evt.latency_ms > 1000 ? 'text-yellow-400' : evt.latency_ms > 500 ? 'text-yellow-300' : 'text-gray-400'}`}>
|
||||||
|
({evt.latency_ms}ms)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-300">probe </span>
|
||||||
|
<span className="text-red-300">✗ failed</span>
|
||||||
|
{evt.latency_ms > 0 && (
|
||||||
|
<span className="text-gray-500 ml-1">({evt.latency_ms}ms)</span>
|
||||||
|
)}
|
||||||
|
{evt.error && (
|
||||||
|
<span className="text-red-400/70 ml-1">— {evt.error}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-refresh indicator */}
|
||||||
|
{autoRefresh && (
|
||||||
|
<div className="text-center text-xs text-base-content/30 flex items-center justify-center gap-2">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-success animate-pulse"></span>
|
||||||
|
每 5 秒自动刷新 · {data?.nodes.length || 0} 个节点
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { login } from '../api/client'
|
||||||
|
|
||||||
|
interface LoginPageProps {
|
||||||
|
onLogin: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage({ onLogin }: LoginPageProps) {
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!password.trim()) {
|
||||||
|
setError('请输入密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(password)
|
||||||
|
onLogin()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '登录失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen w-full flex flex-col lg:flex-row bg-base-100 overflow-hidden">
|
||||||
|
{/* Left Side: Branding / Intro */}
|
||||||
|
<div className="lg:w-5/12 bg-primary/5 p-8 lg:p-16 xl:p-24 flex flex-col justify-between relative overflow-hidden border-b lg:border-b-0 lg:border-r border-base-300/30">
|
||||||
|
{/* Decorative Background Elements */}
|
||||||
|
<div className="absolute top-[-10%] left-[-10%] w-[60%] h-[60%] bg-primary/20 rounded-full blur-[100px] mix-blend-multiply opacity-60"></div>
|
||||||
|
<div className="absolute bottom-[-10%] right-[-10%] w-[60%] h-[60%] bg-secondary/20 rounded-full blur-[100px] mix-blend-multiply opacity-60"></div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-base-100 flex items-center justify-center shadow-sm border border-base-300/50">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-black text-primary tracking-tight">Easy Proxies</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 mt-16 lg:mt-0 flex-1 flex flex-col justify-center">
|
||||||
|
<h1 className="text-4xl lg:text-5xl xl:text-6xl font-black text-base-content tracking-tight leading-tight">
|
||||||
|
高效、轻量的<br className="hidden lg:block" />
|
||||||
|
<span className="text-primary mt-2 block">代理池管理系统</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-base-content/60 mt-6 text-base lg:text-lg max-w-md font-medium leading-relaxed">
|
||||||
|
为高可用代理服务提供强劲动力。一站式解决多节点负载均衡、健康检查、区域路由与实时监控。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-12 flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-4 text-sm font-medium bg-base-100/60 backdrop-blur-md p-4 rounded-2xl border border-base-300/40 shadow-sm max-w-md transition-transform hover:-translate-y-1 duration-300">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base-content font-bold text-base">高可用架构</div>
|
||||||
|
<div className="text-base-content/60 text-xs mt-0.5">多重调度策略与毫秒级故障转移</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-sm font-medium bg-base-100/60 backdrop-blur-md p-4 rounded-2xl border border-base-300/40 shadow-sm max-w-md transition-transform hover:-translate-y-1 duration-300">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-secondary/10 flex items-center justify-center text-secondary shrink-0">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base-content font-bold text-base">自动化管理</div>
|
||||||
|
<div className="text-base-content/60 text-xs mt-0.5">订阅定时更新与 GeoIP 智能分发</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 mt-12 hidden lg:block">
|
||||||
|
<p className="text-sm font-medium text-base-content/40">© {new Date().getFullYear()} Easy Proxies. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Side: Login Form */}
|
||||||
|
<div className="lg:w-7/12 p-8 lg:p-24 flex flex-col justify-center items-center bg-base-100 relative">
|
||||||
|
<div className="max-w-md w-full">
|
||||||
|
<div className="mb-12 text-center lg:text-left">
|
||||||
|
<h2 className="text-3xl lg:text-4xl font-bold text-base-content mb-3 tracking-tight">欢迎回来</h2>
|
||||||
|
<p className="text-base text-base-content/50 font-medium">请输入管理面板密码以继续使用系统</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/70">管理密码</legend>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-base-content/40">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
className={`input input-lg w-full pl-12 bg-base-200/50 focus:bg-base-100 transition-colors text-base ${error ? 'input-error ring-1 ring-error/50' : 'focus:ring-2 focus:ring-primary/20'}`}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPassword(e.target.value)
|
||||||
|
setError('')
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div role="alert" className="alert alert-error alert-soft py-3 text-sm animate-in fade-in slide-in-from-top-2 duration-300">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary w-full h-14 text-lg font-semibold shadow-lg shadow-primary/20 hover:shadow-primary/30 transition-all active:scale-[0.98] mt-4"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="loading loading-spinner loading-md"></span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>登录系统</span>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||||
|
</svg>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-16 text-center lg:text-left lg:hidden">
|
||||||
|
<p className="text-xs text-base-content/30 font-medium">
|
||||||
|
© {new Date().getFullYear()} Easy Proxies. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,555 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
|
import type { NodeSnapshot, NodesResponse, ConfigNodeConfig, ConfigNodesResponse, TrafficStreamEvent } from '../types'
|
||||||
|
import { fetchNodes, fetchConfigNodes, streamTraffic } from '../api/client'
|
||||||
|
import { formatBytes, formatSpeed } from '../utils/format'
|
||||||
|
import DonutChart from './charts/DonutChart'
|
||||||
|
import BarChart from './charts/BarChart'
|
||||||
|
import LatencyRanking from './charts/LatencyRanking'
|
||||||
|
import TrafficRanking from './charts/TrafficRanking'
|
||||||
|
import RegionCards from './charts/RegionCards'
|
||||||
|
|
||||||
|
function latencyColor(ms: number): string {
|
||||||
|
if (ms < 0) return 'text-base-content/50'
|
||||||
|
if (ms <= 100) return 'text-success'
|
||||||
|
if (ms <= 300) return 'text-warning'
|
||||||
|
return 'text-error'
|
||||||
|
}
|
||||||
|
|
||||||
|
type AutoRefreshInterval = 0 | 5 | 10 | 30 | 60
|
||||||
|
|
||||||
|
export default function MonitorPanel() {
|
||||||
|
const [data, setData] = useState<NodesResponse | null>(null)
|
||||||
|
const [configData, setConfigData] = useState<ConfigNodesResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState<AutoRefreshInterval>(5)
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
const trafficAbortRef = useRef<AbortController | null>(null)
|
||||||
|
const [trafficRealtime, setTrafficRealtime] = useState<{
|
||||||
|
connected: boolean
|
||||||
|
uploadSpeed: number
|
||||||
|
downloadSpeed: number
|
||||||
|
sampledAt: string
|
||||||
|
}>({
|
||||||
|
connected: false,
|
||||||
|
uploadSpeed: 0,
|
||||||
|
downloadSpeed: 0,
|
||||||
|
sampledAt: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setError('')
|
||||||
|
const [monitorRes, configRes] = await Promise.all([
|
||||||
|
fetchNodes(),
|
||||||
|
fetchConfigNodes().catch(() => null),
|
||||||
|
])
|
||||||
|
|
||||||
|
setData(monitorRes)
|
||||||
|
if (configRes) setConfigData(configRes)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
// Auto-refresh timer
|
||||||
|
useEffect(() => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current)
|
||||||
|
timerRef.current = null
|
||||||
|
}
|
||||||
|
if (autoRefresh > 0) {
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
loadData()
|
||||||
|
}, autoRefresh * 1000)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [autoRefresh, loadData])
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
await loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real-time speed stream (SSE): reconnect automatically on disconnect.
|
||||||
|
useEffect(() => {
|
||||||
|
let stopped = false
|
||||||
|
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (stopped) return
|
||||||
|
|
||||||
|
if (trafficAbortRef.current) {
|
||||||
|
trafficAbortRef.current.abort()
|
||||||
|
trafficAbortRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
trafficAbortRef.current = streamTraffic(
|
||||||
|
(event: TrafficStreamEvent) => {
|
||||||
|
setTrafficRealtime({
|
||||||
|
connected: true,
|
||||||
|
uploadSpeed: event.upload_speed || 0,
|
||||||
|
downloadSpeed: event.download_speed || 0,
|
||||||
|
sampledAt: event.sampled_at || '',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
setTrafficRealtime((prev) => ({ ...prev, connected: false }))
|
||||||
|
if (!stopped) {
|
||||||
|
retryTimer = setTimeout(connect, 2000)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
connect()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopped = true
|
||||||
|
if (retryTimer) {
|
||||||
|
clearTimeout(retryTimer)
|
||||||
|
}
|
||||||
|
if (trafficAbortRef.current) {
|
||||||
|
trafficAbortRef.current.abort()
|
||||||
|
trafficAbortRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// ---- Computed stats ----
|
||||||
|
const allNodes = useMemo(() => data?.nodes || [], [data])
|
||||||
|
const allConfigNodes = useMemo(() => configData?.nodes || [], [configData])
|
||||||
|
|
||||||
|
// Config-level counts (includes disabled nodes)
|
||||||
|
const totalConfigNodes = allConfigNodes.length
|
||||||
|
const disabledNodes = useMemo(
|
||||||
|
() => allConfigNodes.filter((n: ConfigNodeConfig) => n.disabled).length,
|
||||||
|
[allConfigNodes]
|
||||||
|
)
|
||||||
|
// enabledConfigNodes not directly displayed but used implicitly in monitor node count
|
||||||
|
|
||||||
|
// Runtime monitor counts (only enabled nodes that are loaded in the box)
|
||||||
|
const availableNodes = useMemo(
|
||||||
|
() => allNodes.filter((n: NodeSnapshot) => n.initial_check_done && n.available && !n.blacklisted).length,
|
||||||
|
[allNodes]
|
||||||
|
)
|
||||||
|
|
||||||
|
const unavailableNodes = useMemo(
|
||||||
|
() => allNodes.filter((n: NodeSnapshot) => n.initial_check_done && !n.available && !n.blacklisted).length,
|
||||||
|
[allNodes]
|
||||||
|
)
|
||||||
|
|
||||||
|
const blacklistedNodes = useMemo(
|
||||||
|
() => allNodes.filter((n: NodeSnapshot) => n.blacklisted).length,
|
||||||
|
[allNodes]
|
||||||
|
)
|
||||||
|
|
||||||
|
const pendingNodes = useMemo(
|
||||||
|
() => allNodes.filter((n: NodeSnapshot) => !n.initial_check_done && !n.blacklisted).length,
|
||||||
|
[allNodes]
|
||||||
|
)
|
||||||
|
|
||||||
|
const healthRate = useMemo(() => {
|
||||||
|
const checked = allNodes.filter((n: NodeSnapshot) => n.initial_check_done).length
|
||||||
|
if (checked === 0) return -1
|
||||||
|
return Math.round((availableNodes / checked) * 100)
|
||||||
|
}, [allNodes, availableNodes])
|
||||||
|
|
||||||
|
const avgLatency = useMemo(() => {
|
||||||
|
const validNodes = allNodes.filter((n: NodeSnapshot) => n.last_latency_ms > 0)
|
||||||
|
if (validNodes.length === 0) return -1
|
||||||
|
return Math.round(validNodes.reduce((sum: number, n: NodeSnapshot) => sum + n.last_latency_ms, 0) / validNodes.length)
|
||||||
|
}, [allNodes])
|
||||||
|
|
||||||
|
const totalConnections = useMemo(
|
||||||
|
() => allNodes.reduce((sum: number, n: NodeSnapshot) => sum + n.active_connections, 0),
|
||||||
|
[allNodes]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Traffic totals
|
||||||
|
const totalUpload = useMemo(() => data?.total_upload || 0, [data])
|
||||||
|
const totalDownload = useMemo(() => data?.total_download || 0, [data])
|
||||||
|
|
||||||
|
// Speed from backend realtime stream, fallback to periodic /api/nodes snapshot values.
|
||||||
|
const uploadSpeed = trafficRealtime.connected
|
||||||
|
? trafficRealtime.uploadSpeed
|
||||||
|
: (data?.upload_speed || 0)
|
||||||
|
const downloadSpeed = trafficRealtime.connected
|
||||||
|
? trafficRealtime.downloadSpeed
|
||||||
|
: (data?.download_speed || 0)
|
||||||
|
|
||||||
|
// ---- Chart data ----
|
||||||
|
|
||||||
|
// Status donut - includes all node states
|
||||||
|
const statusSegments = useMemo(() => [
|
||||||
|
{ label: '正常', value: availableNodes, color: 'oklch(0.72 0.19 142)' },
|
||||||
|
{ label: '不可用', value: unavailableNodes, color: 'oklch(0.63 0.24 29)' },
|
||||||
|
{ label: '黑名单', value: blacklistedNodes, color: 'oklch(0.55 0.20 15)' },
|
||||||
|
{ label: '待检查', value: pendingNodes, color: 'oklch(0.80 0.18 84)' },
|
||||||
|
{ label: '已禁用', value: disabledNodes, color: 'oklch(0.65 0.05 250)' },
|
||||||
|
], [availableNodes, unavailableNodes, blacklistedNodes, pendingNodes, disabledNodes])
|
||||||
|
|
||||||
|
// Latency distribution bars
|
||||||
|
const latencyBars = useMemo(() => {
|
||||||
|
const buckets = [
|
||||||
|
{ label: '0-50', min: 0, max: 50, count: 0, color: 'oklch(0.72 0.19 142)' },
|
||||||
|
{ label: '50-100', min: 50, max: 100, count: 0, color: 'oklch(0.75 0.18 120)' },
|
||||||
|
{ label: '100-200', min: 100, max: 200, count: 0, color: 'oklch(0.80 0.18 84)' },
|
||||||
|
{ label: '200-300', min: 200, max: 300, count: 0, color: 'oklch(0.75 0.18 55)' },
|
||||||
|
{ label: '300+', min: 300, max: Infinity, count: 0, color: 'oklch(0.63 0.24 29)' },
|
||||||
|
{ label: '超时', min: -1, max: 0, count: 0, color: 'oklch(0.50 0.10 250)' },
|
||||||
|
]
|
||||||
|
for (const node of allNodes) {
|
||||||
|
const ms = node.last_latency_ms
|
||||||
|
if (ms < 0 || !node.initial_check_done) {
|
||||||
|
buckets[5].count++
|
||||||
|
} else if (ms <= 50) {
|
||||||
|
buckets[0].count++
|
||||||
|
} else if (ms <= 100) {
|
||||||
|
buckets[1].count++
|
||||||
|
} else if (ms <= 200) {
|
||||||
|
buckets[2].count++
|
||||||
|
} else if (ms <= 300) {
|
||||||
|
buckets[3].count++
|
||||||
|
} else {
|
||||||
|
buckets[4].count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buckets.map(b => ({ label: b.label, value: b.count, color: b.color }))
|
||||||
|
}, [allNodes])
|
||||||
|
|
||||||
|
// Latency ranking
|
||||||
|
const rankingItems = useMemo(() =>
|
||||||
|
allNodes
|
||||||
|
.filter((n: NodeSnapshot) => n.last_latency_ms > 0)
|
||||||
|
.map((n: NodeSnapshot) => ({
|
||||||
|
name: n.name || n.tag,
|
||||||
|
latency: n.last_latency_ms,
|
||||||
|
region: n.region,
|
||||||
|
})),
|
||||||
|
[allNodes]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Traffic ranking
|
||||||
|
const trafficRankItems = useMemo(() =>
|
||||||
|
allNodes
|
||||||
|
.filter((n: NodeSnapshot) => (n.total_upload || 0) + (n.total_download || 0) > 0)
|
||||||
|
.map((n: NodeSnapshot) => ({
|
||||||
|
name: n.name || n.tag,
|
||||||
|
upload: n.total_upload || 0,
|
||||||
|
download: n.total_download || 0,
|
||||||
|
region: n.region,
|
||||||
|
})),
|
||||||
|
[allNodes]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Region stats
|
||||||
|
const regionStats = useMemo(() => {
|
||||||
|
const map = new Map<string, { total: number; healthy: number; latencies: number[] }>()
|
||||||
|
for (const node of allNodes) {
|
||||||
|
const region = node.region || 'other'
|
||||||
|
const entry = map.get(region) || { total: 0, healthy: 0, latencies: [] }
|
||||||
|
entry.total++
|
||||||
|
if (node.initial_check_done && node.available && !node.blacklisted) {
|
||||||
|
entry.healthy++
|
||||||
|
}
|
||||||
|
if (node.last_latency_ms > 0) {
|
||||||
|
entry.latencies.push(node.last_latency_ms)
|
||||||
|
}
|
||||||
|
map.set(region, entry)
|
||||||
|
}
|
||||||
|
return Array.from(map.entries()).map(([region, stats]) => ({
|
||||||
|
region,
|
||||||
|
total: stats.total,
|
||||||
|
healthy: stats.healthy,
|
||||||
|
avgLatency: stats.latencies.length > 0
|
||||||
|
? Math.round(stats.latencies.reduce((a, b) => a + b, 0) / stats.latencies.length)
|
||||||
|
: 0,
|
||||||
|
}))
|
||||||
|
}, [allNodes])
|
||||||
|
|
||||||
|
// ---- Render ----
|
||||||
|
|
||||||
|
if (loading && !data) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<span className="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-full animate-in fade-in duration-500">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="sticky top-0 z-30 bg-base-100/80 backdrop-blur-xl px-4 lg:px-8 py-4 border-b border-base-300/60 shadow-sm">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 max-w-[1600px] mx-auto w-full">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary shrink-0 border border-primary/20">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
节点监控
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-base-content/50 mt-1.5 ml-[3.25rem]">实时数据仪表盘 · 可视化节点健康与流量状况</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Auto refresh selector */}
|
||||||
|
<div className="flex items-center gap-2 bg-base-200/50 px-2 py-1 lg:py-1.5 rounded-lg border border-base-300/50 transition-colors">
|
||||||
|
{autoRefresh > 0 ? (
|
||||||
|
<div className="hidden sm:flex items-center gap-1.5 px-2 py-0.5 rounded-md text-xs font-medium text-success whitespace-nowrap">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-success animate-pulse"></div>
|
||||||
|
<span>自动刷新开启</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="hidden sm:inline text-sm font-medium pl-2 whitespace-nowrap text-base-content/70">自动刷新</span>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
className="select select-sm border-0 focus:outline-none focus:ring-0 bg-transparent min-w-[90px] text-sm"
|
||||||
|
value={autoRefresh}
|
||||||
|
onChange={(e) => setAutoRefresh(Number(e.target.value) as AutoRefreshInterval)}
|
||||||
|
>
|
||||||
|
<option value={0}>关闭</option>
|
||||||
|
<option value={5}>每 5 秒</option>
|
||||||
|
<option value={10}>每 10 秒</option>
|
||||||
|
<option value={30}>每 30 秒</option>
|
||||||
|
<option value={60}>每 60 秒</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-sm lg:btn-md btn-primary shadow-sm gap-2" onClick={handleRefresh} disabled={loading}>
|
||||||
|
{loading ? (
|
||||||
|
<span className="loading loading-spinner loading-sm"></span>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
|
||||||
|
)}
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 lg:p-8 space-y-6 flex-1 pb-10 max-w-[1600px] mx-auto w-full">
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div role="alert" className="alert alert-error alert-soft">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{/* Total Nodes */}
|
||||||
|
<div className="rounded-2xl bg-base-100 border border-base-300/50 p-5 shadow-sm hover:shadow-md transition-all group overflow-hidden relative">
|
||||||
|
<div className="absolute -right-4 -top-4 w-24 h-24 bg-primary/5 rounded-full blur-2xl group-hover:bg-primary/10 transition-colors"></div>
|
||||||
|
<div className="flex items-center gap-3 mb-2 relative z-10">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" /></svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-base-content/60">总节点</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-black tabular-nums tracking-tight text-base-content mb-2 relative z-10">{totalConfigNodes}</div>
|
||||||
|
<div className="text-xs flex flex-wrap gap-1.5 relative z-10">
|
||||||
|
<span className="badge badge-success badge-sm badge-outline border-success/30 bg-success/5 font-medium">可用 {availableNodes}</span>
|
||||||
|
{unavailableNodes > 0 && <span className="badge badge-error badge-sm badge-outline border-error/30 bg-error/5 font-medium">不可用 {unavailableNodes}</span>}
|
||||||
|
{blacklistedNodes > 0 && <span className="badge badge-error badge-sm badge-outline border-error/30 bg-error/5 font-medium">黑名单 {blacklistedNodes}</span>}
|
||||||
|
{pendingNodes > 0 && <span className="badge badge-warning badge-sm badge-outline border-warning/30 bg-warning/5 font-medium">待检查 {pendingNodes}</span>}
|
||||||
|
{disabledNodes > 0 && <span className="badge badge-ghost badge-sm bg-base-200/50 font-medium text-base-content/50">禁用 {disabledNodes}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Health Rate */}
|
||||||
|
<div className="rounded-2xl bg-base-100 border border-base-300/50 p-5 shadow-sm hover:shadow-md transition-all group overflow-hidden relative">
|
||||||
|
<div className="absolute -right-4 -top-4 w-24 h-24 bg-success/5 rounded-full blur-2xl group-hover:bg-success/10 transition-colors"></div>
|
||||||
|
<div className="flex items-center gap-3 mb-2 relative z-10">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-success/10 flex items-center justify-center text-success">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-base-content/60">健康率</div>
|
||||||
|
</div>
|
||||||
|
<div className={`text-3xl font-black tabular-nums tracking-tight mb-2 relative z-10 ${
|
||||||
|
healthRate < 0 ? 'text-base-content/30' :
|
||||||
|
healthRate >= 80 ? 'text-success' :
|
||||||
|
healthRate >= 50 ? 'text-warning' : 'text-error'
|
||||||
|
}`}>
|
||||||
|
{healthRate >= 0 ? `${healthRate}%` : '-'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-medium text-base-content/40 relative z-10 bg-base-200/50 w-fit px-2 py-1 rounded-md">
|
||||||
|
{healthRate >= 0 ? `${availableNodes} / ${availableNodes + unavailableNodes} 已检查` : '等待检查'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Average Latency */}
|
||||||
|
<div className="rounded-2xl bg-base-100 border border-base-300/50 p-5 shadow-sm hover:shadow-md transition-all group overflow-hidden relative">
|
||||||
|
<div className="absolute -right-4 -top-4 w-24 h-24 bg-warning/5 rounded-full blur-2xl group-hover:bg-warning/10 transition-colors"></div>
|
||||||
|
<div className="flex items-center gap-3 mb-2 relative z-10">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-warning/10 flex items-center justify-center text-warning">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-base-content/60">平均延迟</div>
|
||||||
|
</div>
|
||||||
|
<div className={`text-3xl font-black tabular-nums tracking-tight mb-2 relative z-10 ${latencyColor(avgLatency)}`}>
|
||||||
|
{avgLatency > 0 ? `${avgLatency}ms` : '-'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-medium text-base-content/40 relative z-10 bg-base-200/50 w-fit px-2 py-1 rounded-md">
|
||||||
|
{allNodes.filter(n => n.last_latency_ms > 0).length} 个节点有数据
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Connections */}
|
||||||
|
<div className="rounded-2xl bg-base-100 border border-base-300/50 p-5 shadow-sm hover:shadow-md transition-all group overflow-hidden relative">
|
||||||
|
<div className="absolute -right-4 -top-4 w-24 h-24 bg-info/5 rounded-full blur-2xl group-hover:bg-info/10 transition-colors"></div>
|
||||||
|
<div className="flex items-center gap-3 mb-2 relative z-10">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-info/10 flex items-center justify-center text-info">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-base-content/60">活跃连接</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-black tabular-nums tracking-tight text-info mb-2 relative z-10">{totalConnections}</div>
|
||||||
|
<div className="text-xs font-medium text-base-content/40 relative z-10 bg-base-200/50 w-fit px-2 py-1 rounded-md">
|
||||||
|
{allNodes.filter(n => n.active_connections > 0).length} 个节点活跃
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload Speed */}
|
||||||
|
<div className="rounded-2xl bg-base-100 border border-base-300/50 p-5 shadow-sm hover:shadow-md transition-all group overflow-hidden relative">
|
||||||
|
<div className="absolute -right-4 -top-4 w-24 h-24 bg-blue-500/5 rounded-full blur-2xl group-hover:bg-blue-500/10 transition-colors"></div>
|
||||||
|
<div className="flex items-center gap-3 mb-2 relative z-10">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center text-blue-500">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 11l5-5m0 0l5 5m-5-5v12" /></svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-base-content/60">上传速度</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-black tabular-nums tracking-tight text-blue-500 mb-2 relative z-10">{formatSpeed(uploadSpeed)}</div>
|
||||||
|
<div className="text-xs font-medium text-base-content/40 relative z-10 bg-base-200/50 w-fit px-2 py-1 rounded-md">
|
||||||
|
{trafficRealtime.connected ? '实时流数据' : '轮询数据'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Speed */}
|
||||||
|
<div className="rounded-2xl bg-base-100 border border-base-300/50 p-5 shadow-sm hover:shadow-md transition-all group overflow-hidden relative">
|
||||||
|
<div className="absolute -right-4 -top-4 w-24 h-24 bg-emerald-500/5 rounded-full blur-2xl group-hover:bg-emerald-500/10 transition-colors"></div>
|
||||||
|
<div className="flex items-center gap-3 mb-2 relative z-10">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-emerald-500/10 flex items-center justify-center text-emerald-500">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 13l-5 5m0 0l-5-5m5 5V6" /></svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-base-content/60">下载速度</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-black tabular-nums tracking-tight text-emerald-500 mb-2 relative z-10">{formatSpeed(downloadSpeed)}</div>
|
||||||
|
<div className="text-xs font-medium text-base-content/40 relative z-10 bg-base-200/50 w-fit px-2 py-1 rounded-md">
|
||||||
|
{trafficRealtime.connected ? '实时流数据' : '轮询数据'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload Traffic */}
|
||||||
|
<div className="rounded-2xl bg-base-100 border border-base-300/50 p-5 shadow-sm hover:shadow-md transition-all group overflow-hidden relative">
|
||||||
|
<div className="absolute -right-4 -top-4 w-24 h-24 bg-blue-500/5 rounded-full blur-2xl group-hover:bg-blue-500/10 transition-colors"></div>
|
||||||
|
<div className="flex items-center gap-3 mb-2 relative z-10">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center text-blue-500">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" /></svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-base-content/60">总上传</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-black tabular-nums tracking-tight text-blue-500 mb-2 relative z-10">{formatBytes(totalUpload)}</div>
|
||||||
|
<div className="text-xs font-medium text-base-content/40 relative z-10 bg-base-200/50 w-fit px-2 py-1 rounded-md">累计流量统计</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Traffic */}
|
||||||
|
<div className="rounded-2xl bg-base-100 border border-base-300/50 p-5 shadow-sm hover:shadow-md transition-all group overflow-hidden relative">
|
||||||
|
<div className="absolute -right-4 -top-4 w-24 h-24 bg-emerald-500/5 rounded-full blur-2xl group-hover:bg-emerald-500/10 transition-colors"></div>
|
||||||
|
<div className="flex items-center gap-3 mb-2 relative z-10">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-emerald-500/10 flex items-center justify-center text-emerald-500">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" /></svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-base-content/60">总下载</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-black tabular-nums tracking-tight text-emerald-500 mb-2 relative z-10">{formatBytes(totalDownload)}</div>
|
||||||
|
<div className="text-xs font-medium text-base-content/40 relative z-10 bg-base-200/50 w-fit px-2 py-1 rounded-md">累计流量统计</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Row: Donut + Bar */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Status Distribution Donut */}
|
||||||
|
<div className="rounded-xl border border-base-300/40 bg-base-200/30 p-5">
|
||||||
|
<h3 className="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<span className="w-1 h-4 bg-primary rounded-full"></span>
|
||||||
|
状态分布
|
||||||
|
</h3>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<DonutChart
|
||||||
|
segments={statusSegments}
|
||||||
|
size={180}
|
||||||
|
strokeWidth={32}
|
||||||
|
centerValue={healthRate >= 0 ? `${healthRate}%` : '-'}
|
||||||
|
centerLabel="健康率"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Latency Distribution Bar */}
|
||||||
|
<div className="rounded-xl border border-base-300/40 bg-base-200/30 p-5">
|
||||||
|
<BarChart
|
||||||
|
bars={latencyBars}
|
||||||
|
maxHeight={130}
|
||||||
|
title="延迟分布"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Region Cards */}
|
||||||
|
<div className="rounded-xl border border-base-300/40 bg-base-200/30 p-5">
|
||||||
|
<RegionCards
|
||||||
|
regions={regionStats}
|
||||||
|
title="地区统计"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rankings Row: Latency + Traffic */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Latency Ranking */}
|
||||||
|
<div className="rounded-xl border border-base-300/40 bg-base-200/30 p-5">
|
||||||
|
<LatencyRanking
|
||||||
|
items={rankingItems}
|
||||||
|
maxItems={10}
|
||||||
|
title="延迟排行 TOP 10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Traffic Ranking */}
|
||||||
|
<div className="rounded-xl border border-base-300/40 bg-base-200/30 p-5">
|
||||||
|
<TrafficRanking
|
||||||
|
items={trafficRankItems}
|
||||||
|
maxItems={10}
|
||||||
|
title="流量排行 TOP 10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer info */}
|
||||||
|
<div className="text-center text-xs text-base-content/30">
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
共 {totalConfigNodes} 个节点 ·
|
||||||
|
{Object.keys(data.region_stats || {}).length} 个地区 ·
|
||||||
|
数据来自运行时监控
|
||||||
|
{autoRefresh > 0 && ` · 每 ${autoRefresh} 秒自动刷新`}
|
||||||
|
{trafficRealtime.connected ? ' · 速度流已连接' : ' · 速度流重连中'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,865 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import type { SettingsData, SubscriptionStatus } from '../types'
|
||||||
|
import { fetchSettings, updateSettings, triggerReload, fetchSubscriptionStatus, refreshSubscription } from '../api/client'
|
||||||
|
|
||||||
|
const defaultSettings: SettingsData = {
|
||||||
|
mode: 'pool',
|
||||||
|
log_level: 'info',
|
||||||
|
external_ip: '',
|
||||||
|
skip_cert_verify: false,
|
||||||
|
|
||||||
|
listener_address: '0.0.0.0',
|
||||||
|
listener_port: 2323,
|
||||||
|
listener_username: '',
|
||||||
|
listener_password: '',
|
||||||
|
|
||||||
|
multi_port_address: '0.0.0.0',
|
||||||
|
multi_port_base_port: 24000,
|
||||||
|
multi_port_username: '',
|
||||||
|
multi_port_password: '',
|
||||||
|
|
||||||
|
pool_mode: 'sequential',
|
||||||
|
pool_failure_threshold: 3,
|
||||||
|
pool_blacklist_duration: '24h0m0s',
|
||||||
|
|
||||||
|
management_enabled: true,
|
||||||
|
management_listen: '0.0.0.0:9090',
|
||||||
|
management_probe_target: '',
|
||||||
|
management_password: '',
|
||||||
|
|
||||||
|
sub_refresh_enabled: false,
|
||||||
|
sub_refresh_interval: '1h0m0s',
|
||||||
|
sub_refresh_timeout: '30s',
|
||||||
|
sub_refresh_health_check_timeout: '1m0s',
|
||||||
|
sub_refresh_drain_timeout: '30s',
|
||||||
|
sub_refresh_min_available_nodes: 1,
|
||||||
|
|
||||||
|
geoip_enabled: false,
|
||||||
|
geoip_database_path: './GeoLite2-Country.mmdb',
|
||||||
|
geoip_auto_update_enabled: true,
|
||||||
|
geoip_auto_update_interval: '24h0m0s',
|
||||||
|
|
||||||
|
subscriptions: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsPanel() {
|
||||||
|
const [settings, setSettings] = useState<SettingsData>(defaultSettings)
|
||||||
|
const [savedSettings, setSavedSettings] = useState<SettingsData>(defaultSettings)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [reloading, setReloading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState('')
|
||||||
|
const [needReload, setNeedReload] = useState(false)
|
||||||
|
const [isDirty, setIsDirty] = useState(false)
|
||||||
|
|
||||||
|
// Subscription status
|
||||||
|
const [subStatus, setSubStatus] = useState<SubscriptionStatus | null>(null)
|
||||||
|
const [subRefreshing, setSubRefreshing] = useState(false)
|
||||||
|
|
||||||
|
// New subscription input
|
||||||
|
const [newSubUrl, setNewSubUrl] = useState('')
|
||||||
|
|
||||||
|
const refreshSubStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const subData = await fetchSubscriptionStatus()
|
||||||
|
if (subData) setSubStatus(subData)
|
||||||
|
} catch {
|
||||||
|
// ignore errors
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const [settingsData] = await Promise.all([
|
||||||
|
fetchSettings(),
|
||||||
|
refreshSubStatus(),
|
||||||
|
])
|
||||||
|
const merged = { ...defaultSettings, ...settingsData }
|
||||||
|
setSettings(merged)
|
||||||
|
setSavedSettings(merged)
|
||||||
|
setIsDirty(false)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '加载设置失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [refreshSubStatus])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (success) {
|
||||||
|
const timer = setTimeout(() => setSuccess(''), 5000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [success])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
setSuccess('')
|
||||||
|
try {
|
||||||
|
const res = await updateSettings(settings)
|
||||||
|
setSuccess(res.message || '设置已保存')
|
||||||
|
setSavedSettings({ ...settings })
|
||||||
|
setIsDirty(false)
|
||||||
|
if (res.need_reload) setNeedReload(true)
|
||||||
|
// Refresh subscription status after saving (config may have changed)
|
||||||
|
await refreshSubStatus()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '保存失败')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReload = async () => {
|
||||||
|
setReloading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await triggerReload()
|
||||||
|
setSuccess(res.message || '重载成功')
|
||||||
|
setNeedReload(false)
|
||||||
|
// Refresh subscription status after reload (subscription manager config updated)
|
||||||
|
await refreshSubStatus()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '重载失败')
|
||||||
|
} finally {
|
||||||
|
setReloading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubRefresh = async () => {
|
||||||
|
setSubRefreshing(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await refreshSubscription()
|
||||||
|
setSuccess(`订阅刷新成功,共 ${res.node_count} 个节点`)
|
||||||
|
await refreshSubStatus()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '刷新订阅失败')
|
||||||
|
} finally {
|
||||||
|
setSubRefreshing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addSubscription = () => {
|
||||||
|
const url = newSubUrl.trim()
|
||||||
|
if (!url) return
|
||||||
|
if (settings.subscriptions.includes(url)) {
|
||||||
|
setError('该订阅地址已存在')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSettings(s => {
|
||||||
|
const updated = { ...s, subscriptions: [...s.subscriptions, url] }
|
||||||
|
setIsDirty(JSON.stringify(updated) !== JSON.stringify(savedSettings))
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
setNewSubUrl('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeSubscription = (index: number) => {
|
||||||
|
setSettings(s => {
|
||||||
|
const updated = { ...s, subscriptions: s.subscriptions.filter((_, i) => i !== index) }
|
||||||
|
setIsDirty(JSON.stringify(updated) !== JSON.stringify(savedSettings))
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateField = <K extends keyof SettingsData>(key: K, value: SettingsData[K]) => {
|
||||||
|
setSettings(s => {
|
||||||
|
const updated = { ...s, [key]: value }
|
||||||
|
setIsDirty(JSON.stringify(updated) !== JSON.stringify(savedSettings))
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn before leaving with unsaved changes
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
|
if (isDirty) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.returnValue = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
}, [isDirty])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<span className="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-full animate-in fade-in duration-500">
|
||||||
|
{/* Header - sticky */}
|
||||||
|
<div className="sticky top-0 z-30 bg-base-100/80 backdrop-blur-xl px-4 lg:px-8 py-4 border-b border-base-300/60 shadow-sm">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 max-w-[1200px] mx-auto w-full">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary shrink-0 border border-primary/20">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
系统设置
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-base-content/50 mt-1.5 ml-[3.25rem]">管理系统所有配置项,修改后需保存生效</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className={`btn btn-sm lg:btn-md gap-2 shadow-sm ${isDirty ? 'btn-primary' : 'btn-ghost border border-base-300'}`}
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !isDirty}
|
||||||
|
>
|
||||||
|
{saving ? <span className="loading loading-spinner loading-sm"></span> : isDirty ? (
|
||||||
|
<>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
|
||||||
|
</svg>
|
||||||
|
保存设置
|
||||||
|
</>
|
||||||
|
) : '✅ 已保存'}
|
||||||
|
</button>
|
||||||
|
{needReload && (
|
||||||
|
<button
|
||||||
|
className="btn btn-warning btn-sm lg:btn-md gap-2 shadow-sm animate-pulse"
|
||||||
|
onClick={handleReload}
|
||||||
|
disabled={reloading}
|
||||||
|
>
|
||||||
|
{reloading ? <span className="loading loading-spinner loading-sm"></span> : (
|
||||||
|
<>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
重载配置
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 lg:p-8 space-y-6 max-w-[1200px] mx-auto w-full pb-10 flex-1">
|
||||||
|
{/* Alerts */}
|
||||||
|
{error && (
|
||||||
|
<div role="alert" className="alert alert-error alert-soft text-sm">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div role="alert" className="alert alert-success alert-soft text-sm">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>{success}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{needReload && (
|
||||||
|
<div role="alert" className="alert alert-warning alert-soft text-sm">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<span>配置已保存。</span>
|
||||||
|
<span className="font-medium">WebUI 密码、探测目标、外部 IP、SSL 验证</span>
|
||||||
|
<span>已立即生效;其他配置(运行模式、监听端口、代理池等)需要点击「重载配置」才能生效。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Settings Cards Grid */}
|
||||||
|
<div className="grid gap-5 lg:grid-cols-2">
|
||||||
|
|
||||||
|
{/* ===== 全局设置 ===== */}
|
||||||
|
<div className="rounded-2xl border border-base-300/50 bg-base-100 p-6 lg:p-8 space-y-5 shadow-sm transition-shadow hover:shadow-md">
|
||||||
|
<div className="flex items-center gap-3 mb-2 border-b border-base-200 pb-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-info/10 flex items-center justify-center text-info shrink-0">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-lg text-base-content">全局设置</h3>
|
||||||
|
<p className="text-xs text-base-content/50 font-medium">系统基础运行参数</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/80">运行模式</legend>
|
||||||
|
<select
|
||||||
|
className="select select-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
value={settings.mode}
|
||||||
|
onChange={(e) => updateField('mode', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="pool">pool - 单端口代理池</option>
|
||||||
|
<option value="multi-port">multi-port - 多端口模式</option>
|
||||||
|
<option value="hybrid">hybrid - 混合模式</option>
|
||||||
|
</select>
|
||||||
|
<p className="label text-base-content/50 mt-1">pool: 共享端口 | multi-port: 独立端口 | hybrid: 两者并存</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/80">日志级别</legend>
|
||||||
|
<select
|
||||||
|
className="select select-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
value={settings.log_level}
|
||||||
|
onChange={(e) => updateField('log_level', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="debug">debug</option>
|
||||||
|
<option value="info">info</option>
|
||||||
|
<option value="warn">warn</option>
|
||||||
|
<option value="error">error</option>
|
||||||
|
</select>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/80">外部 IP 地址</legend>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="例如: 1.2.3.4"
|
||||||
|
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
value={settings.external_ip}
|
||||||
|
onChange={(e) => updateField('external_ip', e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="label text-base-content/50 mt-1">用于导出时替换 0.0.0.0</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between cursor-pointer gap-4 bg-base-200/30 p-4 rounded-xl border border-base-200 hover:border-base-300 transition-colors">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-base-content/90 block mb-0.5">跳过 SSL 证书验证</span>
|
||||||
|
<p className="text-xs text-base-content/50 m-0">全局跳过上游代理的 SSL 证书验证</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="toggle toggle-primary toggle-md"
|
||||||
|
checked={settings.skip_cert_verify}
|
||||||
|
onChange={(e) => updateField('skip_cert_verify', e.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 监听配置 (Pool 入口) ===== */}
|
||||||
|
<div className="rounded-2xl border border-base-300/50 bg-base-100 p-6 lg:p-8 space-y-5 shadow-sm transition-shadow hover:shadow-md">
|
||||||
|
<div className="flex items-center gap-3 mb-2 border-b border-base-200 pb-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-success/10 flex items-center justify-center text-success shrink-0">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-lg text-base-content">监听配置 (Pool)</h3>
|
||||||
|
<p className="text-xs text-base-content/50 font-medium">代理池入口网络参数</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/80">监听地址</legend>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
value={settings.listener_address}
|
||||||
|
onChange={(e) => updateField('listener_address', e.target.value)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/80">监听端口</legend>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
value={settings.listener_port}
|
||||||
|
onChange={(e) => updateField('listener_port', parseInt(e.target.value) || 0)}
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/80">代理用户名</legend>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
placeholder="可选,留空表示无验证"
|
||||||
|
value={settings.listener_username}
|
||||||
|
onChange={(e) => updateField('listener_username', e.target.value)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/80">代理密码</legend>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
placeholder="可选,留空表示无验证"
|
||||||
|
value={settings.listener_password}
|
||||||
|
onChange={(e) => updateField('listener_password', e.target.value)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 多端口配置 ===== */}
|
||||||
|
<div className="rounded-2xl border border-base-300/50 bg-base-100 p-6 lg:p-8 space-y-5 shadow-sm transition-shadow hover:shadow-md">
|
||||||
|
<div className="flex items-center gap-3 mb-2 border-b border-base-200 pb-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-secondary/10 flex items-center justify-center text-secondary shrink-0">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-lg text-base-content">多端口配置</h3>
|
||||||
|
<p className="text-xs text-base-content/50 font-medium">用于 multi-port 或 hybrid 模式</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/80">监听地址</legend>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
value={settings.multi_port_address}
|
||||||
|
onChange={(e) => updateField('multi_port_address', e.target.value)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/80">起始端口</legend>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
value={settings.multi_port_base_port}
|
||||||
|
onChange={(e) => updateField('multi_port_base_port', parseInt(e.target.value) || 0)}
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/80">默认用户名</legend>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
placeholder="可选"
|
||||||
|
value={settings.multi_port_username}
|
||||||
|
onChange={(e) => updateField('multi_port_username', e.target.value)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/80">默认密码</legend>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
placeholder="可选"
|
||||||
|
value={settings.multi_port_password}
|
||||||
|
onChange={(e) => updateField('multi_port_password', e.target.value)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 代理池配置 ===== */}
|
||||||
|
<div className="rounded-2xl border border-base-300/50 bg-base-100 p-6 lg:p-8 space-y-5 shadow-sm transition-shadow hover:shadow-md">
|
||||||
|
<div className="flex items-center gap-3 mb-2 border-b border-base-200 pb-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-accent/10 flex items-center justify-center text-accent shrink-0">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 002-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-lg text-base-content">代理池调度</h3>
|
||||||
|
<p className="text-xs text-base-content/50 font-medium">节点选择与高可用策略</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/80">调度模式</legend>
|
||||||
|
<select
|
||||||
|
className="select select-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
value={settings.pool_mode}
|
||||||
|
onChange={(e) => updateField('pool_mode', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="sequential">sequential - 顺序轮询</option>
|
||||||
|
<option value="random">random - 随机选择</option>
|
||||||
|
<option value="balance">balance - 最小连接数负载均衡</option>
|
||||||
|
</select>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/80">失败阈值</legend>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
value={settings.pool_failure_threshold}
|
||||||
|
onChange={(e) => updateField('pool_failure_threshold', parseInt(e.target.value) || 1)}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
<p className="label text-base-content/50 mt-1">连续失败多少次后加入黑名单</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/80">黑名单持续时间</legend>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
placeholder="例如: 24h, 1h30m"
|
||||||
|
value={settings.pool_blacklist_duration}
|
||||||
|
onChange={(e) => updateField('pool_blacklist_duration', e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="label text-base-content/50 mt-1">Go duration 格式: 24h, 1h30m, 30m 等</p>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 管理面板 ===== */}
|
||||||
|
<div className="rounded-2xl border border-base-300/50 bg-base-100 p-6 lg:p-8 space-y-5 shadow-sm transition-shadow hover:shadow-md">
|
||||||
|
<div className="flex items-center gap-3 mb-2 border-b border-base-200 pb-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-lg text-base-content">管理面板</h3>
|
||||||
|
<p className="text-xs text-base-content/50 font-medium">Web 界面及探针设置</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between cursor-pointer gap-4 bg-base-200/30 p-4 rounded-xl border border-base-200 hover:border-base-300 transition-colors">
|
||||||
|
<span className="font-semibold text-base-content/90">启用管理面板</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="toggle toggle-primary toggle-md"
|
||||||
|
checked={settings.management_enabled}
|
||||||
|
onChange={(e) => updateField('management_enabled', e.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/80">监听地址</legend>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
placeholder="0.0.0.0:9090"
|
||||||
|
value={settings.management_listen}
|
||||||
|
onChange={(e) => updateField('management_listen', e.target.value)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/80">探测目标</legend>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
placeholder="http://www.google.com"
|
||||||
|
value={settings.management_probe_target}
|
||||||
|
onChange={(e) => updateField('management_probe_target', e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="label text-base-content/50 mt-1">健康检查的目标地址</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/80">WebUI 密码</legend>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
placeholder="为空则无需密码"
|
||||||
|
value={settings.management_password}
|
||||||
|
onChange={(e) => updateField('management_password', e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="label text-base-content/50 mt-1">为空则不需要登录密码</p>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== GeoIP ===== */}
|
||||||
|
<div className="rounded-2xl border border-base-300/50 bg-base-100 p-6 lg:p-8 space-y-5 shadow-sm transition-shadow hover:shadow-md">
|
||||||
|
<div className="flex items-center gap-3 mb-2 border-b border-base-200 pb-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-info/10 flex items-center justify-center text-info shrink-0">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-lg text-base-content">GeoIP 地域分区</h3>
|
||||||
|
<p className="text-xs text-base-content/50 font-medium">节点地域解析与自动更新</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between cursor-pointer gap-4 bg-base-200/30 p-4 rounded-xl border border-base-200 hover:border-base-300 transition-colors">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-base-content/90 block mb-0.5">启用 GeoIP</span>
|
||||||
|
<p className="text-xs text-base-content/50 m-0">按地域自动分组节点</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="toggle toggle-primary toggle-md"
|
||||||
|
checked={settings.geoip_enabled}
|
||||||
|
onChange={(e) => updateField('geoip_enabled', e.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{settings.geoip_enabled && (
|
||||||
|
<div className="space-y-4 pt-2 animate-in fade-in slide-in-from-top-2">
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/80">数据库路径</legend>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
value={settings.geoip_database_path}
|
||||||
|
onChange={(e) => updateField('geoip_database_path', e.target.value)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between cursor-pointer gap-4 bg-base-200/30 p-4 rounded-xl border border-base-200 hover:border-base-300 transition-colors">
|
||||||
|
<span className="font-semibold text-base-content/90">自动更新数据库</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="toggle toggle-primary toggle-md"
|
||||||
|
checked={settings.geoip_auto_update_enabled}
|
||||||
|
onChange={(e) => updateField('geoip_auto_update_enabled', e.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{settings.geoip_auto_update_enabled && (
|
||||||
|
<fieldset className="fieldset animate-in fade-in slide-in-from-top-2">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/80">更新间隔</legend>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
placeholder="24h"
|
||||||
|
value={settings.geoip_auto_update_interval}
|
||||||
|
onChange={(e) => updateField('geoip_auto_update_interval', e.target.value)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 订阅刷新 ===== */}
|
||||||
|
<div className="rounded-2xl border border-base-300/50 bg-base-100 p-6 lg:p-8 space-y-5 shadow-sm transition-shadow hover:shadow-md">
|
||||||
|
<div className="flex items-center gap-3 mb-2 border-b border-base-200 pb-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-warning/10 flex items-center justify-center text-warning shrink-0">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-lg text-base-content">订阅自动刷新</h3>
|
||||||
|
<p className="text-xs text-base-content/50 font-medium">配置订阅定时更新及健康检查</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between cursor-pointer gap-4 bg-base-200/30 p-4 rounded-xl border border-base-200 hover:border-base-300 transition-colors">
|
||||||
|
<span className="font-semibold text-base-content/90">启用定时刷新</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="toggle toggle-primary toggle-md"
|
||||||
|
checked={settings.sub_refresh_enabled}
|
||||||
|
onChange={(e) => updateField('sub_refresh_enabled', e.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{settings.sub_refresh_enabled && (
|
||||||
|
<div className="space-y-4 pt-2 animate-in fade-in slide-in-from-top-2">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/80">刷新间隔</legend>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
placeholder="1h"
|
||||||
|
value={settings.sub_refresh_interval}
|
||||||
|
onChange={(e) => updateField('sub_refresh_interval', e.target.value)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/80">获取超时</legend>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
placeholder="30s"
|
||||||
|
value={settings.sub_refresh_timeout}
|
||||||
|
onChange={(e) => updateField('sub_refresh_timeout', e.target.value)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/80">健康检查超时</legend>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
placeholder="1m"
|
||||||
|
value={settings.sub_refresh_health_check_timeout}
|
||||||
|
onChange={(e) => updateField('sub_refresh_health_check_timeout', e.target.value)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/80">排空超时</legend>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
placeholder="30s"
|
||||||
|
value={settings.sub_refresh_drain_timeout}
|
||||||
|
onChange={(e) => updateField('sub_refresh_drain_timeout', e.target.value)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend className="fieldset-legend font-semibold text-base-content/80">最少可用节点数</legend>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
value={settings.sub_refresh_min_available_nodes}
|
||||||
|
onChange={(e) => updateField('sub_refresh_min_available_nodes', parseInt(e.target.value) || 1)}
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
<p className="label text-base-content/50 mt-1">低于此值时不切换新节点</p>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 订阅管理 (full width) ===== */}
|
||||||
|
<div className="rounded-2xl border border-base-300/50 bg-base-100 p-6 lg:p-8 space-y-5 shadow-sm transition-shadow hover:shadow-md lg:col-span-2">
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 border-b border-base-200 pb-4 mb-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-error/10 flex items-center justify-center text-error shrink-0">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-lg text-base-content">订阅链接管理</h3>
|
||||||
|
<p className="text-xs text-base-content/50 font-medium">配置节点获取来源</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show refresh button when subscriptions exist (saved or in current settings) */}
|
||||||
|
{(subStatus?.has_subscriptions || settings.subscriptions.length > 0) && (
|
||||||
|
<div className="flex items-center gap-3 bg-base-200/50 px-3 py-1.5 rounded-lg border border-base-300/50">
|
||||||
|
{subStatus && subStatus.node_count != null && subStatus.node_count > 0 && (
|
||||||
|
<span className="text-sm font-medium text-base-content/70">
|
||||||
|
节点: <strong className="text-base-content">{subStatus.node_count}</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{subStatus?.enabled && (
|
||||||
|
<span className="badge badge-success badge-sm border-none bg-success/20 text-success font-semibold">自动刷新</span>
|
||||||
|
)}
|
||||||
|
<div className="w-px h-4 bg-base-300 mx-1"></div>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-ghost hover:bg-primary/10 hover:text-primary gap-1.5 px-2"
|
||||||
|
onClick={handleSubRefresh}
|
||||||
|
disabled={subRefreshing || subStatus?.is_refreshing || isDirty}
|
||||||
|
title={isDirty ? '请先保存设置并重载配置' : '立即刷新订阅'}
|
||||||
|
>
|
||||||
|
{subRefreshing || subStatus?.is_refreshing ? (
|
||||||
|
<span className="loading loading-spinner loading-xs"></span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
立即刷新
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subStatus?.last_error && (
|
||||||
|
<div role="alert" className="alert alert-error alert-soft text-sm py-3 animate-in fade-in">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>{subStatus.last_error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add subscription */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-base-content/40">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-md w-full pl-10 font-mono text-sm bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||||
|
placeholder="https://example.com/subscribe?token=xxx"
|
||||||
|
value={newSubUrl}
|
||||||
|
onChange={(e) => setNewSubUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && addSubscription()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-md btn-primary shadow-sm"
|
||||||
|
onClick={addSubscription}
|
||||||
|
disabled={!newSubUrl.trim()}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
添加
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subscription list */}
|
||||||
|
{settings.subscriptions.length > 0 ? (
|
||||||
|
<div className="space-y-3 mt-4">
|
||||||
|
{settings.subscriptions.map((url, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-3 p-3 lg:p-4 rounded-xl border border-base-200 bg-base-200/30 hover:bg-base-200/60 transition-colors group">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<code className="text-sm font-mono text-base-content/80 break-all">{url}</code>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-square btn-ghost text-base-content/40 hover:text-error hover:bg-error/10 shrink-0 opacity-0 group-hover:opacity-100 transition-all"
|
||||||
|
onClick={() => removeSubscription(index)}
|
||||||
|
title="删除订阅"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-10 px-4 text-center rounded-xl border border-dashed border-base-300 bg-base-200/20">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-base-200 flex items-center justify-center text-base-content/30 mb-3">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-base font-medium text-base-content/60">暂无订阅链接</p>
|
||||||
|
<p className="text-sm text-base-content/40 mt-1">在上方输入框添加您的节点订阅地址</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-base-content/40 text-center mt-4">⚠️ 添加或删除订阅后,需点击顶部「保存设置」并「重载配置」才能生效</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
interface BarData {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BarChartProps {
|
||||||
|
bars: BarData[]
|
||||||
|
maxHeight?: number
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BarChart({ bars, maxHeight = 120, title }: BarChartProps) {
|
||||||
|
const maxValue = Math.max(...bars.map(b => b.value), 1)
|
||||||
|
|
||||||
|
if (bars.every(b => b.value === 0)) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8">
|
||||||
|
<div className="text-base-content/30 text-sm">暂无数据</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{title && (
|
||||||
|
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<span className="w-1 h-4 bg-primary rounded-full"></span>
|
||||||
|
{title}
|
||||||
|
</h4>
|
||||||
|
)}
|
||||||
|
<div className="flex items-end gap-2 justify-center" style={{ height: maxHeight + 32 }}>
|
||||||
|
{bars.map((bar, i) => {
|
||||||
|
const height = (bar.value / maxValue) * maxHeight
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex flex-col items-center gap-1 flex-1 min-w-0">
|
||||||
|
{/* Value label */}
|
||||||
|
<span className="text-xs font-mono font-medium text-base-content/60 tabular-nums">
|
||||||
|
{bar.value > 0 ? bar.value : ''}
|
||||||
|
</span>
|
||||||
|
{/* Bar */}
|
||||||
|
<div
|
||||||
|
className="w-full max-w-12 rounded-t-md transition-all duration-500 min-h-[2px]"
|
||||||
|
style={{
|
||||||
|
height: Math.max(height, bar.value > 0 ? 4 : 2),
|
||||||
|
backgroundColor: bar.value > 0 ? bar.color : 'oklch(var(--bc) / 0.1)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Label */}
|
||||||
|
<span className="text-[10px] text-base-content/50 text-center leading-tight whitespace-nowrap">
|
||||||
|
{bar.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
interface DonutSegment {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DonutChartProps {
|
||||||
|
segments: DonutSegment[]
|
||||||
|
size?: number
|
||||||
|
strokeWidth?: number
|
||||||
|
centerLabel?: string
|
||||||
|
centerValue?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DonutChart({
|
||||||
|
segments,
|
||||||
|
size = 160,
|
||||||
|
strokeWidth = 28,
|
||||||
|
centerLabel,
|
||||||
|
centerValue,
|
||||||
|
}: DonutChartProps) {
|
||||||
|
const radius = (size - strokeWidth) / 2
|
||||||
|
const circumference = 2 * Math.PI * radius
|
||||||
|
const center = size / 2
|
||||||
|
const total = segments.reduce((sum, s) => sum + s.value, 0)
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center" style={{ width: size, height: size }}>
|
||||||
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||||
|
<circle
|
||||||
|
cx={center}
|
||||||
|
cy={center}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
className="text-base-300/50"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute text-center">
|
||||||
|
<div className="text-2xl font-bold text-base-content/30">-</div>
|
||||||
|
<div className="text-xs text-base-content/30">无数据</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredSegments = segments.filter(s => s.value > 0)
|
||||||
|
const arcs = filteredSegments.reduce<Array<DonutSegment & {
|
||||||
|
dashLength: number
|
||||||
|
gapLength: number
|
||||||
|
dashOffset: number
|
||||||
|
percentage: number
|
||||||
|
}>>((acc, segment) => {
|
||||||
|
const accumulated = acc.reduce((sum, a) => sum + a.value / total, 0)
|
||||||
|
const ratio = segment.value / total
|
||||||
|
const dashLength = ratio * circumference
|
||||||
|
const dashOffset = -accumulated * circumference + circumference * 0.25
|
||||||
|
acc.push({
|
||||||
|
...segment,
|
||||||
|
dashLength,
|
||||||
|
gapLength: circumference - dashLength,
|
||||||
|
dashOffset,
|
||||||
|
percentage: Math.round(ratio * 100),
|
||||||
|
})
|
||||||
|
return acc
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="relative" style={{ width: size, height: size }}>
|
||||||
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||||
|
{/* Background circle */}
|
||||||
|
<circle
|
||||||
|
cx={center}
|
||||||
|
cy={center}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
className="text-base-300/30"
|
||||||
|
/>
|
||||||
|
{/* Segments */}
|
||||||
|
{arcs.map((arc, i) => (
|
||||||
|
<circle
|
||||||
|
key={i}
|
||||||
|
cx={center}
|
||||||
|
cy={center}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke={arc.color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeDasharray={`${arc.dashLength} ${arc.gapLength}`}
|
||||||
|
strokeDashoffset={arc.dashOffset}
|
||||||
|
strokeLinecap="butt"
|
||||||
|
className="transition-all duration-500"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
{/* Center text */}
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
{centerValue && (
|
||||||
|
<div className="text-2xl font-bold">{centerValue}</div>
|
||||||
|
)}
|
||||||
|
{centerLabel && (
|
||||||
|
<div className="text-xs text-base-content/50">{centerLabel}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1">
|
||||||
|
{arcs.map((arc, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-1.5 text-xs">
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: arc.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-base-content/70">{arc.label}</span>
|
||||||
|
<span className="font-mono font-medium">{arc.value}</span>
|
||||||
|
<span className="text-base-content/40">({arc.percentage}%)</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
interface RankItem {
|
||||||
|
name: string
|
||||||
|
latency: number
|
||||||
|
region?: string
|
||||||
|
flag?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LatencyRankingProps {
|
||||||
|
items: RankItem[]
|
||||||
|
maxItems?: number
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function latencyColor(ms: number): string {
|
||||||
|
if (ms <= 100) return 'oklch(0.72 0.19 142)' // green
|
||||||
|
if (ms <= 200) return 'oklch(0.80 0.18 84)' // yellow
|
||||||
|
if (ms <= 300) return 'oklch(0.75 0.18 55)' // orange
|
||||||
|
return 'oklch(0.63 0.24 29)' // red
|
||||||
|
}
|
||||||
|
|
||||||
|
function regionFlag(region?: string): string {
|
||||||
|
const flags: Record<string, string> = {
|
||||||
|
hk: '🇭🇰', jp: '🇯🇵', kr: '🇰🇷', us: '🇺🇸', tw: '🇹🇼',
|
||||||
|
sg: '🇸🇬', de: '🇩🇪', gb: '🇬🇧', fr: '🇫🇷', ca: '🇨🇦',
|
||||||
|
au: '🇦🇺', in: '🇮🇳', br: '🇧🇷', ru: '🇷🇺', nl: '🇳🇱',
|
||||||
|
}
|
||||||
|
return flags[region?.toLowerCase() || ''] || '🌐'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LatencyRanking({ items, maxItems = 10, title }: LatencyRankingProps) {
|
||||||
|
const sorted = [...items]
|
||||||
|
.filter(i => i.latency > 0)
|
||||||
|
.sort((a, b) => a.latency - b.latency)
|
||||||
|
.slice(0, maxItems)
|
||||||
|
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8">
|
||||||
|
<div className="text-base-content/30 text-sm">暂无延迟数据</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxLatency = sorted[sorted.length - 1].latency
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{title && (
|
||||||
|
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<span className="w-1 h-4 bg-accent rounded-full"></span>
|
||||||
|
{title}
|
||||||
|
</h4>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sorted.map((item, i) => {
|
||||||
|
const widthPct = Math.max((item.latency / maxLatency) * 100, 8)
|
||||||
|
const flag = item.flag || regionFlag(item.region)
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
{/* Rank */}
|
||||||
|
<span className="w-5 text-xs font-mono text-base-content/40 text-right shrink-0">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
{/* Flag + Name */}
|
||||||
|
<span className="text-sm shrink-0 w-32 truncate">
|
||||||
|
{flag} {item.name}
|
||||||
|
</span>
|
||||||
|
{/* Bar */}
|
||||||
|
<div className="flex-1 h-5 bg-base-300/30 rounded-md overflow-hidden relative">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-md transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
width: `${widthPct}%`,
|
||||||
|
backgroundColor: latencyColor(item.latency),
|
||||||
|
opacity: 0.8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Latency value */}
|
||||||
|
<span className="text-xs font-mono font-medium w-14 text-right shrink-0 tabular-nums"
|
||||||
|
style={{ color: latencyColor(item.latency) }}
|
||||||
|
>
|
||||||
|
{item.latency}ms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
interface RegionStat {
|
||||||
|
region: string
|
||||||
|
total: number
|
||||||
|
healthy: number
|
||||||
|
avgLatency: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegionCardsProps {
|
||||||
|
regions: RegionStat[]
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function regionFlag(region?: string): string {
|
||||||
|
const flags: Record<string, string> = {
|
||||||
|
hk: '🇭🇰', jp: '🇯🇵', kr: '🇰🇷', us: '🇺🇸', tw: '🇹🇼',
|
||||||
|
sg: '🇸🇬', de: '🇩🇪', gb: '🇬🇧', fr: '🇫🇷', ca: '🇨🇦',
|
||||||
|
au: '🇦🇺', in: '🇮🇳', br: '🇧🇷', ru: '🇷🇺', nl: '🇳🇱',
|
||||||
|
}
|
||||||
|
return flags[region?.toLowerCase() || ''] || '🌐'
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthColor(rate: number): string {
|
||||||
|
if (rate >= 0.8) return 'progress-success'
|
||||||
|
if (rate >= 0.5) return 'progress-warning'
|
||||||
|
return 'progress-error'
|
||||||
|
}
|
||||||
|
|
||||||
|
function latencyColorClass(ms: number): string {
|
||||||
|
if (ms <= 0) return 'text-base-content/40'
|
||||||
|
if (ms <= 100) return 'text-success'
|
||||||
|
if (ms <= 300) return 'text-warning'
|
||||||
|
return 'text-error'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RegionCards({ regions, title }: RegionCardsProps) {
|
||||||
|
const sorted = [...regions].sort((a, b) => b.total - a.total)
|
||||||
|
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8">
|
||||||
|
<div className="text-base-content/30 text-sm">暂无地区数据</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{title && (
|
||||||
|
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<span className="w-1 h-4 bg-secondary rounded-full"></span>
|
||||||
|
{title}
|
||||||
|
</h4>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||||
|
{sorted.map((r) => {
|
||||||
|
const healthRate = r.total > 0 ? r.healthy / r.total : 0
|
||||||
|
const healthPct = Math.round(healthRate * 100)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={r.region}
|
||||||
|
className="rounded-xl bg-base-200/50 border border-base-300/30 p-3 hover:border-primary/30 transition-colors"
|
||||||
|
>
|
||||||
|
{/* Header: Flag + Region */}
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-lg">{regionFlag(r.region)}</span>
|
||||||
|
<span className="font-semibold text-sm uppercase">{r.region}</span>
|
||||||
|
<span className="text-xs text-base-content/40 ml-auto">{r.total} 节点</span>
|
||||||
|
</div>
|
||||||
|
{/* Health bar */}
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<progress
|
||||||
|
className={`progress ${healthColor(healthRate)} h-2 flex-1`}
|
||||||
|
value={healthPct}
|
||||||
|
max={100}
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-mono font-medium w-8 text-right tabular-nums">
|
||||||
|
{healthPct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Stats row */}
|
||||||
|
<div className="flex justify-between text-xs text-base-content/50">
|
||||||
|
<span>
|
||||||
|
健康 <strong className="text-success">{r.healthy}</strong>/{r.total}
|
||||||
|
</span>
|
||||||
|
<span className={latencyColorClass(r.avgLatency)}>
|
||||||
|
{r.avgLatency > 0 ? `${r.avgLatency}ms` : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { formatBytes } from '../../utils/format'
|
||||||
|
|
||||||
|
interface TrafficRankItem {
|
||||||
|
name: string
|
||||||
|
upload: number
|
||||||
|
download: number
|
||||||
|
region?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrafficRankingProps {
|
||||||
|
items: TrafficRankItem[]
|
||||||
|
maxItems?: number
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function regionFlag(region?: string): string {
|
||||||
|
const flags: Record<string, string> = {
|
||||||
|
hk: '🇭🇰', jp: '🇯🇵', kr: '🇰🇷', us: '🇺🇸', tw: '🇹🇼',
|
||||||
|
sg: '🇸🇬', de: '🇩🇪', gb: '🇬🇧', fr: '🇫🇷', ca: '🇨🇦',
|
||||||
|
au: '🇦🇺', in: '🇮🇳', br: '🇧🇷', ru: '🇷🇺', nl: '🇳🇱',
|
||||||
|
}
|
||||||
|
return flags[region?.toLowerCase() || ''] || '🌐'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TrafficRanking({ items, maxItems = 10, title }: TrafficRankingProps) {
|
||||||
|
const sorted = [...items]
|
||||||
|
.filter(i => (i.upload + i.download) > 0)
|
||||||
|
.sort((a, b) => (b.upload + b.download) - (a.upload + a.download))
|
||||||
|
.slice(0, maxItems)
|
||||||
|
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8">
|
||||||
|
<div className="text-base-content/30 text-sm">暂无流量数据</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxTraffic = sorted[0].upload + sorted[0].download
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{title && (
|
||||||
|
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<span className="w-1 h-4 bg-secondary rounded-full"></span>
|
||||||
|
{title}
|
||||||
|
</h4>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sorted.map((item, i) => {
|
||||||
|
const total = item.upload + item.download
|
||||||
|
const widthPct = Math.max((total / maxTraffic) * 100, 8)
|
||||||
|
const uploadPct = total > 0 ? (item.upload / total) * 100 : 0
|
||||||
|
const flag = regionFlag(item.region)
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
{/* Rank */}
|
||||||
|
<span className="w-5 text-xs font-mono text-base-content/40 text-right shrink-0">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
{/* Flag + Name */}
|
||||||
|
<span className="text-sm shrink-0 w-32 truncate">
|
||||||
|
{flag} {item.name}
|
||||||
|
</span>
|
||||||
|
{/* Stacked bar: upload + download */}
|
||||||
|
<div className="flex-1 h-5 bg-base-300/30 rounded-md overflow-hidden relative">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-md flex transition-all duration-500"
|
||||||
|
style={{ width: `${widthPct}%` }}
|
||||||
|
>
|
||||||
|
{/* Upload portion */}
|
||||||
|
<div
|
||||||
|
className="h-full"
|
||||||
|
style={{
|
||||||
|
width: `${uploadPct}%`,
|
||||||
|
backgroundColor: 'oklch(0.70 0.15 250)',
|
||||||
|
opacity: 0.8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Download portion */}
|
||||||
|
<div
|
||||||
|
className="h-full flex-1"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'oklch(0.72 0.19 142)',
|
||||||
|
opacity: 0.8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Traffic values */}
|
||||||
|
<span className="text-xs font-mono w-20 text-right shrink-0 tabular-nums text-base-content/70">
|
||||||
|
{formatBytes(total)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex items-center justify-end gap-4 mt-2 text-xs text-base-content/40">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: 'oklch(0.70 0.15 250)', opacity: 0.8 }}></span>
|
||||||
|
上传
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: 'oklch(0.72 0.19 142)', opacity: 0.8 }}></span>
|
||||||
|
下载
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "daisyui" {
|
||||||
|
themes: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar Styles */
|
||||||
|
@layer utilities {
|
||||||
|
/* For Chrome, Edge, and Safari */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-base-content/10;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: background 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
@apply bg-base-content/30;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For Firefox */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
/* First color is thumb, second is track. Cannot use Tailwind arbitrary opacity syntax in plain CSS easily here without var(), so we fallback to a neutral color, or rely on webkit-scrollbar for webkit browsers which covers most */
|
||||||
|
scrollbar-color: var(--fallback-bc, rgba(128, 128, 128, 0.3)) transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
// ---- Node & Snapshot types (maps to monitor.Snapshot) ----
|
||||||
|
|
||||||
|
export interface NodeInfo {
|
||||||
|
tag: string
|
||||||
|
name: string
|
||||||
|
uri: string
|
||||||
|
mode: string
|
||||||
|
listen_address?: string
|
||||||
|
port?: number
|
||||||
|
region?: string
|
||||||
|
country?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineEvent {
|
||||||
|
time: string
|
||||||
|
success: boolean
|
||||||
|
latency_ms: number
|
||||||
|
error?: string
|
||||||
|
destination?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeSnapshot extends NodeInfo {
|
||||||
|
failure_count: number
|
||||||
|
success_count: number
|
||||||
|
blacklisted: boolean
|
||||||
|
blacklisted_until: string
|
||||||
|
active_connections: number
|
||||||
|
last_error?: string
|
||||||
|
last_failure?: string
|
||||||
|
last_success?: string
|
||||||
|
last_probe_latency?: number
|
||||||
|
last_latency_ms: number
|
||||||
|
available: boolean
|
||||||
|
initial_check_done: boolean
|
||||||
|
total_upload: number
|
||||||
|
total_download: number
|
||||||
|
timeline?: TimelineEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- API Response types ----
|
||||||
|
|
||||||
|
export interface NodesResponse {
|
||||||
|
nodes: NodeSnapshot[]
|
||||||
|
total_nodes: number
|
||||||
|
total_upload: number
|
||||||
|
total_download: number
|
||||||
|
upload_speed?: number
|
||||||
|
download_speed?: number
|
||||||
|
traffic_sampled?: string
|
||||||
|
region_stats: Record<string, number>
|
||||||
|
region_healthy: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DebugNode {
|
||||||
|
tag: string
|
||||||
|
name: string
|
||||||
|
mode: string
|
||||||
|
port: number
|
||||||
|
failure_count: number
|
||||||
|
success_count: number
|
||||||
|
active_connections: number
|
||||||
|
last_latency_ms: number
|
||||||
|
last_success: string
|
||||||
|
last_failure: string
|
||||||
|
last_error: string
|
||||||
|
blacklisted: boolean
|
||||||
|
total_upload: number
|
||||||
|
total_download: number
|
||||||
|
timeline: TimelineEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DebugResponse {
|
||||||
|
nodes: DebugNode[]
|
||||||
|
total_calls: number
|
||||||
|
total_success: number
|
||||||
|
success_rate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsData {
|
||||||
|
// Global
|
||||||
|
mode: string
|
||||||
|
log_level: string
|
||||||
|
external_ip: string
|
||||||
|
skip_cert_verify: boolean
|
||||||
|
|
||||||
|
// Listener
|
||||||
|
listener_address: string
|
||||||
|
listener_port: number
|
||||||
|
listener_username: string
|
||||||
|
listener_password: string
|
||||||
|
|
||||||
|
// Multi-port
|
||||||
|
multi_port_address: string
|
||||||
|
multi_port_base_port: number
|
||||||
|
multi_port_username: string
|
||||||
|
multi_port_password: string
|
||||||
|
|
||||||
|
// Pool
|
||||||
|
pool_mode: string
|
||||||
|
pool_failure_threshold: number
|
||||||
|
pool_blacklist_duration: string
|
||||||
|
|
||||||
|
// Management
|
||||||
|
management_enabled: boolean
|
||||||
|
management_listen: string
|
||||||
|
management_probe_target: string
|
||||||
|
management_password: string
|
||||||
|
|
||||||
|
// Subscription refresh
|
||||||
|
sub_refresh_enabled: boolean
|
||||||
|
sub_refresh_interval: string
|
||||||
|
sub_refresh_timeout: string
|
||||||
|
sub_refresh_health_check_timeout: string
|
||||||
|
sub_refresh_drain_timeout: string
|
||||||
|
sub_refresh_min_available_nodes: number
|
||||||
|
|
||||||
|
// GeoIP
|
||||||
|
geoip_enabled: boolean
|
||||||
|
geoip_database_path: string
|
||||||
|
geoip_auto_update_enabled: boolean
|
||||||
|
geoip_auto_update_interval: string
|
||||||
|
|
||||||
|
// Subscriptions
|
||||||
|
subscriptions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsUpdateResponse {
|
||||||
|
message: string
|
||||||
|
need_reload: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Auth types ----
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
message: string
|
||||||
|
token?: string
|
||||||
|
no_password?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorResponse {
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Config Node CRUD types ----
|
||||||
|
|
||||||
|
export interface ConfigNodePayload {
|
||||||
|
name: string
|
||||||
|
uri: string
|
||||||
|
port: number
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigNodeConfig {
|
||||||
|
name: string
|
||||||
|
uri: string
|
||||||
|
port: number
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
source?: string
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigNodesResponse {
|
||||||
|
nodes: ConfigNodeConfig[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigNodeMutationResponse {
|
||||||
|
node?: ConfigNodeConfig
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Subscription types ----
|
||||||
|
|
||||||
|
export interface SubscriptionStatus {
|
||||||
|
enabled: boolean
|
||||||
|
has_subscriptions?: boolean
|
||||||
|
last_refresh?: string
|
||||||
|
next_refresh?: string
|
||||||
|
node_count?: number
|
||||||
|
last_error?: string
|
||||||
|
refresh_count?: number
|
||||||
|
is_refreshing?: boolean
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- SSE Probe types ----
|
||||||
|
|
||||||
|
export interface ProbeSSEStart {
|
||||||
|
type: 'start'
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProbeSSEProgress {
|
||||||
|
type: 'progress'
|
||||||
|
tag: string
|
||||||
|
name: string
|
||||||
|
latency: number
|
||||||
|
status: 'success' | 'error'
|
||||||
|
error: string
|
||||||
|
current: number
|
||||||
|
total: number
|
||||||
|
progress: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProbeSSEComplete {
|
||||||
|
type: 'complete'
|
||||||
|
total: number
|
||||||
|
success: number
|
||||||
|
failed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProbeSSEEvent = ProbeSSEStart | ProbeSSEProgress | ProbeSSEComplete
|
||||||
|
|
||||||
|
// ---- SSE Traffic stream types ----
|
||||||
|
|
||||||
|
export interface TrafficStreamNode {
|
||||||
|
tag: string
|
||||||
|
upload_speed: number
|
||||||
|
download_speed: number
|
||||||
|
total_upload: number
|
||||||
|
total_download: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrafficStreamEvent {
|
||||||
|
type: 'traffic'
|
||||||
|
node_count: number
|
||||||
|
total_upload: number
|
||||||
|
total_download: number
|
||||||
|
upload_speed: number
|
||||||
|
download_speed: number
|
||||||
|
sampled_at: string
|
||||||
|
nodes: TrafficStreamNode[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Format a byte count into a human-readable string (e.g., "1.5 GB").
|
||||||
|
*/
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
if (bytes < 0) return '-'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
const idx = Math.min(i, sizes.length - 1)
|
||||||
|
return `${(bytes / Math.pow(k, idx)).toFixed(idx === 0 ? 0 : 1)} ${sizes[idx]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a speed in bytes/sec into a human-readable string (e.g., "2.3 MB/s").
|
||||||
|
*/
|
||||||
|
export function formatSpeed(bytesPerSec: number): string {
|
||||||
|
if (bytesPerSec <= 0) return '0 B/s'
|
||||||
|
return `${formatBytes(bytesPerSec)}/s`
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
tailwindcss(),
|
||||||
|
react()
|
||||||
|
],
|
||||||
|
build: {
|
||||||
|
outDir: '../internal/monitor/assets',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
module easy_proxies
|
||||||
|
|
||||||
|
go 1.24.1
|
||||||
|
|
||||||
|
toolchain go1.24.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/oschwald/geoip2-golang v1.13.0
|
||||||
|
github.com/sagernet/sing v0.7.13
|
||||||
|
github.com/sagernet/sing-box v1.12.12
|
||||||
|
golang.org/x/sync v0.19.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
modernc.org/sqlite v1.46.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
github.com/ajg/form v1.5.1 // indirect
|
||||||
|
github.com/akutz/memconn v0.1.0 // indirect
|
||||||
|
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
|
||||||
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
|
github.com/anytls/sing-anytls v0.0.11 // indirect
|
||||||
|
github.com/bits-and-blooms/bitset v1.13.0 // indirect
|
||||||
|
github.com/caddyserver/certmagic v0.25.0 // indirect
|
||||||
|
github.com/caddyserver/zerossl v0.1.3 // indirect
|
||||||
|
github.com/coder/websocket v1.8.13 // indirect
|
||||||
|
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect
|
||||||
|
github.com/cretz/bine v0.2.0 // indirect
|
||||||
|
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
|
||||||
|
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||||
|
github.com/gaissmai/bart v0.11.1 // indirect
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3 // indirect
|
||||||
|
github.com/go-chi/render v1.0.3 // indirect
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 // indirect
|
||||||
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
|
github.com/gobwas/httphead v0.1.0 // indirect
|
||||||
|
github.com/gobwas/pool v0.2.1 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
|
||||||
|
github.com/gofrs/uuid/v5 v5.4.0 // indirect
|
||||||
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
|
github.com/google/btree v1.1.3 // indirect
|
||||||
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
|
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 // indirect
|
||||||
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
|
github.com/hashicorp/yamux v0.1.2 // indirect
|
||||||
|
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
|
||||||
|
github.com/illarion/gonotify/v2 v2.0.3 // indirect
|
||||||
|
github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f // indirect
|
||||||
|
github.com/jsimonetti/rtnetlink v1.4.0 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.1 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect
|
||||||
|
github.com/libdns/alidns v1.0.5-libdns.v1.beta1 // indirect
|
||||||
|
github.com/libdns/cloudflare v0.2.2 // indirect
|
||||||
|
github.com/libdns/libdns v1.1.1 // indirect
|
||||||
|
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mdlayher/genetlink v1.3.2 // indirect
|
||||||
|
github.com/mdlayher/netlink v1.8.0 // indirect
|
||||||
|
github.com/mdlayher/sdnotify v1.0.0 // indirect
|
||||||
|
github.com/mdlayher/socket v0.5.1 // indirect
|
||||||
|
github.com/metacubex/tfo-go v0.0.0-20251024101424-368b42b59148 // indirect
|
||||||
|
github.com/metacubex/utls v1.8.3 // indirect
|
||||||
|
github.com/mholt/acmez/v3 v3.1.4 // indirect
|
||||||
|
github.com/miekg/dns v1.1.68 // indirect
|
||||||
|
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||||
|
github.com/prometheus-community/pro-bing v0.4.0 // indirect
|
||||||
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/safchain/ethtool v0.3.0 // indirect
|
||||||
|
github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a // indirect
|
||||||
|
github.com/sagernet/cors v1.2.1 // indirect
|
||||||
|
github.com/sagernet/fswatch v0.1.1 // indirect
|
||||||
|
github.com/sagernet/gvisor v0.0.0-20250811-sing-box-mod.1 // indirect
|
||||||
|
github.com/sagernet/netlink v0.0.0-20240916134442-83396419aa8b // indirect
|
||||||
|
github.com/sagernet/nftables v0.3.0-mod.1 // indirect
|
||||||
|
github.com/sagernet/quic-go v0.52.0-sing-box-mod.3 // indirect
|
||||||
|
github.com/sagernet/sing-mux v0.3.3 // indirect
|
||||||
|
github.com/sagernet/sing-quic v0.5.2-0.20250909083218-00a55617c0fb // indirect
|
||||||
|
github.com/sagernet/sing-shadowsocks v0.2.8 // indirect
|
||||||
|
github.com/sagernet/sing-shadowsocks2 v0.2.1 // indirect
|
||||||
|
github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 // indirect
|
||||||
|
github.com/sagernet/sing-tun v0.7.3 // indirect
|
||||||
|
github.com/sagernet/sing-vmess v0.2.7 // indirect
|
||||||
|
github.com/sagernet/smux v1.5.34-mod.2 // indirect
|
||||||
|
github.com/sagernet/tailscale v1.80.3-sing-box-1.12-mod.2 // indirect
|
||||||
|
github.com/sagernet/wireguard-go v0.0.1-beta.7 // indirect
|
||||||
|
github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 // indirect
|
||||||
|
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect
|
||||||
|
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
|
||||||
|
github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 // indirect
|
||||||
|
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect
|
||||||
|
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect
|
||||||
|
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect
|
||||||
|
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
|
||||||
|
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
|
||||||
|
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect
|
||||||
|
github.com/vishvananda/netns v0.0.5 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
|
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||||
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
go.uber.org/zap v1.27.0 // indirect
|
||||||
|
go.uber.org/zap/exp v0.3.0 // indirect
|
||||||
|
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
|
||||||
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||||
|
golang.org/x/crypto v0.44.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
|
||||||
|
golang.org/x/mod v0.30.0 // indirect
|
||||||
|
golang.org/x/net v0.47.0 // indirect
|
||||||
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
|
golang.org/x/term v0.37.0 // indirect
|
||||||
|
golang.org/x/text v0.31.0 // indirect
|
||||||
|
golang.org/x/time v0.14.0 // indirect
|
||||||
|
golang.org/x/tools v0.39.0 // indirect
|
||||||
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
|
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
|
||||||
|
google.golang.org/grpc v1.73.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.6 // indirect
|
||||||
|
lukechampine.com/blake3 v1.3.0 // indirect
|
||||||
|
modernc.org/libc v1.67.6 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
|
||||||
|
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
|
||||||
|
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
|
||||||
|
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
|
||||||
|
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
|
||||||
|
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||||
|
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
|
github.com/anytls/sing-anytls v0.0.11 h1:w8e9Uj1oP3m4zxkyZDewPk0EcQbvVxb7Nn+rapEx4fc=
|
||||||
|
github.com/anytls/sing-anytls v0.0.11/go.mod h1:7rjN6IukwysmdusYsrV51Fgu1uW6vsrdd6ctjnEAln8=
|
||||||
|
github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE=
|
||||||
|
github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||||
|
github.com/caddyserver/certmagic v0.25.0 h1:VMleO/XA48gEWes5l+Fh6tRWo9bHkhwAEhx63i+F5ic=
|
||||||
|
github.com/caddyserver/certmagic v0.25.0/go.mod h1:m9yB7Mud24OQbPHOiipAoyKPn9pKHhpSJxXR1jydBxA=
|
||||||
|
github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA=
|
||||||
|
github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
|
||||||
|
github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk=
|
||||||
|
github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso=
|
||||||
|
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
|
||||||
|
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||||
|
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0=
|
||||||
|
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
|
||||||
|
github.com/cretz/bine v0.2.0 h1:8GiDRGlTgz+o8H9DSnsl+5MeBK4HsExxgl6WgzOCuZo=
|
||||||
|
github.com/cretz/bine v0.2.0/go.mod h1:WU4o9QR9wWp8AVKtTM1XD5vUHkEqnf2vVSo6dBqbetI=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk=
|
||||||
|
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ=
|
||||||
|
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q=
|
||||||
|
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||||
|
github.com/gaissmai/bart v0.11.1 h1:5Uv5XwsaFBRo4E5VBcb9TzY8B7zxFf+U7isDxqOrRfc=
|
||||||
|
github.com/gaissmai/bart v0.11.1/go.mod h1:KHeYECXQiBjTzQz/om2tqn3sZF1J7hw9m6z41ftj3fg=
|
||||||
|
github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
|
||||||
|
github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
|
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
|
||||||
|
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 h1:KbX3Z3CgiYlbaavUq3Cj9/MjpO+88S7/AGXzynVDv84=
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288/go.mod h1:BWmvoE1Xia34f3l/ibJweyhrT+aROb/FQ6d+37F0e2s=
|
||||||
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||||
|
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||||
|
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||||
|
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU=
|
||||||
|
github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0=
|
||||||
|
github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
|
||||||
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||||
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||||
|
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
|
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI=
|
||||||
|
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 h1:fiJdrgVBkjZ5B1HJ2WQwNOaXB+QyYcNXTA3t1XYLz0M=
|
||||||
|
github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
||||||
|
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||||
|
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
|
||||||
|
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
|
||||||
|
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
|
||||||
|
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
|
||||||
|
github.com/illarion/gonotify/v2 v2.0.3 h1:B6+SKPo/0Sw8cRJh1aLzNEeNVFfzE3c6N+o+vyxM+9A=
|
||||||
|
github.com/illarion/gonotify/v2 v2.0.3/go.mod h1:38oIJTgFqupkEydkkClkbL6i5lXV/bxdH9do5TALPEE=
|
||||||
|
github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f h1:dd33oobuIv9PcBVqvbEiCXEbNTomOHyj3WFuC5YiPRU=
|
||||||
|
github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f/go.mod h1:zhFlBeJssZ1YBCMZ5Lzu1pX4vhftDvU10WUVb1uXKtM=
|
||||||
|
github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I=
|
||||||
|
github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E=
|
||||||
|
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||||
|
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ=
|
||||||
|
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk=
|
||||||
|
github.com/libdns/alidns v1.0.5-libdns.v1.beta1 h1:txHK7UxDed3WFBDjrTZPuMn8X+WmhjBTTAMW5xdy5pQ=
|
||||||
|
github.com/libdns/alidns v1.0.5-libdns.v1.beta1/go.mod h1:ystHmPwcGoWjPrGpensQSMY9VoCx4cpR2hXNlwk9H/g=
|
||||||
|
github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI=
|
||||||
|
github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60=
|
||||||
|
github.com/libdns/libdns v1.0.0-beta.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
|
||||||
|
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
|
||||||
|
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
|
||||||
|
github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=
|
||||||
|
github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
|
||||||
|
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
|
||||||
|
github.com/mdlayher/netlink v1.8.0 h1:e7XNIYJKD7hUct3Px04RuIGJbBxy1/c4nX7D5YyvvlM=
|
||||||
|
github.com/mdlayher/netlink v1.8.0/go.mod h1:UhgKXUlDQhzb09DrCl2GuRNEglHmhYoWAHid9HK3594=
|
||||||
|
github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c=
|
||||||
|
github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE=
|
||||||
|
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
|
||||||
|
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
|
||||||
|
github.com/metacubex/tfo-go v0.0.0-20251024101424-368b42b59148 h1:Zd0QqciLIhv9MKbGKTPEgN8WUFsgQGA1WJBy6spEnVU=
|
||||||
|
github.com/metacubex/tfo-go v0.0.0-20251024101424-368b42b59148/go.mod h1:l9oLnLoEXyGZ5RVLsh7QCC5XsouTUyKk4F2nLm2DHLw=
|
||||||
|
github.com/metacubex/utls v1.8.3 h1:0m/yCxm3SK6kWve2lKiFb1pue1wHitJ8sQQD4Ikqde4=
|
||||||
|
github.com/metacubex/utls v1.8.3/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko=
|
||||||
|
github.com/mholt/acmez/v3 v3.1.4 h1:DyzZe/RnAzT3rpZj/2Ii5xZpiEvvYk3cQEN/RmqxwFQ=
|
||||||
|
github.com/mholt/acmez/v3 v3.1.4/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ=
|
||||||
|
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
|
||||||
|
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
|
||||||
|
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
|
||||||
|
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||||
|
github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI=
|
||||||
|
github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
|
||||||
|
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
||||||
|
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4=
|
||||||
|
github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4=
|
||||||
|
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||||
|
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
|
||||||
|
github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs=
|
||||||
|
github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a h1:+NkI2670SQpQWvkkD2QgdTuzQG263YZ+2emfpeyGqW0=
|
||||||
|
github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a/go.mod h1:63s7jpZqcDAIpj8oI/1v4Izok+npJOHACFCU6+huCkM=
|
||||||
|
github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ=
|
||||||
|
github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI=
|
||||||
|
github.com/sagernet/fswatch v0.1.1 h1:YqID+93B7VRfqIH3PArW/XpJv5H4OLEVWDfProGoRQs=
|
||||||
|
github.com/sagernet/fswatch v0.1.1/go.mod h1:nz85laH0mkQqJfaOrqPpkwtU1znMFNVTpT/5oRsVz/o=
|
||||||
|
github.com/sagernet/gvisor v0.0.0-20250811-sing-box-mod.1 h1:bYLFFxOBLbmeMjMSCzsXJwNAS1EHoBb+G9GlE5oBgM8=
|
||||||
|
github.com/sagernet/gvisor v0.0.0-20250811-sing-box-mod.1/go.mod h1:NJKBtm9nVEK3iyOYWsUlrDQuoGh4zJ4KOPhSYVidvQ4=
|
||||||
|
github.com/sagernet/netlink v0.0.0-20240916134442-83396419aa8b h1:ppoda3bl004POPsp3ut7V+4Mn6+DUbTxyxpB0BjpaIk=
|
||||||
|
github.com/sagernet/netlink v0.0.0-20240916134442-83396419aa8b/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM=
|
||||||
|
github.com/sagernet/nftables v0.3.0-mod.1 h1:OMe+qoEAx8EipYAQbD2FI5erVvKmTS9+cYhdpg+vezY=
|
||||||
|
github.com/sagernet/nftables v0.3.0-mod.1/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ=
|
||||||
|
github.com/sagernet/quic-go v0.52.0-sing-box-mod.3 h1:ySqffGm82rPqI1TUPqmtHIYd12pfEGScygnOxjTL56w=
|
||||||
|
github.com/sagernet/quic-go v0.52.0-sing-box-mod.3/go.mod h1:OV+V5kEBb8kJS7k29MzDu6oj9GyMc7HA07sE1tedxz4=
|
||||||
|
github.com/sagernet/sing v0.6.9/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
|
||||||
|
github.com/sagernet/sing v0.7.13 h1:XNYgd8e3cxMULs/LLJspdn/deHrnPWyrrglNHeCUAYM=
|
||||||
|
github.com/sagernet/sing v0.7.13/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
|
||||||
|
github.com/sagernet/sing-box v1.12.12 h1:brSb4zdL5CfqXB0ss1jrT+srPUbKanyWhbswkte/z5Y=
|
||||||
|
github.com/sagernet/sing-box v1.12.12/go.mod h1:ObMeEc1VAcJdXN6B/3SUIJRuK7J38m0W6yLNJ+E5f+0=
|
||||||
|
github.com/sagernet/sing-mux v0.3.3 h1:YFgt9plMWzH994BMZLmyKL37PdIVaIilwP0Jg+EcLfw=
|
||||||
|
github.com/sagernet/sing-mux v0.3.3/go.mod h1:pht8iFY4c9Xltj7rhVd208npkNaeCxzyXCgulDPLUDA=
|
||||||
|
github.com/sagernet/sing-quic v0.5.2-0.20250909083218-00a55617c0fb h1:5Wx3XeTiKrrrcrAky7Hc1bO3CGxrvho2Vu5b/adlEIM=
|
||||||
|
github.com/sagernet/sing-quic v0.5.2-0.20250909083218-00a55617c0fb/go.mod h1:evP1e++ZG8TJHVV5HudXV4vWeYzGfCdF4HwSJZcdqkI=
|
||||||
|
github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE=
|
||||||
|
github.com/sagernet/sing-shadowsocks v0.2.8/go.mod h1:lo7TWEMDcN5/h5B8S0ew+r78ZODn6SwVaFhvB6H+PTI=
|
||||||
|
github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnqqs2gQ2/Qioo=
|
||||||
|
github.com/sagernet/sing-shadowsocks2 v0.2.1/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ=
|
||||||
|
github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 h1:tK+75l64tm9WvEFrYRE1t0YxoFdWQqw/h7Uhzj0vJ+w=
|
||||||
|
github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA=
|
||||||
|
github.com/sagernet/sing-tun v0.7.3 h1:MFnAir+l24ElEyxdfwtY8mqvUUL9nPnL9TDYLkOmVes=
|
||||||
|
github.com/sagernet/sing-tun v0.7.3/go.mod h1:pUEjh9YHQ2gJT6Lk0TYDklh3WJy7lz+848vleGM3JPM=
|
||||||
|
github.com/sagernet/sing-vmess v0.2.7 h1:2ee+9kO0xW5P4mfe6TYVWf9VtY8k1JhNysBqsiYj0sk=
|
||||||
|
github.com/sagernet/sing-vmess v0.2.7/go.mod h1:5aYoOtYksAyS0NXDm0qKeTYW1yoE1bJVcv+XLcVoyJs=
|
||||||
|
github.com/sagernet/smux v1.5.34-mod.2 h1:gkmBjIjlJ2zQKpLigOkFur5kBKdV6bNRoFu2WkltRQ4=
|
||||||
|
github.com/sagernet/smux v1.5.34-mod.2/go.mod h1:0KW0+R+ycvA2INW4gbsd7BNyg+HEfLIAxa5N02/28Zc=
|
||||||
|
github.com/sagernet/tailscale v1.80.3-sing-box-1.12-mod.2 h1:MO7s4ni2bSfAOhcan2rdQSWCztkMXmqyg6jYPZp8bEE=
|
||||||
|
github.com/sagernet/tailscale v1.80.3-sing-box-1.12-mod.2/go.mod h1:EBxXsWu4OH2ELbQLq32WoBeIubG8KgDrg4/Oaxjs6lI=
|
||||||
|
github.com/sagernet/wireguard-go v0.0.1-beta.7 h1:ltgBwYHfr+9Wz1eG59NiWnHrYEkDKHG7otNZvu85DXI=
|
||||||
|
github.com/sagernet/wireguard-go v0.0.1-beta.7/go.mod h1:jGXij2Gn2wbrWuYNUmmNhf1dwcZtvyAvQoe8Xd8MbUo=
|
||||||
|
github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc=
|
||||||
|
github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ=
|
||||||
|
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
|
||||||
|
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
|
||||||
|
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
|
||||||
|
github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 h1:rXZGgEa+k2vJM8xT0PoSKfVXwFGPQ3z3CJfmnHJkZZw=
|
||||||
|
github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ=
|
||||||
|
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio=
|
||||||
|
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8=
|
||||||
|
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw=
|
||||||
|
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=
|
||||||
|
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU=
|
||||||
|
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
|
||||||
|
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA=
|
||||||
|
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
|
||||||
|
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14=
|
||||||
|
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
|
||||||
|
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
|
||||||
|
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
|
||||||
|
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
|
||||||
|
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
|
||||||
|
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
|
||||||
|
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||||
|
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
|
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
|
||||||
|
github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
|
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
|
||||||
|
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
|
||||||
|
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
|
||||||
|
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
|
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
||||||
|
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
||||||
|
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
||||||
|
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
|
||||||
|
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
||||||
|
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||||
|
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
|
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
|
||||||
|
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
|
||||||
|
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
|
||||||
|
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
||||||
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||||
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||||
|
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||||
|
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||||
|
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||||
|
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
|
||||||
|
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
||||||
|
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||||
|
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||||
|
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||||
|
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
|
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||||
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||||
|
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||||
|
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||||
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||||
|
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||||
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||||
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||||
|
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
|
||||||
|
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||||
|
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
|
||||||
|
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
|
||||||
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
|
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
|
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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE=
|
||||||
|
lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
|
||||||
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||||
|
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||||
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
|
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||||
|
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||||
|
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||||
|
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
|
||||||
|
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"easy_proxies/internal/boxmgr"
|
||||||
|
"easy_proxies/internal/config"
|
||||||
|
"easy_proxies/internal/monitor"
|
||||||
|
"easy_proxies/internal/store"
|
||||||
|
"easy_proxies/internal/subscription"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run builds the runtime components from config and blocks until shutdown.
|
||||||
|
func Run(ctx context.Context, cfg *config.Config) error {
|
||||||
|
// ── 1. Open SQLite store ──
|
||||||
|
dbPath := cfg.DatabasePath
|
||||||
|
if dbPath == "" {
|
||||||
|
dbPath = filepath.Join(filepath.Dir(cfg.FilePath()), "data", "data.db")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the directory for the database file exists
|
||||||
|
if dir := filepath.Dir(dbPath); dir != "." {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create database directory %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dataStore, err := store.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open database: %w", err)
|
||||||
|
}
|
||||||
|
defer dataStore.Close()
|
||||||
|
|
||||||
|
// ── 2. Load nodes from Store into config (if any exist) ──
|
||||||
|
if err := loadNodesFromStore(ctx, cfg, dataStore); err != nil {
|
||||||
|
log.Printf("⚠️ Failed to load nodes from store: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Build monitor config ──
|
||||||
|
proxyUsername := cfg.Listener.Username
|
||||||
|
proxyPassword := cfg.Listener.Password
|
||||||
|
if cfg.Mode == "multi-port" || cfg.Mode == "hybrid" {
|
||||||
|
proxyUsername = cfg.MultiPort.Username
|
||||||
|
proxyPassword = cfg.MultiPort.Password
|
||||||
|
}
|
||||||
|
|
||||||
|
monitorCfg := monitor.Config{
|
||||||
|
Enabled: cfg.ManagementEnabled(),
|
||||||
|
Listen: cfg.Management.Listen,
|
||||||
|
ProbeTarget: cfg.Management.ProbeTarget,
|
||||||
|
Password: cfg.Management.Password,
|
||||||
|
ProxyUsername: proxyUsername,
|
||||||
|
ProxyPassword: proxyPassword,
|
||||||
|
ExternalIP: cfg.ExternalIP,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Create and start BoxManager ──
|
||||||
|
boxMgr := boxmgr.New(cfg, monitorCfg, boxmgr.WithStore(dataStore))
|
||||||
|
if err := boxMgr.Start(ctx); err != nil {
|
||||||
|
return fmt.Errorf("start box manager: %w", err)
|
||||||
|
}
|
||||||
|
defer boxMgr.Close()
|
||||||
|
|
||||||
|
// Wire up config and store to monitor server for settings API
|
||||||
|
if server := boxMgr.MonitorServer(); server != nil {
|
||||||
|
server.SetConfig(cfg)
|
||||||
|
server.SetStore(dataStore)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Create and start SubscriptionManager ──
|
||||||
|
// Always created so it can dynamically respond to config changes
|
||||||
|
// (e.g., user enables subscriptions via WebUI). The manager's internal
|
||||||
|
// refresh loop checks config state to decide when to actually refresh.
|
||||||
|
subMgr := subscription.New(cfg, boxMgr, subscription.WithStore(dataStore))
|
||||||
|
subMgr.Start()
|
||||||
|
defer subMgr.Stop()
|
||||||
|
|
||||||
|
// Register as config update listener so baseCfg stays in sync after reloads
|
||||||
|
boxMgr.AddConfigListener(subMgr)
|
||||||
|
|
||||||
|
if server := boxMgr.MonitorServer(); server != nil {
|
||||||
|
server.SetSubscriptionRefresher(subMgr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. Start periodic stats flush ──
|
||||||
|
statsCtx, statsCancel := context.WithCancel(ctx)
|
||||||
|
defer statsCancel()
|
||||||
|
go periodicStatsFlush(statsCtx, boxMgr, dataStore)
|
||||||
|
|
||||||
|
// ── 7. Wait for shutdown signal ──
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer signal.Stop(sigCh)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
fmt.Println("Context cancelled, initiating graceful shutdown...")
|
||||||
|
case sig := <-sigCh:
|
||||||
|
fmt.Printf("Received %s, initiating graceful shutdown...\n", sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 8. Graceful shutdown ──
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
|
||||||
|
// Flush stats one final time before shutdown
|
||||||
|
flushStatsToStore(context.Background(), boxMgr, dataStore)
|
||||||
|
|
||||||
|
fmt.Println("Stopping subscription manager...")
|
||||||
|
if subMgr != nil {
|
||||||
|
subMgr.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Stopping box manager...")
|
||||||
|
if err := boxMgr.Close(); err != nil {
|
||||||
|
fmt.Printf("Error closing box manager: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Waiting for connections to drain...")
|
||||||
|
select {
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
fmt.Println("Graceful shutdown completed")
|
||||||
|
case <-shutdownCtx.Done():
|
||||||
|
fmt.Println("Shutdown timeout exceeded, forcing exit")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadNodesFromStore replaces config.Nodes with nodes from the Store.
|
||||||
|
// If the Store is empty, seeds it with whatever nodes config already has
|
||||||
|
// (from config.yaml inline nodes or subscription fetch), then returns.
|
||||||
|
func loadNodesFromStore(ctx context.Context, cfg *config.Config, s store.Store) error {
|
||||||
|
nodes, err := s.ListNodes(ctx, store.NodeFilter{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list nodes from store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
// Store is empty — seed it with the nodes that config already loaded
|
||||||
|
// (inline nodes, subscription fetch, manual nodes, etc.)
|
||||||
|
if len(cfg.Nodes) > 0 {
|
||||||
|
log.Printf("[app] store is empty, seeding %d nodes from config", len(cfg.Nodes))
|
||||||
|
if err := seedStoreFromConfig(ctx, cfg, s); err != nil {
|
||||||
|
log.Printf("⚠️ Failed to seed store from config: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("[app] no nodes in store and no nodes in config")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert store.Node to config.NodeConfig
|
||||||
|
var configNodes []config.NodeConfig
|
||||||
|
for _, n := range nodes {
|
||||||
|
if !n.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
configNodes = append(configNodes, config.NodeConfig{
|
||||||
|
Name: n.Name,
|
||||||
|
URI: n.URI,
|
||||||
|
Port: n.Port,
|
||||||
|
Username: n.Username,
|
||||||
|
Password: n.Password,
|
||||||
|
Source: config.NodeSource(n.Source),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(configNodes) > 0 {
|
||||||
|
cfg.Nodes = configNodes
|
||||||
|
log.Printf("[app] loaded %d nodes from store", len(configNodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedStoreFromConfig writes all cfg.Nodes into the Store as a bulk upsert.
|
||||||
|
// This is called once on first startup when the database is empty.
|
||||||
|
func seedStoreFromConfig(ctx context.Context, cfg *config.Config, s store.Store) error {
|
||||||
|
var storeNodes []store.Node
|
||||||
|
for _, n := range cfg.Nodes {
|
||||||
|
source := string(n.Source)
|
||||||
|
if source == "" {
|
||||||
|
source = store.NodeSourceSubscription
|
||||||
|
}
|
||||||
|
storeNodes = append(storeNodes, store.Node{
|
||||||
|
URI: n.URI,
|
||||||
|
Name: n.Name,
|
||||||
|
Source: source,
|
||||||
|
Port: n.Port,
|
||||||
|
Username: n.Username,
|
||||||
|
Password: n.Password,
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.BulkUpsertNodes(ctx, storeNodes); err != nil {
|
||||||
|
return fmt.Errorf("bulk upsert seed nodes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[app] seeded %d nodes into store", len(storeNodes))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// periodicStatsFlush periodically writes in-memory node stats to the Store.
|
||||||
|
func periodicStatsFlush(ctx context.Context, boxMgr *boxmgr.Manager, s store.Store) {
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
flushStatsToStore(ctx, boxMgr, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushStatsToStore writes current node stats from monitor to the Store.
|
||||||
|
func flushStatsToStore(ctx context.Context, boxMgr *boxmgr.Manager, s store.Store) {
|
||||||
|
mgr := boxMgr.MonitorManager()
|
||||||
|
if mgr == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshots := mgr.Snapshot()
|
||||||
|
if len(snapshots) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build URI/name -> node ID lookup from store
|
||||||
|
storeNodes, err := s.ListNodes(ctx, store.NodeFilter{})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[app] stats flush: failed to list store nodes: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uriToID := make(map[string]int64, len(storeNodes))
|
||||||
|
nameToID := make(map[string]int64, len(storeNodes))
|
||||||
|
for _, n := range storeNodes {
|
||||||
|
uriToID[n.URI] = n.ID
|
||||||
|
nameToID[n.Name] = n.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
var updates []store.StatsUpdate
|
||||||
|
for _, snap := range snapshots {
|
||||||
|
nodeID, ok := uriToID[snap.URI]
|
||||||
|
if !ok {
|
||||||
|
nodeID, ok = nameToID[snap.Name]
|
||||||
|
}
|
||||||
|
if !ok || nodeID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
updates = append(updates, store.StatsUpdate{
|
||||||
|
NodeID: nodeID,
|
||||||
|
FailureCount: snap.FailureCount,
|
||||||
|
SuccessCount: snap.SuccessCount,
|
||||||
|
Blacklisted: snap.Blacklisted,
|
||||||
|
BlacklistedUntil: snap.BlacklistedUntil,
|
||||||
|
LastError: snap.LastError,
|
||||||
|
LastFailureAt: snap.LastFailure,
|
||||||
|
LastSuccessAt: snap.LastSuccess,
|
||||||
|
LastLatencyMs: snap.LastLatencyMs,
|
||||||
|
Available: snap.Available,
|
||||||
|
InitialCheckDone: snap.InitialCheckDone,
|
||||||
|
TotalUploadBytes: snap.TotalUpload,
|
||||||
|
TotalDownloadBytes: snap.TotalDownload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(updates) > 0 {
|
||||||
|
if err := s.BatchUpdateStats(ctx, updates); err != nil {
|
||||||
|
log.Printf("[app] stats flush failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,934 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"easy_proxies/internal/config"
|
||||||
|
"easy_proxies/internal/geoip"
|
||||||
|
poolout "easy_proxies/internal/outbound/pool"
|
||||||
|
|
||||||
|
C "github.com/sagernet/sing-box/constant"
|
||||||
|
"github.com/sagernet/sing-box/option"
|
||||||
|
"github.com/sagernet/sing/common/auth"
|
||||||
|
"github.com/sagernet/sing/common/json/badoption"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build converts high level config into sing-box Options tree.
|
||||||
|
func Build(cfg *config.Config) (option.Options, error) {
|
||||||
|
baseOutbounds := make([]option.Outbound, 0, len(cfg.Nodes))
|
||||||
|
memberTags := make([]string, 0, len(cfg.Nodes))
|
||||||
|
metadata := make(map[string]poolout.MemberMeta)
|
||||||
|
var failedNodes []string
|
||||||
|
usedTags := make(map[string]int) // Track tag usage for uniqueness
|
||||||
|
|
||||||
|
// Initialize GeoIP lookup if enabled
|
||||||
|
var geoLookup *geoip.Lookup
|
||||||
|
if cfg.GeoIP.Enabled && cfg.GeoIP.DatabasePath != "" {
|
||||||
|
var err error
|
||||||
|
// Use auto-update if enabled
|
||||||
|
if cfg.GeoIP.AutoUpdateEnabled {
|
||||||
|
interval := cfg.GeoIP.AutoUpdateInterval
|
||||||
|
if interval == 0 {
|
||||||
|
interval = 24 * time.Hour // Default to 24 hours
|
||||||
|
}
|
||||||
|
geoLookup, err = geoip.NewWithAutoUpdate(cfg.GeoIP.DatabasePath, interval)
|
||||||
|
} else {
|
||||||
|
geoLookup, err = geoip.New(cfg.GeoIP.DatabasePath)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠️ GeoIP database load failed: %v (region routing disabled)", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("✅ GeoIP database loaded: %s", cfg.GeoIP.DatabasePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track nodes by region for GeoIP routing
|
||||||
|
regionMembers := make(map[string][]string)
|
||||||
|
for _, region := range geoip.AllRegions() {
|
||||||
|
regionMembers[region] = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, node := range cfg.Nodes {
|
||||||
|
baseTag := sanitizeTag(node.Name)
|
||||||
|
if baseTag == "" {
|
||||||
|
baseTag = fmt.Sprintf("node-%d", len(memberTags)+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure tag uniqueness by appending a counter if needed
|
||||||
|
tag := baseTag
|
||||||
|
if count, exists := usedTags[baseTag]; exists {
|
||||||
|
usedTags[baseTag] = count + 1
|
||||||
|
tag = fmt.Sprintf("%s-%d", baseTag, count+1)
|
||||||
|
} else {
|
||||||
|
usedTags[baseTag] = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
outbound, err := buildNodeOutbound(tag, node.URI, cfg.SkipCertVerify)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ Failed to build node '%s': %v (skipping)", node.Name, err)
|
||||||
|
failedNodes = append(failedNodes, node.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
memberTags = append(memberTags, tag)
|
||||||
|
baseOutbounds = append(baseOutbounds, outbound)
|
||||||
|
meta := poolout.MemberMeta{
|
||||||
|
Name: node.Name,
|
||||||
|
URI: node.URI,
|
||||||
|
Mode: cfg.Mode,
|
||||||
|
}
|
||||||
|
// For multi-port and hybrid modes, use per-node port
|
||||||
|
if cfg.Mode == "multi-port" || cfg.Mode == "hybrid" {
|
||||||
|
meta.ListenAddress = cfg.MultiPort.Address
|
||||||
|
meta.Port = node.Port
|
||||||
|
} else {
|
||||||
|
meta.ListenAddress = cfg.Listener.Address
|
||||||
|
meta.Port = cfg.Listener.Port
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeoIP lookup for region classification
|
||||||
|
if geoLookup != nil && geoLookup.IsEnabled() {
|
||||||
|
regionInfo := geoLookup.LookupURI(node.URI)
|
||||||
|
meta.Region = regionInfo.Code
|
||||||
|
meta.Country = regionInfo.Country
|
||||||
|
regionMembers[regionInfo.Code] = append(regionMembers[regionInfo.Code], tag)
|
||||||
|
} else {
|
||||||
|
meta.Region = geoip.RegionOther
|
||||||
|
meta.Country = "Unknown"
|
||||||
|
regionMembers[geoip.RegionOther] = append(regionMembers[geoip.RegionOther], tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata[tag] = meta
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close GeoIP database after lookup
|
||||||
|
if geoLookup != nil {
|
||||||
|
geoLookup.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have at least one valid node
|
||||||
|
if len(baseOutbounds) == 0 {
|
||||||
|
return option.Options{}, fmt.Errorf("no valid nodes available (all %d nodes failed to build)", len(cfg.Nodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log summary
|
||||||
|
if len(failedNodes) > 0 {
|
||||||
|
log.Printf("⚠️ %d/%d nodes failed and were skipped: %v", len(failedNodes), len(cfg.Nodes), failedNodes)
|
||||||
|
}
|
||||||
|
log.Printf("✅ Successfully built %d/%d nodes", len(baseOutbounds), len(cfg.Nodes))
|
||||||
|
|
||||||
|
// Log GeoIP region distribution
|
||||||
|
if cfg.GeoIP.Enabled {
|
||||||
|
log.Println("🌍 GeoIP Region Distribution:")
|
||||||
|
for _, region := range geoip.AllRegions() {
|
||||||
|
count := len(regionMembers[region])
|
||||||
|
if count > 0 {
|
||||||
|
log.Printf(" %s %s: %d nodes", geoip.RegionEmoji(region), geoip.RegionName(region), count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print proxy links for each node
|
||||||
|
printProxyLinks(cfg, metadata)
|
||||||
|
|
||||||
|
var (
|
||||||
|
inbounds []option.Inbound
|
||||||
|
outbounds = make([]option.Outbound, len(baseOutbounds))
|
||||||
|
route option.RouteOptions
|
||||||
|
)
|
||||||
|
copy(outbounds, baseOutbounds)
|
||||||
|
|
||||||
|
// Determine which components to enable based on mode
|
||||||
|
enablePoolInbound := cfg.Mode == "pool" || cfg.Mode == "hybrid"
|
||||||
|
enableMultiPort := cfg.Mode == "multi-port" || cfg.Mode == "hybrid"
|
||||||
|
|
||||||
|
if !enablePoolInbound && !enableMultiPort {
|
||||||
|
return option.Options{}, fmt.Errorf("unsupported mode %s", cfg.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build pool inbound (single entry point for all nodes)
|
||||||
|
if enablePoolInbound {
|
||||||
|
inbound, err := buildPoolInbound(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return option.Options{}, err
|
||||||
|
}
|
||||||
|
inbounds = append(inbounds, inbound)
|
||||||
|
poolOptions := poolout.Options{
|
||||||
|
Mode: cfg.Pool.Mode,
|
||||||
|
Members: memberTags,
|
||||||
|
FailureThreshold: cfg.Pool.FailureThreshold,
|
||||||
|
BlacklistDuration: cfg.Pool.BlacklistDuration,
|
||||||
|
Metadata: metadata,
|
||||||
|
}
|
||||||
|
outbounds = append(outbounds, option.Outbound{
|
||||||
|
Type: poolout.Type,
|
||||||
|
Tag: poolout.Tag,
|
||||||
|
Options: &poolOptions,
|
||||||
|
})
|
||||||
|
route.Final = poolout.Tag
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build multi-port inbounds (one port per node)
|
||||||
|
if enableMultiPort {
|
||||||
|
addr, err := parseAddr(cfg.MultiPort.Address)
|
||||||
|
if err != nil {
|
||||||
|
return option.Options{}, fmt.Errorf("parse multi-port address: %w", err)
|
||||||
|
}
|
||||||
|
for _, tag := range memberTags {
|
||||||
|
meta := metadata[tag]
|
||||||
|
perMeta := map[string]poolout.MemberMeta{tag: meta}
|
||||||
|
poolTag := fmt.Sprintf("%s-%s", poolout.Tag, tag)
|
||||||
|
perOptions := poolout.Options{
|
||||||
|
Mode: "sequential",
|
||||||
|
Members: []string{tag},
|
||||||
|
FailureThreshold: cfg.Pool.FailureThreshold,
|
||||||
|
BlacklistDuration: cfg.Pool.BlacklistDuration,
|
||||||
|
Metadata: perMeta,
|
||||||
|
}
|
||||||
|
perPool := option.Outbound{
|
||||||
|
Type: poolout.Type,
|
||||||
|
Tag: poolTag,
|
||||||
|
Options: &perOptions,
|
||||||
|
}
|
||||||
|
outbounds = append(outbounds, perPool)
|
||||||
|
inboundOptions := &option.HTTPMixedInboundOptions{
|
||||||
|
ListenOptions: option.ListenOptions{
|
||||||
|
Listen: addr,
|
||||||
|
ListenPort: meta.Port,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
username := cfg.MultiPort.Username
|
||||||
|
password := cfg.MultiPort.Password
|
||||||
|
if username != "" {
|
||||||
|
inboundOptions.Users = []auth.User{{Username: username, Password: password}}
|
||||||
|
}
|
||||||
|
inboundTag := fmt.Sprintf("in-%s", tag)
|
||||||
|
inbounds = append(inbounds, option.Inbound{
|
||||||
|
Type: C.TypeHTTP,
|
||||||
|
Tag: inboundTag,
|
||||||
|
Options: inboundOptions,
|
||||||
|
})
|
||||||
|
route.Rules = append(route.Rules, option.Rule{
|
||||||
|
Type: C.RuleTypeDefault,
|
||||||
|
DefaultOptions: option.DefaultRule{
|
||||||
|
RawDefaultRule: option.RawDefaultRule{
|
||||||
|
Inbound: badoption.Listable[string]{inboundTag},
|
||||||
|
},
|
||||||
|
RuleAction: option.RuleAction{
|
||||||
|
Action: C.RuleActionTypeRoute,
|
||||||
|
RouteOptions: option.RouteActionOptions{
|
||||||
|
Outbound: poolTag,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build GeoIP region-based pool outbounds and routing
|
||||||
|
if cfg.GeoIP.Enabled && enablePoolInbound {
|
||||||
|
// Create pool outbound for each region that has nodes
|
||||||
|
for _, region := range geoip.AllRegions() {
|
||||||
|
members := regionMembers[region]
|
||||||
|
if len(members) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build metadata for this region's members
|
||||||
|
regionMeta := make(map[string]poolout.MemberMeta)
|
||||||
|
for _, tag := range members {
|
||||||
|
regionMeta[tag] = metadata[tag]
|
||||||
|
}
|
||||||
|
|
||||||
|
regionPoolTag := fmt.Sprintf("pool-%s", region)
|
||||||
|
regionPoolOptions := poolout.Options{
|
||||||
|
Mode: cfg.Pool.Mode,
|
||||||
|
Members: members,
|
||||||
|
FailureThreshold: cfg.Pool.FailureThreshold,
|
||||||
|
BlacklistDuration: cfg.Pool.BlacklistDuration,
|
||||||
|
Metadata: regionMeta,
|
||||||
|
}
|
||||||
|
outbounds = append(outbounds, option.Outbound{
|
||||||
|
Type: poolout.Type,
|
||||||
|
Tag: regionPoolTag,
|
||||||
|
Options: ®ionPoolOptions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log GeoIP routing info
|
||||||
|
geoipPort := cfg.GeoIP.Port
|
||||||
|
if geoipPort == 0 {
|
||||||
|
geoipPort = cfg.Listener.Port
|
||||||
|
}
|
||||||
|
geoipListen := cfg.GeoIP.Listen
|
||||||
|
if geoipListen == "" {
|
||||||
|
geoipListen = cfg.Listener.Address
|
||||||
|
}
|
||||||
|
log.Println("🌐 GeoIP Region Routing Enabled:")
|
||||||
|
log.Printf(" Access via: http://%s:%d/{region}", geoipListen, geoipPort)
|
||||||
|
log.Println(" Available regions: /jp, /kr, /us, /hk, /tw, /other")
|
||||||
|
log.Println(" Default (no path): all nodes pool")
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := option.Options{
|
||||||
|
Log: &option.LogOptions{Level: strings.ToLower(cfg.LogLevel)},
|
||||||
|
Inbounds: inbounds,
|
||||||
|
Outbounds: outbounds,
|
||||||
|
Route: &route,
|
||||||
|
}
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPoolInbound(cfg *config.Config) (option.Inbound, error) {
|
||||||
|
listenAddr, err := parseAddr(cfg.Listener.Address)
|
||||||
|
if err != nil {
|
||||||
|
return option.Inbound{}, fmt.Errorf("parse listener address: %w", err)
|
||||||
|
}
|
||||||
|
inboundOptions := &option.HTTPMixedInboundOptions{
|
||||||
|
ListenOptions: option.ListenOptions{
|
||||||
|
Listen: listenAddr,
|
||||||
|
ListenPort: cfg.Listener.Port,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if cfg.Listener.Username != "" {
|
||||||
|
inboundOptions.Users = []auth.User{{
|
||||||
|
Username: cfg.Listener.Username,
|
||||||
|
Password: cfg.Listener.Password,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
inbound := option.Inbound{
|
||||||
|
Type: C.TypeHTTP,
|
||||||
|
Tag: "http-in",
|
||||||
|
Options: inboundOptions,
|
||||||
|
}
|
||||||
|
return inbound, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildNodeOutbound(tag, rawURI string, skipCertVerify bool) (option.Outbound, error) {
|
||||||
|
parsed, err := url.Parse(rawURI)
|
||||||
|
if err != nil {
|
||||||
|
return option.Outbound{}, fmt.Errorf("parse uri: %w", err)
|
||||||
|
}
|
||||||
|
switch strings.ToLower(parsed.Scheme) {
|
||||||
|
case "vless":
|
||||||
|
opts, err := buildVLESSOptions(parsed, skipCertVerify)
|
||||||
|
if err != nil {
|
||||||
|
return option.Outbound{}, err
|
||||||
|
}
|
||||||
|
return option.Outbound{Type: C.TypeVLESS, Tag: tag, Options: &opts}, nil
|
||||||
|
case "hysteria2", "hy2":
|
||||||
|
opts, err := buildHysteria2Options(parsed, skipCertVerify)
|
||||||
|
if err != nil {
|
||||||
|
return option.Outbound{}, err
|
||||||
|
}
|
||||||
|
return option.Outbound{Type: C.TypeHysteria2, Tag: tag, Options: &opts}, nil
|
||||||
|
case "ss", "shadowsocks":
|
||||||
|
opts, err := buildShadowsocksOptions(parsed)
|
||||||
|
if err != nil {
|
||||||
|
return option.Outbound{}, err
|
||||||
|
}
|
||||||
|
return option.Outbound{Type: C.TypeShadowsocks, Tag: tag, Options: &opts}, nil
|
||||||
|
case "trojan":
|
||||||
|
opts, err := buildTrojanOptions(parsed, skipCertVerify)
|
||||||
|
if err != nil {
|
||||||
|
return option.Outbound{}, err
|
||||||
|
}
|
||||||
|
return option.Outbound{Type: C.TypeTrojan, Tag: tag, Options: &opts}, nil
|
||||||
|
case "vmess":
|
||||||
|
opts, err := buildVMessOptions(rawURI, skipCertVerify)
|
||||||
|
if err != nil {
|
||||||
|
return option.Outbound{}, err
|
||||||
|
}
|
||||||
|
return option.Outbound{Type: C.TypeVMess, Tag: tag, Options: &opts}, nil
|
||||||
|
default:
|
||||||
|
return option.Outbound{}, fmt.Errorf("unsupported scheme %q", parsed.Scheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildVLESSOptions(u *url.URL, skipCertVerify bool) (option.VLESSOutboundOptions, error) {
|
||||||
|
uuid := u.User.Username()
|
||||||
|
if uuid == "" {
|
||||||
|
return option.VLESSOutboundOptions{}, errors.New("vless uri missing uuid in userinfo")
|
||||||
|
}
|
||||||
|
server, port, err := hostPort(u, 443)
|
||||||
|
if err != nil {
|
||||||
|
return option.VLESSOutboundOptions{}, err
|
||||||
|
}
|
||||||
|
query := u.Query()
|
||||||
|
opts := option.VLESSOutboundOptions{
|
||||||
|
UUID: uuid,
|
||||||
|
ServerOptions: option.ServerOptions{Server: server, ServerPort: uint16(port)},
|
||||||
|
Network: option.NetworkList(""),
|
||||||
|
}
|
||||||
|
if flow := query.Get("flow"); flow != "" {
|
||||||
|
opts.Flow = flow
|
||||||
|
}
|
||||||
|
if packetEncoding := query.Get("packetEncoding"); packetEncoding != "" {
|
||||||
|
opts.PacketEncoding = &packetEncoding
|
||||||
|
}
|
||||||
|
if transport, err := buildV2RayTransport(query); err != nil {
|
||||||
|
return option.VLESSOutboundOptions{}, err
|
||||||
|
} else if transport != nil {
|
||||||
|
opts.Transport = transport
|
||||||
|
}
|
||||||
|
if tlsOptions, err := buildTLSOptions(query, skipCertVerify); err != nil {
|
||||||
|
return option.VLESSOutboundOptions{}, err
|
||||||
|
} else if tlsOptions != nil {
|
||||||
|
opts.OutboundTLSOptionsContainer = option.OutboundTLSOptionsContainer{TLS: tlsOptions}
|
||||||
|
}
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildHysteria2Options(u *url.URL, skipCertVerify bool) (option.Hysteria2OutboundOptions, error) {
|
||||||
|
password := u.User.String()
|
||||||
|
server, port, err := hostPort(u, 443)
|
||||||
|
if err != nil {
|
||||||
|
return option.Hysteria2OutboundOptions{}, err
|
||||||
|
}
|
||||||
|
query := u.Query()
|
||||||
|
opts := option.Hysteria2OutboundOptions{
|
||||||
|
ServerOptions: option.ServerOptions{Server: server, ServerPort: uint16(port)},
|
||||||
|
Password: password,
|
||||||
|
}
|
||||||
|
if up := query.Get("upMbps"); up != "" {
|
||||||
|
opts.UpMbps = atoiDefault(up)
|
||||||
|
}
|
||||||
|
if down := query.Get("downMbps"); down != "" {
|
||||||
|
opts.DownMbps = atoiDefault(down)
|
||||||
|
}
|
||||||
|
if obfs := query.Get("obfs"); obfs != "" {
|
||||||
|
opts.Obfs = &option.Hysteria2Obfs{Type: obfs, Password: query.Get("obfs-password")}
|
||||||
|
}
|
||||||
|
opts.OutboundTLSOptionsContainer = option.OutboundTLSOptionsContainer{TLS: hysteriaTLSOptions(server, query, skipCertVerify)}
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hysteriaTLSOptions(host string, query url.Values, skipCertVerify bool) *option.OutboundTLSOptions {
|
||||||
|
tlsOptions := &option.OutboundTLSOptions{
|
||||||
|
Enabled: true,
|
||||||
|
ServerName: host,
|
||||||
|
Insecure: skipCertVerify,
|
||||||
|
}
|
||||||
|
if sni := query.Get("sni"); sni != "" {
|
||||||
|
tlsOptions.ServerName = sni
|
||||||
|
}
|
||||||
|
insecure := query.Get("insecure")
|
||||||
|
if insecure == "" {
|
||||||
|
insecure = query.Get("allowInsecure")
|
||||||
|
}
|
||||||
|
if insecure != "" {
|
||||||
|
tlsOptions.Insecure = insecure == "1" || strings.EqualFold(insecure, "true")
|
||||||
|
}
|
||||||
|
if alpn := query.Get("alpn"); alpn != "" {
|
||||||
|
tlsOptions.ALPN = badoption.Listable[string](strings.Split(alpn, ","))
|
||||||
|
}
|
||||||
|
return tlsOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTLSOptions(query url.Values, skipCertVerify bool) (*option.OutboundTLSOptions, error) {
|
||||||
|
security := strings.ToLower(query.Get("security"))
|
||||||
|
if security == "" || security == "none" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
tlsOptions := &option.OutboundTLSOptions{Enabled: true, Insecure: skipCertVerify}
|
||||||
|
if sni := query.Get("sni"); sni != "" {
|
||||||
|
tlsOptions.ServerName = sni
|
||||||
|
}
|
||||||
|
insecure := query.Get("allowInsecure")
|
||||||
|
if insecure == "" {
|
||||||
|
insecure = query.Get("insecure")
|
||||||
|
}
|
||||||
|
if insecure != "" {
|
||||||
|
tlsOptions.Insecure = insecure == "1" || strings.EqualFold(insecure, "true")
|
||||||
|
}
|
||||||
|
if alpn := query.Get("alpn"); alpn != "" {
|
||||||
|
tlsOptions.ALPN = badoption.Listable[string](strings.Split(alpn, ","))
|
||||||
|
}
|
||||||
|
fp := query.Get("fp")
|
||||||
|
if fp != "" {
|
||||||
|
tlsOptions.UTLS = &option.OutboundUTLSOptions{Enabled: true, Fingerprint: fp}
|
||||||
|
}
|
||||||
|
if security == "reality" {
|
||||||
|
tlsOptions.Reality = &option.OutboundRealityOptions{Enabled: true, PublicKey: query.Get("pbk"), ShortID: query.Get("sid")}
|
||||||
|
// Reality requires uTLS; use default fingerprint if not specified
|
||||||
|
if tlsOptions.UTLS == nil {
|
||||||
|
if fp == "" {
|
||||||
|
fp = "chrome"
|
||||||
|
}
|
||||||
|
tlsOptions.UTLS = &option.OutboundUTLSOptions{Enabled: true, Fingerprint: fp}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tlsOptions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildV2RayTransport(query url.Values) (*option.V2RayTransportOptions, error) {
|
||||||
|
transportType := strings.ToLower(query.Get("type"))
|
||||||
|
if transportType == "" || transportType == "tcp" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
options := &option.V2RayTransportOptions{Type: transportType}
|
||||||
|
switch transportType {
|
||||||
|
case C.V2RayTransportTypeWebsocket:
|
||||||
|
wsPath := query.Get("path")
|
||||||
|
// 解析 path 中的 early data 参数,如 /path?ed=2048
|
||||||
|
if idx := strings.Index(wsPath, "?ed="); idx != -1 {
|
||||||
|
edPart := wsPath[idx+4:]
|
||||||
|
wsPath = wsPath[:idx]
|
||||||
|
// 解析 ed 值
|
||||||
|
edValue := edPart
|
||||||
|
if ampIdx := strings.Index(edPart, "&"); ampIdx != -1 {
|
||||||
|
edValue = edPart[:ampIdx]
|
||||||
|
}
|
||||||
|
if ed, err := strconv.Atoi(edValue); err == nil && ed > 0 {
|
||||||
|
options.WebsocketOptions.MaxEarlyData = uint32(ed)
|
||||||
|
options.WebsocketOptions.EarlyDataHeaderName = "Sec-WebSocket-Protocol"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
options.WebsocketOptions.Path = wsPath
|
||||||
|
if host := query.Get("host"); host != "" {
|
||||||
|
options.WebsocketOptions.Headers = badoption.HTTPHeader{"Host": {host}}
|
||||||
|
}
|
||||||
|
case C.V2RayTransportTypeHTTP:
|
||||||
|
options.HTTPOptions.Path = query.Get("path")
|
||||||
|
if host := query.Get("host"); host != "" {
|
||||||
|
options.HTTPOptions.Host = badoption.Listable[string]{host}
|
||||||
|
}
|
||||||
|
case C.V2RayTransportTypeGRPC:
|
||||||
|
options.GRPCOptions.ServiceName = query.Get("serviceName")
|
||||||
|
case C.V2RayTransportTypeHTTPUpgrade:
|
||||||
|
options.HTTPUpgradeOptions.Path = query.Get("path")
|
||||||
|
case "xhttp":
|
||||||
|
// XHTTP is not supported by sing-box, fallback to HTTPUpgrade
|
||||||
|
log.Printf("⚠️ XHTTP transport not supported by sing-box, falling back to HTTPUpgrade")
|
||||||
|
options.Type = C.V2RayTransportTypeHTTPUpgrade
|
||||||
|
options.HTTPUpgradeOptions.Path = query.Get("path")
|
||||||
|
if host := query.Get("host"); host != "" {
|
||||||
|
options.HTTPUpgradeOptions.Headers = badoption.HTTPHeader{"Host": {host}}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported transport type %q", transportType)
|
||||||
|
}
|
||||||
|
return options, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildShadowsocksOptions(u *url.URL) (option.ShadowsocksOutboundOptions, error) {
|
||||||
|
server, port, err := hostPort(u, 8388)
|
||||||
|
if err != nil {
|
||||||
|
return option.ShadowsocksOutboundOptions{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode userinfo (base64 encoded method:password)
|
||||||
|
userInfo := u.User.String()
|
||||||
|
decoded, err := base64.RawURLEncoding.DecodeString(userInfo)
|
||||||
|
if err != nil {
|
||||||
|
// Try standard base64
|
||||||
|
decoded, err = base64.StdEncoding.DecodeString(userInfo)
|
||||||
|
if err != nil {
|
||||||
|
return option.ShadowsocksOutboundOptions{}, fmt.Errorf("decode shadowsocks userinfo: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(string(decoded), ":", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return option.ShadowsocksOutboundOptions{}, errors.New("shadowsocks userinfo format must be method:password")
|
||||||
|
}
|
||||||
|
|
||||||
|
method := parts[0]
|
||||||
|
password := parts[1]
|
||||||
|
|
||||||
|
opts := option.ShadowsocksOutboundOptions{
|
||||||
|
ServerOptions: option.ServerOptions{Server: server, ServerPort: uint16(port)},
|
||||||
|
Method: method,
|
||||||
|
Password: password,
|
||||||
|
}
|
||||||
|
|
||||||
|
query := u.Query()
|
||||||
|
if plugin := query.Get("plugin"); plugin != "" {
|
||||||
|
opts.Plugin = plugin
|
||||||
|
opts.PluginOptions = query.Get("plugin-opts")
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTrojanOptions(u *url.URL, skipCertVerify bool) (option.TrojanOutboundOptions, error) {
|
||||||
|
password := u.User.Username()
|
||||||
|
if password == "" {
|
||||||
|
return option.TrojanOutboundOptions{}, errors.New("trojan uri missing password in userinfo")
|
||||||
|
}
|
||||||
|
|
||||||
|
server, port, err := hostPort(u, 443)
|
||||||
|
if err != nil {
|
||||||
|
return option.TrojanOutboundOptions{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := u.Query()
|
||||||
|
opts := option.TrojanOutboundOptions{
|
||||||
|
ServerOptions: option.ServerOptions{Server: server, ServerPort: uint16(port)},
|
||||||
|
Password: password,
|
||||||
|
Network: option.NetworkList(""),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse TLS options
|
||||||
|
if tlsOptions, err := buildTrojanTLSOptions(query, skipCertVerify); err != nil {
|
||||||
|
return option.TrojanOutboundOptions{}, err
|
||||||
|
} else if tlsOptions != nil {
|
||||||
|
opts.OutboundTLSOptionsContainer = option.OutboundTLSOptionsContainer{TLS: tlsOptions}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse transport options
|
||||||
|
if transport, err := buildV2RayTransport(query); err != nil {
|
||||||
|
return option.TrojanOutboundOptions{}, err
|
||||||
|
} else if transport != nil {
|
||||||
|
opts.Transport = transport
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// vmessJSON represents the JSON structure of a VMess URI
|
||||||
|
type vmessJSON struct {
|
||||||
|
V interface{} `json:"v"` // Version, can be string or int
|
||||||
|
PS string `json:"ps"` // Remarks/name
|
||||||
|
Add string `json:"add"` // Server address
|
||||||
|
Port interface{} `json:"port"` // Server port, can be string or int
|
||||||
|
ID string `json:"id"` // UUID
|
||||||
|
Aid interface{} `json:"aid"` // Alter ID, can be string or int
|
||||||
|
Scy string `json:"scy"` // Security/cipher
|
||||||
|
Net string `json:"net"` // Network type (tcp, ws, etc.)
|
||||||
|
Type string `json:"type"` // Header type
|
||||||
|
Host string `json:"host"` // Host header
|
||||||
|
Path string `json:"path"` // Path
|
||||||
|
TLS string `json:"tls"` // TLS (tls or empty)
|
||||||
|
SNI string `json:"sni"` // SNI
|
||||||
|
ALPN string `json:"alpn"` // ALPN
|
||||||
|
FP string `json:"fp"` // Fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *vmessJSON) GetPort() int {
|
||||||
|
switch p := v.Port.(type) {
|
||||||
|
case float64:
|
||||||
|
return int(p)
|
||||||
|
case int:
|
||||||
|
return p
|
||||||
|
case string:
|
||||||
|
port, _ := strconv.Atoi(p)
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
return 443
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *vmessJSON) GetAlterId() int {
|
||||||
|
switch a := v.Aid.(type) {
|
||||||
|
case float64:
|
||||||
|
return int(a)
|
||||||
|
case int:
|
||||||
|
return a
|
||||||
|
case string:
|
||||||
|
aid, _ := strconv.Atoi(a)
|
||||||
|
return aid
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildVMessOptions(rawURI string, skipCertVerify bool) (option.VMessOutboundOptions, error) {
|
||||||
|
// Remove vmess:// prefix
|
||||||
|
encoded := strings.TrimPrefix(rawURI, "vmess://")
|
||||||
|
|
||||||
|
// Try to decode as base64 JSON (standard format)
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
// Try URL-safe base64
|
||||||
|
decoded, err = base64.RawURLEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
// Try as URL format: vmess://uuid@server:port?...
|
||||||
|
return buildVMessOptionsFromURL(rawURI, skipCertVerify)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var vmess vmessJSON
|
||||||
|
if err := json.Unmarshal(decoded, &vmess); err != nil {
|
||||||
|
return option.VMessOutboundOptions{}, fmt.Errorf("parse vmess json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if vmess.Add == "" {
|
||||||
|
return option.VMessOutboundOptions{}, errors.New("vmess missing server address")
|
||||||
|
}
|
||||||
|
if vmess.ID == "" {
|
||||||
|
return option.VMessOutboundOptions{}, errors.New("vmess missing uuid")
|
||||||
|
}
|
||||||
|
|
||||||
|
port := vmess.GetPort()
|
||||||
|
if port == 0 {
|
||||||
|
port = 443
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := option.VMessOutboundOptions{
|
||||||
|
ServerOptions: option.ServerOptions{
|
||||||
|
Server: vmess.Add,
|
||||||
|
ServerPort: uint16(port),
|
||||||
|
},
|
||||||
|
UUID: vmess.ID,
|
||||||
|
AlterId: vmess.GetAlterId(),
|
||||||
|
Security: vmess.Scy,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default security
|
||||||
|
if opts.Security == "" {
|
||||||
|
opts.Security = "auto"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build transport options
|
||||||
|
if vmess.Net != "" && vmess.Net != "tcp" {
|
||||||
|
transport := &option.V2RayTransportOptions{}
|
||||||
|
switch vmess.Net {
|
||||||
|
case "ws":
|
||||||
|
transport.Type = C.V2RayTransportTypeWebsocket
|
||||||
|
wsPath := vmess.Path
|
||||||
|
// Handle early data in path
|
||||||
|
if idx := strings.Index(wsPath, "?ed="); idx != -1 {
|
||||||
|
edPart := wsPath[idx+4:]
|
||||||
|
wsPath = wsPath[:idx]
|
||||||
|
edValue := edPart
|
||||||
|
if ampIdx := strings.Index(edPart, "&"); ampIdx != -1 {
|
||||||
|
edValue = edPart[:ampIdx]
|
||||||
|
}
|
||||||
|
if ed, err := strconv.Atoi(edValue); err == nil && ed > 0 {
|
||||||
|
transport.WebsocketOptions.MaxEarlyData = uint32(ed)
|
||||||
|
transport.WebsocketOptions.EarlyDataHeaderName = "Sec-WebSocket-Protocol"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transport.WebsocketOptions.Path = wsPath
|
||||||
|
if vmess.Host != "" {
|
||||||
|
transport.WebsocketOptions.Headers = badoption.HTTPHeader{"Host": {vmess.Host}}
|
||||||
|
}
|
||||||
|
case "h2":
|
||||||
|
transport.Type = C.V2RayTransportTypeHTTP
|
||||||
|
transport.HTTPOptions.Path = vmess.Path
|
||||||
|
if vmess.Host != "" {
|
||||||
|
transport.HTTPOptions.Host = badoption.Listable[string]{vmess.Host}
|
||||||
|
}
|
||||||
|
case "grpc":
|
||||||
|
transport.Type = C.V2RayTransportTypeGRPC
|
||||||
|
transport.GRPCOptions.ServiceName = vmess.Path
|
||||||
|
default:
|
||||||
|
transport.Type = vmess.Net
|
||||||
|
}
|
||||||
|
opts.Transport = transport
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build TLS options
|
||||||
|
if vmess.TLS == "tls" {
|
||||||
|
tlsOptions := &option.OutboundTLSOptions{Enabled: true, Insecure: skipCertVerify}
|
||||||
|
if vmess.SNI != "" {
|
||||||
|
tlsOptions.ServerName = vmess.SNI
|
||||||
|
} else if vmess.Host != "" {
|
||||||
|
tlsOptions.ServerName = vmess.Host
|
||||||
|
}
|
||||||
|
if vmess.ALPN != "" {
|
||||||
|
tlsOptions.ALPN = badoption.Listable[string](strings.Split(vmess.ALPN, ","))
|
||||||
|
}
|
||||||
|
if vmess.FP != "" {
|
||||||
|
tlsOptions.UTLS = &option.OutboundUTLSOptions{Enabled: true, Fingerprint: vmess.FP}
|
||||||
|
}
|
||||||
|
opts.OutboundTLSOptionsContainer = option.OutboundTLSOptionsContainer{TLS: tlsOptions}
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildVMessOptionsFromURL(rawURI string, skipCertVerify bool) (option.VMessOutboundOptions, error) {
|
||||||
|
parsed, err := url.Parse(rawURI)
|
||||||
|
if err != nil {
|
||||||
|
return option.VMessOutboundOptions{}, fmt.Errorf("parse vmess url: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
uuid := parsed.User.Username()
|
||||||
|
if uuid == "" {
|
||||||
|
return option.VMessOutboundOptions{}, errors.New("vmess uri missing uuid")
|
||||||
|
}
|
||||||
|
|
||||||
|
server, port, err := hostPort(parsed, 443)
|
||||||
|
if err != nil {
|
||||||
|
return option.VMessOutboundOptions{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := parsed.Query()
|
||||||
|
opts := option.VMessOutboundOptions{
|
||||||
|
ServerOptions: option.ServerOptions{
|
||||||
|
Server: server,
|
||||||
|
ServerPort: uint16(port),
|
||||||
|
},
|
||||||
|
UUID: uuid,
|
||||||
|
Security: query.Get("encryption"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Security == "" {
|
||||||
|
opts.Security = "auto"
|
||||||
|
}
|
||||||
|
|
||||||
|
if aid := query.Get("alterId"); aid != "" {
|
||||||
|
opts.AlterId, _ = strconv.Atoi(aid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build transport
|
||||||
|
if transport, err := buildV2RayTransport(query); err != nil {
|
||||||
|
return option.VMessOutboundOptions{}, err
|
||||||
|
} else if transport != nil {
|
||||||
|
opts.Transport = transport
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build TLS
|
||||||
|
if tlsOptions, err := buildTLSOptions(query, skipCertVerify); err != nil {
|
||||||
|
return option.VMessOutboundOptions{}, err
|
||||||
|
} else if tlsOptions != nil {
|
||||||
|
opts.OutboundTLSOptionsContainer = option.OutboundTLSOptionsContainer{TLS: tlsOptions}
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTrojanTLSOptions(query url.Values, skipCertVerify bool) (*option.OutboundTLSOptions, error) {
|
||||||
|
// Trojan always uses TLS by default
|
||||||
|
tlsOptions := &option.OutboundTLSOptions{Enabled: true, Insecure: skipCertVerify}
|
||||||
|
|
||||||
|
if sni := query.Get("sni"); sni != "" {
|
||||||
|
tlsOptions.ServerName = sni
|
||||||
|
}
|
||||||
|
if peer := query.Get("peer"); peer != "" && tlsOptions.ServerName == "" {
|
||||||
|
tlsOptions.ServerName = peer
|
||||||
|
}
|
||||||
|
|
||||||
|
insecure := query.Get("allowInsecure")
|
||||||
|
if insecure == "" {
|
||||||
|
insecure = query.Get("insecure")
|
||||||
|
}
|
||||||
|
if insecure != "" {
|
||||||
|
tlsOptions.Insecure = insecure == "1" || strings.EqualFold(insecure, "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
if alpn := query.Get("alpn"); alpn != "" {
|
||||||
|
tlsOptions.ALPN = badoption.Listable[string](strings.Split(alpn, ","))
|
||||||
|
}
|
||||||
|
|
||||||
|
if fp := query.Get("fp"); fp != "" {
|
||||||
|
tlsOptions.UTLS = &option.OutboundUTLSOptions{Enabled: true, Fingerprint: fp}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tlsOptions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostPort(u *url.URL, defaultPort int) (string, int, error) {
|
||||||
|
host := u.Hostname()
|
||||||
|
if host == "" {
|
||||||
|
return "", 0, errors.New("missing host")
|
||||||
|
}
|
||||||
|
portStr := u.Port()
|
||||||
|
if portStr == "" {
|
||||||
|
portStr = strconv.Itoa(defaultPort)
|
||||||
|
}
|
||||||
|
port, err := strconv.Atoi(portStr)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, fmt.Errorf("invalid port %q", portStr)
|
||||||
|
}
|
||||||
|
return host, port, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAddr(value string) (*badoption.Addr, error) {
|
||||||
|
addr := strings.TrimSpace(value)
|
||||||
|
if addr == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
parsed, err := netip.ParseAddr(addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bad := badoption.Addr(parsed)
|
||||||
|
return &bad, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeTag(name string) string {
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
lower = strings.TrimSpace(lower)
|
||||||
|
if lower == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
segments := strings.FieldsFunc(lower, func(r rune) bool {
|
||||||
|
return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9')
|
||||||
|
})
|
||||||
|
result := strings.Join(segments, "-")
|
||||||
|
result = strings.Trim(result, "-")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func atoiDefault(value string) int {
|
||||||
|
if strings.HasSuffix(value, "mbps") {
|
||||||
|
value = strings.TrimSuffix(value, "mbps")
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(value, "Mbps") {
|
||||||
|
value = strings.TrimSuffix(value, "Mbps")
|
||||||
|
}
|
||||||
|
v, _ := strconv.Atoi(value)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// printProxyLinks prints all proxy connection information at startup
|
||||||
|
func printProxyLinks(cfg *config.Config, metadata map[string]poolout.MemberMeta) {
|
||||||
|
log.Println("")
|
||||||
|
log.Println("📡 Proxy Links:")
|
||||||
|
log.Println("═══════════════════════════════════════════════════════════════")
|
||||||
|
|
||||||
|
showPoolEntry := cfg.Mode == "pool" || cfg.Mode == "hybrid"
|
||||||
|
showMultiPort := cfg.Mode == "multi-port" || cfg.Mode == "hybrid"
|
||||||
|
|
||||||
|
if showPoolEntry {
|
||||||
|
// Pool mode: single entry point for all nodes
|
||||||
|
var auth string
|
||||||
|
if cfg.Listener.Username != "" {
|
||||||
|
auth = fmt.Sprintf("%s:%s@", cfg.Listener.Username, cfg.Listener.Password)
|
||||||
|
}
|
||||||
|
proxyURL := fmt.Sprintf("http://%s%s:%d", auth, cfg.Listener.Address, cfg.Listener.Port)
|
||||||
|
log.Printf("🌐 Pool Entry Point:")
|
||||||
|
log.Printf(" %s", proxyURL)
|
||||||
|
log.Println("")
|
||||||
|
log.Printf(" Nodes in pool (%d):", len(metadata))
|
||||||
|
for _, meta := range metadata {
|
||||||
|
log.Printf(" • %s", meta.Name)
|
||||||
|
}
|
||||||
|
if showMultiPort {
|
||||||
|
log.Println("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if showMultiPort {
|
||||||
|
// Multi-port mode: each node has its own port
|
||||||
|
log.Printf("🔌 Multi-Port Entry Points (%d nodes):", len(cfg.Nodes))
|
||||||
|
log.Println("")
|
||||||
|
for _, node := range cfg.Nodes {
|
||||||
|
var auth string
|
||||||
|
username := node.Username
|
||||||
|
password := node.Password
|
||||||
|
if username == "" {
|
||||||
|
username = cfg.MultiPort.Username
|
||||||
|
password = cfg.MultiPort.Password
|
||||||
|
}
|
||||||
|
if username != "" {
|
||||||
|
auth = fmt.Sprintf("%s:%s@", username, password)
|
||||||
|
}
|
||||||
|
proxyURL := fmt.Sprintf("http://%s%s:%d", auth, cfg.MultiPort.Address, node.Port)
|
||||||
|
log.Printf(" [%d] %s", node.Port, node.Name)
|
||||||
|
log.Printf(" %s", proxyURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("═══════════════════════════════════════════════════════════════")
|
||||||
|
log.Println("")
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,615 @@
|
|||||||
|
package geoip
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oschwald/geoip2-golang"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Region codes
|
||||||
|
const (
|
||||||
|
RegionJP = "jp"
|
||||||
|
RegionKR = "kr"
|
||||||
|
RegionUS = "us"
|
||||||
|
RegionHK = "hk"
|
||||||
|
RegionTW = "tw"
|
||||||
|
RegionOther = "other"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default GeoIP database download URL
|
||||||
|
const (
|
||||||
|
DefaultGeoIPURL = "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegionInfo contains region details
|
||||||
|
type RegionInfo struct {
|
||||||
|
Code string // "jp", "kr", "us", "hk", "tw", "other"
|
||||||
|
Country string // Full country name
|
||||||
|
ISOCode string // ISO country code
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup provides GeoIP lookup functionality
|
||||||
|
type Lookup struct {
|
||||||
|
db *geoip2.Reader
|
||||||
|
mu sync.RWMutex
|
||||||
|
path string
|
||||||
|
updateInterval time.Duration
|
||||||
|
stopChan chan struct{}
|
||||||
|
updateOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureDatabase checks if the GeoIP database exists, and downloads it if not
|
||||||
|
func EnsureDatabase(dbPath string) error {
|
||||||
|
if dbPath == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file already exists and is valid
|
||||||
|
info, err := os.Stat(dbPath)
|
||||||
|
if err == nil {
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return fmt.Errorf("geoip database path is not a file: %s", dbPath)
|
||||||
|
}
|
||||||
|
if info.Size() > 0 {
|
||||||
|
return nil // File exists and has content
|
||||||
|
}
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("stat geoip database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("📥 GeoIP database not found at %s, downloading...", dbPath)
|
||||||
|
|
||||||
|
// Create parent directory if needed
|
||||||
|
dir := filepath.Dir(dbPath)
|
||||||
|
if dir != "." {
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("create directory: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download with timeout
|
||||||
|
client := &http.Client{Timeout: 60 * time.Second}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, DefaultGeoIPURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create download request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("download failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("download failed: unexpected status %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download to temporary file
|
||||||
|
tempFile, err := os.CreateTemp(dir, ".geoip-*.mmdb")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create temp file: %w", err)
|
||||||
|
}
|
||||||
|
tempPath := tempFile.Name()
|
||||||
|
cleanup := true
|
||||||
|
defer func() {
|
||||||
|
if tempFile != nil {
|
||||||
|
tempFile.Close()
|
||||||
|
}
|
||||||
|
if cleanup {
|
||||||
|
os.Remove(tempPath)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Copy with progress tracking
|
||||||
|
progress := &progressWriter{total: resp.ContentLength}
|
||||||
|
reader := io.TeeReader(resp.Body, progress)
|
||||||
|
written, err := io.Copy(tempFile, reader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("download failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify download completeness
|
||||||
|
if resp.ContentLength > 0 && written < resp.ContentLength {
|
||||||
|
return fmt.Errorf("incomplete download (%d/%d bytes)", written, resp.ContentLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync and close temp file
|
||||||
|
if err := tempFile.Sync(); err != nil {
|
||||||
|
return fmt.Errorf("sync temp file: %w", err)
|
||||||
|
}
|
||||||
|
if err := tempFile.Close(); err != nil {
|
||||||
|
return fmt.Errorf("close temp file: %w", err)
|
||||||
|
}
|
||||||
|
tempFile = nil
|
||||||
|
|
||||||
|
// Validate MMDB format
|
||||||
|
if err := validateMMDB(tempPath); err != nil {
|
||||||
|
return fmt.Errorf("validation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic rename
|
||||||
|
if err := os.Rename(tempPath, dbPath); err != nil {
|
||||||
|
return fmt.Errorf("rename failed: %w", err)
|
||||||
|
}
|
||||||
|
cleanup = false
|
||||||
|
|
||||||
|
log.Printf("✅ GeoIP database downloaded successfully to %s", dbPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// progressWriter tracks download progress
|
||||||
|
type progressWriter struct {
|
||||||
|
total int64
|
||||||
|
downloaded int64
|
||||||
|
lastPercent int64
|
||||||
|
lastLog time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *progressWriter) Write(b []byte) (int, error) {
|
||||||
|
n := len(b)
|
||||||
|
p.downloaded += int64(n)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if p.total > 0 {
|
||||||
|
percent := p.downloaded * 100 / p.total
|
||||||
|
if percent >= 100 || percent >= p.lastPercent+10 || now.Sub(p.lastLog) >= 3*time.Second {
|
||||||
|
log.Printf(" Progress: %d%% (%d/%d bytes)", percent, p.downloaded, p.total)
|
||||||
|
p.lastPercent = percent
|
||||||
|
p.lastLog = now
|
||||||
|
}
|
||||||
|
} else if now.Sub(p.lastLog) >= 3*time.Second {
|
||||||
|
log.Printf(" Downloaded: %d bytes", p.downloaded)
|
||||||
|
p.lastLog = now
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateMMDB performs basic validation of MMDB file format
|
||||||
|
func validateMMDB(path string) error {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.Size() < 1024 {
|
||||||
|
return fmt.Errorf("file too small (%d bytes)", info.Size())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for MaxMind metadata in the last 8KB
|
||||||
|
const tailSize int64 = 8192
|
||||||
|
readSize := tailSize
|
||||||
|
if info.Size() < readSize {
|
||||||
|
readSize = info.Size()
|
||||||
|
}
|
||||||
|
if _, err := file.Seek(-readSize, io.SeekEnd); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buf := make([]byte, readSize)
|
||||||
|
if _, err := io.ReadFull(file, buf); err != nil && err != io.ErrUnexpectedEOF {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !bytes.Contains(buf, []byte("MaxMind.com")) {
|
||||||
|
return fmt.Errorf("missing MaxMind metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadFile downloads a file from URL to the specified path
|
||||||
|
func downloadFile(filepath string, url string) error {
|
||||||
|
// Create the file
|
||||||
|
out, err := os.Create(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
// Get the data
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Check server response
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("bad status: %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writer the body to file
|
||||||
|
size, err := io.Copy(out, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf(" Downloaded %d bytes", size)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new GeoIP lookup instance
|
||||||
|
func New(dbPath string) (*Lookup, error) {
|
||||||
|
return NewWithAutoUpdate(dbPath, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithAutoUpdate creates a new GeoIP lookup instance with auto-update support
|
||||||
|
func NewWithAutoUpdate(dbPath string, updateInterval time.Duration) (*Lookup, error) {
|
||||||
|
if dbPath == "" {
|
||||||
|
return &Lookup{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure database exists (download if needed)
|
||||||
|
if err := EnsureDatabase(dbPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("ensure database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := geoip2.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup := &Lookup{
|
||||||
|
db: db,
|
||||||
|
path: dbPath,
|
||||||
|
updateInterval: updateInterval,
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start auto-update goroutine if interval is set
|
||||||
|
if updateInterval > 0 {
|
||||||
|
go lookup.autoUpdateLoop()
|
||||||
|
log.Printf("🔄 GeoIP auto-update enabled (interval: %v)", updateInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lookup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoUpdateLoop periodically updates the GeoIP database
|
||||||
|
func (l *Lookup) autoUpdateLoop() {
|
||||||
|
ticker := time.NewTicker(l.updateInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := l.Update(); err != nil {
|
||||||
|
log.Printf("⚠️ GeoIP auto-update failed: %v", err)
|
||||||
|
}
|
||||||
|
case <-l.stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update downloads and reloads the GeoIP database
|
||||||
|
func (l *Lookup) Update() error {
|
||||||
|
log.Printf("🔄 Updating GeoIP database...")
|
||||||
|
|
||||||
|
// Download to temporary file
|
||||||
|
tempPath := l.path + ".update"
|
||||||
|
if err := downloadDatabase(tempPath); err != nil {
|
||||||
|
return fmt.Errorf("download failed: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tempPath) // Clean up temp file
|
||||||
|
|
||||||
|
// Validate the downloaded database
|
||||||
|
if err := validateMMDB(tempPath); err != nil {
|
||||||
|
return fmt.Errorf("validation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open new database
|
||||||
|
newDB, err := geoip2.Open(tempPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open new database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hot-swap the database
|
||||||
|
l.mu.Lock()
|
||||||
|
oldDB := l.db
|
||||||
|
l.db = newDB
|
||||||
|
l.mu.Unlock()
|
||||||
|
|
||||||
|
// Close old database
|
||||||
|
if oldDB != nil {
|
||||||
|
oldDB.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the old file with new one
|
||||||
|
if err := os.Rename(tempPath, l.path); err != nil {
|
||||||
|
log.Printf("⚠️ Failed to replace database file: %v (using in-memory version)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("✅ GeoIP database updated successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadDatabase downloads the GeoIP database to the specified path
|
||||||
|
func downloadDatabase(filepath string) error {
|
||||||
|
// Create parent directory if needed
|
||||||
|
dir := filepath[:strings.LastIndex(filepath, "/")]
|
||||||
|
if dir != "" && dir != "." {
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("create directory: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download with timeout
|
||||||
|
client := &http.Client{Timeout: 60 * time.Second}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, DefaultGeoIPURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create download request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("unexpected status %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create temp file
|
||||||
|
tempFile, err := os.CreateTemp(dir, ".geoip-download-*.mmdb")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create temp file: %w", err)
|
||||||
|
}
|
||||||
|
tempPath := tempFile.Name()
|
||||||
|
cleanup := true
|
||||||
|
defer func() {
|
||||||
|
if tempFile != nil {
|
||||||
|
tempFile.Close()
|
||||||
|
}
|
||||||
|
if cleanup {
|
||||||
|
os.Remove(tempPath)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Copy with progress tracking
|
||||||
|
progress := &progressWriter{total: resp.ContentLength}
|
||||||
|
reader := io.TeeReader(resp.Body, progress)
|
||||||
|
written, err := io.Copy(tempFile, reader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify download completeness
|
||||||
|
if resp.ContentLength > 0 && written < resp.ContentLength {
|
||||||
|
return fmt.Errorf("incomplete download (%d/%d bytes)", written, resp.ContentLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync and close
|
||||||
|
if err := tempFile.Sync(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tempFile.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tempFile = nil
|
||||||
|
|
||||||
|
// Rename to target path
|
||||||
|
if err := os.Rename(tempPath, filepath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cleanup = false
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the GeoIP database and stops auto-update
|
||||||
|
func (l *Lookup) Close() error {
|
||||||
|
// Stop auto-update goroutine
|
||||||
|
l.updateOnce.Do(func() {
|
||||||
|
if l.stopChan != nil {
|
||||||
|
close(l.stopChan)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
if l.db != nil {
|
||||||
|
return l.db.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEnabled returns true if GeoIP lookup is available
|
||||||
|
func (l *Lookup) IsEnabled() bool {
|
||||||
|
l.mu.RLock()
|
||||||
|
defer l.mu.RUnlock()
|
||||||
|
return l.db != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupIP returns region info for an IP address
|
||||||
|
func (l *Lookup) LookupIP(ipStr string) RegionInfo {
|
||||||
|
l.mu.RLock()
|
||||||
|
defer l.mu.RUnlock()
|
||||||
|
|
||||||
|
if l.db == nil {
|
||||||
|
return RegionInfo{Code: RegionOther, Country: "Unknown", ISOCode: ""}
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := net.ParseIP(ipStr)
|
||||||
|
if ip == nil {
|
||||||
|
return RegionInfo{Code: RegionOther, Country: "Unknown", ISOCode: ""}
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := l.db.Country(ip)
|
||||||
|
if err != nil {
|
||||||
|
return RegionInfo{Code: RegionOther, Country: "Unknown", ISOCode: ""}
|
||||||
|
}
|
||||||
|
|
||||||
|
isoCode := record.Country.IsoCode
|
||||||
|
country := record.Country.Names["en"]
|
||||||
|
if country == "" {
|
||||||
|
country = isoCode
|
||||||
|
}
|
||||||
|
|
||||||
|
return RegionInfo{
|
||||||
|
Code: isoCodeToRegion(isoCode),
|
||||||
|
Country: country,
|
||||||
|
ISOCode: isoCode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupURI extracts server from URI and returns region info
|
||||||
|
func (l *Lookup) LookupURI(uri string) RegionInfo {
|
||||||
|
host := extractHostFromURI(uri)
|
||||||
|
if host == "" {
|
||||||
|
return RegionInfo{Code: RegionOther, Country: "Unknown", ISOCode: ""}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve hostname to IP if needed
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
if ip == nil {
|
||||||
|
// It's a hostname, try to resolve
|
||||||
|
ips, err := net.LookupIP(host)
|
||||||
|
if err != nil || len(ips) == 0 {
|
||||||
|
return RegionInfo{Code: RegionOther, Country: "Unknown", ISOCode: ""}
|
||||||
|
}
|
||||||
|
host = ips[0].String()
|
||||||
|
}
|
||||||
|
|
||||||
|
return l.LookupIP(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractHostFromURI extracts the host/IP from various proxy URI formats
|
||||||
|
func extractHostFromURI(uri string) string {
|
||||||
|
// Handle different URI schemes
|
||||||
|
lowerURI := strings.ToLower(uri)
|
||||||
|
|
||||||
|
// vmess:// vless:// trojan:// ss:// ssr:// hysteria:// hysteria2:// hy2://
|
||||||
|
if strings.HasPrefix(lowerURI, "vmess://") ||
|
||||||
|
strings.HasPrefix(lowerURI, "vless://") ||
|
||||||
|
strings.HasPrefix(lowerURI, "trojan://") ||
|
||||||
|
strings.HasPrefix(lowerURI, "hysteria://") ||
|
||||||
|
strings.HasPrefix(lowerURI, "hysteria2://") ||
|
||||||
|
strings.HasPrefix(lowerURI, "hy2://") {
|
||||||
|
// Standard URL format: scheme://user@host:port?params#fragment
|
||||||
|
parsed, err := url.Parse(uri)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parsed.Hostname()
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(lowerURI, "ss://") {
|
||||||
|
// Shadowsocks format: ss://base64(method:password)@host:port#name
|
||||||
|
// or ss://base64@host:port#name
|
||||||
|
return extractSSHost(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(lowerURI, "ssr://") {
|
||||||
|
// SSR format is base64 encoded
|
||||||
|
return extractSSRHost(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractSSHost(uri string) string {
|
||||||
|
// Remove ss:// prefix
|
||||||
|
content := strings.TrimPrefix(uri, "ss://")
|
||||||
|
|
||||||
|
// Remove fragment (#name)
|
||||||
|
if idx := strings.Index(content, "#"); idx != -1 {
|
||||||
|
content = content[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's the new format: base64@host:port
|
||||||
|
if atIdx := strings.LastIndex(content, "@"); atIdx != -1 {
|
||||||
|
hostPort := content[atIdx+1:]
|
||||||
|
if colonIdx := strings.LastIndex(hostPort, ":"); colonIdx != -1 {
|
||||||
|
return hostPort[:colonIdx]
|
||||||
|
}
|
||||||
|
return hostPort
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old format: entire content is base64
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractSSRHost(uri string) string {
|
||||||
|
// SSR is complex, skip for now - will be marked as "other"
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// isoCodeToRegion maps ISO country codes to our region codes
|
||||||
|
func isoCodeToRegion(isoCode string) string {
|
||||||
|
switch strings.ToUpper(isoCode) {
|
||||||
|
case "JP":
|
||||||
|
return RegionJP
|
||||||
|
case "KR":
|
||||||
|
return RegionKR
|
||||||
|
case "US":
|
||||||
|
return RegionUS
|
||||||
|
case "HK":
|
||||||
|
return RegionHK
|
||||||
|
case "TW":
|
||||||
|
return RegionTW
|
||||||
|
default:
|
||||||
|
return RegionOther
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllRegions returns all supported region codes
|
||||||
|
func AllRegions() []string {
|
||||||
|
return []string{RegionJP, RegionKR, RegionUS, RegionHK, RegionTW, RegionOther}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegionName returns the display name for a region code
|
||||||
|
func RegionName(code string) string {
|
||||||
|
switch code {
|
||||||
|
case RegionJP:
|
||||||
|
return "Japan"
|
||||||
|
case RegionKR:
|
||||||
|
return "Korea"
|
||||||
|
case RegionUS:
|
||||||
|
return "USA"
|
||||||
|
case RegionHK:
|
||||||
|
return "Hong Kong"
|
||||||
|
case RegionTW:
|
||||||
|
return "Taiwan"
|
||||||
|
case RegionOther:
|
||||||
|
return "Other"
|
||||||
|
default:
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegionEmoji returns the flag emoji for a region code
|
||||||
|
func RegionEmoji(code string) string {
|
||||||
|
switch code {
|
||||||
|
case RegionJP:
|
||||||
|
return "🇯🇵"
|
||||||
|
case RegionKR:
|
||||||
|
return "🇰🇷"
|
||||||
|
case RegionUS:
|
||||||
|
return "🇺🇸"
|
||||||
|
case RegionHK:
|
||||||
|
return "🇭🇰"
|
||||||
|
case RegionTW:
|
||||||
|
return "🇹🇼"
|
||||||
|
case RegionOther:
|
||||||
|
return "🌍"
|
||||||
|
default:
|
||||||
|
return "❓"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package geoip
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RouterConfig holds configuration for the GeoIP router
|
||||||
|
type RouterConfig struct {
|
||||||
|
Listen string
|
||||||
|
Port uint16
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PoolDialer is an interface for dialing through a specific pool
|
||||||
|
type PoolDialer interface {
|
||||||
|
DialContext(ctx context.Context, network, address string) (net.Conn, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Router handles HTTP proxy requests with path-based region routing
|
||||||
|
type Router struct {
|
||||||
|
cfg RouterConfig
|
||||||
|
pools map[string]PoolDialer // region -> dialer
|
||||||
|
global PoolDialer // default pool for requests without region path
|
||||||
|
server *http.Server
|
||||||
|
mu sync.RWMutex
|
||||||
|
logger *log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRouter creates a new GeoIP router
|
||||||
|
func NewRouter(cfg RouterConfig, logger *log.Logger) *Router {
|
||||||
|
if logger == nil {
|
||||||
|
logger = log.Default()
|
||||||
|
}
|
||||||
|
return &Router{
|
||||||
|
cfg: cfg,
|
||||||
|
pools: make(map[string]PoolDialer),
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPool registers a pool dialer for a specific region
|
||||||
|
func (r *Router) SetPool(region string, dialer PoolDialer) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.pools[region] = dialer
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetGlobalPool sets the default pool for requests without region path
|
||||||
|
func (r *Router) SetGlobalPool(dialer PoolDialer) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.global = dialer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the GeoIP router HTTP server
|
||||||
|
func (r *Router) Start(ctx context.Context) error {
|
||||||
|
addr := fmt.Sprintf("%s:%d", r.cfg.Listen, r.cfg.Port)
|
||||||
|
|
||||||
|
r.server = &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: r,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
r.logger.Printf("🌐 GeoIP Router started on %s", addr)
|
||||||
|
r.logger.Println(" Routes: /jp, /kr, /us, /hk, /tw, /other (default: all nodes)")
|
||||||
|
if err := r.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
r.logger.Printf("GeoIP router error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
r.Stop()
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the GeoIP router
|
||||||
|
func (r *Router) Stop() error {
|
||||||
|
if r.server != nil {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return r.server.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP handles incoming HTTP proxy requests
|
||||||
|
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||||
|
// Check authentication if configured
|
||||||
|
if r.cfg.Username != "" {
|
||||||
|
user, pass, ok := req.BasicAuth()
|
||||||
|
if !ok || user != r.cfg.Username || pass != r.cfg.Password {
|
||||||
|
w.Header().Set("Proxy-Authenticate", `Basic realm="Proxy"`)
|
||||||
|
http.Error(w, "Proxy authentication required", http.StatusProxyAuthRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract region from path
|
||||||
|
region, targetHost := r.parseRequest(req)
|
||||||
|
|
||||||
|
// Get the appropriate pool
|
||||||
|
r.mu.RLock()
|
||||||
|
var dialer PoolDialer
|
||||||
|
if region != "" {
|
||||||
|
dialer = r.pools[region]
|
||||||
|
}
|
||||||
|
if dialer == nil {
|
||||||
|
dialer = r.global
|
||||||
|
}
|
||||||
|
r.mu.RUnlock()
|
||||||
|
|
||||||
|
if dialer == nil {
|
||||||
|
http.Error(w, "No proxy pool available", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Method == http.MethodConnect {
|
||||||
|
r.handleConnect(w, req, dialer, targetHost)
|
||||||
|
} else {
|
||||||
|
r.handleHTTP(w, req, dialer, targetHost)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRequest extracts region and target host from the request
|
||||||
|
func (r *Router) parseRequest(req *http.Request) (region, targetHost string) {
|
||||||
|
// For CONNECT requests, the host is in req.Host
|
||||||
|
// For regular requests, check the path prefix
|
||||||
|
|
||||||
|
if req.Method == http.MethodConnect {
|
||||||
|
// CONNECT requests: check if host starts with region prefix
|
||||||
|
// e.g., CONNECT jp/example.com:443 or just example.com:443
|
||||||
|
host := req.Host
|
||||||
|
for _, reg := range AllRegions() {
|
||||||
|
prefix := reg + "/"
|
||||||
|
if strings.HasPrefix(host, prefix) {
|
||||||
|
return reg, strings.TrimPrefix(host, prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", host
|
||||||
|
}
|
||||||
|
|
||||||
|
// For regular HTTP requests, check URL path
|
||||||
|
path := req.URL.Path
|
||||||
|
for _, reg := range AllRegions() {
|
||||||
|
prefix := "/" + reg + "/"
|
||||||
|
if strings.HasPrefix(path, prefix) {
|
||||||
|
// Rewrite the path
|
||||||
|
req.URL.Path = "/" + strings.TrimPrefix(path, prefix)
|
||||||
|
return reg, req.Host
|
||||||
|
}
|
||||||
|
// Also check for exact match like /jp
|
||||||
|
if path == "/"+reg {
|
||||||
|
req.URL.Path = "/"
|
||||||
|
return reg, req.Host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", req.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleConnect handles HTTPS CONNECT tunneling
|
||||||
|
func (r *Router) handleConnect(w http.ResponseWriter, req *http.Request, dialer PoolDialer, targetHost string) {
|
||||||
|
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
targetConn, err := dialer.DialContext(ctx, "tcp", targetHost)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to connect: %v", err), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer targetConn.Close()
|
||||||
|
|
||||||
|
hijacker, ok := w.(http.Hijacker)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Hijacking not supported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientConn, _, err := hijacker.Hijack()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Hijack failed: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer clientConn.Close()
|
||||||
|
|
||||||
|
// Send 200 Connection Established
|
||||||
|
clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
|
||||||
|
|
||||||
|
// Bidirectional copy
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
io.Copy(targetConn, clientConn)
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
io.Copy(clientConn, targetConn)
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHTTP handles regular HTTP requests
|
||||||
|
func (r *Router) handleHTTP(w http.ResponseWriter, req *http.Request, dialer PoolDialer, targetHost string) {
|
||||||
|
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Create a new request to the target
|
||||||
|
targetURL := req.URL
|
||||||
|
if targetURL.Host == "" {
|
||||||
|
targetURL.Host = targetHost
|
||||||
|
}
|
||||||
|
if targetURL.Scheme == "" {
|
||||||
|
targetURL.Scheme = "http"
|
||||||
|
}
|
||||||
|
|
||||||
|
outReq, err := http.NewRequestWithContext(ctx, req.Method, targetURL.String(), req.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to create request: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy headers
|
||||||
|
for key, values := range req.Header {
|
||||||
|
for _, value := range values {
|
||||||
|
outReq.Header.Add(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove hop-by-hop headers
|
||||||
|
outReq.Header.Del("Proxy-Connection")
|
||||||
|
outReq.Header.Del("Proxy-Authorization")
|
||||||
|
|
||||||
|
// Create transport with custom dialer
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: dialer.DialContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := transport.RoundTrip(outReq)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Request failed: %v", err), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Copy response headers
|
||||||
|
for key, values := range resp.Header {
|
||||||
|
for _, value := range values {
|
||||||
|
w.Header().Add(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
io.Copy(w, resp.Body)
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Easy Proxies</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-BkdNNtGR.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-CZdhzSdd.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
package monitor
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const managerKey contextKey = "easy_proxies.monitor"
|
||||||
|
|
||||||
|
// ContextWith attaches the manager into context so downstream components can reuse it.
|
||||||
|
func ContextWith(ctx context.Context, mgr *Manager) context.Context {
|
||||||
|
if mgr == nil {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, managerKey, mgr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromContext extracts a manager if present.
|
||||||
|
func FromContext(ctx context.Context) *Manager {
|
||||||
|
mgr, _ := ctx.Value(managerKey).(*Manager)
|
||||||
|
return mgr
|
||||||
|
}
|
||||||
@@ -0,0 +1,868 @@
|
|||||||
|
package monitor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"runtime"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
M "github.com/sagernet/sing/common/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config mirrors user settings needed by the monitoring server.
|
||||||
|
type Config struct {
|
||||||
|
Enabled bool
|
||||||
|
Listen string
|
||||||
|
ProbeTarget string
|
||||||
|
Password string
|
||||||
|
ProxyUsername string // 代理池的用户名(用于导出)
|
||||||
|
ProxyPassword string // 代理池的密码(用于导出)
|
||||||
|
ExternalIP string // 外部 IP 地址,用于导出时替换 0.0.0.0
|
||||||
|
SkipCertVerify bool // 全局跳过 SSL 证书验证
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeInfo is static metadata about a proxy entry.
|
||||||
|
type NodeInfo struct {
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
URI string `json:"uri"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
ListenAddress string `json:"listen_address,omitempty"`
|
||||||
|
Port uint16 `json:"port,omitempty"`
|
||||||
|
Region string `json:"region,omitempty"` // GeoIP region code: "jp", "kr", "us", "hk", "tw", "other"
|
||||||
|
Country string `json:"country,omitempty"` // Full country name from GeoIP
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimelineEvent represents a single usage event for debug tracking.
|
||||||
|
type TimelineEvent struct {
|
||||||
|
Time time.Time `json:"time"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
LatencyMs int64 `json:"latency_ms"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Destination string `json:"destination,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxTimelineSize = 20
|
||||||
|
|
||||||
|
// Snapshot is a runtime view of a proxy node.
|
||||||
|
type Snapshot struct {
|
||||||
|
NodeInfo
|
||||||
|
FailureCount int `json:"failure_count"`
|
||||||
|
SuccessCount int64 `json:"success_count"`
|
||||||
|
Blacklisted bool `json:"blacklisted"`
|
||||||
|
BlacklistedUntil time.Time `json:"blacklisted_until"`
|
||||||
|
ActiveConnections int32 `json:"active_connections"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
LastFailure time.Time `json:"last_failure,omitempty"`
|
||||||
|
LastSuccess time.Time `json:"last_success,omitempty"`
|
||||||
|
LastProbeLatency time.Duration `json:"last_probe_latency,omitempty"`
|
||||||
|
LastLatencyMs int64 `json:"last_latency_ms"`
|
||||||
|
Available bool `json:"available"`
|
||||||
|
InitialCheckDone bool `json:"initial_check_done"`
|
||||||
|
TotalUpload int64 `json:"total_upload"`
|
||||||
|
TotalDownload int64 `json:"total_download"`
|
||||||
|
UploadSpeed int64 `json:"upload_speed"` // bytes/sec
|
||||||
|
DownloadSpeed int64 `json:"download_speed"` // bytes/sec
|
||||||
|
Timeline []TimelineEvent `json:"timeline,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NodeTrafficSpeed struct {
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
UploadSpeed int64 `json:"upload_speed"` // bytes/sec
|
||||||
|
DownloadSpeed int64 `json:"download_speed"` // bytes/sec
|
||||||
|
TotalUpload int64 `json:"total_upload"`
|
||||||
|
TotalDownload int64 `json:"total_download"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrafficSummary struct {
|
||||||
|
NodeCount int `json:"node_count"`
|
||||||
|
TotalUpload int64 `json:"total_upload"`
|
||||||
|
TotalDownload int64 `json:"total_download"`
|
||||||
|
UploadSpeed int64 `json:"upload_speed"` // bytes/sec
|
||||||
|
DownloadSpeed int64 `json:"download_speed"` // bytes/sec
|
||||||
|
Nodes []NodeTrafficSpeed `json:"nodes,omitempty"`
|
||||||
|
SampledAt time.Time `json:"sampled_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type probeFunc func(ctx context.Context) (time.Duration, error)
|
||||||
|
type releaseFunc func()
|
||||||
|
|
||||||
|
type EntryHandle struct {
|
||||||
|
ref *entry
|
||||||
|
}
|
||||||
|
|
||||||
|
type entry struct {
|
||||||
|
info NodeInfo
|
||||||
|
failure int
|
||||||
|
success int64
|
||||||
|
timeline []TimelineEvent
|
||||||
|
blacklist bool
|
||||||
|
until time.Time
|
||||||
|
lastError string
|
||||||
|
lastFail time.Time
|
||||||
|
lastOK time.Time
|
||||||
|
lastProbe time.Duration
|
||||||
|
active atomic.Int32
|
||||||
|
totalUpload atomic.Int64
|
||||||
|
totalDownload atomic.Int64
|
||||||
|
uploadSpeed int64
|
||||||
|
downloadSpeed int64
|
||||||
|
lastSpeedUpload int64
|
||||||
|
lastSpeedDown int64
|
||||||
|
lastSpeedAt time.Time
|
||||||
|
probe probeFunc
|
||||||
|
release releaseFunc
|
||||||
|
initialCheckDone bool
|
||||||
|
available bool
|
||||||
|
reloadGen uint64 // generation counter to track active registrations
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager aggregates all node states for the UI/API.
|
||||||
|
type Manager struct {
|
||||||
|
cfg Config
|
||||||
|
reloadGen uint64 // current reload generation
|
||||||
|
probeDst M.Socksaddr
|
||||||
|
probeReady bool
|
||||||
|
mu sync.RWMutex
|
||||||
|
nodes map[string]*entry
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
logger Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logger interface for logging
|
||||||
|
type Logger interface {
|
||||||
|
Info(args ...any)
|
||||||
|
Warn(args ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager constructs a manager and pre-validates the probe target.
|
||||||
|
func NewManager(cfg Config) (*Manager, error) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
m := &Manager{
|
||||||
|
cfg: cfg,
|
||||||
|
nodes: make(map[string]*entry),
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
if cfg.ProbeTarget != "" {
|
||||||
|
target := cfg.ProbeTarget
|
||||||
|
// Strip URL scheme if present (e.g., "https://www.google.com:443" -> "www.google.com:443")
|
||||||
|
if strings.HasPrefix(target, "https://") {
|
||||||
|
target = strings.TrimPrefix(target, "https://")
|
||||||
|
} else if strings.HasPrefix(target, "http://") {
|
||||||
|
target = strings.TrimPrefix(target, "http://")
|
||||||
|
}
|
||||||
|
// Remove trailing path if present
|
||||||
|
if idx := strings.Index(target, "/"); idx != -1 {
|
||||||
|
target = target[:idx]
|
||||||
|
}
|
||||||
|
host, port, err := net.SplitHostPort(target)
|
||||||
|
if err != nil {
|
||||||
|
// If no port specified, use default based on original scheme
|
||||||
|
if strings.HasPrefix(cfg.ProbeTarget, "https://") {
|
||||||
|
host = target
|
||||||
|
port = "443"
|
||||||
|
} else {
|
||||||
|
host = target
|
||||||
|
port = "80"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsed := M.ParseSocksaddrHostPort(host, parsePort(port))
|
||||||
|
m.probeDst = parsed
|
||||||
|
m.probeReady = true
|
||||||
|
}
|
||||||
|
go m.startTrafficSpeedSampler()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLogger sets the logger for the manager.
|
||||||
|
func (m *Manager) SetLogger(logger Logger) {
|
||||||
|
m.logger = logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartPeriodicHealthCheck starts a background goroutine that periodically checks all nodes.
|
||||||
|
// interval: how often to check (e.g., 30 * time.Second)
|
||||||
|
// timeout: timeout for each probe (e.g., 10 * time.Second)
|
||||||
|
func (m *Manager) StartPeriodicHealthCheck(interval, timeout time.Duration) {
|
||||||
|
if !m.probeReady {
|
||||||
|
if m.logger != nil {
|
||||||
|
m.logger.Warn("probe target not configured, periodic health check disabled")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// 启动后立即进行一次检查
|
||||||
|
m.probeAllNodes(timeout)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-m.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
m.probeAllNodes(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if m.logger != nil {
|
||||||
|
m.logger.Info("periodic health check started, interval: ", interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeAllNodes checks all registered nodes concurrently.
|
||||||
|
func (m *Manager) probeAllNodes(timeout time.Duration) {
|
||||||
|
m.mu.RLock()
|
||||||
|
entries := make([]*entry, 0, len(m.nodes))
|
||||||
|
for _, e := range m.nodes {
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.logger != nil {
|
||||||
|
m.logger.Info("starting health check for ", len(entries), " nodes")
|
||||||
|
}
|
||||||
|
|
||||||
|
workerLimit := runtime.NumCPU() * 2
|
||||||
|
if workerLimit < 8 {
|
||||||
|
workerLimit = 8
|
||||||
|
}
|
||||||
|
sem := make(chan struct{}, workerLimit)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var availableCount atomic.Int32
|
||||||
|
var failedCount atomic.Int32
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
e.mu.RLock()
|
||||||
|
probeFn := e.probe
|
||||||
|
tag := e.info.Tag
|
||||||
|
e.mu.RUnlock()
|
||||||
|
|
||||||
|
if probeFn == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sem <- struct{}{}
|
||||||
|
wg.Add(1)
|
||||||
|
go func(entry *entry, probe probeFunc, tag string) {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() { <-sem }()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(m.ctx, timeout)
|
||||||
|
latency, err := probe(ctx)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
entry.mu.Lock()
|
||||||
|
if err != nil {
|
||||||
|
failedCount.Add(1)
|
||||||
|
entry.lastError = err.Error()
|
||||||
|
entry.lastFail = time.Now()
|
||||||
|
entry.lastProbe = 0
|
||||||
|
entry.available = false
|
||||||
|
entry.initialCheckDone = true
|
||||||
|
} else {
|
||||||
|
availableCount.Add(1)
|
||||||
|
entry.lastOK = time.Now()
|
||||||
|
entry.lastProbe = latency
|
||||||
|
entry.available = true
|
||||||
|
entry.initialCheckDone = true
|
||||||
|
}
|
||||||
|
entry.mu.Unlock()
|
||||||
|
|
||||||
|
if err != nil && m.logger != nil {
|
||||||
|
m.logger.Warn("probe failed for ", tag, ": ", err)
|
||||||
|
}
|
||||||
|
}(e, probeFn, tag)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if m.logger != nil {
|
||||||
|
m.logger.Info("health check completed: ", availableCount.Load(), " available, ", failedCount.Load(), " failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the periodic health check.
|
||||||
|
func (m *Manager) Stop() {
|
||||||
|
if m.cancel != nil {
|
||||||
|
m.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) startTrafficSpeedSampler() {
|
||||||
|
ticker := time.NewTicker(1 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-m.ctx.Done():
|
||||||
|
return
|
||||||
|
case now := <-ticker.C:
|
||||||
|
m.sampleTrafficSpeeds(now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) sampleTrafficSpeeds(now time.Time) {
|
||||||
|
m.mu.RLock()
|
||||||
|
entries := make([]*entry, 0, len(m.nodes))
|
||||||
|
for _, e := range m.nodes {
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
e.updateTrafficSpeed(now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePort(value string) uint16 {
|
||||||
|
p, err := strconv.Atoi(value)
|
||||||
|
if err != nil || p <= 0 || p > 65535 {
|
||||||
|
return 80
|
||||||
|
}
|
||||||
|
return uint16(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeginReload bumps the generation counter. Nodes registered after this call
|
||||||
|
// will be marked with the new generation. Call SweepStaleNodes after reload
|
||||||
|
// to remove nodes that were not re-registered (disabled/deleted nodes).
|
||||||
|
func (m *Manager) BeginReload() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.reloadGen++
|
||||||
|
}
|
||||||
|
|
||||||
|
// SweepStaleNodes removes nodes that were not re-registered during the current
|
||||||
|
// reload cycle. This preserves monitoring data (latency, success/failure counts)
|
||||||
|
// for nodes that are still active, while cleaning up disabled/removed nodes.
|
||||||
|
func (m *Manager) SweepStaleNodes() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
for tag, e := range m.nodes {
|
||||||
|
if e.reloadGen != m.reloadGen {
|
||||||
|
delete(m.nodes, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearNodes removes all registered nodes. Use BeginReload + SweepStaleNodes
|
||||||
|
// for reload scenarios to preserve data for active nodes.
|
||||||
|
func (m *Manager) ClearNodes() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.nodes = make(map[string]*entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register ensures a node is tracked and returns its entry.
|
||||||
|
// If the node already exists, its info is updated but monitoring stats
|
||||||
|
// (latency, success/failure counts, etc.) are preserved.
|
||||||
|
func (m *Manager) Register(info NodeInfo) *EntryHandle {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
e, ok := m.nodes[info.Tag]
|
||||||
|
if !ok {
|
||||||
|
e = &entry{
|
||||||
|
info: info,
|
||||||
|
timeline: make([]TimelineEvent, 0, maxTimelineSize),
|
||||||
|
reloadGen: m.reloadGen,
|
||||||
|
}
|
||||||
|
m.nodes[info.Tag] = e
|
||||||
|
} else {
|
||||||
|
e.info = info
|
||||||
|
e.reloadGen = m.reloadGen
|
||||||
|
}
|
||||||
|
return &EntryHandle{ref: e}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DestinationForProbe exposes the configured destination for health checks.
|
||||||
|
func (m *Manager) DestinationForProbe() (M.Socksaddr, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
if !m.probeReady {
|
||||||
|
return M.Socksaddr{}, false
|
||||||
|
}
|
||||||
|
return m.probeDst, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProbeTarget dynamically updates the probe destination at runtime.
|
||||||
|
func (m *Manager) UpdateProbeTarget(target string) error {
|
||||||
|
if target == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip URL scheme if present
|
||||||
|
if strings.HasPrefix(target, "https://") {
|
||||||
|
target = strings.TrimPrefix(target, "https://")
|
||||||
|
} else if strings.HasPrefix(target, "http://") {
|
||||||
|
target = strings.TrimPrefix(target, "http://")
|
||||||
|
}
|
||||||
|
// Remove trailing path if present
|
||||||
|
if idx := strings.Index(target, "/"); idx != -1 {
|
||||||
|
target = target[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
host, port, err := net.SplitHostPort(target)
|
||||||
|
if err != nil {
|
||||||
|
// If no port specified, use default port 80
|
||||||
|
host = target
|
||||||
|
port = "80"
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed := M.ParseSocksaddrHostPort(host, parsePort(port))
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.probeDst = parsed
|
||||||
|
m.probeReady = true
|
||||||
|
m.cfg.ProbeTarget = target
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns a sorted copy of current node states.
|
||||||
|
// If onlyAvailable is true, only returns nodes that passed initial health check.
|
||||||
|
func (m *Manager) Snapshot() []Snapshot {
|
||||||
|
return m.SnapshotFiltered(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SnapshotFiltered returns a sorted copy of current node states.
|
||||||
|
// If onlyAvailable is true, only returns nodes that passed initial health check.
|
||||||
|
// Nodes that haven't been checked yet are also included (they will be checked on first use).
|
||||||
|
func (m *Manager) SnapshotFiltered(onlyAvailable bool) []Snapshot {
|
||||||
|
m.mu.RLock()
|
||||||
|
list := make([]*entry, 0, len(m.nodes))
|
||||||
|
for _, e := range m.nodes {
|
||||||
|
list = append(list, e)
|
||||||
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
snapshots := make([]Snapshot, 0, len(list))
|
||||||
|
for _, e := range list {
|
||||||
|
snap := e.snapshot()
|
||||||
|
// 如果只要可用节点:
|
||||||
|
// - 跳过已完成检查但不可用的节点
|
||||||
|
// - 保留未完成检查的节点(它们会在首次使用时被检查)
|
||||||
|
if onlyAvailable && snap.InitialCheckDone && !snap.Available {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
snapshots = append(snapshots, snap)
|
||||||
|
}
|
||||||
|
// 按延迟排序(延迟小的在前面,未测试的排在最后)
|
||||||
|
sort.Slice(snapshots, func(i, j int) bool {
|
||||||
|
latencyI := snapshots[i].LastLatencyMs
|
||||||
|
latencyJ := snapshots[j].LastLatencyMs
|
||||||
|
// -1 表示未测试,排在最后
|
||||||
|
if latencyI < 0 && latencyJ < 0 {
|
||||||
|
return snapshots[i].Name < snapshots[j].Name // 都未测试时按名称排序
|
||||||
|
}
|
||||||
|
if latencyI < 0 {
|
||||||
|
return false // i 未测试,排在后面
|
||||||
|
}
|
||||||
|
if latencyJ < 0 {
|
||||||
|
return true // j 未测试,i 排在前面
|
||||||
|
}
|
||||||
|
if latencyI == latencyJ {
|
||||||
|
return snapshots[i].Name < snapshots[j].Name // 延迟相同时按名称排序
|
||||||
|
}
|
||||||
|
return latencyI < latencyJ
|
||||||
|
})
|
||||||
|
return snapshots
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrafficSummary returns aggregated traffic totals/speeds and per-node speeds.
|
||||||
|
// includeNodes controls whether per-node details are returned.
|
||||||
|
func (m *Manager) TrafficSummary(includeNodes bool) TrafficSummary {
|
||||||
|
m.mu.RLock()
|
||||||
|
list := make([]*entry, 0, len(m.nodes))
|
||||||
|
for _, e := range m.nodes {
|
||||||
|
list = append(list, e)
|
||||||
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
summary := TrafficSummary{
|
||||||
|
NodeCount: len(list),
|
||||||
|
SampledAt: time.Now(),
|
||||||
|
}
|
||||||
|
if includeNodes {
|
||||||
|
summary.Nodes = make([]NodeTrafficSpeed, 0, len(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range list {
|
||||||
|
totalUp := e.totalUpload.Load()
|
||||||
|
totalDown := e.totalDownload.Load()
|
||||||
|
|
||||||
|
e.mu.RLock()
|
||||||
|
upSpeed := e.uploadSpeed
|
||||||
|
downSpeed := e.downloadSpeed
|
||||||
|
tag := e.info.Tag
|
||||||
|
e.mu.RUnlock()
|
||||||
|
|
||||||
|
summary.TotalUpload += totalUp
|
||||||
|
summary.TotalDownload += totalDown
|
||||||
|
summary.UploadSpeed += upSpeed
|
||||||
|
summary.DownloadSpeed += downSpeed
|
||||||
|
|
||||||
|
if includeNodes {
|
||||||
|
summary.Nodes = append(summary.Nodes, NodeTrafficSpeed{
|
||||||
|
Tag: tag,
|
||||||
|
UploadSpeed: upSpeed,
|
||||||
|
DownloadSpeed: downSpeed,
|
||||||
|
TotalUpload: totalUp,
|
||||||
|
TotalDownload: totalDown,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe triggers a manual health check.
|
||||||
|
func (m *Manager) Probe(ctx context.Context, tag string) (time.Duration, error) {
|
||||||
|
e, err := m.entry(tag)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if e.probe == nil {
|
||||||
|
return 0, errors.New("probe not available for this node")
|
||||||
|
}
|
||||||
|
latency, err := e.probe(ctx)
|
||||||
|
if err != nil {
|
||||||
|
// 探测失败:标记节点为不可用,清除旧延迟数据
|
||||||
|
e.mu.Lock()
|
||||||
|
e.available = false
|
||||||
|
e.initialCheckDone = true
|
||||||
|
e.lastProbe = 0 // 清除延迟,前端显示"未测试"
|
||||||
|
e.lastError = err.Error()
|
||||||
|
e.lastFail = time.Now()
|
||||||
|
e.mu.Unlock()
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
// 探测成功:recordSuccessWithLatency 已在 probe 函数内更新 available=true
|
||||||
|
e.recordProbeLatency(latency)
|
||||||
|
return latency, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release clears blacklist state for the given node.
|
||||||
|
func (m *Manager) Release(tag string) error {
|
||||||
|
e, err := m.entry(tag)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if e.release == nil {
|
||||||
|
return errors.New("release not available for this node")
|
||||||
|
}
|
||||||
|
e.release()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) entry(tag string) (*entry, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
e, ok := m.nodes[tag]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("node %s not found", tag)
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *entry) snapshot() Snapshot {
|
||||||
|
e.mu.RLock()
|
||||||
|
defer e.mu.RUnlock()
|
||||||
|
|
||||||
|
latencyMs := int64(-1)
|
||||||
|
if e.lastProbe > 0 {
|
||||||
|
latencyMs = e.lastProbe.Milliseconds()
|
||||||
|
if latencyMs == 0 {
|
||||||
|
latencyMs = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var timelineCopy []TimelineEvent
|
||||||
|
if len(e.timeline) > 0 {
|
||||||
|
timelineCopy = make([]TimelineEvent, len(e.timeline))
|
||||||
|
copy(timelineCopy, e.timeline)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Snapshot{
|
||||||
|
NodeInfo: e.info,
|
||||||
|
FailureCount: e.failure,
|
||||||
|
SuccessCount: e.success,
|
||||||
|
Blacklisted: e.blacklist,
|
||||||
|
BlacklistedUntil: e.until,
|
||||||
|
ActiveConnections: e.active.Load(),
|
||||||
|
LastError: e.lastError,
|
||||||
|
LastFailure: e.lastFail,
|
||||||
|
LastSuccess: e.lastOK,
|
||||||
|
LastProbeLatency: e.lastProbe,
|
||||||
|
LastLatencyMs: latencyMs,
|
||||||
|
Available: e.available,
|
||||||
|
InitialCheckDone: e.initialCheckDone,
|
||||||
|
TotalUpload: e.totalUpload.Load(),
|
||||||
|
TotalDownload: e.totalDownload.Load(),
|
||||||
|
UploadSpeed: e.uploadSpeed,
|
||||||
|
DownloadSpeed: e.downloadSpeed,
|
||||||
|
Timeline: timelineCopy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *entry) recordFailure(err error, destination string) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
errStr := err.Error()
|
||||||
|
e.failure++
|
||||||
|
e.lastError = errStr
|
||||||
|
e.lastFail = time.Now()
|
||||||
|
// 注意:不修改 available/initialCheckDone
|
||||||
|
// 流量失败不代表节点不可用(可能是目标网站的问题)
|
||||||
|
// available 只由探测操作控制
|
||||||
|
e.appendTimelineLocked(false, 0, errStr, destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *entry) recordSuccess(destination string) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
e.success++
|
||||||
|
e.lastOK = time.Now()
|
||||||
|
// 注意:不修改 available/initialCheckDone
|
||||||
|
// 流量成功不代表需要更新探测状态
|
||||||
|
// available 只由探测操作控制
|
||||||
|
e.appendTimelineLocked(true, 0, "", destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *entry) recordSuccessWithLatency(latency time.Duration) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
e.success++
|
||||||
|
e.lastOK = time.Now()
|
||||||
|
e.lastProbe = latency
|
||||||
|
e.available = true
|
||||||
|
e.initialCheckDone = true
|
||||||
|
latencyMs := latency.Milliseconds()
|
||||||
|
if latencyMs == 0 && latency > 0 {
|
||||||
|
latencyMs = 1
|
||||||
|
}
|
||||||
|
e.appendTimelineLocked(true, latencyMs, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *entry) appendTimelineLocked(success bool, latencyMs int64, errStr string, destination string) {
|
||||||
|
evt := TimelineEvent{
|
||||||
|
Time: time.Now(),
|
||||||
|
Success: success,
|
||||||
|
LatencyMs: latencyMs,
|
||||||
|
Error: errStr,
|
||||||
|
Destination: destination,
|
||||||
|
}
|
||||||
|
if len(e.timeline) >= maxTimelineSize {
|
||||||
|
copy(e.timeline, e.timeline[1:])
|
||||||
|
e.timeline[len(e.timeline)-1] = evt
|
||||||
|
} else {
|
||||||
|
e.timeline = append(e.timeline, evt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *entry) blacklistUntil(until time.Time) {
|
||||||
|
e.mu.Lock()
|
||||||
|
e.blacklist = true
|
||||||
|
e.until = until
|
||||||
|
e.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *entry) clearBlacklist() {
|
||||||
|
e.mu.Lock()
|
||||||
|
e.blacklist = false
|
||||||
|
e.until = time.Time{}
|
||||||
|
e.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *entry) incActive() {
|
||||||
|
e.active.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *entry) decActive() {
|
||||||
|
e.active.Add(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *entry) setProbe(fn probeFunc) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
e.probe = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *entry) setRelease(fn releaseFunc) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
e.release = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *entry) recordProbeLatency(d time.Duration) {
|
||||||
|
e.mu.Lock()
|
||||||
|
e.lastProbe = d
|
||||||
|
e.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *entry) updateTrafficSpeed(now time.Time) {
|
||||||
|
curUp := e.totalUpload.Load()
|
||||||
|
curDown := e.totalDownload.Load()
|
||||||
|
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
|
||||||
|
if e.lastSpeedAt.IsZero() {
|
||||||
|
e.lastSpeedAt = now
|
||||||
|
e.lastSpeedUpload = curUp
|
||||||
|
e.lastSpeedDown = curDown
|
||||||
|
e.uploadSpeed = 0
|
||||||
|
e.downloadSpeed = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := now.Sub(e.lastSpeedAt).Seconds()
|
||||||
|
if elapsed <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deltaUp := curUp - e.lastSpeedUpload
|
||||||
|
deltaDown := curDown - e.lastSpeedDown
|
||||||
|
if deltaUp < 0 {
|
||||||
|
deltaUp = 0
|
||||||
|
}
|
||||||
|
if deltaDown < 0 {
|
||||||
|
deltaDown = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
e.uploadSpeed = int64(float64(deltaUp) / elapsed)
|
||||||
|
e.downloadSpeed = int64(float64(deltaDown) / elapsed)
|
||||||
|
e.lastSpeedUpload = curUp
|
||||||
|
e.lastSpeedDown = curDown
|
||||||
|
e.lastSpeedAt = now
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordFailure updates failure counters with destination info.
|
||||||
|
func (h *EntryHandle) RecordFailure(err error, destination string) {
|
||||||
|
if h == nil || h.ref == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.ref.recordFailure(err, destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordSuccess updates the last success timestamp with destination info.
|
||||||
|
func (h *EntryHandle) RecordSuccess(destination string) {
|
||||||
|
if h == nil || h.ref == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.ref.recordSuccess(destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordSuccessWithLatency updates the last success timestamp and latency.
|
||||||
|
func (h *EntryHandle) RecordSuccessWithLatency(latency time.Duration) {
|
||||||
|
if h == nil || h.ref == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.ref.recordSuccessWithLatency(latency)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blacklist marks the node unavailable until the given deadline.
|
||||||
|
func (h *EntryHandle) Blacklist(until time.Time) {
|
||||||
|
if h == nil || h.ref == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.ref.blacklistUntil(until)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearBlacklist removes the blacklist flag.
|
||||||
|
func (h *EntryHandle) ClearBlacklist() {
|
||||||
|
if h == nil || h.ref == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.ref.clearBlacklist()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncActive increments the active connection counter.
|
||||||
|
func (h *EntryHandle) IncActive() {
|
||||||
|
if h == nil || h.ref == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.ref.incActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecActive decrements the active connection counter.
|
||||||
|
func (h *EntryHandle) DecActive() {
|
||||||
|
if h == nil || h.ref == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.ref.decActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetProbe assigns a probe function.
|
||||||
|
func (h *EntryHandle) SetProbe(fn func(ctx context.Context) (time.Duration, error)) {
|
||||||
|
if h == nil || h.ref == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.ref.setProbe(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRelease assigns a release function.
|
||||||
|
func (h *EntryHandle) SetRelease(fn func()) {
|
||||||
|
if h == nil || h.ref == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.ref.setRelease(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkInitialCheckDone marks the initial health check as completed.
|
||||||
|
func (h *EntryHandle) MarkInitialCheckDone(available bool) {
|
||||||
|
if h == nil || h.ref == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.ref.mu.Lock()
|
||||||
|
h.ref.initialCheckDone = true
|
||||||
|
h.ref.available = available
|
||||||
|
h.ref.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAvailable updates the availability status.
|
||||||
|
func (h *EntryHandle) MarkAvailable(available bool) {
|
||||||
|
if h == nil || h.ref == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.ref.mu.Lock()
|
||||||
|
h.ref.available = available
|
||||||
|
h.ref.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTraffic adds upload and download byte counts to the node's traffic counters.
|
||||||
|
func (h *EntryHandle) AddTraffic(upload, download int64) {
|
||||||
|
if h == nil || h.ref == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if upload > 0 {
|
||||||
|
h.ref.totalUpload.Add(upload)
|
||||||
|
}
|
||||||
|
if download > 0 {
|
||||||
|
h.ref.totalDownload.Add(download)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTraffic sets the traffic counters to specific values (used for restoring from store).
|
||||||
|
func (h *EntryHandle) SetTraffic(upload, download int64) {
|
||||||
|
if h == nil || h.ref == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.ref.totalUpload.Store(upload)
|
||||||
|
h.ref.totalDownload.Store(download)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,749 @@
|
|||||||
|
package pool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"easy_proxies/internal/monitor"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/adapter"
|
||||||
|
"github.com/sagernet/sing-box/adapter/outbound"
|
||||||
|
"github.com/sagernet/sing-box/log"
|
||||||
|
"github.com/sagernet/sing/common"
|
||||||
|
E "github.com/sagernet/sing/common/exceptions"
|
||||||
|
M "github.com/sagernet/sing/common/metadata"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
"github.com/sagernet/sing/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Type is the outbound type name exposed to sing-box.
|
||||||
|
Type = "pool"
|
||||||
|
// Tag is the default outbound tag used by builder.
|
||||||
|
Tag = "proxy-pool"
|
||||||
|
|
||||||
|
modeSequential = "sequential"
|
||||||
|
modeRandom = "random"
|
||||||
|
modeBalance = "balance"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Options controls pool outbound behaviour.
|
||||||
|
type Options struct {
|
||||||
|
Mode string
|
||||||
|
Members []string
|
||||||
|
FailureThreshold int
|
||||||
|
BlacklistDuration time.Duration
|
||||||
|
Metadata map[string]MemberMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemberMeta carries optional descriptive information for monitoring UI.
|
||||||
|
type MemberMeta struct {
|
||||||
|
Name string
|
||||||
|
URI string
|
||||||
|
Mode string
|
||||||
|
ListenAddress string
|
||||||
|
Port uint16
|
||||||
|
Region string // GeoIP region code: "jp", "kr", "us", "hk", "tw", "other"
|
||||||
|
Country string // Full country name from GeoIP
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register wires the pool outbound into the registry.
|
||||||
|
func Register(registry *outbound.Registry) {
|
||||||
|
outbound.Register[Options](registry, Type, newPool)
|
||||||
|
}
|
||||||
|
|
||||||
|
type memberState struct {
|
||||||
|
outbound adapter.Outbound
|
||||||
|
tag string
|
||||||
|
entry *monitor.EntryHandle
|
||||||
|
shared *sharedMemberState
|
||||||
|
}
|
||||||
|
|
||||||
|
type poolOutbound struct {
|
||||||
|
outbound.Adapter
|
||||||
|
ctx context.Context
|
||||||
|
logger log.ContextLogger
|
||||||
|
manager adapter.OutboundManager
|
||||||
|
options Options
|
||||||
|
mode string
|
||||||
|
members []*memberState
|
||||||
|
mu sync.Mutex
|
||||||
|
rrCounter atomic.Uint32
|
||||||
|
rng *rand.Rand
|
||||||
|
rngMu sync.Mutex // protects rng for random mode
|
||||||
|
monitor *monitor.Manager
|
||||||
|
candidatesPool sync.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPool(ctx context.Context, _ adapter.Router, logger log.ContextLogger, tag string, options Options) (adapter.Outbound, error) {
|
||||||
|
if len(options.Members) == 0 {
|
||||||
|
return nil, E.New("pool requires at least one member")
|
||||||
|
}
|
||||||
|
manager := service.FromContext[adapter.OutboundManager](ctx)
|
||||||
|
if manager == nil {
|
||||||
|
return nil, E.New("missing outbound manager in context")
|
||||||
|
}
|
||||||
|
monitorMgr := monitor.FromContext(ctx)
|
||||||
|
normalized := normalizeOptions(options)
|
||||||
|
memberCount := len(normalized.Members)
|
||||||
|
p := &poolOutbound{
|
||||||
|
Adapter: outbound.NewAdapter(Type, tag, []string{N.NetworkTCP, N.NetworkUDP}, normalized.Members),
|
||||||
|
ctx: ctx,
|
||||||
|
logger: logger,
|
||||||
|
manager: manager,
|
||||||
|
options: normalized,
|
||||||
|
mode: normalized.Mode,
|
||||||
|
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||||
|
monitor: monitorMgr,
|
||||||
|
candidatesPool: sync.Pool{
|
||||||
|
New: func() any {
|
||||||
|
return make([]*memberState, 0, memberCount)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register nodes immediately if monitor is available
|
||||||
|
if monitorMgr != nil {
|
||||||
|
logger.Info("registering ", len(normalized.Members), " nodes to monitor")
|
||||||
|
for _, memberTag := range normalized.Members {
|
||||||
|
// Acquire shared state for this tag (creates if not exists)
|
||||||
|
state := acquireSharedState(memberTag)
|
||||||
|
|
||||||
|
meta := normalized.Metadata[memberTag]
|
||||||
|
info := monitor.NodeInfo{
|
||||||
|
Tag: memberTag,
|
||||||
|
Name: meta.Name,
|
||||||
|
URI: meta.URI,
|
||||||
|
Mode: meta.Mode,
|
||||||
|
ListenAddress: meta.ListenAddress,
|
||||||
|
Port: meta.Port,
|
||||||
|
Region: meta.Region,
|
||||||
|
Country: meta.Country,
|
||||||
|
}
|
||||||
|
entry := monitorMgr.Register(info)
|
||||||
|
if entry != nil {
|
||||||
|
// Attach entry to shared state so all pool instances share it
|
||||||
|
state.attachEntry(entry)
|
||||||
|
logger.Info("registered node: ", memberTag)
|
||||||
|
// Set probe and release functions immediately
|
||||||
|
entry.SetRelease(p.makeReleaseByTagFunc(memberTag))
|
||||||
|
if probeFn := p.makeProbeByTagFunc(memberTag); probeFn != nil {
|
||||||
|
entry.SetProbe(probeFn)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.Warn("failed to register node: ", memberTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.Warn("monitor manager is nil, skipping node registration")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeOptions(options Options) Options {
|
||||||
|
if options.FailureThreshold <= 0 {
|
||||||
|
options.FailureThreshold = 3
|
||||||
|
}
|
||||||
|
if options.BlacklistDuration <= 0 {
|
||||||
|
options.BlacklistDuration = 24 * time.Hour
|
||||||
|
}
|
||||||
|
if options.Metadata == nil {
|
||||||
|
options.Metadata = make(map[string]MemberMeta)
|
||||||
|
}
|
||||||
|
switch strings.ToLower(options.Mode) {
|
||||||
|
case modeRandom:
|
||||||
|
options.Mode = modeRandom
|
||||||
|
case modeBalance:
|
||||||
|
options.Mode = modeBalance
|
||||||
|
default:
|
||||||
|
options.Mode = modeSequential
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *poolOutbound) Start(stage adapter.StartStage) error {
|
||||||
|
if stage != adapter.StartStateStart {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
err := p.initializeMembersLocked()
|
||||||
|
p.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 在初始化完成后,立即在后台触发健康检查
|
||||||
|
if p.monitor != nil {
|
||||||
|
go p.probeAllMembersOnStartup()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// initializeMembersLocked must be called with p.mu held
|
||||||
|
func (p *poolOutbound) initializeMembersLocked() error {
|
||||||
|
if len(p.members) > 0 {
|
||||||
|
return nil // Already initialized
|
||||||
|
}
|
||||||
|
|
||||||
|
members := make([]*memberState, 0, len(p.options.Members))
|
||||||
|
for _, tag := range p.options.Members {
|
||||||
|
detour, loaded := p.manager.Outbound(tag)
|
||||||
|
if !loaded {
|
||||||
|
return E.New("pool member not found: ", tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire shared state (creates if not exists, reuses if already created)
|
||||||
|
state := acquireSharedState(tag)
|
||||||
|
|
||||||
|
member := &memberState{
|
||||||
|
outbound: detour,
|
||||||
|
tag: tag,
|
||||||
|
shared: state,
|
||||||
|
entry: state.entryHandle(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to existing monitor entry if available
|
||||||
|
if p.monitor != nil {
|
||||||
|
meta := p.options.Metadata[tag]
|
||||||
|
info := monitor.NodeInfo{
|
||||||
|
Tag: tag,
|
||||||
|
Name: meta.Name,
|
||||||
|
URI: meta.URI,
|
||||||
|
Mode: meta.Mode,
|
||||||
|
ListenAddress: meta.ListenAddress,
|
||||||
|
Port: meta.Port,
|
||||||
|
Region: meta.Region,
|
||||||
|
Country: meta.Country,
|
||||||
|
}
|
||||||
|
entry := p.monitor.Register(info)
|
||||||
|
if entry != nil {
|
||||||
|
state.attachEntry(entry)
|
||||||
|
member.entry = entry
|
||||||
|
entry.SetRelease(p.makeReleaseFunc(member))
|
||||||
|
if probe := p.makeProbeFunc(member); probe != nil {
|
||||||
|
entry.SetProbe(probe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
members = append(members, member)
|
||||||
|
}
|
||||||
|
p.members = members
|
||||||
|
p.logger.Info("pool initialized with ", len(members), " members")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeAllMembersOnStartup performs initial health checks on all members
|
||||||
|
func (p *poolOutbound) probeAllMembersOnStartup() {
|
||||||
|
destination, ok := p.monitor.DestinationForProbe()
|
||||||
|
if !ok {
|
||||||
|
p.logger.Warn("probe target not configured, skipping initial health check")
|
||||||
|
// 没有配置探测目标时,标记所有节点为可用
|
||||||
|
p.mu.Lock()
|
||||||
|
for _, member := range p.members {
|
||||||
|
if member.entry != nil {
|
||||||
|
member.entry.MarkInitialCheckDone(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.logger.Info("starting initial health check for all nodes")
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
members := make([]*memberState, len(p.members))
|
||||||
|
copy(members, p.members)
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
availableCount := 0
|
||||||
|
failedCount := 0
|
||||||
|
|
||||||
|
for _, member := range members {
|
||||||
|
// Create a timeout context for each probe
|
||||||
|
ctx, cancel := context.WithTimeout(p.ctx, 15*time.Second)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
conn, err := member.outbound.DialContext(ctx, N.NetworkTCP, destination)
|
||||||
|
|
||||||
|
probeDst := destination.String()
|
||||||
|
if err != nil {
|
||||||
|
p.logger.Warn("initial probe failed for ", member.tag, ": ", err)
|
||||||
|
failedCount++
|
||||||
|
if member.entry != nil {
|
||||||
|
member.entry.RecordFailure(err, probeDst)
|
||||||
|
member.entry.MarkInitialCheckDone(false) // 标记为不可用
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform HTTP probe to measure actual latency (TTFB)
|
||||||
|
_, err = httpProbe(conn, destination.AddrString())
|
||||||
|
conn.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
p.logger.Warn("initial HTTP probe failed for ", member.tag, ": ", err)
|
||||||
|
failedCount++
|
||||||
|
if member.entry != nil {
|
||||||
|
member.entry.RecordFailure(err, probeDst)
|
||||||
|
member.entry.MarkInitialCheckDone(false)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total latency = dial + HTTP probe
|
||||||
|
latency := time.Since(start)
|
||||||
|
latencyMs := latency.Milliseconds()
|
||||||
|
p.logger.Info("initial probe success for ", member.tag, ", latency: ", latencyMs, "ms")
|
||||||
|
availableCount++
|
||||||
|
if member.entry != nil {
|
||||||
|
member.entry.RecordSuccessWithLatency(latency)
|
||||||
|
member.entry.MarkInitialCheckDone(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
p.logger.Info("initial health check completed: ", availableCount, " available, ", failedCount, " failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *poolOutbound) memberName(member *memberState) string {
|
||||||
|
if meta, ok := p.options.Metadata[member.tag]; ok && meta.Name != "" {
|
||||||
|
return meta.Name
|
||||||
|
}
|
||||||
|
return member.tag
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *poolOutbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
|
||||||
|
member, err := p.pickMember(network)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dst := destination.String()
|
||||||
|
p.logger.Info("→ ", dst, " ⇒ ", p.memberName(member), " [", network, "]")
|
||||||
|
p.incActive(member)
|
||||||
|
conn, err := member.outbound.DialContext(ctx, network, destination)
|
||||||
|
if err != nil {
|
||||||
|
p.decActive(member)
|
||||||
|
p.recordFailure(member, err, dst)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.recordSuccess(member, dst)
|
||||||
|
return p.wrapConn(conn, member), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *poolOutbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
|
||||||
|
member, err := p.pickMember(N.NetworkUDP)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dst := destination.String()
|
||||||
|
p.logger.Info("→ ", dst, " ⇒ ", p.memberName(member), " [udp]")
|
||||||
|
p.incActive(member)
|
||||||
|
conn, err := member.outbound.ListenPacket(ctx, destination)
|
||||||
|
if err != nil {
|
||||||
|
p.decActive(member)
|
||||||
|
p.recordFailure(member, err, dst)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.recordSuccess(member, dst)
|
||||||
|
return p.wrapPacketConn(conn, member), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *poolOutbound) pickMember(network string) (*memberState, error) {
|
||||||
|
now := time.Now()
|
||||||
|
candidates := p.getCandidateBuffer()
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
if len(p.members) == 0 {
|
||||||
|
if err := p.initializeMembersLocked(); err != nil {
|
||||||
|
p.mu.Unlock()
|
||||||
|
p.putCandidateBuffer(candidates)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates = p.availableMembersLocked(now, network, candidates)
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
p.mu.Lock()
|
||||||
|
if p.releaseIfAllBlacklistedLocked(now) {
|
||||||
|
candidates = p.availableMembersLocked(now, network, candidates)
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
p.putCandidateBuffer(candidates)
|
||||||
|
return nil, E.New("no healthy proxy available")
|
||||||
|
}
|
||||||
|
|
||||||
|
member := p.selectMember(candidates)
|
||||||
|
p.putCandidateBuffer(candidates)
|
||||||
|
return member, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *poolOutbound) availableMembersLocked(now time.Time, network string, buf []*memberState) []*memberState {
|
||||||
|
result := buf[:0]
|
||||||
|
for _, member := range p.members {
|
||||||
|
// Check blacklist via shared state (auto-clears if expired)
|
||||||
|
if member.shared != nil && member.shared.isBlacklisted(now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if network != "" && !common.Contains(member.outbound.Network(), network) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, member)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *poolOutbound) releaseIfAllBlacklistedLocked(now time.Time) bool {
|
||||||
|
if len(p.members) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Check if all members are blacklisted
|
||||||
|
for _, member := range p.members {
|
||||||
|
if member.shared == nil || !member.shared.isBlacklisted(now) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// All blacklisted, force release all
|
||||||
|
for _, member := range p.members {
|
||||||
|
if member.shared != nil {
|
||||||
|
member.shared.forceRelease()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.logger.Warn("all upstream proxies were blacklisted, releasing them for retry")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *poolOutbound) selectMember(candidates []*memberState) *memberState {
|
||||||
|
switch p.mode {
|
||||||
|
case modeRandom:
|
||||||
|
p.rngMu.Lock()
|
||||||
|
idx := p.rng.Intn(len(candidates))
|
||||||
|
p.rngMu.Unlock()
|
||||||
|
return candidates[idx]
|
||||||
|
case modeBalance:
|
||||||
|
var selected *memberState
|
||||||
|
var minActive int32
|
||||||
|
for _, member := range candidates {
|
||||||
|
var active int32
|
||||||
|
if member.shared != nil {
|
||||||
|
active = member.shared.activeCount()
|
||||||
|
}
|
||||||
|
if selected == nil || active < minActive {
|
||||||
|
selected = member
|
||||||
|
minActive = active
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selected
|
||||||
|
default:
|
||||||
|
idx := int(p.rrCounter.Add(1)-1) % len(candidates)
|
||||||
|
return candidates[idx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *poolOutbound) recordFailure(member *memberState, cause error, destination string) {
|
||||||
|
if member.shared == nil {
|
||||||
|
p.logger.Warn("proxy ", member.tag, " failure (no shared state): ", cause)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
failures, blacklisted, _ := member.shared.recordFailure(cause, p.options.FailureThreshold, p.options.BlacklistDuration, destination)
|
||||||
|
if blacklisted {
|
||||||
|
p.logger.Warn("proxy ", member.tag, " blacklisted for ", p.options.BlacklistDuration, ": ", cause)
|
||||||
|
} else {
|
||||||
|
p.logger.Warn("proxy ", member.tag, " failure ", failures, "/", p.options.FailureThreshold, ": ", cause)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *poolOutbound) recordSuccess(member *memberState, destination string) {
|
||||||
|
if member.shared != nil {
|
||||||
|
member.shared.recordSuccess(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *poolOutbound) wrapConn(conn net.Conn, member *memberState) net.Conn {
|
||||||
|
return &trackedConn{
|
||||||
|
Conn: conn,
|
||||||
|
release: func() {
|
||||||
|
p.decActive(member)
|
||||||
|
},
|
||||||
|
onTraffic: func(upload, download int64) {
|
||||||
|
if member.shared != nil {
|
||||||
|
member.shared.addTraffic(upload, download)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *poolOutbound) wrapPacketConn(conn net.PacketConn, member *memberState) net.PacketConn {
|
||||||
|
return &trackedPacketConn{
|
||||||
|
PacketConn: conn,
|
||||||
|
release: func() {
|
||||||
|
p.decActive(member)
|
||||||
|
},
|
||||||
|
onTraffic: func(upload, download int64) {
|
||||||
|
if member.shared != nil {
|
||||||
|
member.shared.addTraffic(upload, download)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *poolOutbound) makeReleaseFunc(member *memberState) func() {
|
||||||
|
return func() {
|
||||||
|
if member.shared != nil {
|
||||||
|
member.shared.forceRelease()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpProbe performs an HTTP probe through the connection and measures TTFB.
|
||||||
|
// It sends a minimal HTTP request and waits for the first byte of response.
|
||||||
|
func httpProbe(conn net.Conn, host string) (time.Duration, error) {
|
||||||
|
// Build HTTP request
|
||||||
|
req := fmt.Sprintf("GET /generate_204 HTTP/1.1\r\nHost: %s\r\nConnection: close\r\nUser-Agent: Mozilla/5.0\r\n\r\n", host)
|
||||||
|
|
||||||
|
// Try to set write deadline (ignore errors for connections that don't support it)
|
||||||
|
_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
|
||||||
|
// Record time just before sending request
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
// Send HTTP request
|
||||||
|
if _, err := conn.Write([]byte(req)); err != nil {
|
||||||
|
return 0, fmt.Errorf("write request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to set read deadline (ignore errors for connections that don't support it)
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
|
||||||
|
// Read first byte (TTFB - Time To First Byte)
|
||||||
|
reader := bufio.NewReader(conn)
|
||||||
|
_, err := reader.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate TTFB
|
||||||
|
ttfb := time.Since(start)
|
||||||
|
return ttfb, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *poolOutbound) makeProbeFunc(member *memberState) func(ctx context.Context) (time.Duration, error) {
|
||||||
|
if p.monitor == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// 仅在创建时检查是否有探测目标,实际目标在执行时动态获取
|
||||||
|
if _, ok := p.monitor.DestinationForProbe(); !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return func(ctx context.Context) (time.Duration, error) {
|
||||||
|
// 每次执行时动态获取最新的探测目标
|
||||||
|
destination, ok := p.monitor.DestinationForProbe()
|
||||||
|
if !ok {
|
||||||
|
return 0, E.New("probe target not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
probeDst := destination.String()
|
||||||
|
conn, err := member.outbound.DialContext(ctx, N.NetworkTCP, destination)
|
||||||
|
if err != nil {
|
||||||
|
if member.entry != nil {
|
||||||
|
member.entry.RecordFailure(err, probeDst)
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Perform HTTP probe to measure actual latency (TTFB)
|
||||||
|
_, err = httpProbe(conn, destination.AddrString())
|
||||||
|
if err != nil {
|
||||||
|
if member.entry != nil {
|
||||||
|
member.entry.RecordFailure(err, probeDst)
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total duration = dial time + HTTP probe
|
||||||
|
duration := time.Since(start)
|
||||||
|
if member.entry != nil {
|
||||||
|
member.entry.RecordSuccessWithLatency(duration)
|
||||||
|
}
|
||||||
|
return duration, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeProbeByTagFunc creates a probe function that works before member initialization
|
||||||
|
func (p *poolOutbound) makeProbeByTagFunc(tag string) func(ctx context.Context) (time.Duration, error) {
|
||||||
|
if p.monitor == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// 仅在创建时检查是否有探测目标,实际目标在执行时动态获取
|
||||||
|
if _, ok := p.monitor.DestinationForProbe(); !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return func(ctx context.Context) (time.Duration, error) {
|
||||||
|
// 每次执行时动态获取最新的探测目标
|
||||||
|
destination, ok := p.monitor.DestinationForProbe()
|
||||||
|
if !ok {
|
||||||
|
return 0, E.New("probe target not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure members are initialized
|
||||||
|
p.mu.Lock()
|
||||||
|
if len(p.members) == 0 {
|
||||||
|
if err := p.initializeMembersLocked(); err != nil {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the member by tag
|
||||||
|
var member *memberState
|
||||||
|
for _, m := range p.members {
|
||||||
|
if m.tag == tag {
|
||||||
|
member = m
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if member == nil {
|
||||||
|
return 0, E.New("member not found: ", tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
probeDst := destination.String()
|
||||||
|
conn, err := member.outbound.DialContext(ctx, N.NetworkTCP, destination)
|
||||||
|
if err != nil {
|
||||||
|
if member.entry != nil {
|
||||||
|
member.entry.RecordFailure(err, probeDst)
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Perform HTTP probe to measure actual latency (TTFB)
|
||||||
|
_, err = httpProbe(conn, destination.AddrString())
|
||||||
|
if err != nil {
|
||||||
|
if member.entry != nil {
|
||||||
|
member.entry.RecordFailure(err, probeDst)
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total duration = dial time + TTFB
|
||||||
|
duration := time.Since(start)
|
||||||
|
if member.entry != nil {
|
||||||
|
member.entry.RecordSuccessWithLatency(duration)
|
||||||
|
}
|
||||||
|
return duration, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeReleaseByTagFunc creates a release function that works before member initialization
|
||||||
|
func (p *poolOutbound) makeReleaseByTagFunc(tag string) func() {
|
||||||
|
return func() {
|
||||||
|
releaseSharedMember(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type trackedConn struct {
|
||||||
|
net.Conn
|
||||||
|
once sync.Once
|
||||||
|
release func()
|
||||||
|
onTraffic func(upload, download int64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *trackedConn) Read(b []byte) (int, error) {
|
||||||
|
n, err := c.Conn.Read(b)
|
||||||
|
if n > 0 && c.onTraffic != nil {
|
||||||
|
c.onTraffic(0, int64(n))
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *trackedConn) Write(b []byte) (int, error) {
|
||||||
|
n, err := c.Conn.Write(b)
|
||||||
|
if n > 0 && c.onTraffic != nil {
|
||||||
|
c.onTraffic(int64(n), 0)
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *trackedConn) Close() error {
|
||||||
|
err := c.Conn.Close()
|
||||||
|
c.once.Do(c.release)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type trackedPacketConn struct {
|
||||||
|
net.PacketConn
|
||||||
|
once sync.Once
|
||||||
|
release func()
|
||||||
|
onTraffic func(upload, download int64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *trackedPacketConn) ReadFrom(b []byte) (int, net.Addr, error) {
|
||||||
|
n, addr, err := c.PacketConn.ReadFrom(b)
|
||||||
|
if n > 0 && c.onTraffic != nil {
|
||||||
|
c.onTraffic(0, int64(n))
|
||||||
|
}
|
||||||
|
return n, addr, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *trackedPacketConn) WriteTo(b []byte, addr net.Addr) (int, error) {
|
||||||
|
n, err := c.PacketConn.WriteTo(b, addr)
|
||||||
|
if n > 0 && c.onTraffic != nil {
|
||||||
|
c.onTraffic(int64(n), 0)
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *trackedPacketConn) Close() error {
|
||||||
|
err := c.PacketConn.Close()
|
||||||
|
c.once.Do(c.release)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *poolOutbound) incActive(member *memberState) {
|
||||||
|
if member.shared != nil {
|
||||||
|
member.shared.incActive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *poolOutbound) decActive(member *memberState) {
|
||||||
|
if member.shared != nil {
|
||||||
|
member.shared.decActive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *poolOutbound) getCandidateBuffer() []*memberState {
|
||||||
|
if buf := p.candidatesPool.Get(); buf != nil {
|
||||||
|
return buf.([]*memberState)
|
||||||
|
}
|
||||||
|
return make([]*memberState, 0, len(p.options.Members))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *poolOutbound) putCandidateBuffer(buf []*memberState) {
|
||||||
|
if buf == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const maxCached = 4096
|
||||||
|
if cap(buf) > maxCached {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.candidatesPool.Put(buf[:0])
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package pool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"easy_proxies/internal/monitor"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sharedMemberState holds failure/blacklist state shared across all pool instances.
|
||||||
|
// This enables hybrid mode where pool and multi-port modes share the same node state.
|
||||||
|
type sharedMemberState struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
failures int
|
||||||
|
blacklisted bool
|
||||||
|
blacklistedUntil time.Time
|
||||||
|
entry atomic.Pointer[monitor.EntryHandle]
|
||||||
|
active atomic.Int32
|
||||||
|
totalUpload atomic.Int64
|
||||||
|
totalDownload atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
var sharedStateStore sync.Map // map[tag]*sharedMemberState
|
||||||
|
|
||||||
|
// acquireSharedState returns the shared state for a tag, creating if needed.
|
||||||
|
func acquireSharedState(tag string) *sharedMemberState {
|
||||||
|
if v, ok := sharedStateStore.Load(tag); ok {
|
||||||
|
return v.(*sharedMemberState)
|
||||||
|
}
|
||||||
|
state := &sharedMemberState{}
|
||||||
|
actual, _ := sharedStateStore.LoadOrStore(tag, state)
|
||||||
|
return actual.(*sharedMemberState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupSharedState returns the shared state if it exists.
|
||||||
|
func lookupSharedState(tag string) (*sharedMemberState, bool) {
|
||||||
|
v, ok := sharedStateStore.Load(tag)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return v.(*sharedMemberState), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetSharedStateStore clears all shared state (used during config reload).
|
||||||
|
func ResetSharedStateStore() {
|
||||||
|
sharedStateStore.Range(func(key, _ any) bool {
|
||||||
|
sharedStateStore.Delete(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sharedMemberState) attachEntry(entry *monitor.EntryHandle) {
|
||||||
|
if entry == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.entry.Store(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sharedMemberState) entryHandle() *monitor.EntryHandle {
|
||||||
|
return s.entry.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordFailure increments failure count and triggers blacklist if threshold reached.
|
||||||
|
// Returns: (current failures, blacklisted, blacklist until time)
|
||||||
|
func (s *sharedMemberState) recordFailure(cause error, threshold int, duration time.Duration, destination string) (int, bool, time.Time) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.failures++
|
||||||
|
count := s.failures
|
||||||
|
triggered := false
|
||||||
|
var until time.Time
|
||||||
|
if s.failures >= threshold {
|
||||||
|
triggered = true
|
||||||
|
until = time.Now().Add(duration)
|
||||||
|
s.failures = 0
|
||||||
|
s.blacklisted = true
|
||||||
|
s.blacklistedUntil = until
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if entry := s.entry.Load(); entry != nil {
|
||||||
|
entry.RecordFailure(cause, destination)
|
||||||
|
if triggered {
|
||||||
|
entry.Blacklist(until)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count, triggered, until
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sharedMemberState) recordSuccess(destination string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.failures = 0
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if entry := s.entry.Load(); entry != nil {
|
||||||
|
entry.RecordSuccess(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isBlacklisted checks if the node is currently blacklisted, auto-clearing if expired.
|
||||||
|
func (s *sharedMemberState) isBlacklisted(now time.Time) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
expired := s.blacklisted && now.After(s.blacklistedUntil)
|
||||||
|
if expired {
|
||||||
|
s.blacklisted = false
|
||||||
|
s.blacklistedUntil = time.Time{}
|
||||||
|
}
|
||||||
|
blacklisted := s.blacklisted
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if expired {
|
||||||
|
if entry := s.entry.Load(); entry != nil {
|
||||||
|
entry.ClearBlacklist()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return blacklisted
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sharedMemberState) forceRelease() {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.failures = 0
|
||||||
|
s.blacklisted = false
|
||||||
|
s.blacklistedUntil = time.Time{}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if entry := s.entry.Load(); entry != nil {
|
||||||
|
entry.ClearBlacklist()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sharedMemberState) incActive() {
|
||||||
|
s.active.Add(1)
|
||||||
|
if entry := s.entry.Load(); entry != nil {
|
||||||
|
entry.IncActive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sharedMemberState) decActive() {
|
||||||
|
s.active.Add(-1)
|
||||||
|
if entry := s.entry.Load(); entry != nil {
|
||||||
|
entry.DecActive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sharedMemberState) activeCount() int32 {
|
||||||
|
return s.active.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sharedMemberState) addTraffic(upload, download int64) {
|
||||||
|
if upload > 0 {
|
||||||
|
s.totalUpload.Add(upload)
|
||||||
|
}
|
||||||
|
if download > 0 {
|
||||||
|
s.totalDownload.Add(download)
|
||||||
|
}
|
||||||
|
if entry := s.entry.Load(); entry != nil {
|
||||||
|
entry.AddTraffic(upload, download)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseSharedMember clears blacklist state for a tag (called from release functions).
|
||||||
|
func releaseSharedMember(tag string) {
|
||||||
|
if state, ok := lookupSharedState(tag); ok {
|
||||||
|
state.forceRelease()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LegacyNode represents a node from the old file-based storage.
|
||||||
|
type LegacyNode struct {
|
||||||
|
Name string
|
||||||
|
URI string
|
||||||
|
Source string // inline, nodes_file, subscription, manual
|
||||||
|
Port uint16
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MigrateLegacyData imports data from old file-based storage into the Store.
|
||||||
|
// It checks if the database already has nodes; if so, it skips migration.
|
||||||
|
// After successful migration, old files are renamed with .bak suffix.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - configDir: directory containing config.yaml and node files
|
||||||
|
// - inlineNodes: nodes defined directly in config.yaml
|
||||||
|
// - nodesFilePath: path to nodes.txt (subscription/file nodes)
|
||||||
|
// - subscriptions: list of subscription URLs (to determine source type)
|
||||||
|
func MigrateLegacyData(ctx context.Context, s Store, configDir string, inlineNodes []LegacyNode, nodesFilePath string, subscriptions []string) error {
|
||||||
|
// Check if database already has nodes
|
||||||
|
count, err := s.CountNodes(ctx, NodeFilter{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check existing nodes: %w", err)
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
log.Printf("[store] database already has %d nodes, skipping legacy migration", count)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[store] starting legacy data migration...")
|
||||||
|
|
||||||
|
var totalImported int
|
||||||
|
|
||||||
|
// 1. Import inline nodes from config.yaml
|
||||||
|
if len(inlineNodes) > 0 {
|
||||||
|
var nodes []Node
|
||||||
|
for _, ln := range inlineNodes {
|
||||||
|
name := ln.Name
|
||||||
|
if name == "" {
|
||||||
|
name = extractNameFromURI(ln.URI)
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("node-%d", len(nodes)+1)
|
||||||
|
}
|
||||||
|
nodes = append(nodes, Node{
|
||||||
|
URI: ln.URI,
|
||||||
|
Name: name,
|
||||||
|
Source: NodeSourceInline,
|
||||||
|
Port: ln.Port,
|
||||||
|
Username: ln.Username,
|
||||||
|
Password: ln.Password,
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := s.BulkUpsertNodes(ctx, nodes); err != nil {
|
||||||
|
return fmt.Errorf("import inline nodes: %w", err)
|
||||||
|
}
|
||||||
|
totalImported += len(nodes)
|
||||||
|
log.Printf("[store] imported %d inline nodes from config.yaml", len(nodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Import nodes from nodes.txt
|
||||||
|
if nodesFilePath != "" {
|
||||||
|
if _, statErr := os.Stat(nodesFilePath); statErr == nil {
|
||||||
|
fileNodes, err := loadNodesFromLegacyFile(nodesFilePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[store] ⚠️ failed to parse %s: %v (skipping)", nodesFilePath, err)
|
||||||
|
} else if len(fileNodes) > 0 {
|
||||||
|
// Determine source based on whether subscriptions are configured
|
||||||
|
source := NodeSourceFile
|
||||||
|
if len(subscriptions) > 0 {
|
||||||
|
source = NodeSourceSubscription
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodes []Node
|
||||||
|
for _, uri := range fileNodes {
|
||||||
|
name := extractNameFromURI(uri)
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("node-%d", totalImported+len(nodes)+1)
|
||||||
|
}
|
||||||
|
nodes = append(nodes, Node{
|
||||||
|
URI: uri,
|
||||||
|
Name: name,
|
||||||
|
Source: source,
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := s.BulkUpsertNodes(ctx, nodes); err != nil {
|
||||||
|
return fmt.Errorf("import file nodes: %w", err)
|
||||||
|
}
|
||||||
|
totalImported += len(nodes)
|
||||||
|
log.Printf("[store] imported %d nodes from %s (source: %s)", len(nodes), nodesFilePath, source)
|
||||||
|
|
||||||
|
// Rename old file
|
||||||
|
backupPath := nodesFilePath + ".bak"
|
||||||
|
if err := os.Rename(nodesFilePath, backupPath); err != nil {
|
||||||
|
log.Printf("[store] ⚠️ failed to rename %s to %s: %v", nodesFilePath, backupPath, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("[store] renamed %s -> %s", nodesFilePath, backupPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Import manual nodes from manual_nodes.txt
|
||||||
|
manualPath := filepath.Join(configDir, "manual_nodes.txt")
|
||||||
|
if _, statErr := os.Stat(manualPath); statErr == nil {
|
||||||
|
manualURIs, err := loadNodesFromLegacyFile(manualPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[store] ⚠️ failed to parse %s: %v (skipping)", manualPath, err)
|
||||||
|
} else if len(manualURIs) > 0 {
|
||||||
|
var nodes []Node
|
||||||
|
for _, uri := range manualURIs {
|
||||||
|
name := extractNameFromURI(uri)
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("manual-%d", len(nodes)+1)
|
||||||
|
}
|
||||||
|
nodes = append(nodes, Node{
|
||||||
|
URI: uri,
|
||||||
|
Name: name,
|
||||||
|
Source: NodeSourceManual,
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := s.BulkUpsertNodes(ctx, nodes); err != nil {
|
||||||
|
return fmt.Errorf("import manual nodes: %w", err)
|
||||||
|
}
|
||||||
|
totalImported += len(nodes)
|
||||||
|
log.Printf("[store] imported %d manual nodes from %s", len(nodes), manualPath)
|
||||||
|
|
||||||
|
// Rename old file
|
||||||
|
backupPath := manualPath + ".bak"
|
||||||
|
if err := os.Rename(manualPath, backupPath); err != nil {
|
||||||
|
log.Printf("[store] ⚠️ failed to rename %s to %s: %v", manualPath, backupPath, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("[store] renamed %s -> %s", manualPath, backupPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalImported > 0 {
|
||||||
|
log.Printf("[store] ✅ legacy migration completed: %d total nodes imported", totalImported)
|
||||||
|
} else {
|
||||||
|
log.Printf("[store] no legacy data found to migrate")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadNodesFromLegacyFile reads a file where each line is a proxy URI.
|
||||||
|
// Comments (#) and empty lines are skipped.
|
||||||
|
func loadNodesFromLegacyFile(path string) ([]string, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var uris []string
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isProxyURI(line) {
|
||||||
|
uris = append(uris, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uris, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isProxyURI checks if a string looks like a proxy URI.
|
||||||
|
func isProxyURI(s string) bool {
|
||||||
|
schemes := []string{"vmess://", "vless://", "trojan://", "ss://", "ssr://",
|
||||||
|
"hysteria://", "hysteria2://", "hy2://", "http://", "socks5://"}
|
||||||
|
lower := strings.ToLower(s)
|
||||||
|
for _, scheme := range schemes {
|
||||||
|
if strings.HasPrefix(lower, scheme) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractNameFromURI extracts a name from the URI fragment (#name).
|
||||||
|
func extractNameFromURI(uri string) string {
|
||||||
|
parsed, err := url.Parse(uri)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if parsed.Fragment == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
decoded, err := url.QueryUnescape(parsed.Fragment)
|
||||||
|
if err != nil {
|
||||||
|
return parsed.Fragment
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Migration represents a single database schema migration.
|
||||||
|
type Migration struct {
|
||||||
|
Version int
|
||||||
|
Description string
|
||||||
|
Up string
|
||||||
|
}
|
||||||
|
|
||||||
|
// allMigrations returns all migrations in order. New migrations should be
|
||||||
|
// appended to this list with incrementing version numbers.
|
||||||
|
func allMigrations() []Migration {
|
||||||
|
return []Migration{
|
||||||
|
{
|
||||||
|
Version: 1,
|
||||||
|
Description: "initial schema",
|
||||||
|
Up: `
|
||||||
|
-- Nodes table: stores all proxy nodes
|
||||||
|
CREATE TABLE IF NOT EXISTS nodes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
uri TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
source TEXT NOT NULL DEFAULT 'manual',
|
||||||
|
port INTEGER NOT NULL DEFAULT 0,
|
||||||
|
username TEXT NOT NULL DEFAULT '',
|
||||||
|
password TEXT NOT NULL DEFAULT '',
|
||||||
|
region TEXT NOT NULL DEFAULT '',
|
||||||
|
country TEXT NOT NULL DEFAULT '',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(uri)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nodes_source ON nodes(source);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nodes_region ON nodes(region);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nodes_enabled ON nodes(enabled);
|
||||||
|
|
||||||
|
-- Node runtime statistics
|
||||||
|
CREATE TABLE IF NOT EXISTS node_stats (
|
||||||
|
node_id INTEGER PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
||||||
|
failure_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
blacklisted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
blacklisted_until TEXT NOT NULL DEFAULT '',
|
||||||
|
last_error TEXT NOT NULL DEFAULT '',
|
||||||
|
last_failure_at TEXT NOT NULL DEFAULT '',
|
||||||
|
last_success_at TEXT NOT NULL DEFAULT '',
|
||||||
|
last_latency_ms INTEGER NOT NULL DEFAULT -1,
|
||||||
|
available INTEGER NOT NULL DEFAULT 0,
|
||||||
|
initial_check_done INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Node event timeline for debug tracking
|
||||||
|
CREATE TABLE IF NOT EXISTS node_timeline (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
node_id INTEGER NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||||
|
success INTEGER NOT NULL DEFAULT 0,
|
||||||
|
latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
error TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_timeline_node_id ON node_timeline(node_id);
|
||||||
|
|
||||||
|
-- User authentication sessions
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
||||||
|
|
||||||
|
-- Subscription refresh status (singleton row)
|
||||||
|
CREATE TABLE IF NOT EXISTS subscription_status (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
last_refresh TEXT NOT NULL DEFAULT '',
|
||||||
|
next_refresh TEXT NOT NULL DEFAULT '',
|
||||||
|
node_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT NOT NULL DEFAULT '',
|
||||||
|
refresh_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_refreshing INTEGER NOT NULL DEFAULT 0,
|
||||||
|
nodes_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Insert the singleton subscription status row
|
||||||
|
INSERT OR IGNORE INTO subscription_status (id) VALUES (1);
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: 2,
|
||||||
|
Description: "add traffic columns to node_stats",
|
||||||
|
Up: `
|
||||||
|
ALTER TABLE node_stats ADD COLUMN total_upload_bytes INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE node_stats ADD COLUMN total_download_bytes INTEGER NOT NULL DEFAULT 0;
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate applies all pending migrations to the database.
|
||||||
|
// Each migration runs in its own transaction. Already-applied migrations
|
||||||
|
// (tracked in schema_migrations) are skipped.
|
||||||
|
func Migrate(db *sql.DB) error {
|
||||||
|
// Create the migrations tracking table
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
return fmt.Errorf("create schema_migrations table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current version
|
||||||
|
var currentVersion int
|
||||||
|
row := db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_migrations")
|
||||||
|
if err := row.Scan(¤tVersion); err != nil {
|
||||||
|
return fmt.Errorf("query current version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
migrations := allMigrations()
|
||||||
|
applied := 0
|
||||||
|
|
||||||
|
for _, m := range migrations {
|
||||||
|
if m.Version <= currentVersion {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[store] applying migration %d: %s", m.Version, m.Description)
|
||||||
|
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx for migration %d: %w", m.Version, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(m.Up); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return fmt.Errorf("execute migration %d (%s): %w", m.Version, m.Description, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
"INSERT INTO schema_migrations (version, applied_at, description) VALUES (?, ?, ?)",
|
||||||
|
m.Version, time.Now().UTC().Format(time.RFC3339), m.Description,
|
||||||
|
); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return fmt.Errorf("record migration %d: %w", m.Version, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit migration %d: %w", m.Version, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
applied++
|
||||||
|
log.Printf("[store] migration %d applied successfully", m.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
if applied > 0 {
|
||||||
|
log.Printf("[store] %d migration(s) applied, current version: %d", applied, migrations[len(migrations)-1].Version)
|
||||||
|
} else {
|
||||||
|
log.Printf("[store] database schema is up to date (version %d)", currentVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentVersion returns the current schema version.
|
||||||
|
func CurrentVersion(db *sql.DB) (int, error) {
|
||||||
|
// Check if schema_migrations table exists
|
||||||
|
var count int
|
||||||
|
err := db.QueryRow(`
|
||||||
|
SELECT COUNT(*) FROM sqlite_master
|
||||||
|
WHERE type='table' AND name='schema_migrations'
|
||||||
|
`).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var version int
|
||||||
|
err = db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_migrations").Scan(&version)
|
||||||
|
return version, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,816 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sqliteStore implements Store using SQLite.
|
||||||
|
type sqliteStore struct {
|
||||||
|
db *sql.DB
|
||||||
|
tx *sql.Tx // non-nil when operating inside WithTx
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open creates a new SQLite-backed Store at the given path.
|
||||||
|
// It applies all pending migrations and sets optimal PRAGMAs.
|
||||||
|
func Open(dbPath string) (Store, error) {
|
||||||
|
dsn := dbPath + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=synchronous(NORMAL)&_pragma=cache_size(-64000)&_pragma=foreign_keys(ON)"
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open sqlite %q: %w", dbPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection pool settings
|
||||||
|
db.SetMaxOpenConns(1) // SQLite only supports 1 writer
|
||||||
|
db.SetMaxIdleConns(2)
|
||||||
|
db.SetConnMaxLifetime(0) // connections don't expire
|
||||||
|
|
||||||
|
// Verify connection
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
if err := Migrate(db); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("migrate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[store] SQLite store opened: %s", dbPath)
|
||||||
|
return &sqliteStore{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// conn returns the underlying *sql.Tx or *sql.DB for executing queries.
|
||||||
|
func (s *sqliteStore) conn() querier {
|
||||||
|
if s.tx != nil {
|
||||||
|
return s.tx
|
||||||
|
}
|
||||||
|
return s.db
|
||||||
|
}
|
||||||
|
|
||||||
|
// querier abstracts *sql.DB and *sql.Tx for query execution.
|
||||||
|
type querier interface {
|
||||||
|
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||||
|
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||||||
|
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||||
|
PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== Node operations =====================
|
||||||
|
|
||||||
|
func (s *sqliteStore) ListNodes(ctx context.Context, filter NodeFilter) ([]Node, error) {
|
||||||
|
query := "SELECT id, uri, name, source, port, username, password, region, country, enabled, created_at, updated_at FROM nodes"
|
||||||
|
var conditions []string
|
||||||
|
var args []any
|
||||||
|
|
||||||
|
if filter.Source != "" {
|
||||||
|
conditions = append(conditions, "source = ?")
|
||||||
|
args = append(args, filter.Source)
|
||||||
|
}
|
||||||
|
if filter.Region != "" {
|
||||||
|
conditions = append(conditions, "region = ?")
|
||||||
|
args = append(args, filter.Region)
|
||||||
|
}
|
||||||
|
if filter.Enabled != nil {
|
||||||
|
conditions = append(conditions, "enabled = ?")
|
||||||
|
if *filter.Enabled {
|
||||||
|
args = append(args, 1)
|
||||||
|
} else {
|
||||||
|
args = append(args, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(conditions) > 0 {
|
||||||
|
query += " WHERE " + strings.Join(conditions, " AND ")
|
||||||
|
}
|
||||||
|
query += " ORDER BY id ASC"
|
||||||
|
if filter.Limit > 0 {
|
||||||
|
query += fmt.Sprintf(" LIMIT %d", filter.Limit)
|
||||||
|
if filter.Offset > 0 {
|
||||||
|
query += fmt.Sprintf(" OFFSET %d", filter.Offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.conn().QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list nodes: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanNodes(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) GetNode(ctx context.Context, id int64) (*Node, error) {
|
||||||
|
row := s.conn().QueryRowContext(ctx,
|
||||||
|
"SELECT id, uri, name, source, port, username, password, region, country, enabled, created_at, updated_at FROM nodes WHERE id = ?", id)
|
||||||
|
return scanNode(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) GetNodeByURI(ctx context.Context, uri string) (*Node, error) {
|
||||||
|
row := s.conn().QueryRowContext(ctx,
|
||||||
|
"SELECT id, uri, name, source, port, username, password, region, country, enabled, created_at, updated_at FROM nodes WHERE uri = ?", uri)
|
||||||
|
return scanNode(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) GetNodeByName(ctx context.Context, name string) (*Node, error) {
|
||||||
|
row := s.conn().QueryRowContext(ctx,
|
||||||
|
"SELECT id, uri, name, source, port, username, password, region, country, enabled, created_at, updated_at FROM nodes WHERE name = ?", name)
|
||||||
|
return scanNode(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) CreateNode(ctx context.Context, node *Node) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
if node.CreatedAt.IsZero() {
|
||||||
|
node.CreatedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
if node.UpdatedAt.IsZero() {
|
||||||
|
node.UpdatedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
enabled := 0
|
||||||
|
if node.Enabled {
|
||||||
|
enabled = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := s.conn().ExecContext(ctx,
|
||||||
|
`INSERT INTO nodes (uri, name, source, port, username, password, region, country, enabled, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
node.URI, node.Name, node.Source, node.Port,
|
||||||
|
node.Username, node.Password, node.Region, node.Country,
|
||||||
|
enabled, now, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create node: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get last insert id: %w", err)
|
||||||
|
}
|
||||||
|
node.ID = id
|
||||||
|
|
||||||
|
// Create initial stats row
|
||||||
|
_, err = s.conn().ExecContext(ctx,
|
||||||
|
"INSERT OR IGNORE INTO node_stats (node_id) VALUES (?)", id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create initial node stats: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) UpdateNode(ctx context.Context, node *Node) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
enabled := 0
|
||||||
|
if node.Enabled {
|
||||||
|
enabled = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := s.conn().ExecContext(ctx,
|
||||||
|
`UPDATE nodes SET uri=?, name=?, source=?, port=?, username=?, password=?,
|
||||||
|
region=?, country=?, enabled=?, updated_at=?
|
||||||
|
WHERE id=?`,
|
||||||
|
node.URI, node.Name, node.Source, node.Port,
|
||||||
|
node.Username, node.Password, node.Region, node.Country,
|
||||||
|
enabled, now, node.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update node %d: %w", node.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, _ := result.RowsAffected()
|
||||||
|
if affected == 0 {
|
||||||
|
return fmt.Errorf("node %d not found", node.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) DeleteNode(ctx context.Context, id int64) error {
|
||||||
|
result, err := s.conn().ExecContext(ctx, "DELETE FROM nodes WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete node %d: %w", id, err)
|
||||||
|
}
|
||||||
|
affected, _ := result.RowsAffected()
|
||||||
|
if affected == 0 {
|
||||||
|
return fmt.Errorf("node %d not found", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) DeleteNodesBySource(ctx context.Context, source string) (int64, error) {
|
||||||
|
result, err := s.conn().ExecContext(ctx, "DELETE FROM nodes WHERE source = ?", source)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("delete nodes by source %q: %w", source, err)
|
||||||
|
}
|
||||||
|
return result.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) BulkUpsertNodes(ctx context.Context, nodes []Node) error {
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
execFn := func(txStore *sqliteStore) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
stmt, err := txStore.conn().PrepareContext(ctx,
|
||||||
|
`INSERT INTO nodes (uri, name, source, port, username, password, region, country, enabled, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(uri) DO UPDATE SET
|
||||||
|
name=excluded.name, source=excluded.source, port=excluded.port,
|
||||||
|
username=excluded.username, password=excluded.password,
|
||||||
|
region=excluded.region, country=excluded.country,
|
||||||
|
updated_at=excluded.updated_at`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("prepare bulk upsert: %w", err)
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
for i := range nodes {
|
||||||
|
n := &nodes[i]
|
||||||
|
enabled := 0
|
||||||
|
if n.Enabled {
|
||||||
|
enabled = 1
|
||||||
|
}
|
||||||
|
result, err := stmt.ExecContext(ctx,
|
||||||
|
n.URI, n.Name, n.Source, n.Port,
|
||||||
|
n.Username, n.Password, n.Region, n.Country,
|
||||||
|
enabled, now, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("upsert node %q: %w", n.URI, err)
|
||||||
|
}
|
||||||
|
id, _ := result.LastInsertId()
|
||||||
|
if id > 0 {
|
||||||
|
n.ID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create stats rows for new nodes
|
||||||
|
_, err = txStore.conn().ExecContext(ctx,
|
||||||
|
"INSERT OR IGNORE INTO node_stats (node_id) SELECT id FROM nodes")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create stats for new nodes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If already in a transaction, execute directly
|
||||||
|
if s.tx != nil {
|
||||||
|
return execFn(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise wrap in a transaction
|
||||||
|
return s.WithTx(ctx, func(tx Store) error {
|
||||||
|
return execFn(tx.(*sqliteStore))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) CountNodes(ctx context.Context, filter NodeFilter) (int64, error) {
|
||||||
|
query := "SELECT COUNT(*) FROM nodes"
|
||||||
|
var conditions []string
|
||||||
|
var args []any
|
||||||
|
|
||||||
|
if filter.Source != "" {
|
||||||
|
conditions = append(conditions, "source = ?")
|
||||||
|
args = append(args, filter.Source)
|
||||||
|
}
|
||||||
|
if filter.Region != "" {
|
||||||
|
conditions = append(conditions, "region = ?")
|
||||||
|
args = append(args, filter.Region)
|
||||||
|
}
|
||||||
|
if filter.Enabled != nil {
|
||||||
|
conditions = append(conditions, "enabled = ?")
|
||||||
|
if *filter.Enabled {
|
||||||
|
args = append(args, 1)
|
||||||
|
} else {
|
||||||
|
args = append(args, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(conditions) > 0 {
|
||||||
|
query += " WHERE " + strings.Join(conditions, " AND ")
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
err := s.conn().QueryRowContext(ctx, query, args...).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== Node stats =====================
|
||||||
|
|
||||||
|
func (s *sqliteStore) GetNodeStats(ctx context.Context, nodeID int64) (*NodeStats, error) {
|
||||||
|
row := s.conn().QueryRowContext(ctx,
|
||||||
|
`SELECT node_id, failure_count, success_count, blacklisted, blacklisted_until,
|
||||||
|
last_error, last_failure_at, last_success_at, last_latency_ms,
|
||||||
|
available, initial_check_done, total_upload_bytes, total_download_bytes, updated_at
|
||||||
|
FROM node_stats WHERE node_id = ?`, nodeID)
|
||||||
|
|
||||||
|
stats := &NodeStats{}
|
||||||
|
var blacklistedUntilStr, lastFailureStr, lastSuccessStr, updatedAtStr string
|
||||||
|
var blacklisted, available, initialCheckDone int
|
||||||
|
|
||||||
|
err := row.Scan(
|
||||||
|
&stats.NodeID, &stats.FailureCount, &stats.SuccessCount,
|
||||||
|
&blacklisted, &blacklistedUntilStr,
|
||||||
|
&stats.LastError, &lastFailureStr, &lastSuccessStr,
|
||||||
|
&stats.LastLatencyMs, &available, &initialCheckDone,
|
||||||
|
&stats.TotalUploadBytes, &stats.TotalDownloadBytes, &updatedAtStr,
|
||||||
|
)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get node stats %d: %w", nodeID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.Blacklisted = blacklisted != 0
|
||||||
|
stats.Available = available != 0
|
||||||
|
stats.InitialCheckDone = initialCheckDone != 0
|
||||||
|
stats.BlacklistedUntil = parseTime(blacklistedUntilStr)
|
||||||
|
stats.LastFailureAt = parseTime(lastFailureStr)
|
||||||
|
stats.LastSuccessAt = parseTime(lastSuccessStr)
|
||||||
|
stats.UpdatedAt = parseTime(updatedAtStr)
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) UpsertNodeStats(ctx context.Context, stats *NodeStats) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
blacklisted := 0
|
||||||
|
if stats.Blacklisted {
|
||||||
|
blacklisted = 1
|
||||||
|
}
|
||||||
|
available := 0
|
||||||
|
if stats.Available {
|
||||||
|
available = 1
|
||||||
|
}
|
||||||
|
initialCheckDone := 0
|
||||||
|
if stats.InitialCheckDone {
|
||||||
|
initialCheckDone = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.conn().ExecContext(ctx,
|
||||||
|
`INSERT INTO node_stats (node_id, failure_count, success_count, blacklisted, blacklisted_until,
|
||||||
|
last_error, last_failure_at, last_success_at, last_latency_ms, available, initial_check_done,
|
||||||
|
total_upload_bytes, total_download_bytes, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(node_id) DO UPDATE SET
|
||||||
|
failure_count=excluded.failure_count, success_count=excluded.success_count,
|
||||||
|
blacklisted=excluded.blacklisted, blacklisted_until=excluded.blacklisted_until,
|
||||||
|
last_error=excluded.last_error, last_failure_at=excluded.last_failure_at,
|
||||||
|
last_success_at=excluded.last_success_at, last_latency_ms=excluded.last_latency_ms,
|
||||||
|
available=excluded.available, initial_check_done=excluded.initial_check_done,
|
||||||
|
total_upload_bytes=excluded.total_upload_bytes, total_download_bytes=excluded.total_download_bytes,
|
||||||
|
updated_at=excluded.updated_at`,
|
||||||
|
stats.NodeID, stats.FailureCount, stats.SuccessCount,
|
||||||
|
blacklisted, formatTime(stats.BlacklistedUntil),
|
||||||
|
stats.LastError, formatTime(stats.LastFailureAt), formatTime(stats.LastSuccessAt),
|
||||||
|
stats.LastLatencyMs, available, initialCheckDone,
|
||||||
|
stats.TotalUploadBytes, stats.TotalDownloadBytes, now,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) RecordSuccess(ctx context.Context, nodeID int64, latencyMs int64) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
_, err := s.conn().ExecContext(ctx,
|
||||||
|
`UPDATE node_stats SET
|
||||||
|
success_count = success_count + 1,
|
||||||
|
last_success_at = ?,
|
||||||
|
last_latency_ms = ?,
|
||||||
|
available = 1,
|
||||||
|
initial_check_done = 1,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE node_id = ?`,
|
||||||
|
now, latencyMs, now, nodeID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) RecordFailure(ctx context.Context, nodeID int64, errMsg string) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
_, err := s.conn().ExecContext(ctx,
|
||||||
|
`UPDATE node_stats SET
|
||||||
|
failure_count = failure_count + 1,
|
||||||
|
last_error = ?,
|
||||||
|
last_failure_at = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE node_id = ?`,
|
||||||
|
errMsg, now, now, nodeID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) SetBlacklist(ctx context.Context, nodeID int64, until time.Time) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
_, err := s.conn().ExecContext(ctx,
|
||||||
|
`UPDATE node_stats SET
|
||||||
|
blacklisted = 1,
|
||||||
|
blacklisted_until = ?,
|
||||||
|
failure_count = 0,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE node_id = ?`,
|
||||||
|
formatTime(until), now, nodeID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) ClearBlacklist(ctx context.Context, nodeID int64) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
_, err := s.conn().ExecContext(ctx,
|
||||||
|
`UPDATE node_stats SET
|
||||||
|
blacklisted = 0,
|
||||||
|
blacklisted_until = '',
|
||||||
|
updated_at = ?
|
||||||
|
WHERE node_id = ?`,
|
||||||
|
now, nodeID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) ClearAllBlacklists(ctx context.Context) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
_, err := s.conn().ExecContext(ctx,
|
||||||
|
`UPDATE node_stats SET blacklisted = 0, blacklisted_until = '', updated_at = ? WHERE blacklisted = 1`,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) BatchUpdateStats(ctx context.Context, updates []StatsUpdate) error {
|
||||||
|
if len(updates) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
execFn := func(txStore *sqliteStore) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
stmt, err := txStore.conn().PrepareContext(ctx,
|
||||||
|
`INSERT INTO node_stats (node_id, failure_count, success_count, blacklisted, blacklisted_until,
|
||||||
|
last_error, last_failure_at, last_success_at, last_latency_ms, available, initial_check_done,
|
||||||
|
total_upload_bytes, total_download_bytes, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(node_id) DO UPDATE SET
|
||||||
|
failure_count=excluded.failure_count, success_count=excluded.success_count,
|
||||||
|
blacklisted=excluded.blacklisted, blacklisted_until=excluded.blacklisted_until,
|
||||||
|
last_error=excluded.last_error, last_failure_at=excluded.last_failure_at,
|
||||||
|
last_success_at=excluded.last_success_at, last_latency_ms=excluded.last_latency_ms,
|
||||||
|
available=excluded.available, initial_check_done=excluded.initial_check_done,
|
||||||
|
total_upload_bytes=excluded.total_upload_bytes, total_download_bytes=excluded.total_download_bytes,
|
||||||
|
updated_at=excluded.updated_at`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("prepare batch stats: %w", err)
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
for _, u := range updates {
|
||||||
|
blacklisted := 0
|
||||||
|
if u.Blacklisted {
|
||||||
|
blacklisted = 1
|
||||||
|
}
|
||||||
|
available := 0
|
||||||
|
if u.Available {
|
||||||
|
available = 1
|
||||||
|
}
|
||||||
|
initialCheckDone := 0
|
||||||
|
if u.InitialCheckDone {
|
||||||
|
initialCheckDone = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := stmt.ExecContext(ctx,
|
||||||
|
u.NodeID, u.FailureCount, u.SuccessCount,
|
||||||
|
blacklisted, formatTime(u.BlacklistedUntil),
|
||||||
|
u.LastError, formatTime(u.LastFailureAt), formatTime(u.LastSuccessAt),
|
||||||
|
u.LastLatencyMs, available, initialCheckDone,
|
||||||
|
u.TotalUploadBytes, u.TotalDownloadBytes, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("batch update stats for node %d: %w", u.NodeID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.tx != nil {
|
||||||
|
return execFn(s)
|
||||||
|
}
|
||||||
|
return s.WithTx(ctx, func(tx Store) error {
|
||||||
|
return execFn(tx.(*sqliteStore))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) GetAllNodeStats(ctx context.Context) (map[int64]*NodeStats, error) {
|
||||||
|
rows, err := s.conn().QueryContext(ctx,
|
||||||
|
`SELECT node_id, failure_count, success_count, blacklisted, blacklisted_until,
|
||||||
|
last_error, last_failure_at, last_success_at, last_latency_ms,
|
||||||
|
available, initial_check_done, total_upload_bytes, total_download_bytes, updated_at
|
||||||
|
FROM node_stats`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get all node stats: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
result := make(map[int64]*NodeStats)
|
||||||
|
for rows.Next() {
|
||||||
|
stats := &NodeStats{}
|
||||||
|
var blacklistedUntilStr, lastFailureStr, lastSuccessStr, updatedAtStr string
|
||||||
|
var blacklisted, available, initialCheckDone int
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&stats.NodeID, &stats.FailureCount, &stats.SuccessCount,
|
||||||
|
&blacklisted, &blacklistedUntilStr,
|
||||||
|
&stats.LastError, &lastFailureStr, &lastSuccessStr,
|
||||||
|
&stats.LastLatencyMs, &available, &initialCheckDone,
|
||||||
|
&stats.TotalUploadBytes, &stats.TotalDownloadBytes, &updatedAtStr,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan node stats: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.Blacklisted = blacklisted != 0
|
||||||
|
stats.Available = available != 0
|
||||||
|
stats.InitialCheckDone = initialCheckDone != 0
|
||||||
|
stats.BlacklistedUntil = parseTime(blacklistedUntilStr)
|
||||||
|
stats.LastFailureAt = parseTime(lastFailureStr)
|
||||||
|
stats.LastSuccessAt = parseTime(lastSuccessStr)
|
||||||
|
stats.UpdatedAt = parseTime(updatedAtStr)
|
||||||
|
|
||||||
|
result[stats.NodeID] = stats
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== Timeline =====================
|
||||||
|
|
||||||
|
func (s *sqliteStore) AppendTimeline(ctx context.Context, nodeID int64, event TimelineEvent) error {
|
||||||
|
_, err := s.conn().ExecContext(ctx,
|
||||||
|
`INSERT INTO node_timeline (node_id, success, latency_ms, error, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
nodeID, boolToInt(event.Success), event.LatencyMs, event.Error,
|
||||||
|
time.Now().UTC().Format(time.RFC3339),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) GetTimeline(ctx context.Context, nodeID int64, limit int) ([]TimelineEvent, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.conn().QueryContext(ctx,
|
||||||
|
`SELECT id, node_id, success, latency_ms, error, created_at
|
||||||
|
FROM node_timeline WHERE node_id = ?
|
||||||
|
ORDER BY id DESC LIMIT ?`,
|
||||||
|
nodeID, limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get timeline for node %d: %w", nodeID, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var events []TimelineEvent
|
||||||
|
for rows.Next() {
|
||||||
|
var evt TimelineEvent
|
||||||
|
var success int
|
||||||
|
var createdAtStr string
|
||||||
|
err := rows.Scan(&evt.ID, &evt.NodeID, &success, &evt.LatencyMs, &evt.Error, &createdAtStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan timeline event: %w", err)
|
||||||
|
}
|
||||||
|
evt.Success = success != 0
|
||||||
|
evt.CreatedAt = parseTime(createdAtStr)
|
||||||
|
events = append(events, evt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse to get chronological order
|
||||||
|
for i, j := 0, len(events)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
events[i], events[j] = events[j], events[i]
|
||||||
|
}
|
||||||
|
return events, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) CleanupTimeline(ctx context.Context, keepPerNode int) error {
|
||||||
|
if keepPerNode <= 0 {
|
||||||
|
keepPerNode = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.conn().ExecContext(ctx,
|
||||||
|
`DELETE FROM node_timeline WHERE id NOT IN (
|
||||||
|
SELECT id FROM (
|
||||||
|
SELECT id, ROW_NUMBER() OVER (PARTITION BY node_id ORDER BY id DESC) as rn
|
||||||
|
FROM node_timeline
|
||||||
|
) WHERE rn <= ?
|
||||||
|
)`, keepPerNode,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== Sessions =====================
|
||||||
|
|
||||||
|
func (s *sqliteStore) CreateSession(ctx context.Context, session *Session) error {
|
||||||
|
_, err := s.conn().ExecContext(ctx,
|
||||||
|
`INSERT INTO sessions (token, created_at, expires_at) VALUES (?, ?, ?)`,
|
||||||
|
session.Token,
|
||||||
|
session.CreatedAt.UTC().Format(time.RFC3339),
|
||||||
|
session.ExpiresAt.UTC().Format(time.RFC3339),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) GetSession(ctx context.Context, token string) (*Session, error) {
|
||||||
|
row := s.conn().QueryRowContext(ctx,
|
||||||
|
"SELECT token, created_at, expires_at FROM sessions WHERE token = ?", token)
|
||||||
|
|
||||||
|
var sess Session
|
||||||
|
var createdAtStr, expiresAtStr string
|
||||||
|
err := row.Scan(&sess.Token, &createdAtStr, &expiresAtStr)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sess.CreatedAt = parseTime(createdAtStr)
|
||||||
|
sess.ExpiresAt = parseTime(expiresAtStr)
|
||||||
|
return &sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) DeleteSession(ctx context.Context, token string) error {
|
||||||
|
_, err := s.conn().ExecContext(ctx, "DELETE FROM sessions WHERE token = ?", token)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) CleanupExpiredSessions(ctx context.Context) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
_, err := s.conn().ExecContext(ctx, "DELETE FROM sessions WHERE expires_at < ?", now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== Subscription status =====================
|
||||||
|
|
||||||
|
func (s *sqliteStore) GetSubscriptionStatus(ctx context.Context) (*SubscriptionStatus, error) {
|
||||||
|
row := s.conn().QueryRowContext(ctx,
|
||||||
|
`SELECT last_refresh, next_refresh, node_count, last_error,
|
||||||
|
refresh_count, is_refreshing, nodes_hash, updated_at
|
||||||
|
FROM subscription_status WHERE id = 1`)
|
||||||
|
|
||||||
|
var status SubscriptionStatus
|
||||||
|
var lastRefreshStr, nextRefreshStr, updatedAtStr string
|
||||||
|
var isRefreshing int
|
||||||
|
|
||||||
|
err := row.Scan(
|
||||||
|
&lastRefreshStr, &nextRefreshStr, &status.NodeCount,
|
||||||
|
&status.LastError, &status.RefreshCount, &isRefreshing,
|
||||||
|
&status.NodesHash, &updatedAtStr,
|
||||||
|
)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return &SubscriptionStatus{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get subscription status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status.IsRefreshing = isRefreshing != 0
|
||||||
|
status.LastRefresh = parseTime(lastRefreshStr)
|
||||||
|
status.NextRefresh = parseTime(nextRefreshStr)
|
||||||
|
status.UpdatedAt = parseTime(updatedAtStr)
|
||||||
|
|
||||||
|
return &status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) UpdateSubscriptionStatus(ctx context.Context, status *SubscriptionStatus) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
isRefreshing := 0
|
||||||
|
if status.IsRefreshing {
|
||||||
|
isRefreshing = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.conn().ExecContext(ctx,
|
||||||
|
`INSERT INTO subscription_status (id, last_refresh, next_refresh, node_count, last_error,
|
||||||
|
refresh_count, is_refreshing, nodes_hash, updated_at)
|
||||||
|
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
last_refresh=excluded.last_refresh, next_refresh=excluded.next_refresh,
|
||||||
|
node_count=excluded.node_count, last_error=excluded.last_error,
|
||||||
|
refresh_count=excluded.refresh_count, is_refreshing=excluded.is_refreshing,
|
||||||
|
nodes_hash=excluded.nodes_hash, updated_at=excluded.updated_at`,
|
||||||
|
formatTime(status.LastRefresh), formatTime(status.NextRefresh),
|
||||||
|
status.NodeCount, status.LastError, status.RefreshCount,
|
||||||
|
isRefreshing, status.NodesHash, now,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== Lifecycle =====================
|
||||||
|
|
||||||
|
func (s *sqliteStore) Close() error {
|
||||||
|
if s.db != nil {
|
||||||
|
return s.db.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) WithTx(ctx context.Context, fn func(tx Store) error) error {
|
||||||
|
if s.tx != nil {
|
||||||
|
// Already in a transaction, just execute
|
||||||
|
return fn(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
txStore := &sqliteStore{db: s.db, tx: tx}
|
||||||
|
if err := fn(txStore); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== Helpers =====================
|
||||||
|
|
||||||
|
func scanNode(row *sql.Row) (*Node, error) {
|
||||||
|
var n Node
|
||||||
|
var enabled int
|
||||||
|
var createdAtStr, updatedAtStr string
|
||||||
|
|
||||||
|
err := row.Scan(
|
||||||
|
&n.ID, &n.URI, &n.Name, &n.Source, &n.Port,
|
||||||
|
&n.Username, &n.Password, &n.Region, &n.Country,
|
||||||
|
&enabled, &createdAtStr, &updatedAtStr,
|
||||||
|
)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
n.Enabled = enabled != 0
|
||||||
|
n.CreatedAt = parseTime(createdAtStr)
|
||||||
|
n.UpdatedAt = parseTime(updatedAtStr)
|
||||||
|
return &n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanNodes(rows *sql.Rows) ([]Node, error) {
|
||||||
|
var nodes []Node
|
||||||
|
for rows.Next() {
|
||||||
|
var n Node
|
||||||
|
var enabled int
|
||||||
|
var createdAtStr, updatedAtStr string
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&n.ID, &n.URI, &n.Name, &n.Source, &n.Port,
|
||||||
|
&n.Username, &n.Password, &n.Region, &n.Country,
|
||||||
|
&enabled, &createdAtStr, &updatedAtStr,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
n.Enabled = enabled != 0
|
||||||
|
n.CreatedAt = parseTime(createdAtStr)
|
||||||
|
n.UpdatedAt = parseTime(updatedAtStr)
|
||||||
|
nodes = append(nodes, n)
|
||||||
|
}
|
||||||
|
return nodes, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTime(s string) time.Time {
|
||||||
|
if s == "" {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339, s)
|
||||||
|
if err != nil {
|
||||||
|
// Try other formats
|
||||||
|
t, err = time.Parse("2006-01-02 15:04:05", s)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTime(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolToInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
// Package store defines the data persistence layer for easy_proxies.
|
||||||
|
// It abstracts all storage operations behind the Store interface,
|
||||||
|
// allowing different backend implementations (e.g., SQLite).
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store defines all data storage operations.
|
||||||
|
type Store interface {
|
||||||
|
// --- Node operations ---
|
||||||
|
|
||||||
|
// ListNodes returns nodes matching the given filter.
|
||||||
|
ListNodes(ctx context.Context, filter NodeFilter) ([]Node, error)
|
||||||
|
|
||||||
|
// GetNode returns a node by its ID.
|
||||||
|
GetNode(ctx context.Context, id int64) (*Node, error)
|
||||||
|
|
||||||
|
// GetNodeByURI returns a node by its URI.
|
||||||
|
GetNodeByURI(ctx context.Context, uri string) (*Node, error)
|
||||||
|
|
||||||
|
// GetNodeByName returns a node by its name.
|
||||||
|
GetNodeByName(ctx context.Context, name string) (*Node, error)
|
||||||
|
|
||||||
|
// CreateNode inserts a new node and returns its assigned ID.
|
||||||
|
CreateNode(ctx context.Context, node *Node) error
|
||||||
|
|
||||||
|
// UpdateNode updates an existing node by ID.
|
||||||
|
UpdateNode(ctx context.Context, node *Node) error
|
||||||
|
|
||||||
|
// DeleteNode removes a node by ID (cascading to stats/timeline).
|
||||||
|
DeleteNode(ctx context.Context, id int64) error
|
||||||
|
|
||||||
|
// DeleteNodesBySource removes all nodes with the given source.
|
||||||
|
// Returns the number of deleted rows.
|
||||||
|
DeleteNodesBySource(ctx context.Context, source string) (int64, error)
|
||||||
|
|
||||||
|
// BulkUpsertNodes inserts or updates nodes in a single transaction.
|
||||||
|
// Nodes are matched by URI for upsert logic.
|
||||||
|
BulkUpsertNodes(ctx context.Context, nodes []Node) error
|
||||||
|
|
||||||
|
// CountNodes returns the total number of nodes matching the filter.
|
||||||
|
CountNodes(ctx context.Context, filter NodeFilter) (int64, error)
|
||||||
|
|
||||||
|
// --- Node stats ---
|
||||||
|
|
||||||
|
// GetNodeStats returns runtime statistics for a node.
|
||||||
|
GetNodeStats(ctx context.Context, nodeID int64) (*NodeStats, error)
|
||||||
|
|
||||||
|
// UpsertNodeStats creates or updates node statistics.
|
||||||
|
UpsertNodeStats(ctx context.Context, stats *NodeStats) error
|
||||||
|
|
||||||
|
// RecordSuccess increments success count and updates latency.
|
||||||
|
RecordSuccess(ctx context.Context, nodeID int64, latencyMs int64) error
|
||||||
|
|
||||||
|
// RecordFailure increments failure count and records the error.
|
||||||
|
RecordFailure(ctx context.Context, nodeID int64, errMsg string) error
|
||||||
|
|
||||||
|
// SetBlacklist marks a node as blacklisted until the given time.
|
||||||
|
SetBlacklist(ctx context.Context, nodeID int64, until time.Time) error
|
||||||
|
|
||||||
|
// ClearBlacklist removes the blacklist flag for a node.
|
||||||
|
ClearBlacklist(ctx context.Context, nodeID int64) error
|
||||||
|
|
||||||
|
// ClearAllBlacklists removes all blacklist flags.
|
||||||
|
ClearAllBlacklists(ctx context.Context) error
|
||||||
|
|
||||||
|
// BatchUpdateStats applies multiple stat updates in a single transaction.
|
||||||
|
BatchUpdateStats(ctx context.Context, updates []StatsUpdate) error
|
||||||
|
|
||||||
|
// GetAllNodeStats returns stats for all nodes (used for bulk restore).
|
||||||
|
GetAllNodeStats(ctx context.Context) (map[int64]*NodeStats, error)
|
||||||
|
|
||||||
|
// --- Timeline ---
|
||||||
|
|
||||||
|
// AppendTimeline adds an event to a node's timeline.
|
||||||
|
AppendTimeline(ctx context.Context, nodeID int64, event TimelineEvent) error
|
||||||
|
|
||||||
|
// GetTimeline returns the most recent events for a node.
|
||||||
|
GetTimeline(ctx context.Context, nodeID int64, limit int) ([]TimelineEvent, error)
|
||||||
|
|
||||||
|
// CleanupTimeline removes old timeline events, keeping only the most recent per node.
|
||||||
|
CleanupTimeline(ctx context.Context, keepPerNode int) error
|
||||||
|
|
||||||
|
// --- Sessions ---
|
||||||
|
|
||||||
|
// CreateSession stores a new session.
|
||||||
|
CreateSession(ctx context.Context, session *Session) error
|
||||||
|
|
||||||
|
// GetSession retrieves a session by token.
|
||||||
|
GetSession(ctx context.Context, token string) (*Session, error)
|
||||||
|
|
||||||
|
// DeleteSession removes a session by token.
|
||||||
|
DeleteSession(ctx context.Context, token string) error
|
||||||
|
|
||||||
|
// CleanupExpiredSessions removes all expired sessions.
|
||||||
|
CleanupExpiredSessions(ctx context.Context) error
|
||||||
|
|
||||||
|
// --- Subscription status ---
|
||||||
|
|
||||||
|
// GetSubscriptionStatus returns the current subscription refresh status.
|
||||||
|
GetSubscriptionStatus(ctx context.Context) (*SubscriptionStatus, error)
|
||||||
|
|
||||||
|
// UpdateSubscriptionStatus creates or updates the subscription status.
|
||||||
|
UpdateSubscriptionStatus(ctx context.Context, status *SubscriptionStatus) error
|
||||||
|
|
||||||
|
// --- Lifecycle ---
|
||||||
|
|
||||||
|
// Close releases all resources held by the store.
|
||||||
|
Close() error
|
||||||
|
|
||||||
|
// WithTx executes fn within a transaction. If fn returns an error,
|
||||||
|
// the transaction is rolled back; otherwise it is committed.
|
||||||
|
WithTx(ctx context.Context, fn func(tx Store) error) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Data models ---
|
||||||
|
|
||||||
|
// Node represents a proxy node stored in the database.
|
||||||
|
type Node struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
URI string `json:"uri"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Source string `json:"source"` // inline, nodes_file, subscription, manual
|
||||||
|
Port uint16 `json:"port"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
Region string `json:"region,omitempty"`
|
||||||
|
Country string `json:"country,omitempty"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeFilter specifies criteria for listing nodes.
|
||||||
|
type NodeFilter struct {
|
||||||
|
Source string // Filter by source (empty = all)
|
||||||
|
Region string // Filter by region (empty = all)
|
||||||
|
Enabled *bool // Filter by enabled status (nil = all)
|
||||||
|
Limit int // Max results (0 = no limit)
|
||||||
|
Offset int // Pagination offset
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeStats holds runtime statistics for a node.
|
||||||
|
type NodeStats struct {
|
||||||
|
NodeID int64 `json:"node_id"`
|
||||||
|
FailureCount int `json:"failure_count"`
|
||||||
|
SuccessCount int64 `json:"success_count"`
|
||||||
|
Blacklisted bool `json:"blacklisted"`
|
||||||
|
BlacklistedUntil time.Time `json:"blacklisted_until"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
LastFailureAt time.Time `json:"last_failure_at"`
|
||||||
|
LastSuccessAt time.Time `json:"last_success_at"`
|
||||||
|
LastLatencyMs int64 `json:"last_latency_ms"` // -1 = untested
|
||||||
|
Available bool `json:"available"`
|
||||||
|
InitialCheckDone bool `json:"initial_check_done"`
|
||||||
|
TotalUploadBytes int64 `json:"total_upload_bytes"`
|
||||||
|
TotalDownloadBytes int64 `json:"total_download_bytes"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatsUpdate represents a batch update for node statistics.
|
||||||
|
type StatsUpdate struct {
|
||||||
|
NodeID int64
|
||||||
|
FailureCount int
|
||||||
|
SuccessCount int64
|
||||||
|
Blacklisted bool
|
||||||
|
BlacklistedUntil time.Time
|
||||||
|
LastError string
|
||||||
|
LastFailureAt time.Time
|
||||||
|
LastSuccessAt time.Time
|
||||||
|
LastLatencyMs int64
|
||||||
|
Available bool
|
||||||
|
InitialCheckDone bool
|
||||||
|
TotalUploadBytes int64
|
||||||
|
TotalDownloadBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimelineEvent represents a single usage event for debug tracking.
|
||||||
|
type TimelineEvent struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
NodeID int64 `json:"node_id"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
LatencyMs int64 `json:"latency_ms"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session represents a user authentication session.
|
||||||
|
type Session struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscriptionStatus represents subscription refresh status.
|
||||||
|
type SubscriptionStatus struct {
|
||||||
|
LastRefresh time.Time `json:"last_refresh"`
|
||||||
|
NextRefresh time.Time `json:"next_refresh"`
|
||||||
|
NodeCount int `json:"node_count"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
RefreshCount int `json:"refresh_count"`
|
||||||
|
IsRefreshing bool `json:"is_refreshing"`
|
||||||
|
NodesHash string `json:"nodes_hash"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node source constants (matching config.NodeSource values).
|
||||||
|
const (
|
||||||
|
NodeSourceInline = "inline"
|
||||||
|
NodeSourceFile = "nodes_file"
|
||||||
|
NodeSourceSubscription = "subscription"
|
||||||
|
NodeSourceManual = "manual"
|
||||||
|
)
|
||||||
@@ -0,0 +1,684 @@
|
|||||||
|
package subscription
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"easy_proxies/internal/boxmgr"
|
||||||
|
"easy_proxies/internal/config"
|
||||||
|
"easy_proxies/internal/monitor"
|
||||||
|
"easy_proxies/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Logger defines logging interface.
|
||||||
|
type Logger interface {
|
||||||
|
Infof(format string, args ...any)
|
||||||
|
Warnf(format string, args ...any)
|
||||||
|
Errorf(format string, args ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option configures the Manager.
|
||||||
|
type Option func(*Manager)
|
||||||
|
|
||||||
|
// WithLogger sets a custom logger.
|
||||||
|
func WithLogger(l Logger) Option {
|
||||||
|
return func(m *Manager) { m.logger = l }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithStore sets the data store.
|
||||||
|
func WithStore(s store.Store) Option {
|
||||||
|
return func(m *Manager) { m.store = s }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Manager implements boxmgr.ConfigUpdateListener.
|
||||||
|
var _ boxmgr.ConfigUpdateListener = (*Manager)(nil)
|
||||||
|
|
||||||
|
// Manager handles periodic subscription refresh.
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
|
||||||
|
baseCfg *config.Config
|
||||||
|
boxMgr *boxmgr.Manager
|
||||||
|
logger Logger
|
||||||
|
httpClient *http.Client // Custom HTTP client with connection pooling
|
||||||
|
store store.Store // Data store for persisting nodes
|
||||||
|
|
||||||
|
status monitor.SubscriptionStatus
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
refreshMu sync.Mutex // prevents concurrent refreshes
|
||||||
|
manualRefresh chan struct{}
|
||||||
|
configChanged chan struct{} // signals config updates to the refresh loop
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a SubscriptionManager.
|
||||||
|
func New(cfg *config.Config, boxMgr *boxmgr.Manager, opts ...Option) *Manager {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
// Create optimized HTTP client with connection pooling
|
||||||
|
transport := &http.Transport{
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}).DialContext,
|
||||||
|
ForceAttemptHTTP2: true,
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
MaxIdleConnsPerHost: 10,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
|
ResponseHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: transport,
|
||||||
|
Timeout: 60 * time.Second, // Overall timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
m := &Manager{
|
||||||
|
baseCfg: cfg,
|
||||||
|
boxMgr: boxMgr,
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
manualRefresh: make(chan struct{}, 1),
|
||||||
|
configChanged: make(chan struct{}, 1),
|
||||||
|
httpClient: httpClient,
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(m)
|
||||||
|
}
|
||||||
|
if m.logger == nil {
|
||||||
|
m.logger = defaultLogger{}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins the background goroutine that manages periodic subscription refresh.
|
||||||
|
// The goroutine dynamically checks config to decide whether to actually perform refreshes,
|
||||||
|
// so it's safe to call Start() even when subscription refresh is initially disabled.
|
||||||
|
func (m *Manager) Start() {
|
||||||
|
if m.isEnabled() {
|
||||||
|
m.logger.Infof("starting subscription refresh, interval: %s", m.currentInterval())
|
||||||
|
} else {
|
||||||
|
m.logger.Infof("subscription manager started (auto-refresh currently disabled, will activate on config change)")
|
||||||
|
}
|
||||||
|
|
||||||
|
go m.refreshLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the periodic refresh.
|
||||||
|
func (m *Manager) Stop() {
|
||||||
|
if m.cancel != nil {
|
||||||
|
m.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close idle connections
|
||||||
|
if m.httpClient != nil {
|
||||||
|
m.httpClient.CloseIdleConnections()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshNow triggers an immediate refresh, regardless of whether auto-refresh is enabled.
|
||||||
|
// It only requires that subscription URLs are configured.
|
||||||
|
func (m *Manager) RefreshNow() error {
|
||||||
|
m.mu.RLock()
|
||||||
|
hasSubscriptions := len(m.baseCfg.Subscriptions) > 0
|
||||||
|
timeout := m.baseCfg.SubscriptionRefresh.Timeout
|
||||||
|
healthCheckTimeout := m.baseCfg.SubscriptionRefresh.HealthCheckTimeout
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if !hasSubscriptions {
|
||||||
|
return fmt.Errorf("没有配置订阅链接")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case m.manualRefresh <- struct{}{}:
|
||||||
|
default:
|
||||||
|
// Already a refresh pending
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for refresh to complete or timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(m.ctx, timeout+healthCheckTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Poll status until refresh completes
|
||||||
|
ticker := time.NewTicker(500 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
startCount := m.Status().RefreshCount
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return fmt.Errorf("refresh timeout")
|
||||||
|
case <-ticker.C:
|
||||||
|
status := m.Status()
|
||||||
|
if status.RefreshCount > startCount {
|
||||||
|
if status.LastError != "" {
|
||||||
|
return fmt.Errorf("refresh failed: %s", status.LastError)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status returns the current refresh status, including dynamic config state.
|
||||||
|
func (m *Manager) Status() monitor.SubscriptionStatus {
|
||||||
|
m.mu.RLock()
|
||||||
|
status := m.status
|
||||||
|
status.Enabled = m.baseCfg.SubscriptionRefresh.Enabled
|
||||||
|
status.HasSubscriptions = len(m.baseCfg.Subscriptions) > 0
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
// Check if nodes have been modified since last refresh
|
||||||
|
status.NodesModified = m.CheckNodesModified()
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshLoop runs the background loop that manages periodic and manual refreshes.
|
||||||
|
// It dynamically reads config to decide whether to auto-refresh and at what interval.
|
||||||
|
func (m *Manager) refreshLoop() {
|
||||||
|
interval := m.currentInterval()
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Set initial next refresh time
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.isEnabledLocked() {
|
||||||
|
m.status.NextRefresh = time.Now().Add(interval)
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-m.ctx.Done():
|
||||||
|
return
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
// Dynamically adjust interval if config changed
|
||||||
|
newInterval := m.currentInterval()
|
||||||
|
if newInterval != interval {
|
||||||
|
m.logger.Infof("subscription refresh interval changed: %s → %s", interval, newInterval)
|
||||||
|
interval = newInterval
|
||||||
|
ticker.Reset(interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only auto-refresh when enabled and subscriptions exist
|
||||||
|
if m.isEnabled() {
|
||||||
|
m.doRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.isEnabledLocked() {
|
||||||
|
m.status.NextRefresh = time.Now().Add(interval)
|
||||||
|
} else {
|
||||||
|
m.status.NextRefresh = time.Time{}
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
case <-m.manualRefresh:
|
||||||
|
// Manual refresh always executes (caller already verified subscriptions exist)
|
||||||
|
m.doRefresh()
|
||||||
|
// Reset ticker and recalculate interval after manual refresh
|
||||||
|
newInterval := m.currentInterval()
|
||||||
|
if newInterval != interval {
|
||||||
|
interval = newInterval
|
||||||
|
}
|
||||||
|
ticker.Reset(interval)
|
||||||
|
m.mu.Lock()
|
||||||
|
m.status.NextRefresh = time.Now().Add(interval)
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
case <-m.configChanged:
|
||||||
|
// Config was updated (e.g., after reload), recalculate interval
|
||||||
|
newInterval := m.currentInterval()
|
||||||
|
if newInterval != interval {
|
||||||
|
m.logger.Infof("subscription refresh interval changed: %s → %s", interval, newInterval)
|
||||||
|
interval = newInterval
|
||||||
|
ticker.Reset(interval)
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.isEnabledLocked() {
|
||||||
|
m.status.NextRefresh = time.Now().Add(interval)
|
||||||
|
} else {
|
||||||
|
m.status.NextRefresh = time.Time{}
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isEnabled checks if auto-refresh should run (acquires read lock).
|
||||||
|
func (m *Manager) isEnabled() bool {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.isEnabledLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// isEnabledLocked checks if auto-refresh should run (caller must hold mu).
|
||||||
|
func (m *Manager) isEnabledLocked() bool {
|
||||||
|
return m.baseCfg.SubscriptionRefresh.Enabled && len(m.baseCfg.Subscriptions) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentInterval returns the configured refresh interval (acquires read lock).
|
||||||
|
func (m *Manager) currentInterval() time.Duration {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.currentIntervalLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentIntervalLocked returns the configured refresh interval (caller must hold mu).
|
||||||
|
func (m *Manager) currentIntervalLocked() time.Duration {
|
||||||
|
interval := m.baseCfg.SubscriptionRefresh.Interval
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 1 * time.Hour
|
||||||
|
}
|
||||||
|
return interval
|
||||||
|
}
|
||||||
|
|
||||||
|
// doRefresh performs a single refresh operation.
|
||||||
|
// It fetches subscription nodes and persists them to the SQLite Store,
|
||||||
|
// then reloads the config. Manual nodes are preserved in the Store.
|
||||||
|
func (m *Manager) doRefresh() {
|
||||||
|
// Prevent concurrent refreshes
|
||||||
|
if !m.refreshMu.TryLock() {
|
||||||
|
m.logger.Warnf("refresh already in progress, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer m.refreshMu.Unlock()
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.status.IsRefreshing = true
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.status.IsRefreshing = false
|
||||||
|
m.status.RefreshCount++
|
||||||
|
m.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
m.logger.Infof("starting subscription refresh")
|
||||||
|
|
||||||
|
// Fetch nodes from all subscriptions
|
||||||
|
nodes, err := m.fetchAllSubscriptions()
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Errorf("fetch subscriptions failed: %v", err)
|
||||||
|
m.mu.Lock()
|
||||||
|
m.status.LastError = err.Error()
|
||||||
|
m.status.LastRefresh = time.Now()
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
m.logger.Warnf("no nodes fetched from subscriptions")
|
||||||
|
m.mu.Lock()
|
||||||
|
m.status.LastError = "no nodes fetched"
|
||||||
|
m.status.LastRefresh = time.Now()
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.logger.Infof("fetched %d nodes from subscriptions", len(nodes))
|
||||||
|
|
||||||
|
// Persist subscription nodes to Store (replace old subscription nodes)
|
||||||
|
if m.store != nil {
|
||||||
|
// Delete old subscription nodes
|
||||||
|
deleted, err := m.store.DeleteNodesBySource(m.ctx, store.NodeSourceSubscription)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Warnf("failed to delete old subscription nodes from store: %v", err)
|
||||||
|
} else if deleted > 0 {
|
||||||
|
m.logger.Infof("deleted %d old subscription nodes from store", deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert new subscription nodes
|
||||||
|
var storeNodes []store.Node
|
||||||
|
for _, n := range nodes {
|
||||||
|
name := strings.TrimSpace(n.Name)
|
||||||
|
uri := strings.TrimSpace(n.URI)
|
||||||
|
if name == "" {
|
||||||
|
if parsed, parseErr := url.Parse(uri); parseErr == nil && parsed.Fragment != "" {
|
||||||
|
if decoded, decErr := url.QueryUnescape(parsed.Fragment); decErr == nil {
|
||||||
|
name = decoded
|
||||||
|
} else {
|
||||||
|
name = parsed.Fragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("sub-node-%d", len(storeNodes)+1)
|
||||||
|
}
|
||||||
|
storeNodes = append(storeNodes, store.Node{
|
||||||
|
URI: uri,
|
||||||
|
Name: name,
|
||||||
|
Source: store.NodeSourceSubscription,
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := m.store.BulkUpsertNodes(m.ctx, storeNodes); err != nil {
|
||||||
|
m.logger.Errorf("failed to save subscription nodes to store: %v", err)
|
||||||
|
m.mu.Lock()
|
||||||
|
m.status.LastError = fmt.Sprintf("save to store: %v", err)
|
||||||
|
m.status.LastRefresh = time.Now()
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.logger.Infof("saved %d subscription nodes to store", len(storeNodes))
|
||||||
|
} else {
|
||||||
|
m.logger.Errorf("store is not available, cannot persist subscription nodes")
|
||||||
|
m.mu.Lock()
|
||||||
|
m.status.LastError = "store not available"
|
||||||
|
m.status.LastRefresh = time.Now()
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current port mapping to preserve existing node ports
|
||||||
|
portMap := m.boxMgr.CurrentPortMap()
|
||||||
|
|
||||||
|
// Create new config with updated subscription nodes + preserved manual nodes
|
||||||
|
newCfg := m.createNewConfig(nodes)
|
||||||
|
|
||||||
|
// Trigger BoxManager reload with port preservation
|
||||||
|
if err := m.boxMgr.ReloadWithPortMap(newCfg, portMap); err != nil {
|
||||||
|
m.logger.Errorf("reload failed: %v", err)
|
||||||
|
m.mu.Lock()
|
||||||
|
m.status.LastError = err.Error()
|
||||||
|
m.status.LastRefresh = time.Now()
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count total nodes including manual
|
||||||
|
totalNodes := len(newCfg.Nodes)
|
||||||
|
m.mu.Lock()
|
||||||
|
m.status.LastRefresh = time.Now()
|
||||||
|
m.status.NodeCount = totalNodes
|
||||||
|
m.status.LastError = ""
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
m.logger.Infof("subscription refresh completed, %d total nodes active (%d from subscription)", totalNodes, len(nodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnConfigUpdate is called by boxmgr after a successful reload.
|
||||||
|
// It updates the subscription manager's reference to the latest config
|
||||||
|
// so that subsequent refreshes use updated subscription URLs and settings.
|
||||||
|
func (m *Manager) OnConfigUpdate(cfg *config.Config) {
|
||||||
|
if cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.baseCfg = cfg
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.logger.Infof("subscription manager config updated after reload")
|
||||||
|
|
||||||
|
// Notify the refresh loop about config changes so it can
|
||||||
|
// recalculate interval and enable/disable auto-refresh dynamically.
|
||||||
|
select {
|
||||||
|
case m.configChanged <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckNodesModified always returns false — with SQLite Store,
|
||||||
|
// node modifications are tracked in the database, not via file hashes.
|
||||||
|
func (m *Manager) CheckNodesModified() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkNodesModified updates the modification status.
|
||||||
|
func (m *Manager) MarkNodesModified() {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.status.NodesModified = true
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchAllSubscriptions fetches nodes from all configured subscription URLs.
|
||||||
|
func (m *Manager) fetchAllSubscriptions() ([]config.NodeConfig, error) {
|
||||||
|
var allNodes []config.NodeConfig
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
timeout := m.baseCfg.SubscriptionRefresh.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, subURL := range m.baseCfg.Subscriptions {
|
||||||
|
nodes, err := m.fetchSubscription(subURL, timeout)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Warnf("failed to fetch %s: %v", subURL, err)
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.logger.Infof("fetched %d nodes from subscription", len(nodes))
|
||||||
|
allNodes = append(allNodes, nodes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allNodes) == 0 && lastErr != nil {
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return allNodes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchSubscription fetches and parses a single subscription URL.
|
||||||
|
func (m *Manager) fetchSubscription(subURL string, timeout time.Duration) ([]config.NodeConfig, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(m.ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", subURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||||
|
req.Header.Set("Accept", "*/*")
|
||||||
|
|
||||||
|
// Use custom HTTP client with connection pooling
|
||||||
|
resp, err := m.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit read size to prevent memory exhaustion
|
||||||
|
const maxBodySize = 10 * 1024 * 1024 // 10MB
|
||||||
|
limitedReader := io.LimitReader(resp.Body, maxBodySize)
|
||||||
|
|
||||||
|
body, err := io.ReadAll(limitedReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseSubscriptionContent(string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// createNewConfig creates a new config with updated subscription nodes while
|
||||||
|
// preserving inline nodes (from config.yaml) and manual nodes (from Store).
|
||||||
|
func (m *Manager) createNewConfig(subNodes []config.NodeConfig) *config.Config {
|
||||||
|
// Deep copy base config (uses Clone to avoid copying the mutex)
|
||||||
|
m.mu.RLock()
|
||||||
|
baseCfg := m.baseCfg
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
newCfg := baseCfg.Clone()
|
||||||
|
|
||||||
|
// Start with inline nodes from config.yaml
|
||||||
|
var allNodes []config.NodeConfig
|
||||||
|
for _, node := range baseCfg.Nodes {
|
||||||
|
if node.Source == config.NodeSourceInline {
|
||||||
|
allNodes = append(allNodes, node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process subscription node names and mark source
|
||||||
|
for i := range subNodes {
|
||||||
|
subNodes[i].Name = strings.TrimSpace(subNodes[i].Name)
|
||||||
|
subNodes[i].URI = strings.TrimSpace(subNodes[i].URI)
|
||||||
|
subNodes[i].Source = config.NodeSourceSubscription
|
||||||
|
|
||||||
|
// Extract name from URI fragment if not provided
|
||||||
|
if subNodes[i].Name == "" {
|
||||||
|
if parsed, err := url.Parse(subNodes[i].URI); err == nil && parsed.Fragment != "" {
|
||||||
|
if decoded, err := url.QueryUnescape(parsed.Fragment); err == nil {
|
||||||
|
subNodes[i].Name = decoded
|
||||||
|
} else {
|
||||||
|
subNodes[i].Name = parsed.Fragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if subNodes[i].Name == "" {
|
||||||
|
subNodes[i].Name = fmt.Sprintf("node-%d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allNodes = append(allNodes, subNodes...)
|
||||||
|
|
||||||
|
// Load manual nodes from Store
|
||||||
|
if m.store != nil {
|
||||||
|
storeManualNodes, err := m.store.ListNodes(m.ctx, store.NodeFilter{Source: store.NodeSourceManual})
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Warnf("failed to load manual nodes from store: %v", err)
|
||||||
|
} else if len(storeManualNodes) > 0 {
|
||||||
|
for _, sn := range storeManualNodes {
|
||||||
|
name := strings.TrimSpace(sn.Name)
|
||||||
|
uri := strings.TrimSpace(sn.URI)
|
||||||
|
if name == "" {
|
||||||
|
if parsed, err := url.Parse(uri); err == nil && parsed.Fragment != "" {
|
||||||
|
if decoded, err := url.QueryUnescape(parsed.Fragment); err == nil {
|
||||||
|
name = decoded
|
||||||
|
} else {
|
||||||
|
name = parsed.Fragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("manual-%d", sn.ID)
|
||||||
|
}
|
||||||
|
allNodes = append(allNodes, config.NodeConfig{
|
||||||
|
Name: name,
|
||||||
|
URI: uri,
|
||||||
|
Port: sn.Port,
|
||||||
|
Username: sn.Username,
|
||||||
|
Password: sn.Password,
|
||||||
|
Source: config.NodeSourceManual,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
m.logger.Infof("preserved %d manual nodes from store during subscription refresh", len(storeManualNodes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign port numbers to all nodes in multi-port mode
|
||||||
|
if newCfg.Mode == "multi-port" {
|
||||||
|
portCursor := newCfg.MultiPort.BasePort
|
||||||
|
for i := range allNodes {
|
||||||
|
allNodes[i].Port = portCursor
|
||||||
|
portCursor++
|
||||||
|
// Apply default credentials
|
||||||
|
if allNodes[i].Username == "" {
|
||||||
|
allNodes[i].Username = newCfg.MultiPort.Username
|
||||||
|
allNodes[i].Password = newCfg.MultiPort.Password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newCfg.Nodes = allNodes
|
||||||
|
return newCfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSubscriptionContent parses subscription content in various formats.
|
||||||
|
// This is a simplified version - the full implementation is in config package.
|
||||||
|
func parseSubscriptionContent(content string) ([]config.NodeConfig, error) {
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
|
||||||
|
// Check if it's base64 encoded
|
||||||
|
if isBase64(content) {
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(content)
|
||||||
|
if err != nil {
|
||||||
|
decoded, err = base64.RawStdEncoding.DecodeString(content)
|
||||||
|
if err != nil {
|
||||||
|
return parseNodesFromContent(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content = string(decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse as plain text (one URI per line)
|
||||||
|
return parseNodesFromContent(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBase64(s string) bool {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if len(s) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s = strings.ReplaceAll(s, "\n", "")
|
||||||
|
s = strings.ReplaceAll(s, "\r", "")
|
||||||
|
if strings.Contains(s, "://") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := base64.StdEncoding.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
_, err = base64.RawStdEncoding.DecodeString(s)
|
||||||
|
}
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseNodesFromContent(content string) ([]config.NodeConfig, error) {
|
||||||
|
var nodes []config.NodeConfig
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isProxyURI(line) {
|
||||||
|
nodes = append(nodes, config.NodeConfig{URI: line})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nodes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isProxyURI(s string) bool {
|
||||||
|
schemes := []string{"vmess://", "vless://", "trojan://", "ss://", "ssr://", "hysteria://", "hysteria2://", "hy2://"}
|
||||||
|
lower := strings.ToLower(s)
|
||||||
|
for _, scheme := range schemes {
|
||||||
|
if strings.HasPrefix(lower, scheme) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type defaultLogger struct{}
|
||||||
|
|
||||||
|
func (defaultLogger) Infof(format string, args ...any) {
|
||||||
|
log.Printf("[subscription] "+format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (defaultLogger) Warnf(format string, args ...any) {
|
||||||
|
log.Printf("[subscription] WARN: "+format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (defaultLogger) Errorf(format string, args ...any) {
|
||||||
|
log.Printf("[subscription] ERROR: "+format, args...)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user