-
-
-
setTavilyKeyInput(e.target.value)}
- />
+ {webSearch && backend === "tavily" && (
+
+
+
+ setTavilyKeyInput(e.target.value)}
+ />
+
+
+ {webSearch && backend === "tavily" && !tavilyKeySet && (
+
+ 已选择 Tavily 但尚未配置 Key —— 在保存 Key 之前,搜索工具不会启用。
+
+ )}
+
前往 https://tavily.com 注册并获取 API Key。
+
+ )}
+
+ {webSearch && (
+
+
+
+ setProxyInput(e.target.value)}
+ />
+
+
+
+ 独立出口代理,仅用于访问搜索端点(VPN/SOCKS 等)。与记录流量的 MITM 代理无关;网络不通时经此代理访问。
+
+
+ )}
+
+ {webSearch && (
+
+
+ 用当前配置(来源 + 代理 + Key)实际搜索一次「test」,验证是否可用。
+
- {webSearch && backend === "tavily" && !tavilyKeySet && (
-
- 已选择 Tavily 但尚未配置 Key —— 在保存 Key 之前,搜索工具不会启用。
-
- )}
-
- 前往 https://tavily.com 注册并获取 API Key。
-
-
- )}
+ )}
+
+
- {webSearch && (
-
-
-
- setProxyInput(e.target.value)}
- />
-
-
-
- 独立出口代理,仅用于访问搜索端点(VPN/SOCKS 等)。与记录流量的 MITM 代理无关;网络不通时经此代理访问。
-
+
+
+
+
+ 自定义脚本 · Python 解释器
+
+
+ 自定义 script 类型工具用它跑 Python。开机会自动检测(python3 优先);此处可手填 venv /
+ 特定版本的绝对路径,留空则运行时自动检测。
+
+
+
+
+ setPyInterp(e.target.value)}
+ />
+
+
- )}
+
+
- {webSearch && (
-
);
}
From ca1d22b227a528a8e00e042d163bebab4c8dca60 Mon Sep 17 00:00:00 2001
From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com>
Date: Sat, 22 Aug 2026 12:33:26 -0400
Subject: [PATCH 2/8] =?UTF-8?q?docs(insert=5Fassets):=20=E6=98=8E=E7=A1=AE?=
=?UTF-8?q?=20related=20=E5=88=A4=E5=AE=9A=EF=BC=8CCDN/=E9=9D=99=E6=80=81?=
=?UTF-8?q?=E8=B5=84=E6=BA=90=E7=B1=BB=E5=BA=94=E8=AE=BE=20false?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
补充工具描述:与任务无关的资产(如仅存静态资源的 CDN)必须设
related=false 或不插入,避免污染资产覆盖度分母。
Claude-Session: https://claude.ai/code/session_01RtPQYJ8W1mcaace3sHWGDm
---
agent/tools_insert.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/agent/tools_insert.go b/agent/tools_insert.go
index 9d0eca4..2cf808a 100644
--- a/agent/tools_insert.go
+++ b/agent/tools_insert.go
@@ -160,7 +160,7 @@ func (t *ToolSet) insertAssets() actool.CoreTool {
// 覆盖度相关:该资产是否与当前测试任务有关。
"related": map[string]any{
"type": "boolean",
- "description": "该资产是否与【当前测试任务】相关:true(默认)才自动纳入资产覆盖度(测试范围分母);false 则只入库、不计入本任务覆盖度(如顺带发现的旁站/无关资产)。仅在任务开启资产覆盖度功能时生效。",
+ "description": "该资产是否与【当前测试任务】相关:true(默认)才自动纳入资产覆盖度(测试范围分母),该资产会进入待测资产中,如果是和任务无关的,例如CDN仅存储静态资源类,必须设置为false或不进行资产插入;false 则只入库、不计入本任务覆盖度(如顺带发现的旁站/无关资产)。仅在任务开启资产覆盖度功能时生效。",
},
}, "type"),
},
From 43b6247b2ef04f89677b90e51aff981969f354fc Mon Sep 17 00:00:00 2001
From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com>
Date: Sun, 23 Aug 2026 02:45:29 -0400
Subject: [PATCH 3/8] =?UTF-8?q?feat(proxypool):=20=E9=98=B6=E6=AE=B51=20?=
=?UTF-8?q?=E4=BB=A3=E7=90=86=E6=B1=A0=E6=95=B0=E6=8D=AE=E5=B1=82=20+=20?=
=?UTF-8?q?=E5=85=8D=E8=B4=B9=E6=BA=90=20+=20=E9=AA=8C=E6=B4=BB=20+=20?=
=?UTF-8?q?=E7=AE=A1=E7=90=86=E9=A1=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
出口代理池的地基,支持后续 list_proxies 工具(入口B)与 MITM 上游/网关(入口A/C)。
数据层:
- proxies 表(连接拆分存 protocol/host/port/user/pass,去重键 (protocol,host,port);
source/trusted 供安全阀门;anonymity/region 供筛选;质量字段 fail_streak/ok_count
等驱动自动禁用死代理 + 优先稳定节点)+ proxy_sources 表(免费源开关,默认全关)
- db/proxies.go:ProxyStore CRUD / 导入去重 / UpsertFromSource / SelectForHost
(每目标 host 粘性 + 质量排序)/ UpdateHealth(连续失败达阈值自动禁用)/ 源开关
- 密码明文存(与 llm_profiles.api_key 一致),前端序列化打码
proxypool 包:
- dial/connect:http(s) CONNECT 隧道 + socks5/4 拨号(复用 x/net/proxy)
- probe:通过代理探活 + 测延迟
- sources:内置格式稳定的 GitHub raw 源清单(默认关)+ 抓取解析
- pool:抓取 + 验活两个后台 loop(间隔可配,读设置每 tick 生效)+ 手动探活
server:
- Manager 挂 proxyPool,启动/关闭;settings 键 proxy_pool_enabled /
proxy_egress_trusted_only / 抓取·验活间隔;CRUD/导入/探活/源开关 API + 路由
前端:
- 代理池管理页(system/proxies):源开关、节点列表、CRUD、批量导入、健康徽标、
可信/免费源标记、手动探活;侧边栏入口;types + api
验证:go build/vet/test(PG 相关测试本机无 DSN 跳过);纯逻辑测试通过;
tsc + build:static + biome 通过。
Claude-Session: https://claude.ai/code/session_01Q8nhYZfS4H8fsSCBJXxbSy
---
db/proxies.go | 492 ++++++++++++++++++++
db/proxies_test.go | 203 ++++++++
db/schema.sql | 47 ++
proxypool/connect.go | 69 +++
proxypool/dial.go | 48 ++
proxypool/pool.go | 185 ++++++++
proxypool/probe.go | 90 ++++
proxypool/sources.go | 104 +++++
proxypool/sources_test.go | 43 ++
server/manager.go | 85 ++++
server/proxies.go | 202 ++++++++
server/server.go | 9 +
web/src/app/(main)/system/proxies/page.tsx | 466 ++++++++++++++++++
web/src/lib/api.ts | 23 +
web/src/lib/types.ts | 61 +++
web/src/navigation/sidebar/sidebar-items.ts | 2 +
16 files changed, 2129 insertions(+)
create mode 100644 db/proxies.go
create mode 100644 db/proxies_test.go
create mode 100644 proxypool/connect.go
create mode 100644 proxypool/dial.go
create mode 100644 proxypool/pool.go
create mode 100644 proxypool/probe.go
create mode 100644 proxypool/sources.go
create mode 100644 proxypool/sources_test.go
create mode 100644 server/proxies.go
create mode 100644 web/src/app/(main)/system/proxies/page.tsx
diff --git a/db/proxies.go b/db/proxies.go
new file mode 100644
index 0000000..e4b73ed
--- /dev/null
+++ b/db/proxies.go
@@ -0,0 +1,492 @@
+package db
+
+import (
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "hash/fnv"
+ "net"
+ "net/url"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+// =====================================================================
+// 代理池(出口代理轮换)
+// =====================================================================
+
+// Proxy is one row of the proxies table. Connection info is stored split (not a
+// full URL) so entries dedup by (protocol,host,port), the UI can mask the
+// password, and logs never carry credentials. Use URL() to build the dial URL.
+type Proxy struct {
+ ID int64 `json:"id"`
+ Protocol string `json:"protocol"`
+ Host string `json:"host"`
+ Port int `json:"port"`
+ Username string `json:"username,omitempty"`
+ Password string `json:"password,omitempty"`
+ Anonymity string `json:"anonymity,omitempty"`
+ Region string `json:"region,omitempty"`
+ Tags []string `json:"tags"`
+ Label string `json:"label,omitempty"`
+ Source string `json:"source"`
+ Trusted bool `json:"trusted"`
+ Enabled bool `json:"enabled"`
+ Healthy bool `json:"healthy"`
+ LatencyMs int `json:"latency_ms"`
+ LastCheckAt string `json:"last_check_at,omitempty"`
+ LastOkAt string `json:"last_ok_at,omitempty"`
+ LastError string `json:"last_error,omitempty"`
+ FailStreak int `json:"fail_streak"`
+ CheckCount int `json:"check_count"`
+ OkCount int `json:"ok_count"`
+ CreatedAt string `json:"created_at,omitempty"`
+ UpdatedAt string `json:"updated_at,omitempty"`
+}
+
+// URL builds the dial URL from the split fields. Callers use this at connect time
+// only — it is never persisted or logged.
+func (p *Proxy) URL() *url.URL {
+ u := &url.URL{Scheme: p.Protocol, Host: net.JoinHostPort(p.Host, strconv.Itoa(p.Port))}
+ if p.Username != "" || p.Password != "" {
+ u.User = url.UserPassword(p.Username, p.Password)
+ }
+ return u
+}
+
+// Masked returns a copy safe to serialize to the frontend: the password is
+// replaced with a fixed placeholder when set, so it is never sent over the wire.
+func (p Proxy) Masked() Proxy {
+ if p.Password != "" {
+ p.Password = "********"
+ }
+ return p
+}
+
+// ProxyFailAutoDisable is the consecutive-failure threshold past which a proxy is
+// automatically disabled, so dead free-pool nodes drop out without manual cleanup.
+const ProxyFailAutoDisable = 5
+
+var ErrProxyNotFound = errors.New("proxy not found")
+
+// ProxyStore operates on the proxies + proxy_sources tables.
+type ProxyStore struct{ db *DB }
+
+// Proxies returns a store bound to this DB.
+func (d *DB) Proxies() *ProxyStore { return &ProxyStore{db: d} }
+
+// ProxyFilter narrows a ListProxies / SelectForHost query. Zero value = no filter.
+type ProxyFilter struct {
+ Protocol string
+ Region string
+ Anonymity string
+ Tags []string
+ OnlyHealthy bool
+ OnlyEnabled bool
+ TrustedOnly bool
+}
+
+// listProxyCols is the shared SELECT column list. tags is read as a JSON array
+// text (mirrors db/assets.go's array_to_json convention) rather than a driver
+// array type, so no lib/pq dependency is needed.
+const listProxyCols = `id, protocol, host, port, username, password,
+ anonymity, region, array_to_json(tags)::text, label, source, trusted,
+ enabled, healthy, latency_ms,
+ COALESCE(last_check_at::text,''), COALESCE(last_ok_at::text,''), last_error,
+ fail_streak, check_count, ok_count,
+ created_at::text, updated_at::text`
+
+func scanProxy(rows *sql.Rows) (*Proxy, error) {
+ var p Proxy
+ var tagsJSON string
+ if err := rows.Scan(&p.ID, &p.Protocol, &p.Host, &p.Port, &p.Username, &p.Password,
+ &p.Anonymity, &p.Region, &tagsJSON, &p.Label, &p.Source, &p.Trusted,
+ &p.Enabled, &p.Healthy, &p.LatencyMs,
+ &p.LastCheckAt, &p.LastOkAt, &p.LastError,
+ &p.FailStreak, &p.CheckCount, &p.OkCount,
+ &p.CreatedAt, &p.UpdatedAt); err != nil {
+ return nil, err
+ }
+ p.Tags = parseJSONStringArray(tagsJSON)
+ return &p, nil
+}
+
+// ListProxies returns proxies matching the filter, newest first.
+func (s *ProxyStore) ListProxies(f ProxyFilter) ([]*Proxy, error) {
+ q := `SELECT ` + listProxyCols + ` FROM proxies`
+ where, args := proxyWhere(f)
+ if where != "" {
+ q += " WHERE " + where
+ }
+ q += " ORDER BY id DESC"
+ rows, err := s.db.Query(q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []*Proxy{}
+ for rows.Next() {
+ p, err := scanProxy(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, p)
+ }
+ return out, rows.Err()
+}
+
+// proxyWhere builds the shared WHERE clause + args ($1-based) from a filter.
+func proxyWhere(f ProxyFilter) (string, []any) {
+ var conds []string
+ var args []any
+ add := func(cond string, val any) {
+ args = append(args, val)
+ conds = append(conds, fmt.Sprintf(cond, len(args)))
+ }
+ if f.Protocol != "" {
+ add("protocol = $%d", f.Protocol)
+ }
+ if f.Region != "" {
+ add("region = $%d", f.Region)
+ }
+ if f.Anonymity != "" {
+ add("anonymity = $%d", f.Anonymity)
+ }
+ if len(f.Tags) > 0 {
+ add("tags && $%d", marshalStringArray(f.Tags))
+ }
+ if f.OnlyHealthy {
+ conds = append(conds, "healthy")
+ }
+ if f.OnlyEnabled {
+ conds = append(conds, "enabled")
+ }
+ if f.TrustedOnly {
+ conds = append(conds, "trusted")
+ }
+ return strings.Join(conds, " AND "), args
+}
+
+// GetProxy loads one proxy by id.
+func (s *ProxyStore) GetProxy(id int64) (*Proxy, error) {
+ rows, err := s.db.Query(`SELECT `+listProxyCols+` FROM proxies WHERE id = $1`, id)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ if !rows.Next() {
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return nil, ErrProxyNotFound
+ }
+ return scanProxy(rows)
+}
+
+// CreateProxy inserts a manually-entered proxy and returns its id. Duplicate
+// (protocol,host,port) returns the existing row's id (idempotent).
+func (s *ProxyStore) CreateProxy(p *Proxy) (int64, error) {
+ if p.Protocol == "" {
+ p.Protocol = "http"
+ }
+ if p.Host == "" || p.Port <= 0 {
+ return 0, fmt.Errorf("代理需要 host 和 port")
+ }
+ if p.Source == "" {
+ p.Source = "manual"
+ }
+ var id int64
+ err := s.db.QueryRow(`
+INSERT INTO proxies(protocol, host, port, username, password, anonymity, region, tags, label, source, trusted, enabled)
+VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
+ON CONFLICT (protocol, host, port) DO UPDATE SET updated_at = now()
+RETURNING id`,
+ p.Protocol, p.Host, p.Port, p.Username, p.Password, p.Anonymity, p.Region,
+ marshalStringArray(p.Tags), p.Label, p.Source, p.Trusted, p.Enabled).Scan(&id)
+ return id, err
+}
+
+// UpdateProxy updates the user-editable fields of one proxy (not health/quality).
+func (s *ProxyStore) UpdateProxy(p *Proxy) error {
+ res, err := s.db.Exec(`
+UPDATE proxies SET protocol=$2, host=$3, port=$4, username=$5, password=$6,
+ anonymity=$7, region=$8, tags=$9, label=$10, trusted=$11, enabled=$12
+WHERE id=$1`,
+ p.ID, p.Protocol, p.Host, p.Port, p.Username, p.Password,
+ p.Anonymity, p.Region, marshalStringArray(p.Tags), p.Label, p.Trusted, p.Enabled)
+ if err != nil {
+ return err
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ return ErrProxyNotFound
+ }
+ return nil
+}
+
+// DeleteProxy removes one proxy by id.
+func (s *ProxyStore) DeleteProxy(id int64) error {
+ res, err := s.db.Exec(`DELETE FROM proxies WHERE id = $1`, id)
+ if err != nil {
+ return err
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ return ErrProxyNotFound
+ }
+ return nil
+}
+
+// SetProxyEnabled toggles a proxy's user enable switch.
+func (s *ProxyStore) SetProxyEnabled(id int64, enabled bool) error {
+ res, err := s.db.Exec(`UPDATE proxies SET enabled=$2 WHERE id=$1`, id, enabled)
+ if err != nil {
+ return err
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ return ErrProxyNotFound
+ }
+ return nil
+}
+
+// ImportProxies parses a batch of proxy lines (scheme://[user:pass@]host:port or
+// bare host:port, one per line) and inserts them as trusted, source='import'.
+// Returns how many new rows were added (duplicates by (protocol,host,port) are
+// skipped). Unparseable lines are collected and returned for user feedback.
+func (s *ProxyStore) ImportProxies(lines []string) (added int, invalid []string, err error) {
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ p, perr := ParseProxyLine(line)
+ if perr != nil {
+ invalid = append(invalid, line)
+ continue
+ }
+ var id int64
+ e := s.db.QueryRow(`
+INSERT INTO proxies(protocol, host, port, username, password, source, trusted, enabled)
+VALUES ($1,$2,$3,$4,$5,'import',true,true)
+ON CONFLICT (protocol, host, port) DO NOTHING
+RETURNING id`, p.Protocol, p.Host, p.Port, p.Username, p.Password).Scan(&id)
+ switch {
+ case e == sql.ErrNoRows: // duplicate, skipped
+ case e != nil:
+ return added, invalid, e
+ default:
+ added++
+ }
+ }
+ return added, invalid, nil
+}
+
+// UpsertFromSource inserts proxies fetched from a free source as untrusted,
+// source=
. Existing (protocol,host,port) rows are left untouched
+// (a manually-added trusted entry is never downgraded). Returns rows added.
+func (s *ProxyStore) UpsertFromSource(sourceName string, proxies []Proxy) (int, error) {
+ added := 0
+ for i := range proxies {
+ p := proxies[i]
+ if p.Protocol == "" || p.Host == "" || p.Port <= 0 {
+ continue
+ }
+ var id int64
+ e := s.db.QueryRow(`
+INSERT INTO proxies(protocol, host, port, anonymity, region, source, trusted, enabled)
+VALUES ($1,$2,$3,$4,$5,$6,false,true)
+ON CONFLICT (protocol, host, port) DO NOTHING
+RETURNING id`, p.Protocol, p.Host, p.Port, p.Anonymity, p.Region, sourceName).Scan(&id)
+ switch {
+ case e == sql.ErrNoRows:
+ case e != nil:
+ return added, e
+ default:
+ added++
+ }
+ }
+ return added, nil
+}
+
+// SelectForHost picks a proxy for a target host with per-host stickiness: the same
+// host always maps to the same egress (until the healthy set changes), so a scan's
+// requests to one target share an exit IP. Candidates are enabled+healthy (and
+// trusted when trustedOnly), ranked by quality (low fail_streak, high success
+// rate, recent success); a stable index derived from the host name picks within
+// the ranked set. Returns nil when no candidate exists (caller falls back direct).
+func (s *ProxyStore) SelectForHost(host string, trustedOnly bool) (*Proxy, error) {
+ pool, err := s.ListProxies(ProxyFilter{OnlyEnabled: true, OnlyHealthy: true, TrustedOnly: trustedOnly})
+ if err != nil {
+ return nil, err
+ }
+ if len(pool) == 0 {
+ return nil, nil
+ }
+ // Quality order: fewer consecutive fails, higher success rate, more recent success.
+ sort.SliceStable(pool, func(i, j int) bool {
+ a, b := pool[i], pool[j]
+ if a.FailStreak != b.FailStreak {
+ return a.FailStreak < b.FailStreak
+ }
+ ra, rb := successRate(a), successRate(b)
+ if ra != rb {
+ return ra > rb
+ }
+ return a.LastOkAt > b.LastOkAt
+ })
+ // Stable per-host index into the ranked set → same host, same exit.
+ h := fnv.New32a()
+ _, _ = h.Write([]byte(strings.ToLower(host)))
+ return pool[int(h.Sum32())%len(pool)], nil
+}
+
+func successRate(p *Proxy) float64 {
+ if p.CheckCount == 0 {
+ return 0
+ }
+ return float64(p.OkCount) / float64(p.CheckCount)
+}
+
+// UpdateHealth records the outcome of one probe. On success it clears the fail
+// streak and stamps last_ok_at; on failure it increments the streak and, past
+// ProxyFailAutoDisable, auto-disables the proxy so dead nodes drop out.
+func (s *ProxyStore) UpdateHealth(id int64, ok bool, latencyMs int, probeErr string) error {
+ if ok {
+ _, err := s.db.Exec(`
+UPDATE proxies SET healthy=true, latency_ms=$2, last_error='',
+ last_check_at=now(), last_ok_at=now(), fail_streak=0,
+ check_count=check_count+1, ok_count=ok_count+1
+WHERE id=$1`, id, latencyMs)
+ return err
+ }
+ _, err := s.db.Exec(`
+UPDATE proxies SET healthy=false, last_error=$2, last_check_at=now(),
+ fail_streak=fail_streak+1, check_count=check_count+1,
+ enabled = CASE WHEN fail_streak+1 >= $3 THEN false ELSE enabled END
+WHERE id=$1`, id, probeErr, ProxyFailAutoDisable)
+ return err
+}
+
+// ProxySource is one free-pool source's enable switch + last fetch status.
+type ProxySource struct {
+ Name string `json:"name"`
+ Enabled bool `json:"enabled"`
+ LastFetchAt string `json:"last_fetch_at,omitempty"`
+ LastCount int `json:"last_count"`
+ LastError string `json:"last_error,omitempty"`
+}
+
+// ListSources returns the persisted state of every source name passed in, filling
+// defaults (disabled, never fetched) for names with no row yet. This keeps the
+// code-defined source catalog as the source of truth for which sources exist.
+func (s *ProxyStore) ListSources(names []string) ([]ProxySource, error) {
+ rows, err := s.db.Query(`SELECT name, enabled, COALESCE(last_fetch_at::text,''), last_count, last_error FROM proxy_sources`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ byName := map[string]ProxySource{}
+ for rows.Next() {
+ var src ProxySource
+ if err := rows.Scan(&src.Name, &src.Enabled, &src.LastFetchAt, &src.LastCount, &src.LastError); err != nil {
+ return nil, err
+ }
+ byName[src.Name] = src
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ out := make([]ProxySource, 0, len(names))
+ for _, name := range names {
+ if src, ok := byName[name]; ok {
+ out = append(out, src)
+ } else {
+ out = append(out, ProxySource{Name: name})
+ }
+ }
+ return out, nil
+}
+
+// SetSourceEnabled toggles one free-pool source (upserts the row).
+func (s *ProxyStore) SetSourceEnabled(name string, enabled bool) error {
+ _, err := s.db.Exec(`
+INSERT INTO proxy_sources(name, enabled) VALUES ($1,$2)
+ON CONFLICT (name) DO UPDATE SET enabled = EXCLUDED.enabled`, name, enabled)
+ return err
+}
+
+// EnabledSources returns the names of sources currently switched on.
+func (s *ProxyStore) EnabledSources() ([]string, error) {
+ rows, err := s.db.Query(`SELECT name FROM proxy_sources WHERE enabled`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []string
+ for rows.Next() {
+ var name string
+ if err := rows.Scan(&name); err != nil {
+ return nil, err
+ }
+ out = append(out, name)
+ }
+ return out, rows.Err()
+}
+
+// RecordFetch stamps a source's last fetch outcome.
+func (s *ProxyStore) RecordFetch(name string, count int, fetchErr string) error {
+ _, err := s.db.Exec(`
+INSERT INTO proxy_sources(name, enabled, last_fetch_at, last_count, last_error)
+VALUES ($1, true, now(), $2, $3)
+ON CONFLICT (name) DO UPDATE SET last_fetch_at = now(), last_count = $2, last_error = $3`,
+ name, count, fetchErr)
+ return err
+}
+
+// ParseProxyLine parses "scheme://[user:pass@]host:port" or a bare "host:port"
+// (defaulting to http) into a Proxy's connection fields.
+func ParseProxyLine(line string) (Proxy, error) {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ return Proxy{}, fmt.Errorf("空行")
+ }
+ if !strings.Contains(line, "://") {
+ line = "http://" + line
+ }
+ u, err := url.Parse(line)
+ if err != nil {
+ return Proxy{}, err
+ }
+ scheme := strings.ToLower(u.Scheme)
+ switch scheme {
+ case "http", "https", "socks5", "socks4":
+ default:
+ return Proxy{}, fmt.Errorf("不支持的协议: %s", u.Scheme)
+ }
+ host := u.Hostname()
+ portStr := u.Port()
+ if host == "" || portStr == "" {
+ return Proxy{}, fmt.Errorf("需要 host:port")
+ }
+ port, err := strconv.Atoi(portStr)
+ if err != nil || port <= 0 || port > 65535 {
+ return Proxy{}, fmt.Errorf("无效端口: %s", portStr)
+ }
+ p := Proxy{Protocol: scheme, Host: host, Port: port}
+ if u.User != nil {
+ p.Username = u.User.Username()
+ p.Password, _ = u.User.Password()
+ }
+ return p, nil
+}
+
+// parseJSONStringArray unmarshals an array_to_json text into a []string, matching
+// how db/assets.go reads TEXT[] columns. Always returns non-nil for a valid array.
+func parseJSONStringArray(jsonText string) []string {
+ out := []string{}
+ if jsonText == "" || jsonText == "null" {
+ return out
+ }
+ _ = json.Unmarshal([]byte(jsonText), &out)
+ return out
+}
diff --git a/db/proxies_test.go b/db/proxies_test.go
new file mode 100644
index 0000000..46aacbb
--- /dev/null
+++ b/db/proxies_test.go
@@ -0,0 +1,203 @@
+package db
+
+import (
+ "fmt"
+ "testing"
+ "time"
+)
+
+// TestParseProxyLine covers URL/host:port parsing + protocol validation, no DB.
+func TestParseProxyLine(t *testing.T) {
+ cases := []struct {
+ in string
+ proto string
+ host string
+ port int
+ user string
+ pass string
+ wantErr bool
+ }{
+ {in: "1.2.3.4:8080", proto: "http", host: "1.2.3.4", port: 8080},
+ {in: "socks5://9.9.9.9:1080", proto: "socks5", host: "9.9.9.9", port: 1080},
+ {in: "http://user:pass@10.0.0.1:3128", proto: "http", host: "10.0.0.1", port: 3128, user: "user", pass: "pass"},
+ {in: "socks4://5.6.7.8:1080", proto: "socks4", host: "5.6.7.8", port: 1080},
+ {in: " 8.8.8.8:80 ", proto: "http", host: "8.8.8.8", port: 80},
+ {in: "ftp://1.2.3.4:21", wantErr: true}, // unsupported scheme
+ {in: "1.2.3.4", wantErr: true}, // no port
+ {in: "1.2.3.4:notaport", wantErr: true}, // bad port
+ {in: "1.2.3.4:99999", wantErr: true}, // out of range
+ {in: "", wantErr: true},
+ }
+ for _, c := range cases {
+ p, err := ParseProxyLine(c.in)
+ if c.wantErr {
+ if err == nil {
+ t.Errorf("ParseProxyLine(%q) want error, got %+v", c.in, p)
+ }
+ continue
+ }
+ if err != nil {
+ t.Errorf("ParseProxyLine(%q) unexpected error: %v", c.in, err)
+ continue
+ }
+ if p.Protocol != c.proto || p.Host != c.host || p.Port != c.port || p.Username != c.user || p.Password != c.pass {
+ t.Errorf("ParseProxyLine(%q) = %+v, want proto=%s host=%s port=%d user=%s pass=%s",
+ c.in, p, c.proto, c.host, c.port, c.user, c.pass)
+ }
+ }
+}
+
+// TestProxyURLRoundTrip verifies URL() rebuilds a dial URL including auth.
+func TestProxyURLRoundTrip(t *testing.T) {
+ p := Proxy{Protocol: "socks5", Host: "1.2.3.4", Port: 1080, Username: "u", Password: "p"}
+ if got := p.URL().String(); got != "socks5://u:p@1.2.3.4:1080" {
+ t.Fatalf("URL = %q", got)
+ }
+ p2 := Proxy{Protocol: "http", Host: "5.6.7.8", Port: 8080}
+ if got := p2.URL().String(); got != "http://5.6.7.8:8080" {
+ t.Fatalf("URL = %q", got)
+ }
+}
+
+// TestProxyMasked ensures the password is never serialized in the clear.
+func TestProxyMasked(t *testing.T) {
+ p := Proxy{Password: "secret"}
+ if p.Masked().Password != "********" {
+ t.Fatalf("password not masked: %q", p.Masked().Password)
+ }
+ if (Proxy{}).Masked().Password != "" {
+ t.Fatalf("empty password should stay empty")
+ }
+}
+
+// TestProxyStoreLifecycle exercises import de-dup, health update + auto-disable,
+// per-host sticky selection, and trusted-only filtering against dev PG.
+func TestProxyStoreLifecycle(t *testing.T) {
+ d, err := Open(testDSN(t))
+ if err != nil {
+ t.Skipf("postgres unavailable (%v) — skipping", err)
+ }
+ defer d.Close()
+ ps := d.Proxies()
+
+ // Unique host octet per run so parallel/repeat runs don't collide.
+ seed := time.Now().UnixNano() % 250
+ host := func(n int64) string { return fmt.Sprintf("203.0.113.%d", (seed+n)%254+1) }
+ var ids []int64
+ t.Cleanup(func() {
+ for _, id := range ids {
+ _ = ps.DeleteProxy(id)
+ }
+ })
+
+ // Import three trusted proxies; the duplicate line must be skipped.
+ lines := []string{
+ "http://" + host(0) + ":8080",
+ "http://" + host(0) + ":8080", // dup
+ "socks5://" + host(1) + ":1080",
+ }
+ added, invalid, err := ps.ImportProxies(lines)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if added != 2 || len(invalid) != 0 {
+ t.Fatalf("import added=%d invalid=%v, want added=2", added, invalid)
+ }
+
+ all, err := ps.ListProxies(ProxyFilter{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, p := range all {
+ if p.Host == host(0) || p.Host == host(1) {
+ ids = append(ids, p.ID)
+ }
+ }
+ if len(ids) != 2 {
+ t.Fatalf("want 2 imported rows, got %d", len(ids))
+ }
+
+ // Before any probe, nothing is healthy → no selection.
+ pick, err := ps.SelectForHost("target.example.com", true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if pick != nil {
+ t.Fatalf("no healthy proxy yet, got %+v", pick)
+ }
+
+ // Mark both healthy, then selection must return one and be sticky per host.
+ for _, id := range ids {
+ if err := ps.UpdateHealth(id, true, 100, ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+ p1, err := ps.SelectForHost("stickyhost", true)
+ if err != nil || p1 == nil {
+ t.Fatalf("select: %v %+v", err, p1)
+ }
+ p2, _ := ps.SelectForHost("stickyhost", true)
+ if p2 == nil || p1.ID != p2.ID {
+ t.Fatalf("per-host stickiness broken: %v vs %v", p1, p2)
+ }
+
+ // trustedOnly=false must still return (all imports are trusted anyway).
+ if p, _ := ps.SelectForHost("x", false); p == nil {
+ t.Fatal("select trustedOnly=false returned nil")
+ }
+
+ // Fail one proxy ProxyFailAutoDisable times → auto-disabled → drops out.
+ victim := ids[0]
+ for i := 0; i < ProxyFailAutoDisable; i++ {
+ if err := ps.UpdateHealth(victim, false, 0, "timeout"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got, err := ps.GetProxy(victim)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Enabled {
+ t.Fatalf("proxy should auto-disable after %d fails", ProxyFailAutoDisable)
+ }
+}
+
+// TestProxySourceToggle covers source enable persistence + fetch stamping.
+func TestProxySourceToggle(t *testing.T) {
+ d, err := Open(testDSN(t))
+ if err != nil {
+ t.Skipf("postgres unavailable (%v) — skipping", err)
+ }
+ defer d.Close()
+ ps := d.Proxies()
+
+ name := fmt.Sprintf("unittest-source-%d", time.Now().UnixNano())
+ t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM proxy_sources WHERE name=$1`, name) })
+
+ if err := ps.SetSourceEnabled(name, true); err != nil {
+ t.Fatal(err)
+ }
+ enabled, err := ps.EnabledSources()
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, n := range enabled {
+ if n == name {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("source %s not in enabled set %v", name, enabled)
+ }
+ if err := ps.RecordFetch(name, 42, ""); err != nil {
+ t.Fatal(err)
+ }
+ sources, err := ps.ListSources([]string{name})
+ if err != nil || len(sources) != 1 {
+ t.Fatalf("list sources: %v %+v", err, sources)
+ }
+ if sources[0].LastCount != 42 || !sources[0].Enabled {
+ t.Fatalf("fetch not recorded: %+v", sources[0])
+ }
+}
diff --git a/db/schema.sql b/db/schema.sql
index 968bd74..daf92d2 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -782,3 +782,50 @@ CREATE TABLE IF NOT EXISTS server_logs (
text TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_server_logs_id ON server_logs(id DESC);
+
+-- =====================================================================
+-- N. 代理池(出口代理轮换)
+-- =====================================================================
+-- 连接信息拆分存(非完整 URL):去重键 (protocol,host,port)、UI 隐藏密码、日志不落认证。
+-- username/password 明文存(与 llm_profiles.api_key 一致)。source/trusted 供安全阀门,
+-- 免费源抓来的 trusted=false,默认不进主出口轮换。质量字段驱动自动禁用死代理 + 优先稳定节点。
+CREATE TABLE IF NOT EXISTS proxies (
+ id BIGSERIAL PRIMARY KEY,
+ protocol TEXT NOT NULL DEFAULT 'http', -- http/https/socks5/socks4
+ host TEXT NOT NULL,
+ port INTEGER NOT NULL,
+ username TEXT NOT NULL DEFAULT '',
+ password TEXT NOT NULL DEFAULT '',
+ anonymity TEXT NOT NULL DEFAULT '', -- elite/anonymous/transparent/''
+ region TEXT NOT NULL DEFAULT '', -- 国家码 CN/US…
+ tags TEXT[] NOT NULL DEFAULT '{}',
+ label TEXT NOT NULL DEFAULT '',
+ source TEXT NOT NULL DEFAULT 'manual', -- manual/import/<源名>
+ trusted BOOLEAN NOT NULL DEFAULT true,
+ enabled BOOLEAN NOT NULL DEFAULT true,
+ healthy BOOLEAN NOT NULL DEFAULT false,
+ latency_ms INTEGER NOT NULL DEFAULT 0,
+ last_check_at TIMESTAMPTZ,
+ last_ok_at TIMESTAMPTZ,
+ last_error TEXT NOT NULL DEFAULT '',
+ fail_streak INTEGER NOT NULL DEFAULT 0,
+ check_count INTEGER NOT NULL DEFAULT 0,
+ ok_count INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE(protocol, host, port)
+);
+CREATE INDEX IF NOT EXISTS idx_proxies_tags ON proxies USING GIN(tags);
+CREATE INDEX IF NOT EXISTS idx_proxies_pick ON proxies(enabled, healthy, trusted);
+DROP TRIGGER IF EXISTS trg_proxies_upd ON proxies;
+CREATE TRIGGER trg_proxies_upd BEFORE UPDATE ON proxies
+ FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+
+-- 免费代理源开关与抓取状态。默认无行 = 全部未开启(安全底线)。
+CREATE TABLE IF NOT EXISTS proxy_sources (
+ name TEXT PRIMARY KEY,
+ enabled BOOLEAN NOT NULL DEFAULT false,
+ last_fetch_at TIMESTAMPTZ,
+ last_count INTEGER NOT NULL DEFAULT 0,
+ last_error TEXT NOT NULL DEFAULT ''
+);
diff --git a/proxypool/connect.go b/proxypool/connect.go
new file mode 100644
index 0000000..f97f97d
--- /dev/null
+++ b/proxypool/connect.go
@@ -0,0 +1,69 @@
+package proxypool
+
+import (
+ "bufio"
+ "context"
+ "crypto/tls"
+ "encoding/base64"
+ "fmt"
+ "net"
+ "net/http"
+ "net/url"
+ "time"
+)
+
+// dialHTTPConnect tunnels to address through an http/https proxy via a CONNECT
+// request. For an https proxy the hop to the proxy itself is wrapped in TLS
+// first. Mirrors the well-worn net/http dialConn CONNECT flow.
+func dialHTTPConnect(ctx context.Context, proxyURL *url.URL, address string) (net.Conn, error) {
+ d := &net.Dialer{}
+ conn, err := d.DialContext(ctx, "tcp", proxyURL.Host)
+ if err != nil {
+ return nil, err
+ }
+ if proxyURL.Scheme == "https" {
+ tlsConn := tls.Client(conn, &tls.Config{ServerName: proxyURL.Hostname()})
+ if err := tlsConn.HandshakeContext(ctx); err != nil {
+ conn.Close()
+ return nil, err
+ }
+ conn = tlsConn
+ }
+ req := &http.Request{
+ Method: http.MethodConnect,
+ URL: &url.URL{Opaque: address},
+ Host: address,
+ Header: http.Header{},
+ }
+ if proxyURL.User != nil {
+ req.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(proxyURL.User.String())))
+ }
+ connectCtx, cancel := context.WithTimeout(ctx, time.Minute)
+ defer cancel()
+ done := make(chan error, 1)
+ var resp *http.Response
+ go func() {
+ if werr := req.Write(conn); werr != nil {
+ done <- werr
+ return
+ }
+ r, rerr := http.ReadResponse(bufio.NewReader(conn), req)
+ resp = r
+ done <- rerr
+ }()
+ select {
+ case <-connectCtx.Done():
+ conn.Close()
+ return nil, connectCtx.Err()
+ case err = <-done:
+ }
+ if err != nil {
+ conn.Close()
+ return nil, err
+ }
+ if resp.StatusCode != http.StatusOK {
+ conn.Close()
+ return nil, fmt.Errorf("proxy CONNECT failed: %s", resp.Status)
+ }
+ return conn, nil
+}
diff --git a/proxypool/dial.go b/proxypool/dial.go
new file mode 100644
index 0000000..6342c26
--- /dev/null
+++ b/proxypool/dial.go
@@ -0,0 +1,48 @@
+// Package proxypool provides the outbound proxy pool: dialing through a proxy of
+// any supported scheme, active health probing, and fetching free proxy sources.
+// It is deliberately independent of the traffic (MITM) layer so the pool works
+// whether or not traffic recording is on.
+package proxypool
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "net/url"
+ "strings"
+
+ xproxy "golang.org/x/net/proxy"
+)
+
+// DialThrough opens a TCP connection to address (host:port) through the given
+// proxy URL. Supports http/https (CONNECT tunnel) and socks5/socks4. The returned
+// conn is a raw byte stream to the target; TLS to the target (if any) is the
+// caller's job — the proxy only tunnels, it never terminates TLS to the target.
+func DialThrough(ctx context.Context, proxyURL *url.URL, address string) (net.Conn, error) {
+ switch strings.ToLower(proxyURL.Scheme) {
+ case "socks5", "socks4":
+ return dialSOCKS(ctx, proxyURL, address)
+ case "http", "https":
+ return dialHTTPConnect(ctx, proxyURL, address)
+ default:
+ return nil, fmt.Errorf("unsupported proxy scheme %q", proxyURL.Scheme)
+ }
+}
+
+// dialSOCKS tunnels through a SOCKS5 proxy (x/net/proxy also drives socks4 hosts
+// via the same SOCKS5 dialer for the common no-auth case).
+func dialSOCKS(ctx context.Context, proxyURL *url.URL, address string) (net.Conn, error) {
+ var auth *xproxy.Auth
+ if proxyURL.User != nil {
+ pass, _ := proxyURL.User.Password()
+ auth = &xproxy.Auth{User: proxyURL.User.Username(), Password: pass}
+ }
+ dialer, err := xproxy.SOCKS5("tcp", proxyURL.Host, auth, xproxy.Direct)
+ if err != nil {
+ return nil, err
+ }
+ if cd, ok := dialer.(xproxy.ContextDialer); ok {
+ return cd.DialContext(ctx, "tcp", address)
+ }
+ return dialer.Dial("tcp", address)
+}
diff --git a/proxypool/pool.go b/proxypool/pool.go
new file mode 100644
index 0000000..b8c16da
--- /dev/null
+++ b/proxypool/pool.go
@@ -0,0 +1,185 @@
+package proxypool
+
+import (
+ "context"
+ "log"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/Autumn-27/artex/db"
+)
+
+// Config wires the pool's background loops to live settings via callbacks, so a
+// settings change takes effect on the next tick without restarting the loops.
+type Config struct {
+ Enabled func() bool // master switch (proxy_pool_enabled)
+ FetchInterval func() time.Duration // how often to pull enabled free sources
+ CheckInterval func() time.Duration // how often to re-probe every enabled proxy
+ ProbeURL func() string // liveness target (empty = DefaultProbeURL)
+ Concurrency int // parallel probes (default 50)
+ ProbeTimeout time.Duration // per-probe timeout (default 10s)
+}
+
+// Pool runs the proxy pool's two background loops (fetch + probe) and serves
+// one-off manual probes. It owns nothing the DB doesn't; all state lives in PG.
+type Pool struct {
+ db *db.DB
+ cfg Config
+ client *http.Client // for fetching source lists (direct, not through the pool)
+ cancel context.CancelFunc
+ wg sync.WaitGroup
+}
+
+// NewPool builds a pool bound to db with the given config, applying defaults.
+func NewPool(database *db.DB, cfg Config) *Pool {
+ if cfg.Concurrency <= 0 {
+ cfg.Concurrency = 50
+ }
+ if cfg.ProbeTimeout <= 0 {
+ cfg.ProbeTimeout = 10 * time.Second
+ }
+ return &Pool{
+ db: database,
+ cfg: cfg,
+ client: &http.Client{Timeout: 30 * time.Second},
+ }
+}
+
+// Start launches the fetch and probe loops. Idempotent guards are the caller's
+// job; call once. Stop() (or a cancelled parent ctx) ends both loops.
+func (p *Pool) Start(parent context.Context) {
+ ctx, cancel := context.WithCancel(parent)
+ p.cancel = cancel
+ p.wg.Add(2)
+ go p.loop(ctx, p.cfg.FetchInterval, 15*time.Minute, p.fetchOnce)
+ go p.loop(ctx, p.cfg.CheckInterval, 30*time.Minute, p.probeOnce)
+}
+
+// Stop ends the background loops and waits for them to exit.
+func (p *Pool) Stop() {
+ if p.cancel != nil {
+ p.cancel()
+ }
+ p.wg.Wait()
+}
+
+// loop runs work on a self-rescheduling timer whose interval is re-read each round
+// (so settings changes apply next tick). It skips work while the pool is disabled.
+func (p *Pool) loop(ctx context.Context, interval func() time.Duration, fallback time.Duration, work func(context.Context)) {
+ defer p.wg.Done()
+ next := func() time.Duration {
+ d := fallback
+ if interval != nil {
+ if v := interval(); v > 0 {
+ d = v
+ }
+ }
+ if d < time.Minute {
+ d = time.Minute // floor: never hammer sources/targets
+ }
+ return d
+ }
+ timer := time.NewTimer(next())
+ defer timer.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-timer.C:
+ if p.enabled() {
+ work(ctx)
+ }
+ timer.Reset(next())
+ }
+ }
+}
+
+func (p *Pool) enabled() bool { return p.cfg.Enabled == nil || p.cfg.Enabled() }
+
+func (p *Pool) probeURL() string {
+ if p.cfg.ProbeURL == nil {
+ return DefaultProbeURL
+ }
+ return p.cfg.ProbeURL()
+}
+
+// fetchOnce pulls every enabled free source and upserts new (untrusted) proxies,
+// then immediately probes the freshly-added ones so they don't sit unknown for a
+// full check interval.
+func (p *Pool) fetchOnce(ctx context.Context) {
+ store := p.db.Proxies()
+ names, err := store.EnabledSources()
+ if err != nil {
+ log.Printf("[proxypool] list enabled sources: %v", err)
+ return
+ }
+ for _, name := range names {
+ src, ok := sourceByName(name)
+ if !ok {
+ continue
+ }
+ proxies, ferr := fetch(ctx, src, p.client)
+ if ferr != nil {
+ _ = store.RecordFetch(name, 0, trimErr(ferr.Error()))
+ log.Printf("[proxypool] fetch %s: %v", name, ferr)
+ continue
+ }
+ added, uerr := store.UpsertFromSource(name, proxies)
+ if uerr != nil {
+ _ = store.RecordFetch(name, len(proxies), trimErr(uerr.Error()))
+ log.Printf("[proxypool] upsert %s: %v", name, uerr)
+ continue
+ }
+ _ = store.RecordFetch(name, len(proxies), "")
+ log.Printf("[proxypool] source %s: fetched %d, added %d new", name, len(proxies), added)
+ }
+ // Probe whatever is now enabled+unknown so new nodes become usable fast.
+ p.probeOnce(ctx)
+}
+
+// probeOnce concurrently probes every enabled proxy and writes back health.
+func (p *Pool) probeOnce(ctx context.Context) {
+ store := p.db.Proxies()
+ proxies, err := store.ListProxies(db.ProxyFilter{OnlyEnabled: true})
+ if err != nil {
+ log.Printf("[proxypool] list for probe: %v", err)
+ return
+ }
+ if len(proxies) == 0 {
+ return
+ }
+ sem := make(chan struct{}, p.cfg.Concurrency)
+ var wg sync.WaitGroup
+ for _, pr := range proxies {
+ select {
+ case <-ctx.Done():
+ wg.Wait()
+ return
+ case sem <- struct{}{}:
+ }
+ wg.Add(1)
+ go func(pr *db.Proxy) {
+ defer wg.Done()
+ defer func() { <-sem }()
+ res := Probe(ctx, pr.URL(), p.probeURL(), p.cfg.ProbeTimeout)
+ _ = store.UpdateHealth(pr.ID, res.OK, int(res.Latency.Milliseconds()), res.Err)
+ }(pr)
+ }
+ wg.Wait()
+}
+
+// ProbeNow probes a single proxy on demand (manual "check" button) and writes back
+// the result, returning it for immediate UI feedback.
+func (p *Pool) ProbeNow(ctx context.Context, id int64) (ProbeResult, error) {
+ store := p.db.Proxies()
+ pr, err := store.GetProxy(id)
+ if err != nil {
+ return ProbeResult{}, err
+ }
+ res := Probe(ctx, pr.URL(), p.probeURL(), p.cfg.ProbeTimeout)
+ if err := store.UpdateHealth(id, res.OK, int(res.Latency.Milliseconds()), res.Err); err != nil {
+ return res, err
+ }
+ return res, nil
+}
diff --git a/proxypool/probe.go b/proxypool/probe.go
new file mode 100644
index 0000000..10ed2ea
--- /dev/null
+++ b/proxypool/probe.go
@@ -0,0 +1,90 @@
+package proxypool
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+// DefaultProbeURL is the liveness target hit through each proxy. generate_204 is
+// tiny, plain HTTP, and returns 204 with no body — cheap and unambiguous.
+const DefaultProbeURL = "http://www.gstatic.com/generate_204"
+
+// ProbeResult is one liveness check outcome.
+type ProbeResult struct {
+ OK bool
+ Latency time.Duration
+ Err string
+}
+
+// Probe checks whether a proxy can reach probeURL within timeout, returning
+// success + round-trip latency, or the failure reason. A 2xx/3xx response counts
+// as alive (some probe targets redirect).
+func Probe(ctx context.Context, proxyURL *url.URL, probeURL string, timeout time.Duration) ProbeResult {
+ if probeURL == "" {
+ probeURL = DefaultProbeURL
+ }
+ client, err := proxyHTTPClient(proxyURL, timeout)
+ if err != nil {
+ return ProbeResult{Err: err.Error()}
+ }
+ defer client.CloseIdleConnections()
+
+ ctx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, probeURL, nil)
+ if err != nil {
+ return ProbeResult{Err: err.Error()}
+ }
+ start := time.Now()
+ resp, err := client.Do(req)
+ if err != nil {
+ return ProbeResult{Err: trimErr(err.Error())}
+ }
+ defer resp.Body.Close()
+ _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
+ latency := time.Since(start)
+ if resp.StatusCode >= 400 {
+ return ProbeResult{Err: fmt.Sprintf("probe status %d", resp.StatusCode), Latency: latency}
+ }
+ return ProbeResult{OK: true, Latency: latency}
+}
+
+// proxyHTTPClient builds an *http.Client whose transport routes through proxyURL:
+// http/https proxies use Transport.Proxy (native CONNECT), socks proxies use a
+// custom DialContext. One-shot use — caller closes idle connections.
+func proxyHTTPClient(proxyURL *url.URL, timeout time.Duration) (*http.Client, error) {
+ tr := &http.Transport{
+ DisableKeepAlives: true,
+ TLSHandshakeTimeout: timeout,
+ }
+ switch strings.ToLower(proxyURL.Scheme) {
+ case "http", "https":
+ tr.Proxy = http.ProxyURL(proxyURL)
+ case "socks5", "socks4":
+ tr.DialContext = func(ctx context.Context, _, address string) (net.Conn, error) {
+ return dialSOCKS(ctx, proxyURL, address)
+ }
+ default:
+ return nil, fmt.Errorf("unsupported proxy scheme %q", proxyURL.Scheme)
+ }
+ return &http.Client{Transport: tr, Timeout: timeout}, nil
+}
+
+// trimErr shortens noisy dial errors to a storable single line.
+func trimErr(s string) string {
+ s = strings.TrimSpace(s)
+ if i := strings.IndexByte(s, '\n'); i >= 0 {
+ s = s[:i]
+ }
+ const max = 200
+ if len(s) > max {
+ s = s[:max]
+ }
+ return s
+}
diff --git a/proxypool/sources.go b/proxypool/sources.go
new file mode 100644
index 0000000..15ba101
--- /dev/null
+++ b/proxypool/sources.go
@@ -0,0 +1,104 @@
+package proxypool
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/Autumn-27/artex/db"
+)
+
+// Source is one free proxy source. Only format-stable GitHub raw "host:port" lists
+// are used (no HTML scraping) — each line is a bare host:port, protocol is fixed
+// per source. Sources are DISABLED by default; users opt in per source.
+type Source struct {
+ Name string
+ URL string
+ Protocol string // protocol assigned to every entry from this source
+}
+
+// BuiltinSources is the catalog of free proxy sources. These are widely-mirrored,
+// daily-updated raw text lists with a stable "ip:port\n" format. If a source dies
+// (raw lists do rot), the user disables it; adding/removing entries here is the
+// only maintenance touch-point.
+var BuiltinSources = []Source{
+ {Name: "TheSpeedX-http", URL: "https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/http.txt", Protocol: "http"},
+ {Name: "TheSpeedX-socks5", URL: "https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/socks5.txt", Protocol: "socks5"},
+ {Name: "TheSpeedX-socks4", URL: "https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/socks4.txt", Protocol: "socks4"},
+ {Name: "monosans-http", URL: "https://raw.githubusercontent.com/monosans/proxy-list/main/proxies/http.txt", Protocol: "http"},
+ {Name: "monosans-socks5", URL: "https://raw.githubusercontent.com/monosans/proxy-list/main/proxies/socks5.txt", Protocol: "socks5"},
+}
+
+// SourceNames returns every built-in source name (for seeding the source list UI).
+func SourceNames() []string {
+ out := make([]string, len(BuiltinSources))
+ for i, s := range BuiltinSources {
+ out[i] = s.Name
+ }
+ return out
+}
+
+// sourceByName looks up a built-in source definition.
+func sourceByName(name string) (Source, bool) {
+ for _, s := range BuiltinSources {
+ if s.Name == name {
+ return s, true
+ }
+ }
+ return Source{}, false
+}
+
+// fetch downloads and parses one source into proxy rows (connection fields only;
+// health/quality are filled later by probing).
+func fetch(ctx context.Context, src Source, client *http.Client) ([]db.Proxy, error) {
+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, src.URL, nil)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("source status %d", resp.StatusCode)
+ }
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) // 8MB cap
+ if err != nil {
+ return nil, err
+ }
+ return parseHostPortList(body, src.Protocol), nil
+}
+
+// parseHostPortList turns a "host:port\n" list into proxy rows. Lines that are not
+// a valid host:port are skipped. Protocol is fixed per source.
+func parseHostPortList(body []byte, protocol string) []db.Proxy {
+ var out []db.Proxy
+ sc := bufio.NewScanner(bytes.NewReader(body))
+ sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
+ for sc.Scan() {
+ line := strings.TrimSpace(sc.Text())
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ host, portStr, ok := strings.Cut(line, ":")
+ if !ok {
+ continue
+ }
+ host = strings.TrimSpace(host)
+ port, err := strconv.Atoi(strings.TrimSpace(portStr))
+ if host == "" || err != nil || port <= 0 || port > 65535 {
+ continue
+ }
+ out = append(out, db.Proxy{Protocol: protocol, Host: host, Port: port})
+ }
+ return out
+}
diff --git a/proxypool/sources_test.go b/proxypool/sources_test.go
new file mode 100644
index 0000000..6e46771
--- /dev/null
+++ b/proxypool/sources_test.go
@@ -0,0 +1,43 @@
+package proxypool
+
+import "testing"
+
+// TestParseHostPortList verifies the raw "host:port" list parser skips blanks,
+// comments, and malformed lines while tagging every entry with the source protocol.
+func TestParseHostPortList(t *testing.T) {
+ body := []byte(`
+1.2.3.4:8080
+# a comment
+5.6.7.8:1080
+
+9.9.9.9:notaport
+10.0.0.1:70000
+11.22.33.44:3128
+`)
+ got := parseHostPortList(body, "socks5")
+ if len(got) != 3 {
+ t.Fatalf("want 3 valid proxies, got %d: %+v", len(got), got)
+ }
+ for _, p := range got {
+ if p.Protocol != "socks5" {
+ t.Errorf("protocol = %q, want socks5", p.Protocol)
+ }
+ }
+ if got[0].Host != "1.2.3.4" || got[0].Port != 8080 {
+ t.Errorf("first entry = %+v", got[0])
+ }
+}
+
+// TestSourceNamesUnique guards against a copy-paste duplicate in the catalog.
+func TestSourceNamesUnique(t *testing.T) {
+ seen := map[string]bool{}
+ for _, n := range SourceNames() {
+ if seen[n] {
+ t.Fatalf("duplicate source name %q", n)
+ }
+ seen[n] = true
+ }
+ if len(seen) == 0 {
+ t.Fatal("no built-in sources defined")
+ }
+}
diff --git a/server/manager.go b/server/manager.go
index 4bac104..cf07020 100644
--- a/server/manager.go
+++ b/server/manager.go
@@ -1,6 +1,7 @@
package server
import (
+ "context"
"database/sql"
"encoding/json"
"errors"
@@ -19,6 +20,7 @@ import (
"github.com/Autumn-27/artex/enrich"
"github.com/Autumn-27/artex/guard"
"github.com/Autumn-27/artex/intercept"
+ "github.com/Autumn-27/artex/proxypool"
"github.com/Autumn-27/artex/traffic"
actool "github.com/Autumn-27/norma/tool"
)
@@ -224,6 +226,7 @@ type Manager struct {
braveKey string
tavilyKey string
webSearchProxy string
+ proxyPool *proxypool.Pool // 出口代理池后台任务(抓取 + 验活)
}
// Settings keys the UI toggles at runtime.
@@ -245,8 +248,16 @@ const (
// 任务并发上限:开关 + 上限数。默认关闭;开启后默认上限 5(见 defaultConcurrencyLimit)。
settingConcurrencyOn = "task_concurrency_enabled"
settingConcurrencyLimit = "task_concurrency_limit"
+ // 代理池:主开关、"主出口只走可信代理"安全阀门、抓取/验活间隔(分钟)。
+ settingProxyPoolOn = "proxy_pool_enabled"
+ settingProxyTrustedOnly = "proxy_egress_trusted_only"
+ settingProxyFetchMin = "proxy_fetch_interval_min"
+ settingProxyCheckMin = "proxy_check_interval_min"
// defaultWebSearchBackend is used when web search is on but no backend was picked.
defaultWebSearchBackend = "ddgs"
+ // 代理池默认间隔:免费代理失效快,验活默认 30min、抓取默认 15min。
+ defaultProxyFetchMin = 15
+ defaultProxyCheckMin = 30
// defaultWorkers is the concurrent work-agent count when the setting is unset.
defaultWorkers = 3
// defaultConcurrencyLimit is the simultaneous-running-task cap when the feature
@@ -376,6 +387,14 @@ func NewManager(dir, proxyAddr string) (*Manager, error) {
// Reconcile the seeded browser MCP with the persisted capture state, so a
// restart with capture already on keeps Playwright routed through the proxy.
m.syncBrowserMCPProxy()
+ // Outbound proxy pool background loops (fetch free sources + probe liveness).
+ // Loops read the master switch each tick; they no-op while the pool is off.
+ m.proxyPool = proxypool.NewPool(pg, proxypool.Config{
+ Enabled: m.ProxyPoolEnabled,
+ FetchInterval: func() time.Duration { return time.Duration(m.proxyFetchMin()) * time.Minute },
+ CheckInterval: func() time.Duration { return time.Duration(m.proxyCheckMin()) * time.Minute },
+ })
+ m.proxyPool.Start(context.Background())
return m, nil
}
@@ -638,6 +657,69 @@ func (m *Manager) Assets() *pgdb.AssetStore { return m.assets }
func (m *Manager) PG() *pgdb.DB { return m.pg }
func (m *Manager) Traffic() *traffic.Traffic { return m.traffic }
+// Proxies returns the outbound proxy pool store.
+func (m *Manager) Proxies() *pgdb.ProxyStore { return m.pg.Proxies() }
+
+// ProxyPool returns the background pool runner (fetch + probe + manual check).
+func (m *Manager) ProxyPool() *proxypool.Pool { return m.proxyPool }
+
+// ProxyPoolEnabled reports whether the outbound proxy pool is on (default off).
+func (m *Manager) ProxyPoolEnabled() bool { return m.pg.GetBool(settingProxyPoolOn, false) }
+
+// SetProxyPoolEnabled toggles the pool master switch.
+func (m *Manager) SetProxyPoolEnabled(on bool) error { return m.pg.SetBool(settingProxyPoolOn, on) }
+
+// ProxyEgressTrustedOnly reports whether the main egress (MITM upstream / gateway)
+// only rotates trusted (manual/imported) proxies. Default true — free-pool nodes
+// stay out of the main egress unless the user opts them in.
+func (m *Manager) ProxyEgressTrustedOnly() bool {
+ return m.pg.GetBool(settingProxyTrustedOnly, true)
+}
+
+// SetProxyEgressTrustedOnly toggles the trusted-only egress safety valve.
+func (m *Manager) SetProxyEgressTrustedOnly(on bool) error {
+ return m.pg.SetBool(settingProxyTrustedOnly, on)
+}
+
+// proxyFetchMin / proxyCheckMin read the pool intervals (minutes), falling back to
+// defaults when unset or non-positive.
+func (m *Manager) proxyFetchMin() int { return m.settingIntDefault(settingProxyFetchMin, defaultProxyFetchMin) }
+func (m *Manager) proxyCheckMin() int { return m.settingIntDefault(settingProxyCheckMin, defaultProxyCheckMin) }
+
+// ProxyIntervals returns the fetch/check intervals in minutes (for the settings UI).
+func (m *Manager) ProxyIntervals() (fetchMin, checkMin int) {
+ return m.proxyFetchMin(), m.proxyCheckMin()
+}
+
+// SetProxyIntervals persists the fetch/check intervals (minutes); non-positive
+// values are ignored so a partial update keeps the other.
+func (m *Manager) SetProxyIntervals(fetchMin, checkMin int) error {
+ if fetchMin > 0 {
+ if err := m.pg.SetSetting(settingProxyFetchMin, strconv.Itoa(fetchMin)); err != nil {
+ return err
+ }
+ }
+ if checkMin > 0 {
+ if err := m.pg.SetSetting(settingProxyCheckMin, strconv.Itoa(checkMin)); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// settingIntDefault reads an integer setting, returning def when unset/invalid.
+func (m *Manager) settingIntDefault(key string, def int) int {
+ v, ok, err := m.pg.GetSetting(key)
+ if err != nil || !ok || v == "" {
+ return def
+ }
+ n, err := strconv.Atoi(v)
+ if err != nil || n <= 0 {
+ return def
+ }
+ return n
+}
+
// ProxyAddr returns the recording proxy address agents route through — empty when
// traffic capture is off, so no proxy is injected (agent runs direct, no recording).
func (m *Manager) ProxyAddr() string {
@@ -659,6 +741,9 @@ func (m *Manager) ProxyCACert() string {
func (m *Manager) Close() error {
m.mu.Lock()
defer m.mu.Unlock()
+ if m.proxyPool != nil {
+ m.proxyPool.Stop()
+ }
if m.traffic != nil {
m.traffic.Close()
}
diff --git a/server/proxies.go b/server/proxies.go
new file mode 100644
index 0000000..b1aafd0
--- /dev/null
+++ b/server/proxies.go
@@ -0,0 +1,202 @@
+package server
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/Autumn-27/artex/db"
+ "github.com/Autumn-27/artex/proxypool"
+)
+
+const maxProxyBodyBytes = 4 << 20
+
+// maskProxies returns copies safe to serialize (password replaced with a
+// placeholder), so proxy credentials never leave the backend.
+func maskProxies(in []*db.Proxy) []db.Proxy {
+ out := make([]db.Proxy, len(in))
+ for i, p := range in {
+ out[i] = p.Masked()
+ }
+ return out
+}
+
+// listProxies GET /api/proxies — optional ?protocol=®ion=&tag=&healthy=1 filters.
+func (s *Server) listProxies(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ f := db.ProxyFilter{
+ Protocol: q.Get("protocol"),
+ Region: q.Get("region"),
+ Anonymity: q.Get("anonymity"),
+ OnlyHealthy: q.Get("healthy") == "1",
+ OnlyEnabled: q.Get("enabled") == "1",
+ }
+ if tag := q.Get("tag"); tag != "" {
+ f.Tags = []string{tag}
+ }
+ rows, err := s.m.Proxies().ListProxies(f)
+ if err != nil {
+ writeErr(w, 500, err.Error())
+ return
+ }
+ writeJSON(w, 200, map[string]any{"proxies": maskProxies(rows)})
+}
+
+// proxyCreateReq is the create/update body. Password "" on update keeps the
+// existing secret (so the masked value returned by GET is never written back).
+type proxyCreateReq struct {
+ Protocol string `json:"protocol"`
+ Host string `json:"host"`
+ Port int `json:"port"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+ Anonymity string `json:"anonymity"`
+ Region string `json:"region"`
+ Tags []string `json:"tags"`
+ Label string `json:"label"`
+ Enabled bool `json:"enabled"`
+}
+
+func (s *Server) decodeProxyBody(w http.ResponseWriter, r *http.Request, v any) bool {
+ r.Body = http.MaxBytesReader(w, r.Body, maxProxyBodyBytes)
+ if err := json.NewDecoder(r.Body).Decode(v); err != nil {
+ writeErr(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
+ return false
+ }
+ return true
+}
+
+// createProxy POST /api/proxies — add one manual proxy (trusted).
+func (s *Server) createProxy(w http.ResponseWriter, r *http.Request) {
+ var req proxyCreateReq
+ if !s.decodeProxyBody(w, r, &req) {
+ return
+ }
+ p := &db.Proxy{
+ Protocol: req.Protocol, Host: strings.TrimSpace(req.Host), Port: req.Port,
+ Username: req.Username, Password: req.Password, Anonymity: req.Anonymity,
+ Region: req.Region, Tags: req.Tags, Label: req.Label,
+ Source: "manual", Trusted: true, Enabled: req.Enabled,
+ }
+ id, err := s.m.Proxies().CreateProxy(p)
+ if err != nil {
+ writeErr(w, 400, err.Error())
+ return
+ }
+ writeJSON(w, 200, map[string]any{"id": id})
+}
+
+// updateProxy PUT /api/proxies/{id} — edit one proxy. A blank password preserves
+// the stored secret (the UI shows a masked value it must not persist back).
+func (s *Server) updateProxy(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ writeErr(w, 400, "invalid id")
+ return
+ }
+ var req proxyCreateReq
+ if !s.decodeProxyBody(w, r, &req) {
+ return
+ }
+ cur, err := s.m.Proxies().GetProxy(id)
+ if err != nil {
+ writeProxyErr(w, err)
+ return
+ }
+ cur.Protocol, cur.Host, cur.Port = req.Protocol, strings.TrimSpace(req.Host), req.Port
+ cur.Username, cur.Anonymity, cur.Region = req.Username, req.Anonymity, req.Region
+ cur.Tags, cur.Label, cur.Enabled = req.Tags, req.Label, req.Enabled
+ if req.Password != "" && req.Password != "********" {
+ cur.Password = req.Password
+ }
+ if err := s.m.Proxies().UpdateProxy(cur); err != nil {
+ writeProxyErr(w, err)
+ return
+ }
+ writeJSON(w, 200, map[string]any{"ok": true})
+}
+
+// deleteProxy DELETE /api/proxies/{id}.
+func (s *Server) deleteProxy(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ writeErr(w, 400, "invalid id")
+ return
+ }
+ if err := s.m.Proxies().DeleteProxy(id); err != nil {
+ writeProxyErr(w, err)
+ return
+ }
+ writeJSON(w, 200, map[string]any{"ok": true})
+}
+
+// importProxies POST /api/proxies/import — body {"text":"host:port\n..."}; adds
+// each parsed line as a trusted import, de-duped by (protocol,host,port).
+func (s *Server) importProxies(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Text string `json:"text"`
+ }
+ if !s.decodeProxyBody(w, r, &req) {
+ return
+ }
+ lines := strings.Split(req.Text, "\n")
+ added, invalid, err := s.m.Proxies().ImportProxies(lines)
+ if err != nil {
+ writeErr(w, 500, err.Error())
+ return
+ }
+ writeJSON(w, 200, map[string]any{"added": added, "invalid": invalid})
+}
+
+// checkProxy POST /api/proxies/{id}/check — probe one proxy on demand.
+func (s *Server) checkProxy(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ writeErr(w, 400, "invalid id")
+ return
+ }
+ res, err := s.m.ProxyPool().ProbeNow(r.Context(), id)
+ if err != nil {
+ writeProxyErr(w, err)
+ return
+ }
+ writeJSON(w, 200, map[string]any{
+ "ok": res.OK, "latency_ms": res.Latency.Milliseconds(), "error": res.Err,
+ })
+}
+
+// listProxySources GET /api/proxy-sources — built-in source catalog + enable state.
+func (s *Server) listProxySources(w http.ResponseWriter, r *http.Request) {
+ sources, err := s.m.Proxies().ListSources(proxypool.SourceNames())
+ if err != nil {
+ writeErr(w, 500, err.Error())
+ return
+ }
+ writeJSON(w, 200, map[string]any{"sources": sources})
+}
+
+// setProxySource PUT /api/proxy-sources/{name} — body {"enabled":bool}.
+func (s *Server) setProxySource(w http.ResponseWriter, r *http.Request) {
+ name := r.PathValue("name")
+ var req struct {
+ Enabled bool `json:"enabled"`
+ }
+ if !s.decodeProxyBody(w, r, &req) {
+ return
+ }
+ if err := s.m.Proxies().SetSourceEnabled(name, req.Enabled); err != nil {
+ writeErr(w, 500, err.Error())
+ return
+ }
+ writeJSON(w, 200, map[string]any{"ok": true})
+}
+
+func writeProxyErr(w http.ResponseWriter, err error) {
+ if errors.Is(err, db.ErrProxyNotFound) {
+ writeErr(w, 404, "proxy not found")
+ return
+ }
+ writeErr(w, 500, err.Error())
+}
diff --git a/server/server.go b/server/server.go
index f376df2..31af817 100644
--- a/server/server.go
+++ b/server/server.go
@@ -688,6 +688,15 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("DELETE /api/companies/{id}", s.deleteCompany)
mux.HandleFunc("POST /api/companies/{id}/scope", s.addCompanyScope)
mux.HandleFunc("POST /api/companies/reattribute", s.reattribute)
+ // 代理池:条目 CRUD/导入/探活 + 免费源开关
+ mux.HandleFunc("GET /api/proxies", s.listProxies)
+ mux.HandleFunc("POST /api/proxies", s.createProxy)
+ mux.HandleFunc("POST /api/proxies/import", s.importProxies)
+ mux.HandleFunc("PUT /api/proxies/{id}", s.updateProxy)
+ mux.HandleFunc("DELETE /api/proxies/{id}", s.deleteProxy)
+ mux.HandleFunc("POST /api/proxies/{id}/check", s.checkProxy)
+ mux.HandleFunc("GET /api/proxy-sources", s.listProxySources)
+ mux.HandleFunc("PUT /api/proxy-sources/{name}", s.setProxySource)
mux.HandleFunc("GET /api/exploration/frontier", s.frontier)
mux.HandleFunc("GET /api/exploration/findings", s.findings)
diff --git a/web/src/app/(main)/system/proxies/page.tsx b/web/src/app/(main)/system/proxies/page.tsx
new file mode 100644
index 0000000..15b4235
--- /dev/null
+++ b/web/src/app/(main)/system/proxies/page.tsx
@@ -0,0 +1,466 @@
+"use client";
+
+import * as React from "react";
+
+import {
+ CircleCheckIcon,
+ CircleXIcon,
+ DownloadIcon,
+ Loader2Icon,
+ PencilIcon,
+ PlusIcon,
+ RefreshCwIcon,
+ Trash2Icon,
+} from "lucide-react";
+import { toast } from "sonner";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
+import { Switch } from "@/components/ui/switch";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { Textarea } from "@/components/ui/textarea";
+import { api } from "@/lib/api";
+import type { ProxyInput, ProxyNode, ProxyProtocol, ProxySource } from "@/lib/types";
+
+const PROTOCOLS: ProxyProtocol[] = ["http", "https", "socks5", "socks4"];
+
+function emptyInput(): ProxyInput {
+ return { protocol: "http", host: "", port: 8080, enabled: true, tags: [] };
+}
+
+// 健康徽标:健康显示延迟,不健康显示原因,未检测显示灰点。
+function HealthBadge({ p }: { p: ProxyNode }) {
+ if (p.check_count === 0) {
+ return 未检测;
+ }
+ if (p.healthy) {
+ return (
+
+ {p.latency_ms}ms
+
+ );
+ }
+ return (
+
+ 失败
+
+ );
+}
+
+export default function ProxyPoolPage() {
+ const [proxies, setProxies] = React.useState([]);
+ const [sources, setSources] = React.useState([]);
+ const [loading, setLoading] = React.useState(true);
+ const [checking, setChecking] = React.useState>(new Set());
+ const [editing, setEditing] = React.useState(null);
+ const [form, setForm] = React.useState(null);
+ const [importOpen, setImportOpen] = React.useState(false);
+ const [importText, setImportText] = React.useState("");
+ const [saving, setSaving] = React.useState(false);
+
+ const load = React.useCallback(async () => {
+ try {
+ const [px, srcs] = await Promise.all([api.proxies(), api.proxySources()]);
+ setProxies(px);
+ setSources(srcs);
+ } catch (e) {
+ toast.error(`加载失败:${(e as Error).message}`);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ React.useEffect(() => {
+ void load();
+ }, [load]);
+
+ const openCreate = () => {
+ setEditing(null);
+ setForm(emptyInput());
+ };
+ const openEdit = (p: ProxyNode) => {
+ setEditing(p);
+ setForm({
+ protocol: p.protocol,
+ host: p.host,
+ port: p.port,
+ username: p.username ?? "",
+ password: "", // 留空 = 不改(后端已打码)
+ anonymity: p.anonymity ?? "",
+ region: p.region ?? "",
+ tags: p.tags,
+ label: p.label ?? "",
+ enabled: p.enabled,
+ });
+ };
+
+ const save = async () => {
+ if (!form) return;
+ if (!form.host.trim() || form.port <= 0) {
+ toast.error("请填写 host 和有效端口");
+ return;
+ }
+ setSaving(true);
+ try {
+ if (editing) {
+ await api.updateProxy(editing.id, form);
+ toast.success("已保存");
+ } else {
+ await api.createProxy(form);
+ toast.success("已添加");
+ }
+ setForm(null);
+ setEditing(null);
+ await load();
+ } catch (e) {
+ toast.error(`保存失败:${(e as Error).message}`);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const remove = async (p: ProxyNode) => {
+ setProxies((prev) => prev.filter((x) => x.id !== p.id));
+ try {
+ await api.deleteProxy(p.id);
+ } catch (e) {
+ toast.error(`删除失败:${(e as Error).message}`);
+ await load();
+ }
+ };
+
+ const toggleEnabled = async (p: ProxyNode, enabled: boolean) => {
+ setProxies((prev) => prev.map((x) => (x.id === p.id ? { ...x, enabled } : x)));
+ try {
+ await api.updateProxy(p.id, {
+ protocol: p.protocol,
+ host: p.host,
+ port: p.port,
+ username: p.username,
+ anonymity: p.anonymity,
+ region: p.region,
+ tags: p.tags,
+ label: p.label,
+ enabled,
+ });
+ } catch {
+ await load();
+ }
+ };
+
+ const check = async (p: ProxyNode) => {
+ setChecking((prev) => new Set(prev).add(p.id));
+ try {
+ const res = await api.checkProxy(p.id);
+ toast[res.ok ? "success" : "error"](res.ok ? `可用 · ${res.latency_ms}ms` : `不可用:${res.error}`);
+ await load();
+ } catch (e) {
+ toast.error(`探活失败:${(e as Error).message}`);
+ } finally {
+ setChecking((prev) => {
+ const next = new Set(prev);
+ next.delete(p.id);
+ return next;
+ });
+ }
+ };
+
+ const doImport = async () => {
+ setSaving(true);
+ try {
+ const res = await api.importProxies(importText);
+ toast.success(`已导入 ${res.added} 个${res.invalid.length ? `,${res.invalid.length} 行无法解析` : ""}`);
+ setImportOpen(false);
+ setImportText("");
+ await load();
+ } catch (e) {
+ toast.error(`导入失败:${(e as Error).message}`);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const toggleSource = async (name: string, enabled: boolean) => {
+ setSources((prev) => prev.map((s) => (s.name === name ? { ...s, enabled } : s)));
+ try {
+ await api.setProxySource(name, enabled);
+ } catch (e) {
+ toast.error(`切换失败:${(e as Error).message}`);
+ await load();
+ }
+ };
+
+ return (
+
+
+
代理池
+
+ 出口代理管理:手动添加/导入可信代理,或开启免费代理源(默认全关)。后台定时验活,选择时优先稳定节点。
+
+
+
+ {/* 免费代理源开关 */}
+
+
+ 免费代理源
+
+ 默认全部关闭。免费代理不可信、易被目标封禁——出口默认只走手动/导入的可信代理(见系统配置的安全阀门)。
+
+
+
+ {sources.map((s) => (
+
+
+
{s.name}
+
+ {s.last_fetch_at
+ ? `上次抓取 ${s.last_count} 个${s.last_error ? ` · 错误:${s.last_error}` : ""}`
+ : "尚未抓取"}
+
+
+
toggleSource(s.name, v)} />
+
+ ))}
+
+
+
+ {/* 代理列表 */}
+
+
+
+ 代理节点
+ 共 {proxies.length} 个
+
+
+
+
+
+
+
+ {loading && (
+
+ 加载中…
+
+ )}
+ {!loading && proxies.length === 0 && (
+
+ 还没有代理,添加或导入,或开启上方免费源。
+
+ )}
+ {!loading && proxies.length > 0 && (
+
+
+
+
+ 地址
+ 协议
+ 地区
+ 来源
+ 健康
+ 启用
+ 操作
+
+
+
+ {proxies.map((p) => (
+
+
+ {p.host}:{p.port}
+ {p.label ? {p.label} : null}
+
+
+ {p.protocol}
+
+ {p.region || "—"}
+
+ {p.trusted ? (
+
+ 可信
+
+ ) : (
+
+ 免费源
+
+ )}
+
+
+
+
+
+ toggleEnabled(p, v)} />
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ {/* 新增/编辑 dialog */}
+
+
+ {/* 批量导入 dialog */}
+
+
+ );
+}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index fdcdd7d..afee21a 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -46,6 +46,10 @@ import type {
ModelTokenStat,
PromptVar,
PromptVersion,
+ ProxyInput,
+ ProxyNode,
+ ProxyQuery,
+ ProxySource,
SessionTokenUsage,
Settings,
Severity,
@@ -352,6 +356,25 @@ export const api = {
body: JSON.stringify({ delete_assets: deleteAssets }),
}),
+ // ---- proxy pool ----
+ proxies: (q: ProxyQuery = {}) => {
+ const p = new URLSearchParams();
+ if (q.protocol) p.set("protocol", q.protocol);
+ if (q.region) p.set("region", q.region);
+ if (q.tag) p.set("tag", q.tag);
+ if (q.healthy) p.set("healthy", "1");
+ const qs = p.toString();
+ return get<{ proxies: ProxyNode[] }>(`/proxies${qs ? `?${qs}` : ""}`).then((r) => arr(r.proxies));
+ },
+ createProxy: (input: ProxyInput) => post<{ id: number }>("/proxies", input),
+ updateProxy: (id: number, input: ProxyInput) => put<{ ok: boolean }>(`/proxies/${id}`, input),
+ deleteProxy: (id: number) => del<{ ok: boolean }>(`/proxies/${id}`),
+ importProxies: (text: string) => post<{ added: number; invalid: string[] }>("/proxies/import", { text }),
+ checkProxy: (id: number) => post<{ ok: boolean; latency_ms: number; error: string }>(`/proxies/${id}/check`, {}),
+ proxySources: () => get<{ sources: ProxySource[] }>("/proxy-sources").then((r) => arr(r.sources)),
+ setProxySource: (name: string, enabled: boolean) =>
+ put<{ ok: boolean }>(`/proxy-sources/${encodeURIComponent(name)}`, { enabled }),
+
// ---- exploration (per task) ----
frontier: (task?: string) => get(`/exploration/frontier${tq(task)}`).then(arr),
findings: (task?: string) => get(`/exploration/findings${tq(task)}`).then(arr),
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
index 66e7bbb..ded271c 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -249,6 +249,67 @@ export interface TaskScopeRow {
reason?: string;
}
+// ---- 代理池(出口代理轮换)----
+export type ProxyProtocol = "http" | "https" | "socks5" | "socks4";
+
+// 一个代理节点。password 由后端打码返回("********"),前端提交时留空表示不改。
+// 命名 ProxyNode 而非 Proxy:避免遮蔽全局 Proxy 对象(biome noShadowRestrictedNames)。
+export interface ProxyNode {
+ id: number;
+ protocol: ProxyProtocol;
+ host: string;
+ port: number;
+ username?: string;
+ password?: string;
+ anonymity?: string; // elite/anonymous/transparent
+ region?: string;
+ tags: string[];
+ label?: string;
+ source: string; // manual/import/<源名>
+ trusted: boolean;
+ enabled: boolean;
+ healthy: boolean;
+ latency_ms: number;
+ last_check_at?: string;
+ last_ok_at?: string;
+ last_error?: string;
+ fail_streak: number;
+ check_count: number;
+ ok_count: number;
+ created_at?: string;
+ updated_at?: string;
+}
+
+// 新增/编辑代理的提交体。
+export interface ProxyInput {
+ protocol: ProxyProtocol;
+ host: string;
+ port: number;
+ username?: string;
+ password?: string;
+ anonymity?: string;
+ region?: string;
+ tags?: string[];
+ label?: string;
+ enabled: boolean;
+}
+
+export interface ProxyQuery {
+ protocol?: string;
+ region?: string;
+ tag?: string;
+ healthy?: boolean;
+}
+
+// 免费代理源开关 + 上次抓取状态。
+export interface ProxySource {
+ name: string;
+ enabled: boolean;
+ last_fetch_at?: string;
+ last_count: number;
+ last_error?: string;
+}
+
export type CompanyScopeKind = "domain" | "ip" | "cidr" | "icp" | "keyword";
// 新增企业时提交的结构化资产范围规则。
diff --git a/web/src/navigation/sidebar/sidebar-items.ts b/web/src/navigation/sidebar/sidebar-items.ts
index 54459dc..f10c004 100644
--- a/web/src/navigation/sidebar/sidebar-items.ts
+++ b/web/src/navigation/sidebar/sidebar-items.ts
@@ -12,6 +12,7 @@ import {
Network,
Plug,
Radio,
+ Route,
ScrollText,
Settings2,
ShieldAlert,
@@ -85,6 +86,7 @@ export const sidebarItems: NavGroup[] = [
{ id: "mcp", title: "MCP", url: "/system/mcp", icon: Plug },
{ id: "skills", title: "Skill", url: "/system/skills", icon: Sparkles },
{ id: "tools", title: "工具", url: "/system/tools", icon: Wrench },
+ { id: "proxies", title: "代理池", url: "/system/proxies", icon: Route },
{ id: "intercept", title: "拦截规则", url: "/system/intercept", icon: ShieldAlert },
{ id: "approvals", title: "审批记录", url: "/system/intercept/approvals", icon: ClipboardList },
{ id: "logs", title: "日志", url: "/system/logs", icon: ScrollText },
From 55f85a08aecb3575bfa63677dac59b87138e0d5c Mon Sep 17 00:00:00 2001
From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com>
Date: Sun, 23 Aug 2026 02:51:15 -0400
Subject: [PATCH 4/8] =?UTF-8?q?feat(proxypool):=20=E9=98=B6=E6=AE=B52=20li?=
=?UTF-8?q?st=5Fproxies=20=E5=8F=AA=E8=AF=BB=E5=B7=A5=E5=85=B7=EF=BC=88?=
=?UTF-8?q?=E5=85=A5=E5=8F=A3B=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
给 agent 一个只读工具,按条件筛选代理池中健康节点,自己在命令里用
(curl -x / proxychains / nmap --proxies)轮换出口 IP。不改注入层。
- ToolSet 加 ps 字段 + SetProxyStore;新增 listProxies 工具(protocol/region/tag
过滤,仅返回 enabled+healthy,输出 address(scheme://host:port,含认证)/地区/匿名度/延迟)
- 随代理池开关生效:db 导出 SettingProxyPoolEnabled + ProxyStore.PoolEnabled(),
handler 在池关时返回"未启用",避免 agent 包依赖 server 包;server 主开关键复用该常量
- 加进 WorkerTools(自动 seed + 绑定给 worker);db.AssetStore.Proxies() passthrough
- wiring:worker / assembly(auto/自定义 agent domainReg) / orchestration 处 SetProxyStore
系统提示词的代理池段落留到阶段3(与出口开关说明一起);工具 description 已说明用法。
验证:go build/vet/test 通过。
Claude-Session: https://claude.ai/code/session_01Q8nhYZfS4H8fsSCBJXxbSy
---
agent/toolcatalog.go | 2 +-
agent/tools.go | 1 +
agent/tools_insert.go | 65 +++++++++++++++++++++++++++++++++++++++++
agent/worker.go | 1 +
db/assets.go | 4 +++
db/proxies.go | 9 ++++++
server/assembly.go | 1 +
server/manager.go | 3 +-
server/orchestration.go | 1 +
9 files changed, 85 insertions(+), 2 deletions(-)
diff --git a/agent/toolcatalog.go b/agent/toolcatalog.go
index d564efe..3c018d9 100644
--- a/agent/toolcatalog.go
+++ b/agent/toolcatalog.go
@@ -36,7 +36,7 @@ func builtinToolsByAgent() map[string][]actool.CoreTool {
return map[string][]actool.CoreTool{
"mainagent": ts.MainAgentTools(),
"planner": ts.PlannerTools(),
- "worker": ts.WorkerTools(),
+ "worker": ts.WorkerTools(), // 含 list_proxies(代理池只读,随开关生效)
// goals(目标拆解器)默认绑 set_goals:它靠这个工具把拆出的目标写进图。
// 与 mainagent 共用同一受管工具,web 端可改描述/schema、按 agent 勾选。
"goals": {ts.setGoals()},
diff --git a/agent/tools.go b/agent/tools.go
index 53073d0..4b57f79 100644
--- a/agent/tools.go
+++ b/agent/tools.go
@@ -53,6 +53,7 @@ func compactIntents(ns []*db.Node, parentsOf, yieldsOf map[int64][]int64) []map[
type ToolSet struct {
as *db.AssetStore // asset store (optional; nil = asset tools not available)
cs *db.CompanyStore // company store (optional)
+ ps *db.ProxyStore // proxy pool store (optional; nil = list_proxies unavailable)
ts *db.ExplorationStore
worker string
taskID int64 // PG tasks.id; 0 when unknown (tests / orchestrator cross-task reads)
diff --git a/agent/tools_insert.go b/agent/tools_insert.go
index 2cf808a..dbbeb11 100644
--- a/agent/tools_insert.go
+++ b/agent/tools_insert.go
@@ -21,6 +21,9 @@ func (t *ToolSet) SetAssetStore(as *db.AssetStore, cs *db.CompanyStore) {
t.cs = cs
}
+// SetProxyStore wires the proxy pool store so the list_proxies tool is active.
+func (t *ToolSet) SetProxyStore(ps *db.ProxyStore) { t.ps = ps }
+
// assetInputItem is one element of the insert_assets "assets" array.
type assetInputItem struct {
Type string `json:"type"` // root_domain|ip|subdomain|app|service|endpoint
@@ -582,6 +585,66 @@ func (t *ToolSet) listCompanies() actool.CoreTool {
)
}
+// listProxies lets an agent enumerate healthy outbound proxies it can route through
+// on its own (e.g. curl -x, proxychains) for a specific target. Read-only: it only
+// lists nodes, it does not change any global egress setting.
+func (t *ToolSet) listProxies() actool.CoreTool {
+ return readTool(
+ "list_proxies",
+ "列出代理池中【当前健康】的出口代理节点,供你在命令里自行使用(如 curl -x 、"+
+ "proxychains、nmap --proxies)访问目标、轮换出口 IP。返回 http/https/socks5/socks4 代理的 "+
+ "address(scheme://host:port)、地区、匿名度、延迟。可选 protocol/region/tag 过滤。"+
+ "只读:不改变全局出口设置。代理池未开启时返回空。",
+ obj(map[string]any{
+ "protocol": str("按协议过滤(可选):http/https/socks5/socks4"),
+ "region": str("按地区码过滤(可选),如 CN/US"),
+ "tag": str("按标签过滤(可选)"),
+ }),
+ func(_ context.Context, in json.RawMessage) (actool.Result, error) {
+ if t.ps == nil || !t.ps.PoolEnabled() {
+ return actool.Errorf("list_proxies 未启用: 代理池功能已关闭(在系统设置开启后可用)"), nil
+ }
+ var a struct {
+ Protocol string `json:"protocol"`
+ Region string `json:"region"`
+ Tag string `json:"tag"`
+ }
+ _ = json.Unmarshal(in, &a)
+ f := db.ProxyFilter{
+ Protocol: strings.TrimSpace(a.Protocol),
+ Region: strings.TrimSpace(a.Region),
+ OnlyEnabled: true,
+ OnlyHealthy: true,
+ }
+ if tag := strings.TrimSpace(a.Tag); tag != "" {
+ f.Tags = []string{tag}
+ }
+ proxies, err := t.ps.ListProxies(f)
+ if err != nil {
+ return actool.Errorf("查询代理失败: " + err.Error()), nil
+ }
+ type proxyOut struct {
+ Address string `json:"address"` // scheme://host:port (含认证,供命令直接使用)
+ Protocol string `json:"protocol"`
+ Region string `json:"region,omitempty"`
+ Anonymity string `json:"anonymity,omitempty"`
+ LatencyMs int `json:"latency_ms"`
+ }
+ out := make([]proxyOut, 0, len(proxies))
+ for _, p := range proxies {
+ out = append(out, proxyOut{
+ Address: p.URL().String(),
+ Protocol: p.Protocol,
+ Region: p.Region,
+ Anonymity: p.Anonymity,
+ LatencyMs: p.LatencyMs,
+ })
+ }
+ return jsonResult(map[string]any{"count": len(out), "proxies": out})
+ },
+ )
+}
+
// splitLines splits a multi-line string into non-empty trimmed lines.
func splitLines(s string) []string {
var out []string
@@ -603,6 +666,8 @@ func (t *ToolSet) WorkerTools() []actool.CoreTool {
t.searchAllWorkerTraces(), t.listWorkerTraces(), t.getWorkerTrace(),
// asset management (handlers guard nil store internally)
t.insertAssets(), t.addCompanyScope(), t.listAssets(), t.listCompanies(),
+ // outbound proxy pool (read-only; guarded when pool store/switch off)
+ t.listProxies(),
}
}
diff --git a/agent/worker.go b/agent/worker.go
index 99125c9..762443b 100644
--- a/agent/worker.go
+++ b/agent/worker.go
@@ -258,6 +258,7 @@ func (w *Worker) Execute(ctx context.Context, name string, taskID int64, as *db.
tsx.SetCoverageEnabled(coverageEnabled)
if as != nil {
tsx.SetAssetStore(as, as.Companies())
+ tsx.SetProxyStore(as.Proxies()) // list_proxies(只读,随代理池开关生效)
}
tsx.SetOwnerNode(intent.ID) // assets this worker discovers anchor to its intent → visible to the task
tsx.SetEnrich(enr) // async DNS/HTTP auto-completion for assets this worker writes
diff --git a/db/assets.go b/db/assets.go
index 61be527..a1fd248 100644
--- a/db/assets.go
+++ b/db/assets.go
@@ -94,6 +94,10 @@ func (d *DB) Assets() *AssetStore {
// Companies returns the company store associated with this asset store.
func (s *AssetStore) Companies() *CompanyStore { return s.company }
+// Proxies returns the proxy pool store on the same DB, so agent tool wiring that
+// already holds an AssetStore can reach the pool without a separate handle.
+func (s *AssetStore) Proxies() *ProxyStore { return s.db.Proxies() }
+
// withCompanyScopeMutation serializes scope resolution and every asset write
// that consumes its result in one transaction. Nested asset side effects reuse
// the same transaction through the scoped store.
diff --git a/db/proxies.go b/db/proxies.go
index e4b73ed..7df67c4 100644
--- a/db/proxies.go
+++ b/db/proxies.go
@@ -69,6 +69,10 @@ func (p Proxy) Masked() Proxy {
// automatically disabled, so dead free-pool nodes drop out without manual cleanup.
const ProxyFailAutoDisable = 5
+// SettingProxyPoolEnabled is the settings key for the pool master switch. Defined
+// here so both the server (settings UI) and the agent tool layer read one key.
+const SettingProxyPoolEnabled = "proxy_pool_enabled"
+
var ErrProxyNotFound = errors.New("proxy not found")
// ProxyStore operates on the proxies + proxy_sources tables.
@@ -77,6 +81,11 @@ type ProxyStore struct{ db *DB }
// Proxies returns a store bound to this DB.
func (d *DB) Proxies() *ProxyStore { return &ProxyStore{db: d} }
+// PoolEnabled reports whether the proxy pool master switch is on (default off).
+// Read from settings so the agent tool layer can gate list_proxies without a
+// dependency on the server package.
+func (s *ProxyStore) PoolEnabled() bool { return s.db.GetBool(SettingProxyPoolEnabled, false) }
+
// ProxyFilter narrows a ListProxies / SelectForHost query. Zero value = no filter.
type ProxyFilter struct {
Protocol string
diff --git a/server/assembly.go b/server/assembly.go
index 426a9b6..00daf37 100644
--- a/server/assembly.go
+++ b/server/assembly.go
@@ -342,6 +342,7 @@ func buildDomainReg(as *db.AssetStore) map[string]actool.CoreTool {
}
serverTS := agent.NewToolSet(nil, "")
serverTS.SetAssetStore(as, as.Companies())
+ serverTS.SetProxyStore(as.Proxies()) // list_proxies 若被 auto/自定义 agent 勾选,需要池句柄
reg := make(map[string]actool.CoreTool)
for _, t := range serverTS.AllDomainTools() {
reg[t.Name()] = t
diff --git a/server/manager.go b/server/manager.go
index cf07020..be3a9ec 100644
--- a/server/manager.go
+++ b/server/manager.go
@@ -249,7 +249,8 @@ const (
settingConcurrencyOn = "task_concurrency_enabled"
settingConcurrencyLimit = "task_concurrency_limit"
// 代理池:主开关、"主出口只走可信代理"安全阀门、抓取/验活间隔(分钟)。
- settingProxyPoolOn = "proxy_pool_enabled"
+ // 主开关键复用 db.SettingProxyPoolEnabled,agent 工具层与此读同一个键。
+ settingProxyPoolOn = pgdb.SettingProxyPoolEnabled
settingProxyTrustedOnly = "proxy_egress_trusted_only"
settingProxyFetchMin = "proxy_fetch_interval_min"
settingProxyCheckMin = "proxy_check_interval_min"
diff --git a/server/orchestration.go b/server/orchestration.go
index 5ee78f0..6c65cd4 100644
--- a/server/orchestration.go
+++ b/server/orchestration.go
@@ -162,6 +162,7 @@ func (s *Server) delegateToTask(ctx context.Context, in json.RawMessage, pick fu
tsx := agent.NewToolSet(t.Store, "orchestrator")
if s.m.Assets() != nil {
tsx.SetAssetStore(s.m.Assets(), s.m.Assets().Companies())
+ tsx.SetProxyStore(s.m.Assets().Proxies())
}
tsx.SetNotify(t.Notify) // hint writes wake this task's planner (no-op for read tools)
return pick(tsx).Call(ctx, inner, nil)
From bb006b2b7695385963bb932772a6474395f9c59d Mon Sep 17 00:00:00 2001
From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com>
Date: Sun, 23 Aug 2026 03:05:56 -0400
Subject: [PATCH 5/8] =?UTF-8?q?feat(proxypool):=20=E9=98=B6=E6=AE=B53=20MI?=
=?UTF-8?q?TM=E4=B8=8A=E6=B8=B8(A)=20+=20=E4=BB=A3=E7=90=86=E6=B1=A0?=
=?UTF-8?q?=E7=BD=91=E5=85=B3(C)=20+=20=E6=B3=A8=E5=85=A5=E9=80=BB?=
=?UTF-8?q?=E8=BE=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
出口代理落地:代理池开关 × 流量录制开关自动决定 agent 出口,无需第三个"模式"。
入口A(MITM 上游,录制开+池开):
- traffic.SetPoolUpstream 回调;SetUpstreamProxy 从池按目标 host 选上游(每 host 粘性)
- 录制正常抓包,流量经 MITM 转发到池节点再到目标
入口C(代理池网关,录制关+池开):
- proxypool/gateway.go:本地转发服务(CONNECT 隧道 + 普通 HTTP 转发),纯 TCP 不解密、
不抓包,适合证书 pinning / MITM 解不了的目标;CLI flag -proxy-pool-gateway(默认 127.0.0.1:8789)
- 上游选择与入口A 共用 Manager.selectPoolUpstream(尊重 trusted-only 安全阀门)
注入逻辑(Manager.ProxyAddr/ProxyCACert):
- 录制开→MITM 地址+CA;录制关+池开→网关地址+无CA(纯转发不注入 MITM CA,agent 直连目标做 TLS);
全关→直连
- 主开关切换设 changed=true 触发 agent 重建,让新出口生效
其他:
- 协议收敛为 http/https/socks5(去掉 socks4:go-mitmproxy/x/net 均不支持)
- 系统设置页加代理池开关 + 仅可信出口安全阀门;settings API 读写 4 个键;NewManager 加网关参数
- proxypool/dial+connect:http(s) CONNECT / socks5 拨号,入口C 与验活复用
- gateway_test:CONNECT 隧道回环 + host-only 选路
提示词:list_proxies 工具 description 已说明用法,系统提示词段落留作可选增强。
验证:go build/vet/test 全过(PG 测试本机跳过);tsc + build:static 通过;改动文件 lint 零新增。
Claude-Session: https://claude.ai/code/session_01Q8nhYZfS4H8fsSCBJXxbSy
---
agent/tools_insert.go | 4 +-
cmd/artex/main.go | 3 +-
db/proxies.go | 4 +-
db/proxies_test.go | 2 +-
db/schema.sql | 2 +-
proxypool/dial.go | 7 +-
proxypool/gateway.go | 174 ++++++++++++++++++++
proxypool/gateway_test.go | 87 ++++++++++
proxypool/probe.go | 2 +-
proxypool/sources.go | 2 +-
server/assets_scope_test.go | 12 +-
server/core_test.go | 2 +-
server/findings_groups_test.go | 6 +-
server/inheritance_api_test.go | 2 +-
server/manager.go | 68 ++++++--
server/manager_delete_files_test.go | 4 +-
server/mgmt_test.go | 2 +-
server/server.go | 40 +++++
server/task_admission_test.go | 32 ++--
server/task_control_routes_test.go | 2 +-
server/task_delete_barrier_test.go | 2 +-
server/task_llm_test.go | 2 +-
server/task_templates_test.go | 4 +-
traffic/traffic.go | 34 +++-
web/src/app/(main)/system/proxies/page.tsx | 2 +-
web/src/app/(main)/system/settings/page.tsx | 62 ++++++-
web/src/lib/types.ts | 8 +-
27 files changed, 501 insertions(+), 70 deletions(-)
create mode 100644 proxypool/gateway.go
create mode 100644 proxypool/gateway_test.go
diff --git a/agent/tools_insert.go b/agent/tools_insert.go
index dbbeb11..4c0593b 100644
--- a/agent/tools_insert.go
+++ b/agent/tools_insert.go
@@ -592,11 +592,11 @@ func (t *ToolSet) listProxies() actool.CoreTool {
return readTool(
"list_proxies",
"列出代理池中【当前健康】的出口代理节点,供你在命令里自行使用(如 curl -x 、"+
- "proxychains、nmap --proxies)访问目标、轮换出口 IP。返回 http/https/socks5/socks4 代理的 "+
+ "proxychains、nmap --proxies)访问目标、轮换出口 IP。返回 http/https/socks5 代理的 "+
"address(scheme://host:port)、地区、匿名度、延迟。可选 protocol/region/tag 过滤。"+
"只读:不改变全局出口设置。代理池未开启时返回空。",
obj(map[string]any{
- "protocol": str("按协议过滤(可选):http/https/socks5/socks4"),
+ "protocol": str("按协议过滤(可选):http/https/socks5"),
"region": str("按地区码过滤(可选),如 CN/US"),
"tag": str("按标签过滤(可选)"),
}),
diff --git a/cmd/artex/main.go b/cmd/artex/main.go
index 6023245..9ea0012 100644
--- a/cmd/artex/main.go
+++ b/cmd/artex/main.go
@@ -46,6 +46,7 @@ func main() {
addr = flag.String("addr", ":8787", "HTTP listen address")
dataDir = flag.String("data", filepath.Join(config.BaseDir(), "data"), "data directory for SQLite stores (default: data/ next to the executable)")
proxy = flag.String("proxy", ":8788", "traffic recording proxy address (empty to disable)")
+ proxyGW = flag.String("proxy-pool-gateway", "127.0.0.1:8789", "proxy-pool forwarding gateway address (empty to disable 入口C)")
)
flag.Parse()
@@ -76,7 +77,7 @@ func main() {
ctx, shutdown := shutdownContext(sigCtx)
defer shutdown(agent.AbortShutdown)
- mgr, err := server.NewManager(*dataDir, *proxy)
+ mgr, err := server.NewManager(*dataDir, *proxy, *proxyGW)
if err != nil {
log.Fatalf("open stores: %v", err)
}
diff --git a/db/proxies.go b/db/proxies.go
index 7df67c4..7d7ded6 100644
--- a/db/proxies.go
+++ b/db/proxies.go
@@ -468,9 +468,9 @@ func ParseProxyLine(line string) (Proxy, error) {
}
scheme := strings.ToLower(u.Scheme)
switch scheme {
- case "http", "https", "socks5", "socks4":
+ case "http", "https", "socks5":
default:
- return Proxy{}, fmt.Errorf("不支持的协议: %s", u.Scheme)
+ return Proxy{}, fmt.Errorf("不支持的协议: %s(仅支持 http/https/socks5)", u.Scheme)
}
host := u.Hostname()
portStr := u.Port()
diff --git a/db/proxies_test.go b/db/proxies_test.go
index 46aacbb..b8da8fa 100644
--- a/db/proxies_test.go
+++ b/db/proxies_test.go
@@ -20,7 +20,7 @@ func TestParseProxyLine(t *testing.T) {
{in: "1.2.3.4:8080", proto: "http", host: "1.2.3.4", port: 8080},
{in: "socks5://9.9.9.9:1080", proto: "socks5", host: "9.9.9.9", port: 1080},
{in: "http://user:pass@10.0.0.1:3128", proto: "http", host: "10.0.0.1", port: 3128, user: "user", pass: "pass"},
- {in: "socks4://5.6.7.8:1080", proto: "socks4", host: "5.6.7.8", port: 1080},
+ {in: "socks4://5.6.7.8:1080", wantErr: true}, // socks4 不支持(仅 http/https/socks5)
{in: " 8.8.8.8:80 ", proto: "http", host: "8.8.8.8", port: 80},
{in: "ftp://1.2.3.4:21", wantErr: true}, // unsupported scheme
{in: "1.2.3.4", wantErr: true}, // no port
diff --git a/db/schema.sql b/db/schema.sql
index daf92d2..bf92180 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -791,7 +791,7 @@ CREATE INDEX IF NOT EXISTS idx_server_logs_id ON server_logs(id DESC);
-- 免费源抓来的 trusted=false,默认不进主出口轮换。质量字段驱动自动禁用死代理 + 优先稳定节点。
CREATE TABLE IF NOT EXISTS proxies (
id BIGSERIAL PRIMARY KEY,
- protocol TEXT NOT NULL DEFAULT 'http', -- http/https/socks5/socks4
+ protocol TEXT NOT NULL DEFAULT 'http', -- http/https/socks5
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT NOT NULL DEFAULT '',
diff --git a/proxypool/dial.go b/proxypool/dial.go
index 6342c26..b37f23a 100644
--- a/proxypool/dial.go
+++ b/proxypool/dial.go
@@ -15,12 +15,12 @@ import (
)
// DialThrough opens a TCP connection to address (host:port) through the given
-// proxy URL. Supports http/https (CONNECT tunnel) and socks5/socks4. The returned
+// proxy URL. Supports http/https (CONNECT tunnel) and socks5. The returned
// conn is a raw byte stream to the target; TLS to the target (if any) is the
// caller's job — the proxy only tunnels, it never terminates TLS to the target.
func DialThrough(ctx context.Context, proxyURL *url.URL, address string) (net.Conn, error) {
switch strings.ToLower(proxyURL.Scheme) {
- case "socks5", "socks4":
+ case "socks5":
return dialSOCKS(ctx, proxyURL, address)
case "http", "https":
return dialHTTPConnect(ctx, proxyURL, address)
@@ -29,8 +29,7 @@ func DialThrough(ctx context.Context, proxyURL *url.URL, address string) (net.Co
}
}
-// dialSOCKS tunnels through a SOCKS5 proxy (x/net/proxy also drives socks4 hosts
-// via the same SOCKS5 dialer for the common no-auth case).
+// dialSOCKS tunnels through a SOCKS5 proxy.
func dialSOCKS(ctx context.Context, proxyURL *url.URL, address string) (net.Conn, error) {
var auth *xproxy.Auth
if proxyURL.User != nil {
diff --git a/proxypool/gateway.go b/proxypool/gateway.go
new file mode 100644
index 0000000..15c6f90
--- /dev/null
+++ b/proxypool/gateway.go
@@ -0,0 +1,174 @@
+package proxypool
+
+import (
+ "context"
+ "errors"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "net/url"
+ "sync/atomic"
+ "time"
+)
+
+// Gateway is a local forward proxy (入口C): agents point HTTP_PROXY at it and it
+// tunnels each connection out through a pool proxy chosen per target host. It is a
+// plain CONNECT tunnel / HTTP forwarder — it does NOT decrypt TLS or record, so it
+// works with cert-pinned targets and protocols the MITM can't parse. Independent
+// of the traffic layer; used when traffic capture is off but the pool is on.
+type Gateway struct {
+ addr string
+ srv *http.Server
+ upstream atomic.Pointer[func(host string) *url.URL]
+}
+
+// NewGateway builds a gateway listening on addr (e.g. 127.0.0.1:8789).
+func NewGateway(addr string) *Gateway { return &Gateway{addr: addr} }
+
+// Addr returns the listen address.
+func (g *Gateway) Addr() string { return g.addr }
+
+// SetUpstream installs (or clears with nil) the per-host upstream resolver. A nil
+// return for a host means dial that host directly (gateway adds nothing).
+func (g *Gateway) SetUpstream(fn func(host string) *url.URL) {
+ if fn == nil {
+ g.upstream.Store(nil)
+ return
+ }
+ g.upstream.Store(&fn)
+}
+
+func (g *Gateway) pick(host string) *url.URL {
+ if fn := g.upstream.Load(); fn != nil {
+ return (*fn)(hostOnlyGW(host))
+ }
+ return nil
+}
+
+// Start begins serving in the background. Safe to call once.
+func (g *Gateway) Start() error {
+ ln, err := net.Listen("tcp", g.addr)
+ if err != nil {
+ return err
+ }
+ g.srv = &http.Server{Handler: http.HandlerFunc(g.handle)}
+ go func() {
+ if err := g.srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ log.Printf("[proxygw] stopped: %v", err)
+ }
+ }()
+ log.Printf("[proxypool] gateway on %s", g.addr)
+ return nil
+}
+
+// Stop gracefully shuts the gateway down.
+func (g *Gateway) Stop() {
+ if g.srv != nil {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ _ = g.srv.Shutdown(ctx)
+ }
+}
+
+func (g *Gateway) handle(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodConnect {
+ g.handleConnect(w, r)
+ return
+ }
+ g.handleHTTP(w, r)
+}
+
+// handleConnect tunnels an HTTPS (or any TCP) target: dial out (through the chosen
+// pool proxy, or direct), 200 the client, then splice the two connections.
+func (g *Gateway) handleConnect(w http.ResponseWriter, r *http.Request) {
+ target := r.Host // host:port
+ ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
+ defer cancel()
+ upConn, err := g.dialOut(ctx, target)
+ if err != nil {
+ http.Error(w, "gateway dial failed: "+err.Error(), http.StatusBadGateway)
+ return
+ }
+ hj, ok := w.(http.Hijacker)
+ if !ok {
+ upConn.Close()
+ http.Error(w, "hijack unsupported", http.StatusInternalServerError)
+ return
+ }
+ clientConn, _, err := hj.Hijack()
+ if err != nil {
+ upConn.Close()
+ return
+ }
+ if _, err := clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil {
+ clientConn.Close()
+ upConn.Close()
+ return
+ }
+ splice(clientConn, upConn)
+}
+
+// handleHTTP forwards a plain (non-CONNECT) HTTP request through the chosen pool
+// proxy (or direct) and copies the response back.
+func (g *Gateway) handleHTTP(w http.ResponseWriter, r *http.Request) {
+ tr := &http.Transport{DisableKeepAlives: true}
+ if up := g.pick(r.Host); up != nil {
+ switch up.Scheme {
+ case "http", "https":
+ tr.Proxy = http.ProxyURL(up)
+ case "socks5":
+ tr.DialContext = func(ctx context.Context, _, address string) (net.Conn, error) {
+ return dialSOCKS(ctx, up, address)
+ }
+ }
+ }
+ defer tr.CloseIdleConnections()
+ outReq := r.Clone(r.Context())
+ outReq.RequestURI = ""
+ resp, err := tr.RoundTrip(outReq)
+ if err != nil {
+ http.Error(w, "gateway forward failed: "+err.Error(), http.StatusBadGateway)
+ return
+ }
+ defer resp.Body.Close()
+ for k, vs := range resp.Header {
+ for _, v := range vs {
+ w.Header().Add(k, v)
+ }
+ }
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
+}
+
+// dialOut opens a raw connection to target, through the pool proxy chosen for its
+// host when one is available, else directly.
+func (g *Gateway) dialOut(ctx context.Context, target string) (net.Conn, error) {
+ if up := g.pick(target); up != nil {
+ return DialThrough(ctx, up, target)
+ }
+ var d net.Dialer
+ return d.DialContext(ctx, "tcp", target)
+}
+
+// splice copies bytes both ways between two connections until either side closes.
+func splice(a, b net.Conn) {
+ done := make(chan struct{}, 2)
+ cp := func(dst, src net.Conn) {
+ _, _ = io.Copy(dst, src)
+ done <- struct{}{}
+ }
+ go cp(a, b)
+ go cp(b, a)
+ <-done
+ a.Close()
+ b.Close()
+}
+
+// hostOnlyGW strips a trailing :port so the upstream resolver keys on bare host.
+func hostOnlyGW(hostport string) string {
+ if h, _, err := net.SplitHostPort(hostport); err == nil {
+ return h
+ }
+ return hostport
+}
diff --git a/proxypool/gateway_test.go b/proxypool/gateway_test.go
new file mode 100644
index 0000000..a7826a4
--- /dev/null
+++ b/proxypool/gateway_test.go
@@ -0,0 +1,87 @@
+package proxypool
+
+import (
+ "bufio"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// TestGatewayConnectTunnel starts the gateway in direct mode (no upstream) and
+// verifies a CONNECT tunnel to a local echo server round-trips bytes.
+func TestGatewayConnectTunnel(t *testing.T) {
+ // Echo target: writes back whatever it reads.
+ target, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer target.Close()
+ go func() {
+ for {
+ c, err := target.Accept()
+ if err != nil {
+ return
+ }
+ go func(c net.Conn) { _, _ = io.Copy(c, c); c.Close() }(c)
+ }
+ }()
+
+ gw := NewGateway("127.0.0.1:0")
+ // Bind an explicit listener so we know the port (NewGateway+Start uses g.addr).
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var picked atomic.Int32
+ gw.SetUpstream(func(string) *url.URL { picked.Add(1); return nil }) // direct
+ gw.srv = &http.Server{Handler: http.HandlerFunc(gw.handle)}
+ go func() { _ = gw.srv.Serve(ln) }()
+ defer gw.Stop()
+
+ // Client dials the gateway, sends CONNECT to the echo target, then echoes.
+ conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer conn.Close()
+ if _, err := conn.Write([]byte("CONNECT " + target.Addr().String() + " HTTP/1.1\r\nHost: " + target.Addr().String() + "\r\n\r\n")); err != nil {
+ t.Fatal(err)
+ }
+ br := bufio.NewReader(conn)
+ status, err := br.ReadString('\n')
+ if err != nil || status != "HTTP/1.1 200 Connection Established\r\n" {
+ t.Fatalf("connect status=%q err=%v", status, err)
+ }
+ // Consume the blank line terminating the CONNECT response headers.
+ if _, err := br.ReadString('\n'); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := conn.Write([]byte("ping")); err != nil {
+ t.Fatal(err)
+ }
+ buf := make([]byte, 4)
+ if _, err := io.ReadFull(br, buf); err != nil {
+ t.Fatal(err)
+ }
+ if string(buf) != "ping" {
+ t.Fatalf("echo = %q, want ping", string(buf))
+ }
+ if picked.Load() == 0 {
+ t.Fatal("upstream resolver was not consulted")
+ }
+}
+
+// TestGatewayPickUsesHostOnly verifies the resolver receives a bare host (no port).
+func TestGatewayPickUsesHostOnly(t *testing.T) {
+ gw := NewGateway("127.0.0.1:0")
+ var gotHost string
+ gw.SetUpstream(func(host string) *url.URL { gotHost = host; return nil })
+ _ = gw.pick("example.com:443")
+ if gotHost != "example.com" {
+ t.Fatalf("resolver host = %q, want example.com", gotHost)
+ }
+}
diff --git a/proxypool/probe.go b/proxypool/probe.go
index 10ed2ea..e8c04f3 100644
--- a/proxypool/probe.go
+++ b/proxypool/probe.go
@@ -66,7 +66,7 @@ func proxyHTTPClient(proxyURL *url.URL, timeout time.Duration) (*http.Client, er
switch strings.ToLower(proxyURL.Scheme) {
case "http", "https":
tr.Proxy = http.ProxyURL(proxyURL)
- case "socks5", "socks4":
+ case "socks5":
tr.DialContext = func(ctx context.Context, _, address string) (net.Conn, error) {
return dialSOCKS(ctx, proxyURL, address)
}
diff --git a/proxypool/sources.go b/proxypool/sources.go
index 15ba101..8e53a3a 100644
--- a/proxypool/sources.go
+++ b/proxypool/sources.go
@@ -27,10 +27,10 @@ type Source struct {
// daily-updated raw text lists with a stable "ip:port\n" format. If a source dies
// (raw lists do rot), the user disables it; adding/removing entries here is the
// only maintenance touch-point.
+// 仅收录可拨号的协议(http/https/socks5)——socks4 go-mitmproxy/x/net 均不支持,故不纳入。
var BuiltinSources = []Source{
{Name: "TheSpeedX-http", URL: "https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/http.txt", Protocol: "http"},
{Name: "TheSpeedX-socks5", URL: "https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/socks5.txt", Protocol: "socks5"},
- {Name: "TheSpeedX-socks4", URL: "https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/socks4.txt", Protocol: "socks4"},
{Name: "monosans-http", URL: "https://raw.githubusercontent.com/monosans/proxy-list/main/proxies/http.txt", Protocol: "http"},
{Name: "monosans-socks5", URL: "https://raw.githubusercontent.com/monosans/proxy-list/main/proxies/socks5.txt", Protocol: "socks5"},
}
diff --git a/server/assets_scope_test.go b/server/assets_scope_test.go
index c3e6b7c..f60944b 100644
--- a/server/assets_scope_test.go
+++ b/server/assets_scope_test.go
@@ -34,7 +34,7 @@ func TestCompanyScopeInputsAcceptStructuredAndLegacyRules(t *testing.T) {
}
func TestCreateCompanyRejectsNormalizedDuplicateWithoutChangingScope(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v)", err)
}
@@ -74,7 +74,7 @@ func TestCreateCompanyRejectsNormalizedDuplicateWithoutChangingScope(t *testing.
}
func TestDeleteCompanyRefreshesLiveTaskCompanyIDs(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v)", err)
}
@@ -115,7 +115,7 @@ func TestDeleteCompanyRefreshesLiveTaskCompanyIDs(t *testing.T) {
}
func TestCompanyScopeHTTPErrorClassificationAndBounds(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v)", err)
}
@@ -180,7 +180,7 @@ func TestCompanyScopeHTTPErrorClassificationAndBounds(t *testing.T) {
}
func TestListAssetsClassifiesValidationAndDatabaseErrors(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v)", err)
}
@@ -206,7 +206,7 @@ func TestListAssetsClassifiesValidationAndDatabaseErrors(t *testing.T) {
}
func TestDeleteCompanyRejectsBadJSONAndReportsMissing(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v)", err)
}
@@ -245,7 +245,7 @@ func TestDeleteCompanyRejectsBadJSONAndReportsMissing(t *testing.T) {
}
func TestCompanyScopeSystemFailureIsHTTP500(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v)", err)
}
diff --git a/server/core_test.go b/server/core_test.go
index 56b8a18..6754518 100644
--- a/server/core_test.go
+++ b/server/core_test.go
@@ -18,7 +18,7 @@ import (
// TestCoreTaskLifecyclePG exercises the migrated core (tasks/exploration on PG)
// through the real HTTP mux: create → goal nodes seeded → list → delete cascade.
func TestCoreTaskLifecyclePG(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
diff --git a/server/findings_groups_test.go b/server/findings_groups_test.go
index 5cb44ed..4af99ae 100644
--- a/server/findings_groups_test.go
+++ b/server/findings_groups_test.go
@@ -37,7 +37,7 @@ func TestFindingPaginationParam(t *testing.T) {
}
func TestFindingGroupsReturnsTaskBucketsAndNormalizesPagination(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
@@ -125,7 +125,7 @@ func TestFindingGroupsReturnsTaskBucketsAndNormalizesPagination(t *testing.T) {
}
func TestDeepenFindingCreatesAuditedIntentAndRevivesTask(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
@@ -254,7 +254,7 @@ func TestDeepenFindingValidatesDescription(t *testing.T) {
}
func TestDeepenAdmissionFailureDiscardsFollowUpIntent(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
diff --git a/server/inheritance_api_test.go b/server/inheritance_api_test.go
index df9fe4f..807207c 100644
--- a/server/inheritance_api_test.go
+++ b/server/inheritance_api_test.go
@@ -12,7 +12,7 @@ import (
)
func TestInheritedActivityDetailAndRelationDeletion(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
diff --git a/server/manager.go b/server/manager.go
index be3a9ec..ed35d4d 100644
--- a/server/manager.go
+++ b/server/manager.go
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"log"
+ "net/url"
"os"
"path/filepath"
"sort"
@@ -226,7 +227,9 @@ type Manager struct {
braveKey string
tavilyKey string
webSearchProxy string
- proxyPool *proxypool.Pool // 出口代理池后台任务(抓取 + 验活)
+ proxyPool *proxypool.Pool // 出口代理池后台任务(抓取 + 验活)
+ proxyGW *proxypool.Gateway // 入口C:本地转发网关(纯 TCP,不解密)
+ proxyGWAddr string // 网关监听地址(空 = 禁用入口C)
}
// Settings keys the UI toggles at runtime.
@@ -320,7 +323,7 @@ func (m *Manager) Enrich() *enrich.Engine { return m.enrich }
// NewManager connects to PostgreSQL and, if proxyAddr is non-empty, starts the
// traffic-recording proxy. PostgreSQL is required (it is the single data source).
-func NewManager(dir, proxyAddr string) (*Manager, error) {
+func NewManager(dir, proxyAddr, gatewayAddr string) (*Manager, error) {
// Resolve the data dir to an ABSOLUTE path up front. Every data path derives
// from it — notably the MITM CA cert, whose path is injected into worker shells
// (SSL_CERT_FILE/CURL_CA_BUNDLE) and read by WebFetch. A relative path (the
@@ -348,13 +351,16 @@ func NewManager(dir, proxyAddr string) (*Manager, error) {
if err := pg.EnsureLLMUsageTable(); err != nil {
log.Printf("[llmusage] create table: %v", err)
}
- m := &Manager{dir: dir, pg: pg, assets: pg.Assets(), tasks: map[string]*Task{}, interceptor: intercept.New(pg)}
+ m := &Manager{dir: dir, pg: pg, assets: pg.Assets(), tasks: map[string]*Task{}, interceptor: intercept.New(pg), proxyGWAddr: gatewayAddr}
if proxyAddr != "" {
tr, err := traffic.Open(filepath.Join(dir, "traffic"), proxyAddr)
if err != nil {
log.Printf("[traffic] disabled: %v", err)
} else {
m.traffic = tr
+ // 入口A:代理池开启时,MITM 上游按目标 host 从池里选一个出口(每 host 粘性,
+ // 受"仅可信"安全阀门约束);池关或无可用代理时返回 nil → 直连目标。
+ tr.SetPoolUpstream(m.selectPoolUpstream)
go func() {
log.Printf("[traffic] recording proxy on %s (set HTTP_PROXY=%s + trust _ca CA)", proxyAddr, tr.ProxyAddr())
if err := tr.Start(); err != nil {
@@ -396,9 +402,34 @@ func NewManager(dir, proxyAddr string) (*Manager, error) {
CheckInterval: func() time.Duration { return time.Duration(m.proxyCheckMin()) * time.Minute },
})
m.proxyPool.Start(context.Background())
+ // 入口C:本地转发网关(纯 TCP,不解密、不抓包)。常驻监听;仅当 MITM 关 + 池开时
+ // ProxyAddr() 才把它注入给 agent(见下)。上游选择与入口A 共用 selectPoolUpstream。
+ if gatewayAddr != "" {
+ gw := proxypool.NewGateway(gatewayAddr)
+ gw.SetUpstream(m.selectPoolUpstream)
+ if err := gw.Start(); err != nil {
+ log.Printf("[proxypool] gateway disabled: %v", err)
+ } else {
+ m.proxyGW = gw
+ }
+ }
return m, nil
}
+// selectPoolUpstream picks a pool proxy URL for a target host, or nil to dial
+// direct. Shared by the MITM upstream (入口A) and the gateway (入口C): honors the
+// pool master switch and the trusted-only egress safety valve.
+func (m *Manager) selectPoolUpstream(host string) *url.URL {
+ if !m.ProxyPoolEnabled() {
+ return nil
+ }
+ p, err := m.pg.Proxies().SelectForHost(host, m.ProxyEgressTrustedOnly())
+ if err != nil || p == nil {
+ return nil
+ }
+ return p.URL()
+}
+
// TrafficEnabled reports whether traffic capture is on (default off). When off,
// no proxy/traffic tools/prompt are injected into agents (nothing is recorded).
func (m *Manager) TrafficEnabled() bool {
@@ -721,22 +752,30 @@ func (m *Manager) settingIntDefault(key string, def int) int {
return n
}
-// ProxyAddr returns the recording proxy address agents route through — empty when
-// traffic capture is off, so no proxy is injected (agent runs direct, no recording).
+// ProxyAddr returns the local proxy address agents route through, decided by the
+// MITM(traffic) × proxy-pool switch matrix:
+// - MITM on → the recording proxy (入口A; pool-on makes its upstream rotate)
+// - MITM off + pool on → the pool gateway (入口C; plain forward, no capture)
+// - both off → "" (direct, nothing injected)
func (m *Manager) ProxyAddr() string {
- if m.traffic == nil || !m.TrafficEnabled() {
- return ""
+ if m.traffic != nil && m.TrafficEnabled() {
+ return m.traffic.ProxyAddr()
}
- return m.traffic.ProxyAddr()
+ if m.proxyGW != nil && m.ProxyPoolEnabled() {
+ return "http://" + m.proxyGW.Addr()
+ }
+ return ""
}
-// ProxyCACert returns the recording proxy's CA cert path (empty when no proxy or
-// traffic capture is off), which WebFetch trusts to verify HTTPS through the MITM.
+// ProxyCACert returns the CA the injected proxy needs trusted. Only the MITM
+// recording proxy terminates TLS and needs its CA; the gateway is a plain tunnel
+// that never decrypts, so it MUST inject no CA (the agent does real TLS to the
+// target). Empty when direct or in gateway mode.
func (m *Manager) ProxyCACert() string {
- if m.traffic == nil || !m.TrafficEnabled() {
- return ""
+ if m.traffic != nil && m.TrafficEnabled() {
+ return m.traffic.CACertPath()
}
- return m.traffic.CACertPath()
+ return ""
}
func (m *Manager) Close() error {
@@ -745,6 +784,9 @@ func (m *Manager) Close() error {
if m.proxyPool != nil {
m.proxyPool.Stop()
}
+ if m.proxyGW != nil {
+ m.proxyGW.Stop()
+ }
if m.traffic != nil {
m.traffic.Close()
}
diff --git a/server/manager_delete_files_test.go b/server/manager_delete_files_test.go
index 48de983..becaaa2 100644
--- a/server/manager_delete_files_test.go
+++ b/server/manager_delete_files_test.go
@@ -13,7 +13,7 @@ import (
)
func TestSeedAssociatesTargetAssetWithTask(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
@@ -172,7 +172,7 @@ func TestStageTaskFilesRollbackReportsRestoreFailure(t *testing.T) {
func TestManagerDeleteTaskRestoresFilesAndTrafficWhenPostgresDeleteFails(t *testing.T) {
dataDir := t.TempDir()
- m, err := NewManager(dataDir, "")
+ m, err := NewManager(dataDir, "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
diff --git a/server/mgmt_test.go b/server/mgmt_test.go
index 9bfebb7..db4525c 100644
--- a/server/mgmt_test.go
+++ b/server/mgmt_test.go
@@ -11,7 +11,7 @@ import (
// TestMgmtAPI exercises the PostgreSQL-backed management API through the real mux.
func TestMgmtAPI(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("database unavailable (%v) — skipping management API test", err)
}
diff --git a/server/server.go b/server/server.go
index 31af817..0fbf582 100644
--- a/server/server.go
+++ b/server/server.go
@@ -2987,6 +2987,7 @@ func (s *Server) settingsPayload() map[string]any {
if concLimit == 0 {
concLimit = defaultConcurrencyLimit // 关闭时也回显一个合理默认值给 UI
}
+ fetchMin, checkMin := s.m.ProxyIntervals()
return map[string]any{
"traffic_capture": s.m.TrafficEnabled(),
"llm_record": s.m.LLMRecordEnabled(),
@@ -3003,6 +3004,11 @@ func (s *Server) settingsPayload() map[string]any {
// 自动切到下一个配置。bind_fallback 仅在轮询开启时有意义(默认关)。
"llm_pool_enabled": s.m.LLMPoolEnabled(),
"llm_pool_bind_fallback": s.m.LLMPoolBindFallback(),
+ // 代理池:主开关;"主出口只走可信代理"安全阀门;抓取/验活间隔(分钟)。
+ "proxy_pool_enabled": s.m.ProxyPoolEnabled(),
+ "proxy_egress_trusted_only": s.m.ProxyEgressTrustedOnly(),
+ "proxy_fetch_interval_min": fetchMin,
+ "proxy_check_interval_min": checkMin,
}
}
@@ -3043,6 +3049,12 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
// 重建 provider 链才生效,走下面的 changed → applyLLM 路径。
LLMPoolEnabled *bool `json:"llm_pool_enabled"`
LLMPoolBindFallback *bool `json:"llm_pool_bind_fallback"`
+ // 代理池:主开关、"仅可信"安全阀门、抓取/验活间隔(分钟)。即时生效,无需重建 agent
+ // (出口注入在每次取 ProxyAddr 时按开关计算;后台 loop 每 tick 读间隔)。
+ ProxyPoolEnabled *bool `json:"proxy_pool_enabled"`
+ ProxyEgressTrustedOnly *bool `json:"proxy_egress_trusted_only"`
+ ProxyFetchIntervalMin *int `json:"proxy_fetch_interval_min"`
+ ProxyCheckIntervalMin *int `json:"proxy_check_interval_min"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, 400, err.Error())
@@ -3114,6 +3126,34 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
}
changed = true
}
+ if req.ProxyPoolEnabled != nil {
+ if err := s.m.SetProxyPoolEnabled(*req.ProxyPoolEnabled); err != nil {
+ writeErr(w, 500, err.Error())
+ return
+ }
+ // 开关改变 ProxyAddr() 注入的出口地址(traffic 关时:网关 vs 直连),需重建
+ // agent 让新出口生效——与 traffic_capture 同理。
+ changed = true
+ }
+ if req.ProxyEgressTrustedOnly != nil {
+ if err := s.m.SetProxyEgressTrustedOnly(*req.ProxyEgressTrustedOnly); err != nil {
+ writeErr(w, 500, err.Error())
+ return
+ }
+ }
+ if req.ProxyFetchIntervalMin != nil || req.ProxyCheckIntervalMin != nil {
+ fetchMin, checkMin := 0, 0
+ if req.ProxyFetchIntervalMin != nil {
+ fetchMin = *req.ProxyFetchIntervalMin
+ }
+ if req.ProxyCheckIntervalMin != nil {
+ checkMin = *req.ProxyCheckIntervalMin
+ }
+ if err := s.m.SetProxyIntervals(fetchMin, checkMin); err != nil {
+ writeErr(w, 500, err.Error())
+ return
+ }
+ }
if req.WebSearchEnabled != nil || req.WebSearchBackend != nil || req.BraveKey != nil || req.TavilyKey != nil || req.WebSearchProxy != nil {
// Fill unspecified fields from current state so a partial PUT doesn't reset them.
on, backend, _, _, _ := s.m.WebSearch()
diff --git a/server/task_admission_test.go b/server/task_admission_test.go
index 6a3e1ef..457accf 100644
--- a/server/task_admission_test.go
+++ b/server/task_admission_test.go
@@ -43,7 +43,7 @@ func restoreConcurrencySetting(t *testing.T, m *Manager) func() {
}
func TestAdmitAlreadyRunningTaskDoesNotQueueAfterLimitDecrease(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
@@ -85,7 +85,7 @@ func TestAdmitAlreadyRunningTaskDoesNotQueueAfterLimitDecrease(t *testing.T) {
}
func TestAdmissionDoesNotOverwriteConcurrentTerminalStatus(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
@@ -122,7 +122,7 @@ func TestAdmissionDoesNotOverwriteConcurrentTerminalStatus(t *testing.T) {
}
func TestAdmitKeepsQueuedBootstrapMode(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
@@ -165,7 +165,7 @@ func TestAdmitKeepsQueuedBootstrapMode(t *testing.T) {
}
func TestTerminalTaskQueuedByAdmissionKeepsExecutionBarrier(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
@@ -213,7 +213,7 @@ func TestTerminalTaskQueuedByAdmissionKeepsExecutionBarrier(t *testing.T) {
}
func TestTimedOutTaskRevivalResetsClockAndSettlingAcrossFIFO(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
@@ -299,7 +299,7 @@ func TestTimedOutTaskRevivalResetsClockAndSettlingAcrossFIFO(t *testing.T) {
}
func TestQueuedTaskCanPauseAndResumeAtFIFOTail(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
@@ -349,7 +349,7 @@ func TestQueuedTaskCanPauseAndResumeAtFIFOTail(t *testing.T) {
}
func TestReadyFIFOIsAdmittedBeforeNewTask(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
@@ -396,7 +396,7 @@ func TestReadyFIFOIsAdmittedBeforeNewTask(t *testing.T) {
}
func TestUnavailableTaskReleasesSlotForReadyQueue(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
@@ -435,7 +435,7 @@ func TestUnavailableTaskReleasesSlotForReadyQueue(t *testing.T) {
}
func TestUnavailableTaskWaitsForActiveLLMCallBeforeParking(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
@@ -489,7 +489,7 @@ func TestUnavailableTaskWaitsForActiveLLMCallBeforeParking(t *testing.T) {
}
func TestRerunAdmissionFailureRestoresIntentState(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
@@ -533,7 +533,7 @@ func TestRerunAdmissionFailureRestoresIntentState(t *testing.T) {
}
func TestTaskLLMResolutionRejectsInvalidExplicitProfile(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
@@ -574,7 +574,7 @@ func TestTaskLLMResolutionRejectsInvalidExplicitProfile(t *testing.T) {
// An Agent binding outranks the task's LLM chain; roles left unbound keep using
// the chain, so binding one role does not move the others off it.
func TestTaskLLMResolutionPrefersAgentBindingOverTaskChain(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
@@ -641,7 +641,7 @@ func TestTaskLLMResolutionPrefersAgentBindingOverTaskChain(t *testing.T) {
}
func TestTaskLLMResolutionReportsDatabaseFailure(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
@@ -686,7 +686,7 @@ func TestTokenStatsWithoutTaskReturnsStableShape(t *testing.T) {
}
func TestBatchControlLimitCountsDeduplicatedIDs(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
@@ -760,7 +760,7 @@ func TestBatchControlLimitCountsDeduplicatedIDs(t *testing.T) {
}
func TestPauseTaskToolPersistsAndDequeuesTask(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
@@ -796,7 +796,7 @@ func TestPauseTaskToolPersistsAndDequeuesTask(t *testing.T) {
}
func TestPauseTaskToolUsesOrchestratorCancellationCause(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
diff --git a/server/task_control_routes_test.go b/server/task_control_routes_test.go
index 26e2ad6..13d9985 100644
--- a/server/task_control_routes_test.go
+++ b/server/task_control_routes_test.go
@@ -9,7 +9,7 @@ import (
)
func TestWorkerControlRoutes(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
diff --git a/server/task_delete_barrier_test.go b/server/task_delete_barrier_test.go
index 361b161..eaf4d45 100644
--- a/server/task_delete_barrier_test.go
+++ b/server/task_delete_barrier_test.go
@@ -145,7 +145,7 @@ func TestTaskLifecycleRechecksDeleteBarrierAfterConcLock(t *testing.T) {
}
func TestAbortTaskDeleteUsesPersistedPauseAndQueueState(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
diff --git a/server/task_llm_test.go b/server/task_llm_test.go
index 73f28f2..e2d4d74 100644
--- a/server/task_llm_test.go
+++ b/server/task_llm_test.go
@@ -476,7 +476,7 @@ func TestTaskLLMStreamStopsWhenChainExhausted(t *testing.T) {
}
func TestProfileDeleteRestoresQuotaBlockedIntentWhenFallbackAvailable(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
diff --git a/server/task_templates_test.go b/server/task_templates_test.go
index a3a9b1b..ab810a5 100644
--- a/server/task_templates_test.go
+++ b/server/task_templates_test.go
@@ -15,7 +15,7 @@ import (
)
func TestTaskTemplateHTTPCRUD(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
@@ -85,7 +85,7 @@ func TestTaskTemplateHTTPCRUD(t *testing.T) {
}
func TestConversationPatchReturnsPinState(t *testing.T) {
- m, err := NewManager(t.TempDir(), "")
+ m, err := NewManager(t.TempDir(), "", "")
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
diff --git a/traffic/traffic.go b/traffic/traffic.go
index e827443..6ea89d0 100644
--- a/traffic/traffic.go
+++ b/traffic/traffic.go
@@ -120,6 +120,21 @@ type Traffic struct {
// reason; connections to them are tunneled transparently (fail-open) so the
// request still reaches the target — unrecorded — instead of being killed.
pass sync.Map // hostname(string) -> struct{}
+ // poolUpstream, if set, picks an upstream proxy for a target host so recorded
+ // traffic egresses through the proxy pool (入口A). nil / returns nil = dial the
+ // target directly. Set by the server; the pool switch/selection lives there.
+ poolUpstream atomic.Pointer[func(host string) *url.URL]
+}
+
+// SetPoolUpstream installs (or clears with nil) the proxy-pool upstream resolver.
+// When set and it returns a non-nil URL for a host, recorded target traffic to
+// that host is forwarded through the returned proxy instead of dialed directly.
+func (t *Traffic) SetPoolUpstream(fn func(host string) *url.URL) {
+ if fn == nil {
+ t.poolUpstream.Store(nil)
+ return
+ }
+ t.poolUpstream.Store(&fn)
}
// Open initializes the traffic tree, blob store and SQLite index under dir.
@@ -159,12 +174,19 @@ func Open(dir, addr string) (*Traffic, error) {
db.Close()
return nil, err
}
- // Dial targets DIRECTLY. go-mitmproxy's default upstream uses
- // http.ProxyFromEnvironment, so an HTTP_PROXY/HTTPS_PROXY in the environment
- // (a VPN/system proxy) would make it forward target requests through that
- // external proxy — which can't reach the target → 502. We capture target
- // traffic directly, never via the host's proxy.
- p.SetUpstreamProxy(func(*http.Request) (*url.URL, error) { return nil, nil })
+ // Upstream selection: by default dial targets DIRECTLY (never via the host's
+ // HTTP_PROXY/HTTPS_PROXY — go-mitmproxy's default would forward through a
+ // VPN/system proxy that can't reach the target → 502). When the proxy pool is
+ // on (入口A), poolUpstream returns a pool proxy for the host so recorded traffic
+ // egresses through it; a nil return falls back to direct.
+ p.SetUpstreamProxy(func(req *http.Request) (*url.URL, error) {
+ if fn := t.poolUpstream.Load(); fn != nil {
+ if u := (*fn)(hostOnly(req.Host)); u != nil {
+ return u, nil
+ }
+ }
+ return nil, nil
+ })
// Fail-open: MITM every host by default, EXCEPT ones a prior request proved we
// can't intercept without breaking (see maybePassthrough). Those are tunneled
// transparently so the request still reaches the target instead of being killed.
diff --git a/web/src/app/(main)/system/proxies/page.tsx b/web/src/app/(main)/system/proxies/page.tsx
index 15b4235..955dd2e 100644
--- a/web/src/app/(main)/system/proxies/page.tsx
+++ b/web/src/app/(main)/system/proxies/page.tsx
@@ -34,7 +34,7 @@ import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import type { ProxyInput, ProxyNode, ProxyProtocol, ProxySource } from "@/lib/types";
-const PROTOCOLS: ProxyProtocol[] = ["http", "https", "socks5", "socks4"];
+const PROTOCOLS: ProxyProtocol[] = ["http", "https", "socks5"];
function emptyInput(): ProxyInput {
return { protocol: "http", host: "", port: 8080, enabled: true, tags: [] };
diff --git a/web/src/app/(main)/system/settings/page.tsx b/web/src/app/(main)/system/settings/page.tsx
index dece7ce..0aa0208 100644
--- a/web/src/app/(main)/system/settings/page.tsx
+++ b/web/src/app/(main)/system/settings/page.tsx
@@ -2,7 +2,7 @@
import * as React from "react";
-import { CpuIcon, KeyboardIcon, RadioTowerIcon, SearchIcon } from "lucide-react";
+import { CpuIcon, KeyboardIcon, RadioTowerIcon, RouteIcon, SearchIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -33,6 +33,8 @@ export default function SystemSettingsPage() {
const [pyInterp, setPyInterp] = React.useState("");
const [workers, setWorkers] = React.useState("3");
const [savingWorkers, setSavingWorkers] = React.useState(false);
+ const [proxyPool, setProxyPool] = React.useState(false);
+ const [proxyTrustedOnly, setProxyTrustedOnly] = React.useState(true);
// 纯前端偏好:不走 /api/settings,直接读写 localStorage。
const sendMode = useChatSendMode();
@@ -45,8 +47,31 @@ export default function SystemSettingsPage() {
setProxyInput(s.web_search_proxy || "");
setPyInterp(s.python_interpreter || "");
setWorkers(String(s.workers ?? 3));
+ setProxyPool(!!s.proxy_pool_enabled);
+ setProxyTrustedOnly(s.proxy_egress_trusted_only ?? true);
}, []);
+ const toggleProxyPool = (v: boolean) => {
+ setProxyPool(v); // optimistic
+ api
+ .setSettings({ proxy_pool_enabled: v })
+ .then(apply)
+ .catch((e) => {
+ setProxyPool(!v);
+ toast.error(`保存失败:${(e as Error).message}`);
+ });
+ };
+ const toggleProxyTrustedOnly = (v: boolean) => {
+ setProxyTrustedOnly(v); // optimistic
+ api
+ .setSettings({ proxy_egress_trusted_only: v })
+ .then(apply)
+ .catch((e) => {
+ setProxyTrustedOnly(!v);
+ toast.error(`保存失败:${(e as Error).message}`);
+ });
+ };
+
const saveWorkers = () => {
const n = Number(workers);
if (!Number.isInteger(n) || n <= 0) {
@@ -444,6 +469,41 @@ export default function SystemSettingsPage() {
+
+
+
+
+ 代理池(出口代理)
+
+
+ 开启后:流量录制开 → MITM 上游走代理池轮换;流量录制关 → 注入代理池网关(纯转发、不抓包)。
+ 代理节点在「代理池」页管理。
+
+ 安全阀门仅可信出口(默认开):主出口只轮换手动/导入的可信代理,免费源节点不进主出口
+ (agent 仍可用 list_proxies 自行选用)。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
index ded271c..b5a924f 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -250,7 +250,7 @@ export interface TaskScopeRow {
}
// ---- 代理池(出口代理轮换)----
-export type ProxyProtocol = "http" | "https" | "socks5" | "socks4";
+export type ProxyProtocol = "http" | "https" | "socks5";
// 一个代理节点。password 由后端打码返回("********"),前端提交时留空表示不改。
// 命名 ProxyNode 而非 Proxy:避免遮蔽全局 Proxy 对象(biome noShadowRestrictedNames)。
@@ -752,6 +752,12 @@ export interface Settings {
llm_pool_enabled?: boolean; // 默认 false
// 绑定了指定配置的 agent/任务失败时是否也回落到轮询链。默认 false = 绑定即独占。
llm_pool_bind_fallback?: boolean;
+ // 代理池主开关(默认关)。开启后:MITM 开→上游走池轮换;MITM 关→注入代理池网关。
+ proxy_pool_enabled?: boolean;
+ // 安全阀门(默认开):主出口只轮换可信(手动/导入)代理,免费源不进主出口。
+ proxy_egress_trusted_only?: boolean;
+ proxy_fetch_interval_min?: number; // 免费源抓取间隔(分钟,默认 15)
+ proxy_check_interval_min?: number; // 验活间隔(分钟,默认 30)
}
// ---- LLM config ----
From b4d3d8b907ce8cd3acb2398ce4ce5b7ca94c09c1 Mon Sep 17 00:00:00 2001
From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com>
Date: Sun, 23 Aug 2026 03:20:55 -0400
Subject: [PATCH 6/8] =?UTF-8?q?feat(proxypool):=20=E5=85=8D=E8=B4=B9?=
=?UTF-8?q?=E6=BA=90=E6=94=AF=E6=8C=81=E7=AB=8B=E5=8D=B3=E6=8A=93=E5=8F=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
代理池页每个源加"立即抓取"按钮,点击对该源抓取一次、入库并触发验活,不等定时。
- proxypool: 抽出 fetchSource(单源),新增 FetchSourceNow(手动抓取 + 新增节点即时验活);
fetchOnce 复用;不受源 enabled 限制,便于开启前先试抓
- server: POST /api/proxy-sources/{name}/fetch
- 前端: api.fetchProxySource + 源行抓取按钮(转圈/结果 toast)
Claude-Session: https://claude.ai/code/session_01Q8nhYZfS4H8fsSCBJXxbSy
---
proxypool/pool.go | 60 ++++++++++++++--------
server/proxies.go | 11 ++++
server/server.go | 1 +
web/src/app/(main)/system/proxies/page.tsx | 32 +++++++++++-
web/src/lib/api.ts | 2 +
5 files changed, 85 insertions(+), 21 deletions(-)
diff --git a/proxypool/pool.go b/proxypool/pool.go
index b8c16da..08ecd64 100644
--- a/proxypool/pool.go
+++ b/proxypool/pool.go
@@ -2,6 +2,7 @@ package proxypool
import (
"context"
+ "errors"
"log"
"net/http"
"sync"
@@ -10,6 +11,9 @@ import (
"github.com/Autumn-27/artex/db"
)
+// errUnknownSource is returned when a fetch targets a name not in BuiltinSources.
+var errUnknownSource = errors.New("unknown proxy source")
+
// Config wires the pool's background loops to live settings via callbacks, so a
// settings change takes effect on the next tick without restarting the loops.
type Config struct {
@@ -108,36 +112,52 @@ func (p *Pool) probeURL() string {
// then immediately probes the freshly-added ones so they don't sit unknown for a
// full check interval.
func (p *Pool) fetchOnce(ctx context.Context) {
- store := p.db.Proxies()
- names, err := store.EnabledSources()
+ names, err := p.db.Proxies().EnabledSources()
if err != nil {
log.Printf("[proxypool] list enabled sources: %v", err)
return
}
for _, name := range names {
- src, ok := sourceByName(name)
- if !ok {
- continue
- }
- proxies, ferr := fetch(ctx, src, p.client)
- if ferr != nil {
- _ = store.RecordFetch(name, 0, trimErr(ferr.Error()))
- log.Printf("[proxypool] fetch %s: %v", name, ferr)
- continue
- }
- added, uerr := store.UpsertFromSource(name, proxies)
- if uerr != nil {
- _ = store.RecordFetch(name, len(proxies), trimErr(uerr.Error()))
- log.Printf("[proxypool] upsert %s: %v", name, uerr)
- continue
- }
- _ = store.RecordFetch(name, len(proxies), "")
- log.Printf("[proxypool] source %s: fetched %d, added %d new", name, len(proxies), added)
+ _, _, _ = p.fetchSource(ctx, name)
}
// Probe whatever is now enabled+unknown so new nodes become usable fast.
p.probeOnce(ctx)
}
+// fetchSource pulls one source by name and upserts its proxies. Returns the total
+// fetched and how many were newly added. Records the outcome regardless of the
+// source's enabled state, so the manual "fetch now" button works on any source.
+func (p *Pool) fetchSource(ctx context.Context, name string) (total, added int, err error) {
+ store := p.db.Proxies()
+ src, ok := sourceByName(name)
+ if !ok {
+ return 0, 0, errUnknownSource
+ }
+ proxies, ferr := fetch(ctx, src, p.client)
+ if ferr != nil {
+ _ = store.RecordFetch(name, 0, trimErr(ferr.Error()))
+ return 0, 0, ferr
+ }
+ added, uerr := store.UpsertFromSource(name, proxies)
+ if uerr != nil {
+ _ = store.RecordFetch(name, len(proxies), trimErr(uerr.Error()))
+ return len(proxies), 0, uerr
+ }
+ _ = store.RecordFetch(name, len(proxies), "")
+ log.Printf("[proxypool] source %s: fetched %d, added %d new", name, len(proxies), added)
+ return len(proxies), added, nil
+}
+
+// FetchSourceNow pulls one source on demand (manual "fetch now" button), then
+// probes so freshly-added nodes become usable without waiting for the loop.
+func (p *Pool) FetchSourceNow(ctx context.Context, name string) (total, added int, err error) {
+ total, added, err = p.fetchSource(ctx, name)
+ if err == nil && added > 0 {
+ go p.probeOnce(context.WithoutCancel(ctx))
+ }
+ return total, added, err
+}
+
// probeOnce concurrently probes every enabled proxy and writes back health.
func (p *Pool) probeOnce(ctx context.Context) {
store := p.db.Proxies()
diff --git a/server/proxies.go b/server/proxies.go
index b1aafd0..52b4042 100644
--- a/server/proxies.go
+++ b/server/proxies.go
@@ -177,6 +177,17 @@ func (s *Server) listProxySources(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"sources": sources})
}
+// fetchProxySource POST /api/proxy-sources/{name}/fetch — pull one free source now.
+func (s *Server) fetchProxySource(w http.ResponseWriter, r *http.Request) {
+ name := r.PathValue("name")
+ total, added, err := s.m.ProxyPool().FetchSourceNow(r.Context(), name)
+ if err != nil {
+ writeErr(w, 400, err.Error())
+ return
+ }
+ writeJSON(w, 200, map[string]any{"fetched": total, "added": added})
+}
+
// setProxySource PUT /api/proxy-sources/{name} — body {"enabled":bool}.
func (s *Server) setProxySource(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
diff --git a/server/server.go b/server/server.go
index 0fbf582..a004d83 100644
--- a/server/server.go
+++ b/server/server.go
@@ -697,6 +697,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/proxies/{id}/check", s.checkProxy)
mux.HandleFunc("GET /api/proxy-sources", s.listProxySources)
mux.HandleFunc("PUT /api/proxy-sources/{name}", s.setProxySource)
+ mux.HandleFunc("POST /api/proxy-sources/{name}/fetch", s.fetchProxySource)
mux.HandleFunc("GET /api/exploration/frontier", s.frontier)
mux.HandleFunc("GET /api/exploration/findings", s.findings)
diff --git a/web/src/app/(main)/system/proxies/page.tsx b/web/src/app/(main)/system/proxies/page.tsx
index 955dd2e..dbf1167 100644
--- a/web/src/app/(main)/system/proxies/page.tsx
+++ b/web/src/app/(main)/system/proxies/page.tsx
@@ -64,6 +64,7 @@ export default function ProxyPoolPage() {
const [sources, setSources] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [checking, setChecking] = React.useState>(new Set());
+ const [fetching, setFetching] = React.useState>(new Set());
const [editing, setEditing] = React.useState(null);
const [form, setForm] = React.useState(null);
const [importOpen, setImportOpen] = React.useState(false);
@@ -192,6 +193,23 @@ export default function ProxyPoolPage() {
}
};
+ const fetchSource = async (name: string) => {
+ setFetching((prev) => new Set(prev).add(name));
+ try {
+ const res = await api.fetchProxySource(name);
+ toast.success(`抓取 ${res.fetched} 个,新增 ${res.added} 个`);
+ await load();
+ } catch (e) {
+ toast.error(`抓取失败:${(e as Error).message}`);
+ } finally {
+ setFetching((prev) => {
+ const next = new Set(prev);
+ next.delete(name);
+ return next;
+ });
+ }
+ };
+
const toggleSource = async (name: string, enabled: boolean) => {
setSources((prev) => prev.map((s) => (s.name === name ? { ...s, enabled } : s)));
try {
@@ -230,7 +248,19 @@ export default function ProxyPoolPage() {
: "尚未抓取"}