From d2e679401e654957e990e6ffd761ca4f437e45f8 Mon Sep 17 00:00:00 2001 From: jonasen1988 Date: Thu, 26 Mar 2026 21:14:16 +0800 Subject: [PATCH] Initial commit --- .dockerignore | 6 + .gitignore | 17 +++ Dockerfile | 25 ++++ README.md | 249 +++++++++++++++++++++++++++++++++++ checker/checker.go | 65 +++++++++ config/config.go | 147 +++++++++++++++++++++ docker-compose.yml | 24 ++++ fetcher/fetcher.go | 128 ++++++++++++++++++ go.mod | 8 ++ go.sum | 4 + logger/logger.go | 53 ++++++++ main.go | 134 +++++++++++++++++++ proxy/server.go | 197 +++++++++++++++++++++++++++ storage/storage.go | 242 ++++++++++++++++++++++++++++++++++ validator/validator.go | 149 +++++++++++++++++++++ webui/dashboard.go | 292 +++++++++++++++++++++++++++++++++++++++++ webui/html.go | 73 +++++++++++ webui/server.go | 242 ++++++++++++++++++++++++++++++++++ 18 files changed, 2055 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 checker/checker.go create mode 100644 config/config.go create mode 100644 docker-compose.yml create mode 100644 fetcher/fetcher.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 logger/logger.go create mode 100644 main.go create mode 100644 proxy/server.go create mode 100644 storage/storage.go create mode 100644 validator/validator.go create mode 100644 webui/dashboard.go create mode 100644 webui/html.go create mode 100644 webui/server.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0460a4d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +proxy-pool +proxy-pool.log +proxy.db +config.json +data/ +*.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f0ec76 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..42c03c1 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..cf873dc --- /dev/null +++ b/README.md @@ -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 密码初始化和修改 +- 为失败计数、重试和删除策略补齐一致的状态流转 +- 增加自动化测试 diff --git a/checker/checker.go b/checker/checker.go new file mode 100644 index 0000000..db701f6 --- /dev/null +++ b/checker/checker.go @@ -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) +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..be87de1 --- /dev/null +++ b/config/config.go @@ -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) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..690b572 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go new file mode 100644 index 0000000..1b2f9b2 --- /dev/null +++ b/fetcher/fetcher.go @@ -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() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3de2b47 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fec1124 --- /dev/null +++ b/go.sum @@ -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= diff --git a/logger/logger.go b/logger/logger.go new file mode 100644 index 0000000..6556ded --- /dev/null +++ b/logger/logger.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..767faaf --- /dev/null +++ b/main.go @@ -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) + } + } +} diff --git a/proxy/server.go b/proxy/server.go new file mode 100644 index 0000000..714188e --- /dev/null +++ b/proxy/server.go @@ -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) +} diff --git a/storage/storage.go b/storage/storage.go new file mode 100644 index 0000000..cf91d05 --- /dev/null +++ b/storage/storage.go @@ -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() +} diff --git a/validator/validator.go b/validator/validator.go new file mode 100644 index 0000000..9920c90 --- /dev/null +++ b/validator/validator.go @@ -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 +} diff --git a/webui/dashboard.go b/webui/dashboard.go new file mode 100644 index 0000000..2d3716f --- /dev/null +++ b/webui/dashboard.go @@ -0,0 +1,292 @@ +package webui + +const dashboardHTML = dashboardHTMLHead + dashboardHTMLBody + dashboardHTMLJS + +const dashboardHTMLHead = ` + + + + +ProxyGo - 管理面板 + +` + +const dashboardHTMLBody = ` + + + + + +
+
+
全部代理
-
+
HTTP 代理
-
+
SOCKS5 代理
-
+
+
+
+
+ 代理列表 +
+ + + +
+
+
+ + +
+
+
加载中...
+ +
+
+
+ 运行日志 + +
+
加载中...
+
+
` + +const dashboardHTMLJS = ` + + +` diff --git a/webui/html.go b/webui/html.go new file mode 100644 index 0000000..c94e46d --- /dev/null +++ b/webui/html.go @@ -0,0 +1,73 @@ +package webui + +const loginHTML = ` + + + + +ProxyGo - 登录 + + + +
+ +

ProxyGo

+

代理池管理系统

+
+ + + +
+
+ +` + +const loginHTMLWithError = ` + + + + +ProxyGo - 登录 + + + +
+ +

ProxyGo

+

代理池管理系统

+
密码错误,请重试
+
+ + + +
+
+ +` + +// dashboardHTML 已移至 dashboard.go diff --git a/webui/server.go b/webui/server.go new file mode 100644 index 0000000..5549299 --- /dev/null +++ b/webui/server.go @@ -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}) +}