mirror of
https://github.com/isboyjc/GoProxy.git
synced 2026-05-06 20:02:54 +08:00
Initial commit
This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
proxy-pool
|
||||
proxy-pool.log
|
||||
proxy.db
|
||||
config.json
|
||||
data/
|
||||
*.log
|
||||
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
.DS_Store
|
||||
|
||||
# Build artifacts
|
||||
proxy-pool
|
||||
*.exe
|
||||
*.out
|
||||
|
||||
# Runtime data
|
||||
*.db
|
||||
*.log
|
||||
config.json
|
||||
data/*.db
|
||||
data/config.json
|
||||
|
||||
# IDE files
|
||||
.idea/
|
||||
.vscode/
|
||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
# 构建阶段(使用完整 Debian 镜像,内置 gcc,避免 alpine apk 问题)
|
||||
FROM golang:1.25 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -o proxy-pool .
|
||||
|
||||
# 运行阶段(使用轻量 debian-slim)
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates tzdata && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/proxy-pool .
|
||||
|
||||
EXPOSE 7777 7778
|
||||
|
||||
CMD ["./proxy-pool"]
|
||||
249
README.md
Normal file
249
README.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# ProxyGo
|
||||
|
||||
一个基于 Go 的轻量代理池服务。程序会从公开代理源抓取 HTTP/SOCKS5 代理,验证可用性后写入 SQLite,并对外暴露一个统一的本地 HTTP 代理入口,同时提供带登录的 Web 管理后台。
|
||||
|
||||
## 功能概览
|
||||
|
||||
- 启动时自动抓取并验证代理
|
||||
- 后台定时抓取新代理
|
||||
- 后台定时健康检查,自动清理不可用代理
|
||||
- 聚合 HTTP 和 SOCKS5 上游代理,对外统一提供 HTTP 代理端口
|
||||
- 支持普通 HTTP 请求和 HTTPS `CONNECT` 隧道转发
|
||||
- 内置 WebUI,支持查看统计、筛选代理、删除代理、手动触发抓取、查看日志、修改部分运行参数
|
||||
- 使用 SQLite 持久化代理池数据
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
.
|
||||
├── main.go # 程序入口
|
||||
├── config/ # 默认配置、配置加载与保存
|
||||
├── fetcher/ # 代理源抓取
|
||||
├── validator/ # 代理可用性验证
|
||||
├── checker/ # 周期健康检查
|
||||
├── storage/ # SQLite 存储
|
||||
├── proxy/ # 对外 HTTP 代理服务
|
||||
├── webui/ # 登录页、仪表盘、API
|
||||
├── logger/ # 内存日志 + stdout 输出
|
||||
├── Dockerfile
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
## 运行要求
|
||||
|
||||
- Go `1.25`
|
||||
- 需要可用的 CGO 编译环境
|
||||
- 项目依赖 `github.com/mattn/go-sqlite3`
|
||||
- 本地构建通常需要 `gcc` / Xcode Command Line Tools
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 本地运行
|
||||
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
|
||||
或先编译再启动:
|
||||
|
||||
```bash
|
||||
go build -o proxy-pool .
|
||||
./proxy-pool
|
||||
```
|
||||
|
||||
程序启动后会:
|
||||
|
||||
1. 加载默认配置或读取 `config.json`
|
||||
2. 初始化 SQLite 数据库
|
||||
3. 启动 WebUI
|
||||
4. 立即抓取一次代理并开始验证
|
||||
5. 启动定时抓取和健康检查
|
||||
6. 在 `:7777` 启动统一代理服务
|
||||
|
||||
### 默认端口
|
||||
|
||||
- 代理服务:`127.0.0.1:7777` 或 `:7777`
|
||||
- WebUI:`http://127.0.0.1:7778`
|
||||
|
||||
### 使用聚合代理
|
||||
|
||||
例如:
|
||||
|
||||
```bash
|
||||
curl -x http://127.0.0.1:7777 https://httpbin.org/ip
|
||||
```
|
||||
|
||||
也可以给命令行程序设置环境变量:
|
||||
|
||||
```bash
|
||||
export http_proxy=http://127.0.0.1:7777
|
||||
export https_proxy=http://127.0.0.1:7777
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
### 使用 Dockerfile
|
||||
|
||||
```bash
|
||||
docker build -t proxygo .
|
||||
docker run -d \
|
||||
--name proxygo \
|
||||
-p 127.0.0.1:7777:7777 \
|
||||
-p 7778:7778 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-e DATA_DIR=/app/data \
|
||||
-v "$(pwd)/data:/app/data" \
|
||||
proxygo
|
||||
```
|
||||
|
||||
### 使用 docker-compose.yml
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
当前仓库中的 `docker-compose.yml` 有两个前提:
|
||||
|
||||
- 它会将 `./data` 挂载到容器内 `/app/data`
|
||||
- 它依赖一个已存在的外部网络 `cursor2api_default`
|
||||
|
||||
如果宿主机没有这个网络,需要先创建,或者直接修改 `docker-compose.yml` 中的网络配置。
|
||||
|
||||
## 数据目录
|
||||
|
||||
程序支持通过 `DATA_DIR` 指定数据目录。
|
||||
|
||||
- 未设置 `DATA_DIR` 时:
|
||||
- 数据库默认写到项目根目录 `proxy.db`
|
||||
- 配置文件默认读取/写入项目根目录 `config.json`
|
||||
- 设置 `DATA_DIR=/app/data` 时:
|
||||
- 数据库路径变为 `/app/data/proxy.db`
|
||||
- 配置文件路径变为 `/app/data/config.json`
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 可持久化配置
|
||||
|
||||
当前版本只会从 `config.json` 读取并保存以下 4 个字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"fetch_interval": 30,
|
||||
"check_interval": 10,
|
||||
"validate_concurrency": 300,
|
||||
"validate_timeout": 3
|
||||
}
|
||||
```
|
||||
|
||||
字段含义:
|
||||
|
||||
- `fetch_interval`:定时抓取间隔,单位分钟
|
||||
- `check_interval`:健康检查间隔,单位分钟
|
||||
- `validate_concurrency`:并发验证数量
|
||||
- `validate_timeout`:单个代理验证超时,单位秒
|
||||
|
||||
这些参数既可以通过编辑 `config.json` 修改,也可以在 WebUI 的“系统设置”中在线保存。
|
||||
|
||||
### 当前代码中的默认值
|
||||
|
||||
除上面 4 项外,其余配置目前来自代码默认值:
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `WebUIPort` | `:7778` | Web 管理后台端口 |
|
||||
| `ProxyPort` | `:7777` | 对外统一代理端口 |
|
||||
| `DBPath` | `proxy.db` 或 `${DATA_DIR}/proxy.db` | SQLite 数据库路径 |
|
||||
| `ValidateURL` | `https://cursor.com/api/auth/me` | 验证目标地址 |
|
||||
| `MaxResponseMs` | `2500` | 最大可接受延迟,毫秒 |
|
||||
| `MaxFailCount` | `3` | 失败阈值字段已定义,但当前运行逻辑未完整使用 |
|
||||
| `MaxRetry` | `3` | 请求失败后的重试次数 |
|
||||
|
||||
## WebUI
|
||||
|
||||
访问地址:
|
||||
|
||||
- `http://127.0.0.1:7778`
|
||||
|
||||
提供的功能:
|
||||
|
||||
- 登录鉴权
|
||||
- 展示代理总数、HTTP 数量、SOCKS5 数量
|
||||
- 按协议筛选代理
|
||||
- 删除单个代理
|
||||
- 手动触发抓取
|
||||
- 查看最近日志
|
||||
- 在线修改抓取/校验参数
|
||||
|
||||
### 登录密码说明
|
||||
|
||||
当前版本在代码里只保存了 WebUI 密码的 SHA256 哈希值,默认明文密码没有在仓库中说明,也不能通过 `config.json` 或 WebUI 修改。
|
||||
|
||||
如果你要自定义密码,当前可行方式是:
|
||||
|
||||
1. 生成密码的 SHA256
|
||||
2. 修改 `config/config.go` 中的 `WebUIPasswordHash`
|
||||
3. 重新构建并启动程序
|
||||
|
||||
例如生成 SHA256:
|
||||
|
||||
```bash
|
||||
printf 'your-password' | shasum -a 256
|
||||
```
|
||||
|
||||
## API 概览
|
||||
|
||||
除 `/login` 和 `/logout` 外,其余管理 API 都要求已登录会话。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/` | 仪表盘页面 |
|
||||
| `GET/POST` | `/login` | 登录页面 / 提交登录 |
|
||||
| `GET` | `/logout` | 退出登录 |
|
||||
| `GET` | `/api/stats` | 代理统计信息 |
|
||||
| `GET` | `/api/proxies?protocol=http` | 查询代理列表,可按协议筛选 |
|
||||
| `POST` | `/api/proxy/delete` | 删除指定代理 |
|
||||
| `POST` | `/api/fetch` | 手动触发一次抓取 |
|
||||
| `GET` | `/api/logs` | 获取最近 200 条日志 |
|
||||
| `GET/POST` | `/api/config` | 读取/保存运行参数 |
|
||||
|
||||
## 代理抓取与校验逻辑
|
||||
|
||||
当前实现会并发抓取内置代理源,然后做去重与验证:
|
||||
|
||||
- HTTP 源:`https://cdn.jsdelivr.net/gh/databay-labs/free-proxy-list/http.txt`
|
||||
- SOCKS5 源:`https://cdn.jsdelivr.net/gh/databay-labs/free-proxy-list/socks5.txt`
|
||||
- 混合源:`https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/all/data.txt`
|
||||
|
||||
验证规则:
|
||||
|
||||
- 仅接受 HTTP `200` 或 `204`
|
||||
- 响应超时或延迟超过 `MaxResponseMs` 的代理会被丢弃
|
||||
- 默认验证目标为 `https://cursor.com/api/auth/me`
|
||||
|
||||
## 日志
|
||||
|
||||
- 日志会输出到进程标准输出
|
||||
- 同时会保留最近 500 条在内存中供 WebUI 展示
|
||||
- `/api/logs` 当前返回最近 200 条日志
|
||||
|
||||
## 当前实现限制
|
||||
|
||||
- `config.Config` 中虽然定义了 `HTTPSourceURL` 和 `SOCKS5SourceURL`,但抓取器当前实际使用的是 `fetcher/defaultSources` 内置来源
|
||||
- `config.json` 目前只持久化 4 个字段,不包含端口、密码哈希、验证 URL 等配置
|
||||
- WebUI 登录密码不能在线修改
|
||||
- 代理请求失败时,运行逻辑倾向于直接删除上游代理,`MaxFailCount` 目前没有完整参与主流程
|
||||
- 日志没有单独写文件,管理端看到的是内存中的最近日志窗口
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 在本机快速聚合一批公开代理,提供给命令行或程序统一使用
|
||||
- 临时验证免费 HTTP / SOCKS5 代理的可用性
|
||||
- 通过简单 Web 面板查看当前代理池状态
|
||||
|
||||
如果后续要继续完善,优先建议补这几项:
|
||||
|
||||
- 支持通过配置文件完整覆盖所有默认参数
|
||||
- 支持自定义代理源并真正接入抓取器
|
||||
- 支持 WebUI 密码初始化和修改
|
||||
- 为失败计数、重试和删除策略补齐一致的状态流转
|
||||
- 增加自动化测试
|
||||
65
checker/checker.go
Normal file
65
checker/checker.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"proxy-pool/config"
|
||||
"proxy-pool/storage"
|
||||
"proxy-pool/validator"
|
||||
)
|
||||
|
||||
type Checker struct {
|
||||
storage *storage.Storage
|
||||
}
|
||||
|
||||
func New(s *storage.Storage, _ *validator.Validator, _ *config.Config) *Checker {
|
||||
return &Checker{storage: s}
|
||||
}
|
||||
|
||||
func (c *Checker) Start() {
|
||||
go func() {
|
||||
for {
|
||||
cfg := config.Get()
|
||||
time.Sleep(time.Duration(cfg.CheckInterval) * time.Minute)
|
||||
c.run()
|
||||
}
|
||||
}()
|
||||
log.Printf("health checker started, interval: %d min", config.Get().CheckInterval)
|
||||
}
|
||||
|
||||
func (c *Checker) run() {
|
||||
log.Println("[checker] start health check...")
|
||||
|
||||
proxies, err := c.storage.GetAll()
|
||||
if err != nil {
|
||||
log.Printf("[checker] get proxies error: %v", err)
|
||||
return
|
||||
}
|
||||
if len(proxies) == 0 {
|
||||
log.Println("[checker] no proxies to check")
|
||||
return
|
||||
}
|
||||
|
||||
// 每次用最新配置创建 validator
|
||||
cfg := config.Get()
|
||||
validate := validator.New(cfg.ValidateConcurrency, cfg.ValidateTimeout, cfg.ValidateURL)
|
||||
|
||||
log.Printf("[checker] checking %d proxies...", len(proxies))
|
||||
results := validate.ValidateAll(proxies)
|
||||
|
||||
valid, invalid := 0, 0
|
||||
for _, r := range results {
|
||||
if r.Valid {
|
||||
valid++
|
||||
} else {
|
||||
invalid++
|
||||
if err := c.storage.Delete(r.Proxy.Address); err != nil {
|
||||
log.Printf("[checker] delete error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
count, _ := c.storage.Count()
|
||||
log.Printf("[checker] done: valid=%d invalid(deleted)=%d remaining=%d", valid, invalid, count)
|
||||
}
|
||||
147
config/config.go
Normal file
147
config/config.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func dataDir() string {
|
||||
if d := os.Getenv("DATA_DIR"); d != "" {
|
||||
os.MkdirAll(d, 0755)
|
||||
return d + "/"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func ConfigFile() string { return dataDir() + "config.json" }
|
||||
|
||||
type Config struct {
|
||||
// WebUI 端口
|
||||
WebUIPort string
|
||||
|
||||
// WebUI 密码 SHA256 哈希
|
||||
WebUIPasswordHash string
|
||||
|
||||
// 代理池本地监听端口
|
||||
ProxyPort string
|
||||
|
||||
// SQLite 数据库路径
|
||||
DBPath string
|
||||
|
||||
// 验证并发数
|
||||
ValidateConcurrency int
|
||||
|
||||
// 验证超时(秒)
|
||||
ValidateTimeout int
|
||||
|
||||
// 验证目标 URL
|
||||
ValidateURL string
|
||||
|
||||
// 最大响应时间(毫秒),超过则丢弃
|
||||
MaxResponseMs int
|
||||
|
||||
// 代理失败次数阈值,超过后删除
|
||||
MaxFailCount int
|
||||
|
||||
// 自动重试次数
|
||||
MaxRetry int
|
||||
|
||||
// 定时抓取间隔(分钟)
|
||||
FetchInterval int
|
||||
|
||||
// 定时健康检查间隔(分钟)
|
||||
CheckInterval int
|
||||
|
||||
// 代理来源 URL
|
||||
HTTPSourceURL string
|
||||
SOCKS5SourceURL string
|
||||
}
|
||||
|
||||
var (
|
||||
globalCfg *Config
|
||||
cfgMu sync.RWMutex
|
||||
)
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
WebUIPort: ":7778",
|
||||
WebUIPasswordHash: "64c2de42ff93286f5c7108867ffe3167a24f4c1abee648dea7bc7fa1d11e2b21",
|
||||
ProxyPort: ":7777",
|
||||
DBPath: dataDir() + "proxy.db",
|
||||
ValidateConcurrency: 300,
|
||||
ValidateTimeout: 3,
|
||||
ValidateURL: "https://cursor.com/api/auth/me",
|
||||
MaxResponseMs: 2500,
|
||||
MaxFailCount: 3,
|
||||
MaxRetry: 3,
|
||||
FetchInterval: 30,
|
||||
CheckInterval: 10,
|
||||
HTTPSourceURL: "https://cdn.jsdelivr.net/gh/databay-labs/free-proxy-list/http.txt",
|
||||
SOCKS5SourceURL: "https://cdn.jsdelivr.net/gh/databay-labs/free-proxy-list/socks5.txt",
|
||||
}
|
||||
}
|
||||
|
||||
// Load 从文件加载配置,文件不存在则用默认值
|
||||
func Load() *Config {
|
||||
cfg := DefaultConfig()
|
||||
data, err := os.ReadFile(ConfigFile())
|
||||
if err == nil {
|
||||
// 只覆盖可调整的4个字段
|
||||
var saved savedConfig
|
||||
if json.Unmarshal(data, &saved) == nil {
|
||||
if saved.FetchInterval > 0 {
|
||||
cfg.FetchInterval = saved.FetchInterval
|
||||
}
|
||||
if saved.CheckInterval > 0 {
|
||||
cfg.CheckInterval = saved.CheckInterval
|
||||
}
|
||||
if saved.ValidateConcurrency > 0 {
|
||||
cfg.ValidateConcurrency = saved.ValidateConcurrency
|
||||
}
|
||||
if saved.ValidateTimeout > 0 {
|
||||
cfg.ValidateTimeout = saved.ValidateTimeout
|
||||
}
|
||||
}
|
||||
}
|
||||
cfgMu.Lock()
|
||||
globalCfg = cfg
|
||||
cfgMu.Unlock()
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Get 获取当前配置
|
||||
func Get() *Config {
|
||||
cfgMu.RLock()
|
||||
defer cfgMu.RUnlock()
|
||||
return globalCfg
|
||||
}
|
||||
|
||||
// savedConfig 只持久化可调整的字段
|
||||
type savedConfig struct {
|
||||
FetchInterval int `json:"fetch_interval"`
|
||||
CheckInterval int `json:"check_interval"`
|
||||
ValidateConcurrency int `json:"validate_concurrency"`
|
||||
ValidateTimeout int `json:"validate_timeout"`
|
||||
}
|
||||
|
||||
// Save 保存可调整字段到文件,并更新内存配置
|
||||
func Save(fetchInterval, checkInterval, validateConcurrency, validateTimeout int) error {
|
||||
cfgMu.Lock()
|
||||
globalCfg.FetchInterval = fetchInterval
|
||||
globalCfg.CheckInterval = checkInterval
|
||||
globalCfg.ValidateConcurrency = validateConcurrency
|
||||
globalCfg.ValidateTimeout = validateTimeout
|
||||
cfgMu.Unlock()
|
||||
|
||||
data, err := json.MarshalIndent(savedConfig{
|
||||
FetchInterval: fetchInterval,
|
||||
CheckInterval: checkInterval,
|
||||
ValidateConcurrency: validateConcurrency,
|
||||
ValidateTimeout: validateTimeout,
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(ConfigFile(), data, 0644)
|
||||
}
|
||||
24
docker-compose.yml
Normal file
24
docker-compose.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
proxygo:
|
||||
build: .
|
||||
container_name: proxygo
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:7777:7777" # HTTP 代理端口(仅内网)
|
||||
- "7778:7778" # WebUI 端口(外网可访问)
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- DATA_DIR=/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:7778/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
networks:
|
||||
- cursor2api_default
|
||||
|
||||
networks:
|
||||
cursor2api_default:
|
||||
external: true
|
||||
128
fetcher/fetcher.go
Normal file
128
fetcher/fetcher.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package fetcher
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"proxy-pool/storage"
|
||||
)
|
||||
|
||||
// 代理来源定义
|
||||
type Source struct {
|
||||
URL string
|
||||
Protocol string // http 或 socks5
|
||||
}
|
||||
|
||||
// 内置多个免费代理来源
|
||||
var defaultSources = []Source{
|
||||
{"https://cdn.jsdelivr.net/gh/databay-labs/free-proxy-list/http.txt", "http"},
|
||||
{"https://cdn.jsdelivr.net/gh/databay-labs/free-proxy-list/socks5.txt", "socks5"},
|
||||
{"https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/all/data.txt", ""},
|
||||
}
|
||||
|
||||
type Fetcher struct {
|
||||
sources []Source
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func New(httpURL, socks5URL string) *Fetcher {
|
||||
return &Fetcher{
|
||||
sources: defaultSources,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch 从所有来源并发抓取代理
|
||||
func (f *Fetcher) Fetch() ([]storage.Proxy, error) {
|
||||
type result struct {
|
||||
proxies []storage.Proxy
|
||||
source Source
|
||||
err error
|
||||
}
|
||||
|
||||
ch := make(chan result, len(f.sources))
|
||||
for _, src := range f.sources {
|
||||
go func(s Source) {
|
||||
proxies, err := f.fetchFromURL(s.URL, s.Protocol)
|
||||
ch <- result{proxies: proxies, source: s, err: err}
|
||||
}(src)
|
||||
}
|
||||
|
||||
var all []storage.Proxy
|
||||
seen := make(map[string]bool)
|
||||
for range f.sources {
|
||||
r := <-ch
|
||||
if r.err != nil {
|
||||
log.Printf("fetch %s error: %v", r.source.URL, r.err)
|
||||
continue
|
||||
}
|
||||
// 去重
|
||||
var deduped []storage.Proxy
|
||||
for _, p := range r.proxies {
|
||||
if !seen[p.Address] {
|
||||
seen[p.Address] = true
|
||||
deduped = append(deduped, p)
|
||||
}
|
||||
}
|
||||
log.Printf("fetched %d %s proxies from %s", len(deduped), r.source.Protocol, r.source.URL)
|
||||
all = append(all, deduped...)
|
||||
}
|
||||
|
||||
if len(all) == 0 {
|
||||
return nil, fmt.Errorf("no proxies fetched")
|
||||
}
|
||||
log.Printf("total fetched: %d proxies (deduped)", len(all))
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) fetchFromURL(url, protocol string) ([]storage.Proxy, error) {
|
||||
resp, err := f.client.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status %d from %s", resp.StatusCode, url)
|
||||
}
|
||||
|
||||
return parseProxyList(resp.Body, protocol)
|
||||
}
|
||||
|
||||
func parseProxyList(r io.Reader, protocol string) ([]storage.Proxy, error) {
|
||||
var proxies []storage.Proxy
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
addr := line
|
||||
proto := protocol
|
||||
// 支持 protocol://host:port 格式
|
||||
if idx := strings.Index(line, "://"); idx != -1 {
|
||||
proto = line[:idx]
|
||||
addr = line[idx+3:]
|
||||
// socks4 当 socks5 处理
|
||||
if proto == "socks4" {
|
||||
proto = "socks5"
|
||||
}
|
||||
}
|
||||
parts := strings.Split(addr, ":")
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
proxies = append(proxies, storage.Proxy{
|
||||
Address: addr,
|
||||
Protocol: proto,
|
||||
})
|
||||
}
|
||||
return proxies, scanner.Err()
|
||||
}
|
||||
8
go.mod
Normal file
8
go.mod
Normal file
@@ -0,0 +1,8 @@
|
||||
module proxy-pool
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/mattn/go-sqlite3 v1.14.37
|
||||
golang.org/x/net v0.38.0
|
||||
)
|
||||
4
go.sum
Normal file
4
go.sum
Normal file
@@ -0,0 +1,4 @@
|
||||
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
53
logger/logger.go
Normal file
53
logger/logger.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxLines = 500
|
||||
|
||||
var (
|
||||
lines []string
|
||||
mu sync.RWMutex
|
||||
)
|
||||
|
||||
// Init 替换标准 log 输出,同时保留控制台输出
|
||||
func Init() {
|
||||
log.SetFlags(0)
|
||||
log.SetOutput(&writer{})
|
||||
}
|
||||
|
||||
type writer struct{}
|
||||
|
||||
func (w *writer) Write(p []byte) (n int, err error) {
|
||||
line := strings.TrimRight(string(p), "\n")
|
||||
ts := time.Now().Format("2006/01/02 15:04:05")
|
||||
formatted := fmt.Sprintf("%s %s", ts, line)
|
||||
|
||||
mu.Lock()
|
||||
lines = append(lines, formatted)
|
||||
if len(lines) > maxLines {
|
||||
lines = lines[len(lines)-maxLines:]
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
// 同时输出到控制台
|
||||
fmt.Println(formatted)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// GetLines 返回最近 N 条日志
|
||||
func GetLines(n int) []string {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
if n <= 0 || n > len(lines) {
|
||||
n = len(lines)
|
||||
}
|
||||
result := make([]string, n)
|
||||
copy(result, lines[len(lines)-n:])
|
||||
return result
|
||||
}
|
||||
134
main.go
Normal file
134
main.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"proxy-pool/checker"
|
||||
"proxy-pool/config"
|
||||
"proxy-pool/fetcher"
|
||||
"proxy-pool/logger"
|
||||
"proxy-pool/proxy"
|
||||
"proxy-pool/storage"
|
||||
"proxy-pool/validator"
|
||||
"proxy-pool/webui"
|
||||
)
|
||||
|
||||
var fetchRunning atomic.Bool
|
||||
var fetchMu sync.Mutex
|
||||
|
||||
func main() {
|
||||
// 初始化日志收集器
|
||||
logger.Init()
|
||||
|
||||
// 加载配置(优先读取 config.json)
|
||||
cfg := config.Load()
|
||||
|
||||
// 初始化存储
|
||||
store, err := storage.New(cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("init storage: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// 初始化模块
|
||||
fetch := fetcher.New(cfg.HTTPSourceURL, cfg.SOCKS5SourceURL)
|
||||
check := checker.New(store, validator.New(cfg.ValidateConcurrency, cfg.ValidateTimeout, cfg.ValidateURL), cfg)
|
||||
server := proxy.New(store, cfg)
|
||||
|
||||
// 配置变更通知 channel
|
||||
configChanged := make(chan struct{}, 1)
|
||||
|
||||
// 启动 WebUI
|
||||
ui := webui.New(store, cfg, func() {
|
||||
go func() {
|
||||
if err := fetchAndValidate(fetch, store); err != nil {
|
||||
log.Printf("[webui] fetch error: %v", err)
|
||||
}
|
||||
}()
|
||||
}, configChanged)
|
||||
ui.Start()
|
||||
|
||||
// 后台启动:首次抓取验证
|
||||
go func() {
|
||||
log.Println("[main] fetching proxies on startup...")
|
||||
if err := fetchAndValidate(fetch, store); err != nil {
|
||||
log.Printf("[main] initial fetch error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 启动动态定时抓取
|
||||
go startFetchLoop(fetch, store, configChanged)
|
||||
|
||||
// 启动定时健康检查
|
||||
check.Start()
|
||||
|
||||
// 启动代理服务(阻塞)
|
||||
if err := server.Start(); err != nil {
|
||||
log.Fatalf("proxy server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchAndValidate(fetch *fetcher.Fetcher, store *storage.Storage) error {
|
||||
// 防止并发执行
|
||||
if !fetchRunning.CompareAndSwap(false, true) {
|
||||
log.Println("[main] fetch already running, skipping")
|
||||
return nil
|
||||
}
|
||||
defer fetchRunning.Store(false)
|
||||
|
||||
proxies, err := fetch.Fetch()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("[main] validating %d proxies (streaming)...", len(proxies))
|
||||
|
||||
// 每次用最新配置创建 validator
|
||||
cfg := config.Get()
|
||||
validate := validator.New(cfg.ValidateConcurrency, cfg.ValidateTimeout, cfg.ValidateURL)
|
||||
|
||||
var valid, total int
|
||||
for r := range validate.ValidateStream(proxies) {
|
||||
total++
|
||||
if total%1000 == 0 {
|
||||
log.Printf("[main] checked=%d/%d valid=%d", total, len(proxies), valid)
|
||||
}
|
||||
if r.Valid {
|
||||
if valid == 0 {
|
||||
log.Printf("[main] first valid proxy: %s (%s) latency=%v", r.Proxy.Address, r.Proxy.Protocol, r.Latency)
|
||||
}
|
||||
valid++
|
||||
if err := store.AddProxy(r.Proxy.Address, r.Proxy.Protocol); err != nil {
|
||||
log.Printf("[main] addProxy error: %v", err)
|
||||
}
|
||||
if valid%10 == 0 {
|
||||
log.Printf("[main] progress: valid=%d checked=%d/%d", valid, total, len(proxies))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
count, _ := store.Count()
|
||||
log.Printf("[main] validated: valid=%d/%d, pool size=%d", valid, len(proxies), count)
|
||||
return nil
|
||||
}
|
||||
|
||||
func startFetchLoop(fetch *fetcher.Fetcher, store *storage.Storage, configChanged <-chan struct{}) {
|
||||
cfg := config.Get()
|
||||
ticker := time.NewTicker(time.Duration(cfg.FetchInterval) * time.Minute)
|
||||
log.Printf("[main] fetch loop started, interval: %d min", cfg.FetchInterval)
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
log.Println("[main] scheduled fetch started...")
|
||||
if err := fetchAndValidate(fetch, store); err != nil {
|
||||
log.Printf("[main] scheduled fetch error: %v", err)
|
||||
}
|
||||
case <-configChanged:
|
||||
newCfg := config.Get()
|
||||
ticker.Reset(time.Duration(newCfg.FetchInterval) * time.Minute)
|
||||
log.Printf("[main] fetch interval updated to %d min", newCfg.FetchInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
197
proxy/server.go
Normal file
197
proxy/server.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
"proxy-pool/config"
|
||||
"proxy-pool/storage"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
storage *storage.Storage
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func New(s *storage.Storage, cfg *config.Config) *Server {
|
||||
return &Server{
|
||||
storage: s,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
log.Printf("proxy server listening on %s", s.cfg.ProxyPort)
|
||||
return http.ListenAndServe(s.cfg.ProxyPort, s)
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodConnect {
|
||||
s.handleTunnel(w, r)
|
||||
} else {
|
||||
s.handleHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// handleHTTP 处理普通 HTTP 请求(带自动重试)
|
||||
func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
var tried []string
|
||||
for attempt := 0; attempt <= s.cfg.MaxRetry; attempt++ {
|
||||
p, err := s.storage.GetRandomExclude(tried)
|
||||
if err != nil {
|
||||
http.Error(w, "no available proxy", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
client, err := s.buildClient(p)
|
||||
if err != nil {
|
||||
s.storage.Delete(p.Address)
|
||||
continue
|
||||
}
|
||||
|
||||
// 转发请求(使用完整 URL,上游代理通过 client transport 设置)
|
||||
req, err := http.NewRequest(r.Method, r.URL.String(), r.Body)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
req.Header = r.Header.Clone()
|
||||
req.Header.Del("Proxy-Connection")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("[proxy] %s via %s failed, removing", r.RequestURI, p.Address)
|
||||
s.storage.Delete(p.Address)
|
||||
continue
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 写回响应
|
||||
for k, vv := range resp.Header {
|
||||
for _, v := range vv {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
io.Copy(w, resp.Body)
|
||||
if resp.StatusCode == 429 {
|
||||
log.Printf("[proxy] ⚠️ 429 %s via %s (protocol=%s)", r.RequestURI, p.Address, p.Protocol)
|
||||
} else {
|
||||
log.Printf("[proxy] %s via %s -> %d", r.RequestURI, p.Address, resp.StatusCode)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "all proxies failed", http.StatusBadGateway)
|
||||
}
|
||||
|
||||
// handleTunnel 处理 HTTPS CONNECT 隧道(带自动重试)
|
||||
func (s *Server) handleTunnel(w http.ResponseWriter, r *http.Request) {
|
||||
var tried []string
|
||||
for attempt := 0; attempt <= s.cfg.MaxRetry; attempt++ {
|
||||
p, err := s.storage.GetRandomExclude(tried)
|
||||
if err != nil {
|
||||
http.Error(w, "no available proxy", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := s.dialViaProxy(p, r.Host)
|
||||
if err != nil {
|
||||
log.Printf("[tunnel] dial %s via %s failed, removing", r.Host, p.Address)
|
||||
s.storage.Delete(p.Address)
|
||||
continue
|
||||
}
|
||||
|
||||
// 告知客户端隧道建立
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
conn.Close()
|
||||
http.Error(w, "hijack not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
clientConn, _, err := hijacker.Hijack()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(clientConn, "HTTP/1.1 200 Connection Established\r\n\r\n")
|
||||
log.Printf("[tunnel] %s via %s established", r.Host, p.Address)
|
||||
|
||||
// 双向转发
|
||||
go transfer(conn, clientConn)
|
||||
go transfer(clientConn, conn)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "all proxies failed", http.StatusBadGateway)
|
||||
}
|
||||
|
||||
func (s *Server) dialViaProxy(p *storage.Proxy, host string) (net.Conn, error) {
|
||||
timeout := time.Duration(s.cfg.ValidateTimeout) * time.Second
|
||||
switch p.Protocol {
|
||||
case "http":
|
||||
conn, err := net.DialTimeout("tcp", p.Address, timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 发送 CONNECT 请求给上游 HTTP 代理
|
||||
fmt.Fprintf(conn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", host, host)
|
||||
buf := make([]byte, 256)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if n < 12 {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("short response from proxy")
|
||||
}
|
||||
return conn, nil
|
||||
case "socks5":
|
||||
dialer, err := proxy.SOCKS5("tcp", p.Address, nil, proxy.Direct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dialer.Dial("tcp", host)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported protocol: %s", p.Protocol)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) buildClient(p *storage.Proxy) (*http.Client, error) {
|
||||
timeout := time.Duration(s.cfg.ValidateTimeout) * time.Second
|
||||
switch p.Protocol {
|
||||
case "http":
|
||||
proxyURL, err := url.Parse(fmt.Sprintf("http://%s", p.Address))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
|
||||
Timeout: timeout,
|
||||
}, nil
|
||||
case "socks5":
|
||||
dialer, err := proxy.SOCKS5("tcp", p.Address, nil, proxy.Direct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{Dial: dialer.Dial},
|
||||
Timeout: timeout,
|
||||
}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported protocol: %s", p.Protocol)
|
||||
}
|
||||
}
|
||||
|
||||
func transfer(dst io.WriteCloser, src io.ReadCloser) {
|
||||
defer dst.Close()
|
||||
defer src.Close()
|
||||
io.Copy(dst, src)
|
||||
}
|
||||
242
storage/storage.go
Normal file
242
storage/storage.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
type Proxy struct {
|
||||
ID int64
|
||||
Address string // host:port
|
||||
Protocol string // http, socks5
|
||||
FailCount int
|
||||
LastCheck time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Storage struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func New(dbPath string) (*Storage, error) {
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open db: %w", err)
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(1) // SQLite 单写
|
||||
|
||||
s := &Storage{db: db}
|
||||
if err := s.initSchema(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Storage) initSchema() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS proxies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
address TEXT NOT NULL UNIQUE,
|
||||
protocol TEXT NOT NULL,
|
||||
fail_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_check DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// AddProxy 新增代理,已存在则忽略
|
||||
func (s *Storage) AddProxy(address, protocol string) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT OR IGNORE INTO proxies (address, protocol) VALUES (?, ?)`,
|
||||
address, protocol,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// AddProxies 批量新增
|
||||
func (s *Storage) AddProxies(proxies []Proxy) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare(`INSERT OR IGNORE INTO proxies (address, protocol) VALUES (?, ?)`)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, p := range proxies {
|
||||
if _, err := stmt.Exec(p.Address, p.Protocol); err != nil {
|
||||
log.Printf("insert proxy %s error: %v", p.Address, err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// GetRandom 随机取一个可用代理
|
||||
func (s *Storage) GetRandom() (*Proxy, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, address, protocol, fail_count, last_check, created_at
|
||||
FROM proxies WHERE fail_count < 3
|
||||
ORDER BY RANDOM() LIMIT 1`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
if rows.Next() {
|
||||
p := &Proxy{}
|
||||
var lastCheck sql.NullTime
|
||||
if err := rows.Scan(&p.ID, &p.Address, &p.Protocol, &p.FailCount, &lastCheck, &p.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lastCheck.Valid {
|
||||
p.LastCheck = lastCheck.Time
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no available proxy")
|
||||
}
|
||||
|
||||
// GetAll 获取所有可用代理
|
||||
func (s *Storage) GetAll() ([]Proxy, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, address, protocol, fail_count, last_check, created_at
|
||||
FROM proxies WHERE fail_count < 3`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var proxies []Proxy
|
||||
for rows.Next() {
|
||||
p := Proxy{}
|
||||
var lastCheck sql.NullTime
|
||||
if err := rows.Scan(&p.ID, &p.Address, &p.Protocol, &p.FailCount, &lastCheck, &p.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lastCheck.Valid {
|
||||
p.LastCheck = lastCheck.Time
|
||||
}
|
||||
proxies = append(proxies, p)
|
||||
}
|
||||
return proxies, nil
|
||||
}
|
||||
|
||||
// GetRandomExclude 排除指定地址随机取一个
|
||||
func (s *Storage) GetRandomExclude(excludes []string) (*Proxy, error) {
|
||||
proxies, err := s.GetAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
excludeMap := make(map[string]bool)
|
||||
for _, e := range excludes {
|
||||
excludeMap[e] = true
|
||||
}
|
||||
|
||||
var available []Proxy
|
||||
for _, p := range proxies {
|
||||
if !excludeMap[p.Address] {
|
||||
available = append(available, p)
|
||||
}
|
||||
}
|
||||
|
||||
if len(available) == 0 {
|
||||
// 没有可排除的了,随机取任意一个
|
||||
return s.GetRandom()
|
||||
}
|
||||
|
||||
p := available[rand.Intn(len(available))]
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// Delete 立即删除指定代理
|
||||
func (s *Storage) Delete(address string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM proxies WHERE address = ?`, address)
|
||||
return err
|
||||
}
|
||||
|
||||
// IncrFail 增加失败次数
|
||||
func (s *Storage) IncrFail(address string) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE proxies SET fail_count = fail_count + 1, last_check = CURRENT_TIMESTAMP WHERE address = ?`,
|
||||
address,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ResetFail 重置失败次数(验证通过)
|
||||
func (s *Storage) ResetFail(address string) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE proxies SET fail_count = 0, last_check = CURRENT_TIMESTAMP WHERE address = ?`,
|
||||
address,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteInvalid 删除失败次数超过阈值的代理
|
||||
func (s *Storage) DeleteInvalid(maxFailCount int) (int64, error) {
|
||||
res, err := s.db.Exec(`DELETE FROM proxies WHERE fail_count >= ?`, maxFailCount)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// Count 返回可用代理数量
|
||||
func (s *Storage) Count() (int, error) {
|
||||
var count int
|
||||
err := s.db.QueryRow(`SELECT COUNT(*) FROM proxies WHERE fail_count < 3`).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// CountByProtocol 按协议统计数量
|
||||
func (s *Storage) CountByProtocol(protocol string) (int, error) {
|
||||
var count int
|
||||
err := s.db.QueryRow(`SELECT COUNT(*) FROM proxies WHERE fail_count < 3 AND protocol = ?`, protocol).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// GetByProtocol 按协议获取代理列表
|
||||
func (s *Storage) GetByProtocol(protocol string) ([]Proxy, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, address, protocol, fail_count, last_check, created_at
|
||||
FROM proxies WHERE fail_count < 3 AND protocol = ?
|
||||
ORDER BY created_at DESC`, protocol,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var proxies []Proxy
|
||||
for rows.Next() {
|
||||
p := Proxy{}
|
||||
var lastCheck sql.NullTime
|
||||
if err := rows.Scan(&p.ID, &p.Address, &p.Protocol, &p.FailCount, &lastCheck, &p.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lastCheck.Valid {
|
||||
p.LastCheck = lastCheck.Time
|
||||
}
|
||||
proxies = append(proxies, p)
|
||||
}
|
||||
return proxies, nil
|
||||
}
|
||||
|
||||
// Close 关闭数据库
|
||||
func (s *Storage) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
149
validator/validator.go
Normal file
149
validator/validator.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
"proxy-pool/config"
|
||||
"proxy-pool/storage"
|
||||
)
|
||||
|
||||
type Validator struct {
|
||||
concurrency int
|
||||
timeout time.Duration
|
||||
validateURL string
|
||||
maxResponseMs int
|
||||
}
|
||||
|
||||
func concurrencyBuffer(total, concurrency int) int {
|
||||
if total < concurrency*10 {
|
||||
return total
|
||||
}
|
||||
return concurrency * 10
|
||||
}
|
||||
|
||||
func New(concurrency, timeoutSec int, validateURL string) *Validator {
|
||||
cfg := config.Get()
|
||||
maxMs := 0
|
||||
if cfg != nil {
|
||||
maxMs = cfg.MaxResponseMs
|
||||
}
|
||||
return &Validator{
|
||||
concurrency: concurrency,
|
||||
timeout: time.Duration(timeoutSec) * time.Second,
|
||||
validateURL: validateURL,
|
||||
maxResponseMs: maxMs,
|
||||
}
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Proxy storage.Proxy
|
||||
Valid bool
|
||||
Latency time.Duration
|
||||
}
|
||||
|
||||
// ValidateAll 并发验证所有代理,返回验证结果
|
||||
func (v *Validator) ValidateAll(proxies []storage.Proxy) []Result {
|
||||
var results []Result
|
||||
for r := range v.ValidateStream(proxies) {
|
||||
results = append(results, r)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// ValidateStream 并发验证,边验证边通过 channel 返回结果
|
||||
func (v *Validator) ValidateStream(proxies []storage.Proxy) <-chan Result {
|
||||
ch := make(chan Result, concurrencyBuffer(len(proxies), v.concurrency))
|
||||
sem := make(chan struct{}, v.concurrency)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
go func() {
|
||||
for _, p := range proxies {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(px storage.Proxy) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
valid, latency := v.ValidateOne(px)
|
||||
ch <- Result{Proxy: px, Valid: valid, Latency: latency}
|
||||
}(p)
|
||||
}
|
||||
wg.Wait()
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
// ValidateOne 验证单个代理是否可用,返回是否有效和延迟
|
||||
func (v *Validator) ValidateOne(p storage.Proxy) (bool, time.Duration) {
|
||||
var client *http.Client
|
||||
var err error
|
||||
|
||||
switch p.Protocol {
|
||||
case "http":
|
||||
client, err = newHTTPClient(p.Address, v.timeout)
|
||||
case "socks5":
|
||||
client, err = newSOCKS5Client(p.Address, v.timeout)
|
||||
default:
|
||||
log.Printf("unknown protocol %s for %s", p.Protocol, p.Address)
|
||||
return false, 0
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
resp, err := client.Get(v.validateURL)
|
||||
latency := time.Since(start)
|
||||
if err != nil {
|
||||
return false, 0
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
|
||||
// 验证状态码(200 或 204 都接受)
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
return false, latency
|
||||
}
|
||||
|
||||
// 响应时间过滤
|
||||
if v.maxResponseMs > 0 && latency > time.Duration(v.maxResponseMs)*time.Millisecond {
|
||||
return false, latency
|
||||
}
|
||||
|
||||
return true, latency
|
||||
}
|
||||
|
||||
func newHTTPClient(address string, timeout time.Duration) (*http.Client, error) {
|
||||
proxyURL, err := url.Parse(fmt.Sprintf("http://%s", address))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyURL),
|
||||
},
|
||||
Timeout: timeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newSOCKS5Client(address string, timeout time.Duration) (*http.Client, error) {
|
||||
dialer, err := proxy.SOCKS5("tcp", address, nil, proxy.Direct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Dial: dialer.Dial,
|
||||
},
|
||||
Timeout: timeout,
|
||||
}, nil
|
||||
}
|
||||
292
webui/dashboard.go
Normal file
292
webui/dashboard.go
Normal file
@@ -0,0 +1,292 @@
|
||||
package webui
|
||||
|
||||
const dashboardHTML = dashboardHTMLHead + dashboardHTMLBody + dashboardHTMLJS
|
||||
|
||||
const dashboardHTMLHead = `<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ProxyGo - 管理面板</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:#0f172a;color:#e2e8f0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
|
||||
.nav{background:#1e293b;border-bottom:1px solid #334155;padding:0 24px;display:flex;align-items:center;justify-content:space-between;height:56px}
|
||||
.nav-brand{font-size:18px;font-weight:700;color:#f1f5f9;display:flex;align-items:center;gap:8px}
|
||||
.nav-brand span{color:#6366f1}
|
||||
.nav-right{display:flex;align-items:center;gap:16px}
|
||||
.nav-link{color:#94a3b8;text-decoration:none;font-size:14px;cursor:pointer;background:none;border:none;padding:0}
|
||||
.nav-link:hover{color:#f1f5f9}
|
||||
.container{max-width:1200px;margin:0 auto;padding:24px}
|
||||
.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:24px}
|
||||
.stat-card{background:#1e293b;border:1px solid #334155;border-radius:10px;padding:20px}
|
||||
.stat-label{font-size:13px;color:#94a3b8;margin-bottom:8px}
|
||||
.stat-value{font-size:32px;font-weight:700;color:#f1f5f9}
|
||||
.stat-value.green{color:#4ade80}
|
||||
.stat-value.blue{color:#60a5fa}
|
||||
.stat-value.purple{color:#a78bfa}
|
||||
.section{background:#1e293b;border:1px solid #334155;border-radius:10px;margin-bottom:24px}
|
||||
.section-header{padding:16px 20px;border-bottom:1px solid #334155;display:flex;align-items:center;justify-content:space-between}
|
||||
.section-title{font-size:15px;font-weight:600;color:#f1f5f9}
|
||||
.tabs{display:flex;gap:4px}
|
||||
.tab{padding:6px 14px;border-radius:6px;font-size:13px;cursor:pointer;border:none;background:transparent;color:#94a3b8;transition:all 0.2s}
|
||||
.tab.active{background:#6366f1;color:#fff}
|
||||
.tab:hover:not(.active){background:#334155;color:#f1f5f9}
|
||||
.btn{padding:7px 14px;border-radius:6px;font-size:13px;font-weight:500;cursor:pointer;border:none;transition:all 0.2s}
|
||||
.btn-primary{background:#6366f1;color:#fff}
|
||||
.btn-primary:hover{background:#4f46e5}
|
||||
.btn-danger{background:#dc2626;color:#fff;padding:4px 10px;font-size:12px}
|
||||
.btn-danger:hover{background:#b91c1c}
|
||||
.btn-sm{padding:4px 10px;font-size:12px;background:#334155;color:#94a3b8;border:none;border-radius:6px;cursor:pointer}
|
||||
.btn-sm:hover{color:#f1f5f9}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th{padding:10px 16px;text-align:left;font-size:12px;color:#64748b;font-weight:500;border-bottom:1px solid #1e293b;background:#0f172a}
|
||||
td{padding:10px 16px;font-size:13px;border-bottom:1px solid #1e293b;color:#cbd5e1}
|
||||
tr:last-child td{border-bottom:none}
|
||||
tr:hover td{background:#1a2744}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:500}
|
||||
.badge-http{background:#1e3a5f;color:#60a5fa}
|
||||
.badge-socks5{background:#2d1b4e;color:#a78bfa}
|
||||
.log-box{padding:16px 20px;font-family:monospace;font-size:12px;color:#94a3b8;max-height:400px;overflow-y:auto;line-height:1.6}
|
||||
.log-line{padding:2px 0}
|
||||
.log-line.error{color:#f87171}
|
||||
.log-line.success{color:#4ade80}
|
||||
.empty{padding:40px;text-align:center;color:#475569;font-size:14px}
|
||||
.refresh-info{font-size:12px;color:#475569}
|
||||
.proxy-port{font-size:13px;color:#64748b}
|
||||
.pagination{display:flex;align-items:center;gap:6px;padding:12px 20px;border-top:1px solid #1e293b;flex-wrap:wrap}
|
||||
.page-btn{padding:4px 10px;border-radius:6px;font-size:13px;cursor:pointer;border:1px solid #334155;background:#0f172a;color:#94a3b8;transition:all 0.2s}
|
||||
.page-btn:hover:not(:disabled){border-color:#6366f1;color:#f1f5f9}
|
||||
.page-btn:disabled{opacity:0.4;cursor:default}
|
||||
.page-btn.active{background:#6366f1;border-color:#6366f1;color:#fff}
|
||||
.page-info{font-size:12px;color:#475569;margin-left:8px}
|
||||
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:100;align-items:center;justify-content:center}
|
||||
.modal-overlay.show{display:flex}
|
||||
.modal{background:#1e293b;border:1px solid #334155;border-radius:12px;padding:28px;width:440px;box-shadow:0 20px 60px rgba(0,0,0,0.5)}
|
||||
.modal-title{font-size:16px;font-weight:700;color:#f1f5f9;margin-bottom:20px}
|
||||
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:20px}
|
||||
.form-group label{display:block;font-size:12px;color:#94a3b8;margin-bottom:6px}
|
||||
.form-group input{width:100%;padding:8px 12px;background:#0f172a;border:1px solid #334155;border-radius:6px;color:#f1f5f9;font-size:14px;outline:none}
|
||||
.form-group input:focus{border-color:#6366f1}
|
||||
.modal-actions{display:flex;gap:8px;align-items:center;justify-content:flex-end}
|
||||
.save-tip{font-size:12px;color:#4ade80;margin-right:auto}
|
||||
</style>
|
||||
</head>`
|
||||
|
||||
const dashboardHTMLBody = `
|
||||
<body>
|
||||
<nav class="nav">
|
||||
<div class="nav-brand">⚡ <span>Proxy</span>Go</div>
|
||||
<div class="nav-right">
|
||||
<span id="proxy-port" class="proxy-port"></span>
|
||||
<button class="nav-link" onclick="openSettings()">系统设置</button>
|
||||
<a href="/logout" class="nav-link">退出登录</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="modal-overlay" id="settings-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-title">系统设置</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-group"><label>抓取间隔(分钟)</label><input type="number" id="cfg-fetch" min="1"></div>
|
||||
<div class="form-group"><label>健康检查间隔(分钟)</label><input type="number" id="cfg-check" min="1"></div>
|
||||
<div class="form-group"><label>验证并发数</label><input type="number" id="cfg-concurrency" min="1"></div>
|
||||
<div class="form-group"><label>验证超时(秒)</label><input type="number" id="cfg-timeout" min="1"></div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<span class="save-tip" id="save-tip" style="display:none">已保存并生效</span>
|
||||
<button class="btn-sm" onclick="closeSettings()">取消</button>
|
||||
<button class="btn btn-primary" onclick="saveConfig()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="stats">
|
||||
<div class="stat-card"><div class="stat-label">全部代理</div><div class="stat-value green" id="stat-total">-</div></div>
|
||||
<div class="stat-card"><div class="stat-label">HTTP 代理</div><div class="stat-value blue" id="stat-http">-</div></div>
|
||||
<div class="stat-card"><div class="stat-label">SOCKS5 代理</div><div class="stat-value purple" id="stat-socks5">-</div></div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<span class="section-title">代理列表</span>
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('')" id="tab-all">全部</button>
|
||||
<button class="tab" onclick="switchTab('http')" id="tab-http">HTTP</button>
|
||||
<button class="tab" onclick="switchTab('socks5')" id="tab-socks5">SOCKS5</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<span class="refresh-info" id="proxy-count"></span>
|
||||
<button class="btn btn-primary" id="fetch-btn" onclick="triggerFetch()">立即抓取</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="proxy-table-wrap"><div class="empty">加载中...</div></div>
|
||||
<div class="pagination" id="pagination"></div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">运行日志</span>
|
||||
<button class="btn-sm" onclick="loadLogs()">刷新</button>
|
||||
</div>
|
||||
<div class="log-box" id="log-box">加载中...</div>
|
||||
</div>
|
||||
</div>`
|
||||
|
||||
const dashboardHTMLJS = `
|
||||
<script>
|
||||
const PAGE_SIZE = 50;
|
||||
var currentProtocol = '';
|
||||
var allProxies = [];
|
||||
var currentPage = 1;
|
||||
|
||||
async function api(path, opts) {
|
||||
var r = await fetch(path, opts);
|
||||
if (r.status === 401) { location.href = '/login'; return null; }
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
var d = await api('/api/stats');
|
||||
if (!d) return;
|
||||
document.getElementById('stat-total').textContent = d.total;
|
||||
document.getElementById('stat-http').textContent = d.http;
|
||||
document.getElementById('stat-socks5').textContent = d.socks5;
|
||||
document.getElementById('proxy-port').textContent = '代理端口: ' + d.port;
|
||||
}
|
||||
|
||||
function switchTab(protocol) {
|
||||
currentProtocol = protocol;
|
||||
currentPage = 1;
|
||||
['all','http','socks5'].forEach(function(t) {
|
||||
document.getElementById('tab-'+t).className = 'tab' + (t === (protocol||'all') ? ' active' : '');
|
||||
});
|
||||
loadProxies();
|
||||
}
|
||||
|
||||
async function loadProxies() {
|
||||
var d = await api('/api/proxies?protocol=' + currentProtocol);
|
||||
if (!d) return;
|
||||
allProxies = Array.isArray(d) ? d : [];
|
||||
document.getElementById('proxy-count').textContent = allProxies.length + ' 个';
|
||||
renderPage();
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
var total = allProxies.length;
|
||||
var totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
if (currentPage > totalPages) currentPage = totalPages;
|
||||
var start = (currentPage - 1) * PAGE_SIZE;
|
||||
var page = allProxies.slice(start, start + PAGE_SIZE);
|
||||
if (total === 0) {
|
||||
document.getElementById('proxy-table-wrap').innerHTML = '<div class="empty">暂无代理</div>';
|
||||
document.getElementById('pagination').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
var html = '<table><thead><tr><th>地址</th><th>协议</th><th>添加时间</th><th>操作</th></tr></thead><tbody>';
|
||||
page.forEach(function(p) {
|
||||
var badge = p.Protocol === 'http' ? 'badge-http' : 'badge-socks5';
|
||||
var t = new Date(p.CreatedAt).toLocaleString('zh-CN');
|
||||
html += '<tr><td style="font-family:monospace">' + p.Address + '</td>' +
|
||||
'<td><span class="badge ' + badge + '">' + p.Protocol + '</span></td>' +
|
||||
'<td>' + t + '</td>' +
|
||||
'<td><button class="btn btn-danger" onclick="deleteProxy(\'' + p.Address + '\')">删除</button></td></tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('proxy-table-wrap').innerHTML = html;
|
||||
var pag = '<button class="page-btn" onclick="goPage(' + (currentPage-1) + ')"' + (currentPage===1?' disabled':'') + '>上一页</button>';
|
||||
var sp = Math.max(1, currentPage-3), ep = Math.min(totalPages, sp+6);
|
||||
if (ep-sp < 6) sp = Math.max(1, ep-6);
|
||||
for (var i = sp; i <= ep; i++) {
|
||||
pag += '<button class="page-btn' + (i===currentPage?' active':'') + '" onclick="goPage(' + i + ')">' + i + '</button>';
|
||||
}
|
||||
pag += '<button class="page-btn" onclick="goPage(' + (currentPage+1) + ')"' + (currentPage===totalPages?' disabled':'') + '>下一页</button>';
|
||||
pag += '<span class="page-info">' + currentPage + ' / ' + totalPages + ' 页,共 ' + total + ' 条</span>';
|
||||
document.getElementById('pagination').innerHTML = pag;
|
||||
}
|
||||
|
||||
function goPage(p) {
|
||||
var totalPages = Math.ceil(allProxies.length / PAGE_SIZE) || 1;
|
||||
if (p < 1 || p > totalPages) return;
|
||||
currentPage = p;
|
||||
renderPage();
|
||||
}
|
||||
|
||||
async function deleteProxy(address) {
|
||||
if (!confirm('确认删除 ' + address + ' ?')) return;
|
||||
await api('/api/proxy/delete', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({address:address})});
|
||||
loadProxies();
|
||||
loadStats();
|
||||
}
|
||||
|
||||
async function triggerFetch() {
|
||||
var btn = document.getElementById('fetch-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '抓取中...';
|
||||
await api('/api/fetch', {method:'POST'});
|
||||
setTimeout(function() {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '立即抓取';
|
||||
loadStats();
|
||||
loadProxies();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
var d = await api('/api/logs');
|
||||
if (!d || !d.lines) return;
|
||||
var box = document.getElementById('log-box');
|
||||
if (d.lines.length === 0) { box.innerHTML = '<div class="empty">暂无日志</div>'; return; }
|
||||
box.innerHTML = d.lines.map(function(l) {
|
||||
var cls = 'log-line';
|
||||
if (l.indexOf('error') >= 0 || l.indexOf('Error') >= 0 || l.indexOf('failed') >= 0) cls += ' error';
|
||||
else if (l.indexOf('valid=') >= 0 || l.indexOf('pool size') >= 0) cls += ' success';
|
||||
return '<div class="' + cls + '">' + l + '</div>';
|
||||
}).join('');
|
||||
box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
api('/api/config').then(function(d) {
|
||||
if (!d) return;
|
||||
document.getElementById('cfg-fetch').value = d.fetch_interval;
|
||||
document.getElementById('cfg-check').value = d.check_interval;
|
||||
document.getElementById('cfg-concurrency').value = d.validate_concurrency;
|
||||
document.getElementById('cfg-timeout').value = d.validate_timeout;
|
||||
document.getElementById('settings-modal').style.display = 'flex';
|
||||
});
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
document.getElementById('settings-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
var body = {
|
||||
fetch_interval: parseInt(document.getElementById('cfg-fetch').value),
|
||||
check_interval: parseInt(document.getElementById('cfg-check').value),
|
||||
validate_concurrency: parseInt(document.getElementById('cfg-concurrency').value),
|
||||
validate_timeout: parseInt(document.getElementById('cfg-timeout').value)
|
||||
};
|
||||
if (Object.values(body).some(function(v){return isNaN(v)||v<=0;})) {
|
||||
alert('所有值必须为正整数');
|
||||
return;
|
||||
}
|
||||
var d = await api('/api/config', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||
if (d && d.status === 'saved') {
|
||||
var tip = document.getElementById('save-tip');
|
||||
tip.style.display = 'inline';
|
||||
setTimeout(function(){tip.style.display='none';}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
loadStats();
|
||||
loadProxies();
|
||||
loadLogs();
|
||||
setInterval(loadStats, 10000);
|
||||
setInterval(loadProxies, 15000);
|
||||
setInterval(loadLogs, 10000);
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
73
webui/html.go
Normal file
73
webui/html.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package webui
|
||||
|
||||
const loginHTML = `<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ProxyGo - 登录</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:#0f172a;color:#e2e8f0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh}
|
||||
.card{background:#1e293b;border:1px solid #334155;border-radius:12px;padding:40px;width:360px;box-shadow:0 20px 60px rgba(0,0,0,0.5)}
|
||||
h1{font-size:24px;font-weight:700;margin-bottom:8px;color:#f1f5f9}
|
||||
.sub{color:#94a3b8;font-size:14px;margin-bottom:32px}
|
||||
label{display:block;font-size:13px;color:#94a3b8;margin-bottom:6px}
|
||||
input[type=password]{width:100%;padding:10px 14px;background:#0f172a;border:1px solid #334155;border-radius:8px;color:#f1f5f9;font-size:15px;outline:none;transition:border 0.2s}
|
||||
input[type=password]:focus{border-color:#6366f1}
|
||||
button{width:100%;margin-top:20px;padding:11px;background:#6366f1;color:#fff;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;transition:background 0.2s}
|
||||
button:hover{background:#4f46e5}
|
||||
.logo{font-size:32px;margin-bottom:16px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">⚡</div>
|
||||
<h1>ProxyGo</h1>
|
||||
<p class="sub">代理池管理系统</p>
|
||||
<form method="POST" action="/login">
|
||||
<label>管理密码</label>
|
||||
<input type="password" name="password" placeholder="请输入密码" autofocus>
|
||||
<button type="submit">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const loginHTMLWithError = `<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ProxyGo - 登录</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:#0f172a;color:#e2e8f0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh}
|
||||
.card{background:#1e293b;border:1px solid #334155;border-radius:12px;padding:40px;width:360px;box-shadow:0 20px 60px rgba(0,0,0,0.5)}
|
||||
h1{font-size:24px;font-weight:700;margin-bottom:8px;color:#f1f5f9}
|
||||
.sub{color:#94a3b8;font-size:14px;margin-bottom:32px}
|
||||
label{display:block;font-size:13px;color:#94a3b8;margin-bottom:6px}
|
||||
input[type=password]{width:100%;padding:10px 14px;background:#0f172a;border:1px solid #334155;border-radius:8px;color:#f1f5f9;font-size:15px;outline:none;transition:border 0.2s}
|
||||
input[type=password]:focus{border-color:#6366f1}
|
||||
button{width:100%;margin-top:20px;padding:11px;background:#6366f1;color:#fff;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;transition:background 0.2s}
|
||||
button:hover{background:#4f46e5}
|
||||
.logo{font-size:32px;margin-bottom:16px}
|
||||
.error{background:#450a0a;border:1px solid #7f1d1d;color:#fca5a5;padding:10px 14px;border-radius:8px;font-size:13px;margin-bottom:16px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">⚡</div>
|
||||
<h1>ProxyGo</h1>
|
||||
<p class="sub">代理池管理系统</p>
|
||||
<div class="error">密码错误,请重试</div>
|
||||
<form method="POST" action="/login">
|
||||
<label>管理密码</label>
|
||||
<input type="password" name="password" placeholder="请输入密码" autofocus>
|
||||
<button type="submit">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// dashboardHTML 已移至 dashboard.go
|
||||
242
webui/server.go
Normal file
242
webui/server.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"proxy-pool/config"
|
||||
"proxy-pool/logger"
|
||||
"proxy-pool/storage"
|
||||
)
|
||||
|
||||
// 简单内存 session
|
||||
var (
|
||||
sessions = make(map[string]time.Time)
|
||||
sessionsMu sync.Mutex
|
||||
)
|
||||
|
||||
func newSession() string {
|
||||
token := fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprintf("%d", time.Now().UnixNano()))))
|
||||
sessionsMu.Lock()
|
||||
sessions[token] = time.Now().Add(24 * time.Hour)
|
||||
sessionsMu.Unlock()
|
||||
return token
|
||||
}
|
||||
|
||||
func validSession(r *http.Request) bool {
|
||||
cookie, err := r.Cookie("session")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
sessionsMu.Lock()
|
||||
expiry, ok := sessions[cookie.Value]
|
||||
sessionsMu.Unlock()
|
||||
return ok && time.Now().Before(expiry)
|
||||
}
|
||||
|
||||
type FetchTrigger func()
|
||||
|
||||
type Server struct {
|
||||
storage *storage.Storage
|
||||
cfg *config.Config
|
||||
fetchTrigger FetchTrigger
|
||||
configChanged chan<- struct{}
|
||||
}
|
||||
|
||||
func New(s *storage.Storage, cfg *config.Config, ft FetchTrigger, cc chan<- struct{}) *Server {
|
||||
return &Server{storage: s, cfg: cfg, fetchTrigger: ft, configChanged: cc}
|
||||
}
|
||||
|
||||
func (s *Server) Start() {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", s.handleIndex)
|
||||
mux.HandleFunc("/login", s.handleLogin)
|
||||
mux.HandleFunc("/logout", s.handleLogout)
|
||||
mux.HandleFunc("/api/stats", s.authMiddleware(s.apiStats))
|
||||
mux.HandleFunc("/api/proxies", s.authMiddleware(s.apiProxies))
|
||||
mux.HandleFunc("/api/proxy/delete", s.authMiddleware(s.apiDeleteProxy))
|
||||
mux.HandleFunc("/api/fetch", s.authMiddleware(s.apiFetch))
|
||||
mux.HandleFunc("/api/logs", s.authMiddleware(s.apiLogs))
|
||||
mux.HandleFunc("/api/config", s.authMiddleware(s.apiConfig))
|
||||
|
||||
log.Printf("WebUI listening on %s", s.cfg.WebUIPort)
|
||||
go func() {
|
||||
if err := http.ListenAndServe(s.cfg.WebUIPort, mux); err != nil {
|
||||
log.Fatalf("webui: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !validSession(r) {
|
||||
if len(r.URL.Path) >= 4 && r.URL.Path[:4] == "/api" {
|
||||
jsonError(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if !validSession(r) {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, dashboardHTML)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, loginHTML)
|
||||
return
|
||||
}
|
||||
password := r.FormValue("password")
|
||||
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(password)))
|
||||
if hash != s.cfg.WebUIPasswordHash {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, loginHTMLWithError)
|
||||
return
|
||||
}
|
||||
token := newSession()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
Expires: time.Now().Add(24 * time.Hour),
|
||||
HttpOnly: true,
|
||||
})
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie("session"); err == nil {
|
||||
sessionsMu.Lock()
|
||||
delete(sessions, cookie.Value)
|
||||
sessionsMu.Unlock()
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: "", Path: "/", MaxAge: -1})
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) apiStats(w http.ResponseWriter, r *http.Request) {
|
||||
total, _ := s.storage.Count()
|
||||
httpCount, _ := s.storage.CountByProtocol("http")
|
||||
socks5Count, _ := s.storage.CountByProtocol("socks5")
|
||||
jsonOK(w, map[string]interface{}{
|
||||
"total": total,
|
||||
"http": httpCount,
|
||||
"socks5": socks5Count,
|
||||
"port": s.cfg.ProxyPort,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) apiProxies(w http.ResponseWriter, r *http.Request) {
|
||||
protocol := r.URL.Query().Get("protocol")
|
||||
var proxies []storage.Proxy
|
||||
var err error
|
||||
if protocol != "" {
|
||||
proxies, err = s.storage.GetByProtocol(protocol)
|
||||
} else {
|
||||
proxies, err = s.storage.GetAll()
|
||||
}
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, proxies)
|
||||
}
|
||||
|
||||
func (s *Server) apiDeleteProxy(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Address string `json:"address"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Address == "" {
|
||||
jsonError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.storage.Delete(req.Address)
|
||||
jsonOK(w, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
func (s *Server) apiFetch(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
go s.fetchTrigger()
|
||||
jsonOK(w, map[string]string{"status": "fetch started"})
|
||||
}
|
||||
|
||||
func (s *Server) apiLogs(w http.ResponseWriter, r *http.Request) {
|
||||
lines := logger.GetLines(200)
|
||||
jsonOK(w, map[string]interface{}{"lines": lines})
|
||||
}
|
||||
|
||||
func (s *Server) apiConfig(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := config.Get()
|
||||
if r.Method == http.MethodGet {
|
||||
jsonOK(w, map[string]interface{}{
|
||||
"fetch_interval": cfg.FetchInterval,
|
||||
"check_interval": cfg.CheckInterval,
|
||||
"validate_concurrency": cfg.ValidateConcurrency,
|
||||
"validate_timeout": cfg.ValidateTimeout,
|
||||
})
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
FetchInterval int `json:"fetch_interval"`
|
||||
CheckInterval int `json:"check_interval"`
|
||||
ValidateConcurrency int `json:"validate_concurrency"`
|
||||
ValidateTimeout int `json:"validate_timeout"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.FetchInterval <= 0 || req.CheckInterval <= 0 || req.ValidateConcurrency <= 0 || req.ValidateTimeout <= 0 {
|
||||
jsonError(w, "all values must be positive", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := config.Save(req.FetchInterval, req.CheckInterval, req.ValidateConcurrency, req.ValidateTimeout); err != nil {
|
||||
jsonError(w, "save config error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// 通知定时器重置
|
||||
select {
|
||||
case s.configChanged <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
log.Printf("[config] updated: fetch=%dm check=%dm concurrency=%d timeout=%ds",
|
||||
req.FetchInterval, req.CheckInterval, req.ValidateConcurrency, req.ValidateTimeout)
|
||||
jsonOK(w, map[string]string{"status": "saved"})
|
||||
}
|
||||
|
||||
func jsonOK(w http.ResponseWriter, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func jsonError(w http.ResponseWriter, msg string, code int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
Reference in New Issue
Block a user