Initial commit

This commit is contained in:
jonasen1988
2026-03-26 21:14:16 +08:00
commit d2e679401e
18 changed files with 2055 additions and 0 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
proxy-pool
proxy-pool.log
proxy.db
config.json
data/
*.log

17
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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})
}