mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
feat(proxy): 新增全局出口代理配置
所有目标流量可经统一的全局代理出网(http/https/socks5,支持 user:pass): - 开启流量捕获时,作为 MITM 记录代理的上游(流量照录再经代理出网); 拦截与透传两条路径都走上游,不泄露源 IP。 - 关闭捕获时,直接注入 agent 的 bash 环境与 WebFetch;proxyEnv 增加 ALL_PROXY 以支持 socks5。 配置存于 settings KV 表(无需迁移),前端系统配置页新增「全局代理」卡片。 与网络搜索代理、LLM 代理相互独立。
This commit is contained in:
1 parent
651b961ffc
commit
b513b656aa
10 files changed
+349
-34
No files matched your search
+21
-9
@@ -43,21 +43,33 @@ func TestRenderSystemOverrideAndFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
// worker dual-text via {{if .ProxyAddr}} in a user template, plus the code tail:
|
||||
// [B] trafficTool present only with a proxy, [C] artifact spec always present.
|
||||
// [B] trafficTool present only when RECORDING (caCert set — the MITM is on, so
|
||||
// the traffic_* tools exist), [C] artifact spec always present. The trafficTool
|
||||
// block is gated on the CA (arg 2), NOT on ProxyAddr — a global egress proxy
|
||||
// with capture off routes traffic but records nothing.
|
||||
PromptOverride = func(k string) (string, bool) {
|
||||
return "{{if .ProxyAddr}}走代理 {{.ProxyAddr}}{{else}}手动{{end}}", true
|
||||
}
|
||||
withProxy := workerSystem("127.0.0.1:8080", "/data", "/data")
|
||||
if !strings.HasPrefix(withProxy, "走代理 127.0.0.1:8080") {
|
||||
t.Fatalf("worker proxy branch body: %q", withProxy)
|
||||
recording := workerSystem("127.0.0.1:8080", "/ca.pem", "/data", "/data")
|
||||
if !strings.HasPrefix(recording, "走代理 127.0.0.1:8080") {
|
||||
t.Fatalf("worker proxy branch body: %q", recording)
|
||||
}
|
||||
if !strings.Contains(withProxy, "traffic_search") {
|
||||
t.Fatalf("worker with proxy should inject trafficTool: %q", withProxy)
|
||||
if !strings.Contains(recording, "traffic_search") {
|
||||
t.Fatalf("worker while recording should inject trafficTool: %q", recording)
|
||||
}
|
||||
if !strings.Contains(withProxy, "中间产物输出规约") {
|
||||
t.Fatalf("worker missing artifact tail: %q", withProxy)
|
||||
if !strings.Contains(recording, "中间产物输出规约") {
|
||||
t.Fatalf("worker missing artifact tail: %q", recording)
|
||||
}
|
||||
noProxy := workerSystem("", "/data", "/data")
|
||||
// Egress proxy set but capture OFF (no CA): the ProxyAddr template branch still
|
||||
// renders, but the trafficTool block must NOT — those tools are not registered.
|
||||
egressOnly := workerSystem("127.0.0.1:8080", "", "/data", "/data")
|
||||
if !strings.HasPrefix(egressOnly, "走代理 127.0.0.1:8080") {
|
||||
t.Fatalf("worker egress-only branch body: %q", egressOnly)
|
||||
}
|
||||
if strings.Contains(egressOnly, "traffic_search") {
|
||||
t.Fatalf("worker without recording must NOT inject trafficTool: %q", egressOnly)
|
||||
}
|
||||
noProxy := workerSystem("", "", "/data", "/data")
|
||||
if !strings.HasPrefix(noProxy, "手动") {
|
||||
t.Fatalf("worker no-proxy branch body: %q", noProxy)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProxyEnvEmptyIsNil(t *testing.T) {
|
||||
if env := proxyEnv("", ""); env != nil {
|
||||
t.Fatalf("proxyEnv(\"\", \"\") = %v, want nil (direct)", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyEnvSetsAllProxyForSocks5(t *testing.T) {
|
||||
// Capture-off egress path: a socks5 proxy, no MITM CA. ALL_PROXY must be set
|
||||
// (curl reads socks5 only from there), and no CA vars should appear.
|
||||
env := proxyEnv("socks5://10.0.0.1:1080", "")
|
||||
has := func(prefix string) bool {
|
||||
for _, e := range env {
|
||||
if strings.HasPrefix(e, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
for _, want := range []string{"HTTP_PROXY=", "HTTPS_PROXY=", "ALL_PROXY=", "all_proxy="} {
|
||||
if !has(want) {
|
||||
t.Errorf("proxyEnv missing %s: %v", want, env)
|
||||
}
|
||||
}
|
||||
if has("SSL_CERT_FILE=") || has("CURL_CA_BUNDLE=") {
|
||||
t.Errorf("proxyEnv without CA must not inject CA vars: %v", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyEnvInjectsCAWhenRecording(t *testing.T) {
|
||||
env := proxyEnv("http://127.0.0.1:8788", "/data/ca.pem")
|
||||
has := func(prefix string) bool {
|
||||
for _, e := range env {
|
||||
if strings.HasPrefix(e, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
for _, want := range []string{"SSL_CERT_FILE=", "CURL_CA_BUNDLE=", "REQUESTS_CA_BUNDLE=", "NODE_EXTRA_CA_CERTS="} {
|
||||
if !has(want) {
|
||||
t.Errorf("proxyEnv with CA missing %s: %v", want, env)
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-11
@@ -164,11 +164,14 @@ func (w *Worker) SetProxy(addr, caCert string) { w.proxyAddr, w.proxyCACert = ad
|
||||
func (w *Worker) SetWebSearch(o WebSearchOpts) { w.webSearch = o }
|
||||
|
||||
// proxyEnv builds the Bash-subprocess env that routes child-command HTTP through
|
||||
// the recording proxy and makes the common toolchain trust its MITM CA — so tools
|
||||
// need no manual -x/--proxy/-k. Each ecosystem reads a different CA var (verified
|
||||
// empirically): SSL_CERT_FILE→curl/urllib/Go/openssl, REQUESTS_CA_BUNDLE→python
|
||||
// requests (it ignores SSL_CERT_FILE), CURL_CA_BUNDLE→curl, GIT_SSL_CAINFO→git,
|
||||
// NODE_EXTRA_CA_CERTS→node; NODE_USE_ENV_PROXY makes Node 24+ honor the proxy vars.
|
||||
// the egress proxy (the recording MITM when capture is on, or the global proxy
|
||||
// directly when it is off) and, only when a MITM CA is present, makes the common
|
||||
// toolchain trust it — so tools need no manual -x/--proxy/-k. Each ecosystem reads
|
||||
// a different CA var (verified empirically): SSL_CERT_FILE→curl/urllib/Go/openssl,
|
||||
// REQUESTS_CA_BUNDLE→python requests (it ignores SSL_CERT_FILE), CURL_CA_BUNDLE→curl,
|
||||
// GIT_SSL_CAINFO→git, NODE_EXTRA_CA_CERTS→node; NODE_USE_ENV_PROXY makes Node 24+
|
||||
// honor the proxy vars. ALL_PROXY is set too so a socks5 egress proxy (which curl
|
||||
// only reads from ALL_PROXY, not HTTP(S)_PROXY) works in the capture-off path.
|
||||
// Empty proxyAddr → nil (direct, unchanged env).
|
||||
func proxyEnv(proxyAddr, caCert string) []string {
|
||||
if proxyAddr == "" {
|
||||
@@ -177,6 +180,7 @@ func proxyEnv(proxyAddr, caCert string) []string {
|
||||
env := []string{
|
||||
"HTTP_PROXY=" + proxyAddr, "HTTPS_PROXY=" + proxyAddr,
|
||||
"http_proxy=" + proxyAddr, "https_proxy=" + proxyAddr,
|
||||
"ALL_PROXY=" + proxyAddr, "all_proxy=" + proxyAddr, // socks5 egress: curl reads only this
|
||||
"NODE_USE_ENV_PROXY=1", // Node 24+: honor HTTP(S)_PROXY in built-in fetch/http
|
||||
}
|
||||
if caCert != "" {
|
||||
@@ -215,9 +219,12 @@ const workerDefaultTmpl = `你是一个授权渗透测试系统的"执行者"(wo
|
||||
只在授权范围内操作;若系统提示顶部附有【操作约束】,那是最高优先级红线——任何命令/探测在执行前先自检是否违反,违反即不做(哪怕它落在你领到的意图里)。完成本意图后用一句话总结你做了什么、写回了哪些事实。务实、克制、聚焦这一条意图。`
|
||||
|
||||
// workerTrafficBlock is 段 [B]: the traffic-tool note, code-injected only when
|
||||
// traffic capture is on (proxyAddr set). Not stored, not editable.
|
||||
func workerTrafficBlock(proxyAddr string) string {
|
||||
if proxyAddr == "" {
|
||||
// traffic capture (recording) is on — i.e. the traffic_* tools actually exist.
|
||||
// Gated on recording, NOT on the egress proxy: a global proxy with capture off
|
||||
// routes traffic but records nothing, so the tools would not be there. Not stored,
|
||||
// not editable.
|
||||
func workerTrafficBlock(recording bool) string {
|
||||
if !recording {
|
||||
return ""
|
||||
}
|
||||
return "\n\n**流量工具**:\n- traffic_search / traffic_get / traffic_blob:回看响应、找已访问过的资源,**先查流量、不要重复 curl 同一 URL**。traffic_search **必须指定 host**、默认只回 3 条极轻量索引(id/method/url/status/resp_len,无响应内容),需要更多显式调大 limit;可用 body_contains 在请求/响应正文里做全文搜索(至少 3 字符,支持子串和中文,如找密码/密钥/报错/内网地址);要看某条原文用 traffic_get(id),其中超大正文显示为 @blob sha256:<hash>,用 traffic_blob(hash) 分段取全文。"
|
||||
@@ -254,9 +261,11 @@ func ensureRunDir(base string, taskID, intentID int64) string {
|
||||
// cmdOutDir is the SDK large-tool-output spill dir under an agent's run dir.
|
||||
func cmdOutDir(dir string) string { return filepath.Join(dir, "cmd-output") }
|
||||
|
||||
func workerSystem(proxyAddr, dataDir, runDir string) string {
|
||||
func workerSystem(proxyAddr, caCert, dataDir, runDir string) string {
|
||||
body := renderSystem("worker", workerDefaultTmpl, WorkerVars{ProxyAddr: proxyAddr, DataDir: dataDir, Now: nowStr()})
|
||||
return body + workerTrafficBlock(proxyAddr) + workerArtifactSpec(runDir)
|
||||
// caCert is present only when the recording MITM is on, which is exactly when
|
||||
// the traffic_* tools are registered — so it gates the traffic-tool note.
|
||||
return body + workerTrafficBlock(caCert != "") + workerArtifactSpec(runDir)
|
||||
}
|
||||
|
||||
// renderIntentTask formats the claimed intent for the worker's launch USER message:
|
||||
@@ -351,7 +360,7 @@ func (w *Worker) execute(ctx context.Context, name string, taskID int64, as *db.
|
||||
// 压缩。本次意图的专属工作目录 <workDir>/tasks/<taskID>/i<intentID>,引擎侧先建好。
|
||||
runDir := ensureRunDir(w.workDir, taskID, intent.ID)
|
||||
overview := renderWorkerGraphOverview(tsx.graphOverviewData())
|
||||
sysBody := workerSystem(w.proxyAddr, w.workDir, runDir)
|
||||
sysBody := workerSystem(w.proxyAddr, w.proxyCACert, w.workDir, runDir)
|
||||
if w.wantConstraints() {
|
||||
sysBody += constraintBlock(ts) // 操作约束(若有)注入系统提示,worker 执行时严格遵守
|
||||
}
|
||||
|
||||
+74
-9
@@ -240,6 +240,11 @@ type Manager struct {
|
||||
braveKey string
|
||||
tavilyKey string
|
||||
webSearchProxy string
|
||||
// globalProxy is the egress proxy all target traffic routes through
|
||||
// (http/https/socks5, optional user:pass). Empty = direct. When traffic
|
||||
// capture is on it becomes the MITM's upstream; when capture is off it is
|
||||
// injected into agent bash env / WebFetch directly. See ProxyAddr.
|
||||
globalProxy string
|
||||
}
|
||||
|
||||
// Settings keys the UI toggles at runtime.
|
||||
@@ -250,8 +255,12 @@ const (
|
||||
settingBraveKey = "brave_search_api_key"
|
||||
settingTavilyKey = "tavily_search_api_key"
|
||||
settingWebSearchProxy = "web_search_proxy"
|
||||
settingWorkers = "workers"
|
||||
settingLLMRecord = "llm_record"
|
||||
// settingGlobalProxy is the global egress proxy for all target traffic
|
||||
// (http/https/socks5). Empty = direct. Distinct from web_search_proxy (which
|
||||
// only routes the search backend) and the per-profile LLM proxy.
|
||||
settingGlobalProxy = "global_proxy"
|
||||
settingWorkers = "workers"
|
||||
settingLLMRecord = "llm_record"
|
||||
// LLM 轮询(故障转移)。默认关闭——开启后走「全局激活配置」的 agent 在当前配置
|
||||
// 不可用(余额不足/key 失效/限流/服务异常)时自动切到下一个配置。
|
||||
// settingLLMPoolBindFallback 仅在轮询开启时有意义:默认关闭,即 agent/任务显式
|
||||
@@ -407,6 +416,17 @@ func NewManager(dir, proxyAddr string) (*Manager, error) {
|
||||
if v, ok, _ := pg.GetSetting(settingWebSearchProxy); ok {
|
||||
m.webSearchProxy = v
|
||||
}
|
||||
// Global egress proxy (default: direct). When capture is on, feed it to the
|
||||
// MITM as its upstream so recorded traffic exits through it; when capture is
|
||||
// off, ProxyAddr hands it to agents directly (bash env / WebFetch).
|
||||
if v, ok, _ := pg.GetSetting(settingGlobalProxy); ok {
|
||||
m.globalProxy = strings.TrimSpace(v)
|
||||
}
|
||||
if m.traffic != nil {
|
||||
if err := m.traffic.SetUpstreamProxy(m.globalProxy); err != nil {
|
||||
log.Printf("[proxy] 全局代理 %q 无效,已忽略: %v", m.globalProxy, err)
|
||||
}
|
||||
}
|
||||
m.enrich = enrich.New(m.assets, m.ProxyAddr, 4)
|
||||
// Reconcile the seeded browser MCP with the persisted capture state, so a
|
||||
// restart with capture already on keeps Playwright routed through the proxy.
|
||||
@@ -673,17 +693,28 @@ 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 }
|
||||
|
||||
// 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 egress proxy address agents route target traffic through:
|
||||
// - capture ON → the recording MITM proxy (which itself exits via the global
|
||||
// proxy when one is set); agents also get its CA (see ProxyCACert).
|
||||
// - capture OFF → the global egress proxy directly (empty CA — real target
|
||||
// certs), or "" when no global proxy is set (direct, no recording).
|
||||
//
|
||||
// So the global proxy takes effect in both modes: at the MITM's upstream when
|
||||
// capturing, in the agent's own bash env / WebFetch when not.
|
||||
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()
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.globalProxy
|
||||
}
|
||||
|
||||
// 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 cert path agents must trust to verify HTTPS through
|
||||
// the egress proxy. Non-empty ONLY when traffic capture is on (the MITM re-signs
|
||||
// certs): the global proxy used directly (capture off) is a plain forwarder that
|
||||
// preserves real target certs, so no custom CA is needed there. Its emptiness is
|
||||
// also the worker's "recording off" signal (see workerSystem).
|
||||
func (m *Manager) ProxyCACert() string {
|
||||
if m.traffic == nil || !m.TrafficEnabled() {
|
||||
return ""
|
||||
@@ -691,6 +722,40 @@ func (m *Manager) ProxyCACert() string {
|
||||
return m.traffic.CACertPath()
|
||||
}
|
||||
|
||||
// GlobalProxy returns the configured global egress proxy URL (empty = direct).
|
||||
func (m *Manager) GlobalProxy() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.globalProxy
|
||||
}
|
||||
|
||||
// SetGlobalProxy validates, persists and applies the global egress proxy
|
||||
// (http/https/socks5, optional user:pass; empty = direct). It updates the MITM's
|
||||
// upstream immediately; callers must rebuild agents (applyLLM) afterwards so the
|
||||
// capture-off path (bash env / WebFetch) picks up the change too.
|
||||
func (m *Manager) SetGlobalProxy(raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw != "" {
|
||||
if _, err := traffic.ValidateProxyURL(raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := m.pg.SetSetting(settingGlobalProxy, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.globalProxy = raw
|
||||
m.mu.Unlock()
|
||||
if m.traffic != nil {
|
||||
if err := m.traffic.SetUpstreamProxy(raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Keep the browser MCP's egress in sync with the new global proxy too.
|
||||
m.syncBrowserMCPProxy()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Close() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -3106,6 +3106,7 @@ func (s *Server) settingsPayload() map[string]any {
|
||||
"brave_key_set": strings.TrimSpace(braveKey) != "",
|
||||
"tavily_key_set": strings.TrimSpace(tavilyKey) != "",
|
||||
"web_search_proxy": proxy, // 独立出口代理(http/https/socks5),空=直连
|
||||
"global_proxy": s.m.GlobalProxy(), // 全局出口代理(http/https/socks5),所有目标流量走它,空=直连
|
||||
"python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测)
|
||||
"workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效
|
||||
"task_concurrency_enabled": concOn, // 任务并发上限开关(默认关)
|
||||
@@ -3148,6 +3149,7 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
|
||||
BraveKey *string `json:"brave_search_api_key"`
|
||||
TavilyKey *string `json:"tavily_search_api_key"`
|
||||
WebSearchProxy *string `json:"web_search_proxy"` // 独立出口代理(http/https/socks5);null=不改,""=清空
|
||||
GlobalProxy *string `json:"global_proxy"` // 全局出口代理(http/https/socks5);null=不改,""=清空(直连)
|
||||
PythonInterp *string `json:"python_interpreter"` // 自定义脚本工具的 python 解释器路径
|
||||
Workers *int `json:"workers"` // 并发工作 agent 数(>0);对之后启动的任务生效
|
||||
// 任务并发上限:同时「运行中」的任务数上限。关闭=不限;开启后新建任务超限则排队,有空位自动启动。
|
||||
@@ -3243,6 +3245,14 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if req.GlobalProxy != nil {
|
||||
// Validation failure (bad scheme/host) is a client error, not a 500.
|
||||
if err := s.m.SetGlobalProxy(*req.GlobalProxy); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
changed = true // capture-off egress is baked into agents at build time → rebuild
|
||||
}
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package traffic
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateProxyURL(t *testing.T) {
|
||||
ok := []string{
|
||||
"http://127.0.0.1:8080",
|
||||
"https://proxy.example.com:3128",
|
||||
"socks5://10.0.0.1:1080",
|
||||
"socks5://user:[email protected]:1080",
|
||||
}
|
||||
for _, raw := range ok {
|
||||
if _, err := ValidateProxyURL(raw); err != nil {
|
||||
t.Errorf("ValidateProxyURL(%q) unexpected error: %v", raw, err)
|
||||
}
|
||||
}
|
||||
bad := []string{
|
||||
"127.0.0.1:8080", // no scheme
|
||||
"ftp://host:21", // unsupported scheme
|
||||
"http://", // no host
|
||||
"socks4://10.0.0.1:1080", // unsupported scheme
|
||||
}
|
||||
for _, raw := range bad {
|
||||
if _, err := ValidateProxyURL(raw); err == nil {
|
||||
t.Errorf("ValidateProxyURL(%q) expected error, got nil", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetUpstreamProxyStoreClear(t *testing.T) {
|
||||
tr := &Traffic{}
|
||||
if got := tr.upstream.Load(); got != nil {
|
||||
t.Fatalf("initial upstream = %v, want nil", got)
|
||||
}
|
||||
if err := tr.SetUpstreamProxy("socks5://user:[email protected]:1080"); err != nil {
|
||||
t.Fatalf("SetUpstreamProxy: %v", err)
|
||||
}
|
||||
u := tr.upstream.Load()
|
||||
if u == nil || u.Scheme != "socks5" || u.Host != "10.0.0.1:1080" {
|
||||
t.Fatalf("stored upstream = %v, want socks5://10.0.0.1:1080", u)
|
||||
}
|
||||
if pw, _ := u.User.Password(); u.User.Username() != "user" || pw != "pass" {
|
||||
t.Fatalf("stored upstream lost credentials: %v", u)
|
||||
}
|
||||
// Empty clears back to direct.
|
||||
if err := tr.SetUpstreamProxy(" "); err != nil {
|
||||
t.Fatalf("SetUpstreamProxy(clear): %v", err)
|
||||
}
|
||||
if got := tr.upstream.Load(); got != nil {
|
||||
t.Fatalf("after clear upstream = %v, want nil", got)
|
||||
}
|
||||
// Invalid value is rejected and does not mutate current state.
|
||||
if err := tr.SetUpstreamProxy("nope://x"); err == nil {
|
||||
t.Fatal("SetUpstreamProxy(invalid) expected error")
|
||||
}
|
||||
if got := tr.upstream.Load(); got != nil {
|
||||
t.Fatalf("invalid set mutated upstream to %v, want nil", got)
|
||||
}
|
||||
}
|
||||
+53
-5
@@ -76,7 +76,7 @@ CREATE INDEX IF NOT EXISTS idx_blob_refs_ex ON blob_refs(exchange_id);
|
||||
// must degrade to "no full-text search" rather than take the whole recorder down.
|
||||
// trigram (not the default unicode61) is required for two reasons this subsystem
|
||||
// depends on: it matches arbitrary substrings — "ssw0r" finds "P@ssw0rd" — and it
|
||||
// handles CJK, which unicode61 does not tokenize. Contentless (content='') keeps
|
||||
// handles CJK, which unicode61 does not tokenize. Contentless (content=”) keeps
|
||||
// only the index, since the text itself lives in exchange_bodies; contentless_delete
|
||||
// lets rows be deleted without replaying the original text back in.
|
||||
const ftsSchema = `CREATE VIRTUAL TABLE IF NOT EXISTS ex_fts USING fts5(
|
||||
@@ -120,6 +120,11 @@ 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{}
|
||||
// upstream is the global egress proxy every captured request is forwarded
|
||||
// through (nil = dial targets directly). Both the intercepted and the
|
||||
// transparent-passthrough paths honor it (go-mitmproxy's getUpstreamConn),
|
||||
// so no host escapes it. Hot-swappable at runtime via SetUpstreamProxy.
|
||||
upstream atomic.Pointer[url.URL]
|
||||
}
|
||||
|
||||
// Open initializes the traffic tree, blob store and SQLite index under dir.
|
||||
@@ -159,12 +164,15 @@ func Open(dir, addr string) (*Traffic, error) {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
// Dial targets DIRECTLY. go-mitmproxy's default upstream uses
|
||||
// Upstream selection. By default (no global egress proxy set) targets are
|
||||
// dialed DIRECTLY: go-mitmproxy's own 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 })
|
||||
// external proxy — which can't reach the target → 502. Returning nil forces
|
||||
// a direct dial. When a global egress proxy IS configured (SetUpstreamProxy),
|
||||
// every captured request — intercepted AND transparently tunneled — is
|
||||
// forwarded through it instead, so no host leaks the real source IP.
|
||||
p.SetUpstreamProxy(func(*http.Request) (*url.URL, error) { return t.upstream.Load(), 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.
|
||||
@@ -189,6 +197,46 @@ func hostOnly(hostport string) string {
|
||||
// ProxyAddr returns the address workers should set as HTTP(S)_PROXY.
|
||||
func (t *Traffic) ProxyAddr() string { return "http://127.0.0.1" + t.addr }
|
||||
|
||||
// SetUpstreamProxy points every captured request at a global egress proxy
|
||||
// (http/https/socks5, optional user:pass in the URL). An empty raw string clears
|
||||
// it, restoring direct dialing. The change is atomic and takes effect on the next
|
||||
// connection — no restart, no proxy rebuild. go-mitmproxy dials all three schemes
|
||||
// itself, so socks5 works uniformly here regardless of the target tool.
|
||||
func (t *Traffic) SetUpstreamProxy(raw string) error {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
t.upstream.Store(nil)
|
||||
return nil
|
||||
}
|
||||
u, err := ValidateProxyURL(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.upstream.Store(u)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateProxyURL parses and checks a proxy URL (http/https/socks5, optional
|
||||
// user:pass), returning the parsed URL. Exposed so callers can validate a global
|
||||
// proxy before persisting it even when the traffic proxy itself is disabled.
|
||||
func ValidateProxyURL(raw string) (*url.URL, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析代理地址 %q: %w", raw, err)
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "http", "https", "socks5":
|
||||
case "":
|
||||
return nil, fmt.Errorf("代理 %q 缺少协议(用 http://、https:// 或 socks5://)", raw)
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的代理协议 %q(用 http、https 或 socks5)", u.Scheme)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return nil, fmt.Errorf("代理 %q 缺少主机地址", raw)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// CACertPath returns the PEM CA cert clients must trust to verify HTTPS through
|
||||
// the MITM proxy (go-mitmproxy writes it here on first start).
|
||||
func (t *Traffic) CACertPath() string {
|
||||
|
||||
@@ -26,6 +26,8 @@ export default function SystemSettingsPage() {
|
||||
const [savingTavilyKey, setSavingTavilyKey] = React.useState(false);
|
||||
const [proxyInput, setProxyInput] = React.useState("");
|
||||
const [savingProxy, setSavingProxy] = React.useState(false);
|
||||
const [globalProxyInput, setGlobalProxyInput] = React.useState("");
|
||||
const [savingGlobalProxy, setSavingGlobalProxy] = React.useState(false);
|
||||
const [testing, setTesting] = React.useState(false);
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
@@ -46,6 +48,7 @@ export default function SystemSettingsPage() {
|
||||
setBraveKeySet(!!s.brave_key_set);
|
||||
setTavilyKeySet(!!s.tavily_key_set);
|
||||
setProxyInput(s.web_search_proxy || "");
|
||||
setGlobalProxyInput(s.global_proxy || "");
|
||||
setPyInterp(s.python_interpreter || "");
|
||||
setWorkers(String(s.workers ?? 3));
|
||||
setInjectPlanner(s.constraints_inject_planner !== false);
|
||||
@@ -180,6 +183,18 @@ export default function SystemSettingsPage() {
|
||||
.finally(() => setSavingProxy(false));
|
||||
};
|
||||
|
||||
const saveGlobalProxy = () => {
|
||||
setSavingGlobalProxy(true);
|
||||
api
|
||||
.setSettings({ global_proxy: globalProxyInput.trim() })
|
||||
.then((s) => {
|
||||
apply(s);
|
||||
toast.success(globalProxyInput.trim() ? "已保存全局代理" : "已清除全局代理(改为直连)");
|
||||
})
|
||||
.catch((e) => toast.error("保存失败:" + (e as Error).message))
|
||||
.finally(() => setSavingGlobalProxy(false));
|
||||
};
|
||||
|
||||
// Run a real "test" search ("test") against the CURRENT form values (backend +
|
||||
// proxy + entered key), falling back to saved values server-side. Toasts result.
|
||||
const runTest = () => {
|
||||
@@ -241,6 +256,48 @@ export default function SystemSettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<RadioTowerIcon className="size-4" />
|
||||
全局代理
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
所有 Agent 的<b>目标流量</b>经此代理出网(隐藏源 IP / 走跳板)。支持 <b>http / https / socks5</b>,可带{" "}
|
||||
<code>user:pass</code> 认证。留空=直连。
|
||||
<br />
|
||||
开启<b>流量捕获</b>时,它作为记录代理的<b>上游</b>(流量仍全量落库,再经此代理出网);关闭捕获时,直接注入
|
||||
Agent 的 bash / WebFetch 出网。与网络搜索代理、LLM 代理相互独立。
|
||||
<br />
|
||||
<b>提示</b>:socks5 在<b>关闭捕获</b>时依赖各命令行工具对 <code>ALL_PROXY</code> 的支持(curl 可用,部分工具可能忽略);
|
||||
若主要用 socks5,建议开启流量捕获——此路径由 MITM 亲自拨号,工具无感知、稳定生效。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<Label htmlFor="global-proxy" className="text-sm font-normal text-muted-foreground">
|
||||
代理地址
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="global-proxy"
|
||||
autoComplete="off"
|
||||
placeholder="socks5://user:pass@host:1080 或 http://host:port(留空=直连)"
|
||||
value={globalProxyInput}
|
||||
disabled={!loaded || savingGlobalProxy}
|
||||
onChange={(e) => setGlobalProxyInput(e.target.value)}
|
||||
/>
|
||||
<Button type="button" onClick={saveGlobalProxy} disabled={!loaded || savingGlobalProxy}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{globalProxyInput.trim()
|
||||
? "已配置 · 所有目标流量经此代理出网"
|
||||
: "未配置 · 目标流量直连出网"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
|
||||
@@ -3315,6 +3315,7 @@ export const settings: Settings = {
|
||||
brave_key_set: false,
|
||||
tavily_key_set: true,
|
||||
web_search_proxy: "",
|
||||
global_proxy: "",
|
||||
python_interpreter: "/usr/bin/python3",
|
||||
workers: 3,
|
||||
llm_pool_enabled: true,
|
||||
|
||||
@@ -866,6 +866,9 @@ export interface Settings {
|
||||
tavily_search_api_key?: string;
|
||||
// 独立出口代理(http/https/socks5),用于访问搜索端点;与记录流量的 MITM 代理无关。空=直连。
|
||||
web_search_proxy?: string;
|
||||
// 全局出口代理(http/https/socks5,可带 user:pass),所有目标流量走它。开启流量捕获时作为
|
||||
// MITM 上游;关闭捕获时直接注入 agent 的 bash/WebFetch。空=直连。
|
||||
global_proxy?: string;
|
||||
python_interpreter?: string; // 自定义脚本工具的 python 解释器路径(空=运行时检测)
|
||||
workers?: number; // 并发工作 agent 数(默认3);对之后启动的任务生效
|
||||
// 任务并发上限:同时「运行中」的任务数上限。关闭=不限;开启后新建任务超限则排队,有空位自动启动。
|
||||
|
||||
Reference in new issue
Block a user