chore: 准备发布 v1.0.0 版本

This commit is contained in:
xiamuceer-j
2026-03-02 13:13:33 +08:00
parent f57ae3c712
commit 8163e4c193
58 changed files with 19545 additions and 1 deletions
+15
View File
@@ -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
+70
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+70 -1
View File
@@ -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)
+140
View File
@@ -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
+36
View File
@@ -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
+20
View File
@@ -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 "$@"
+24
View File
@@ -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?
+73
View File
@@ -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...
},
},
])
```
+23
View File
@@ -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,
},
},
])
+13
View File
@@ -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>
+3904
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -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"
}
}
+1
View File
@@ -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

+398
View File
@@ -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
+365
View File
@@ -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 }),
})
}
+1
View File
@@ -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

+350
View File
@@ -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>
)
}
+159
View File
@@ -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">&copy; {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">
&copy; {new Date().getFullYear()} Easy Proxies. All rights reserved.
</p>
</div>
</div>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
+555
View File
@@ -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>
)
}
+865
View File
@@ -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>
)
}
+34
View File
@@ -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;
}
}
+10
View File
@@ -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>,
)
+234
View File
@@ -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[]
}
+20
View File
@@ -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`
}
+28
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -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"]
}
+15
View File
@@ -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,
},
})
+132
View File
@@ -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
)
+346
View File
@@ -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=
+284
View File
@@ -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
+934
View File
@@ -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: &regionPoolOptions,
})
}
// 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
+615
View File
@@ -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 "❓"
}
}
+271
View File
@@ -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
+14
View File
@@ -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>
+1
View File
@@ -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

+21
View File
@@ -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
}
+868
View File
@@ -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
+749
View File
@@ -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])
}
+166
View File
@@ -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()
}
}
+212
View File
@@ -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
}
+196
View File
@@ -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(&currentVersion); 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
}
+816
View File
@@ -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
}
+216
View File
@@ -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"
)
+684
View File
@@ -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...)
}