From 4868b1816f7ce99d6ea7d9f1d837c73fa3461e1b Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:00:07 +0800 Subject: [PATCH] fix: persist custom egress IP probe service (#118) --- README.md | 6 + server/egress_probe.go | 47 +++-- server/egress_probe_settings_test.go | 182 ++++++++++++++++++++ server/proxy_autoresume.go | 10 +- server/proxy_pool.go | 6 +- server/server.go | 20 +++ web/src/app/(main)/system/settings/page.tsx | 61 +++++-- web/src/lib/api.ts | 2 +- web/src/lib/mock/data.ts | 1 + web/src/lib/types.ts | 1 + 10 files changed, 300 insertions(+), 36 deletions(-) create mode 100644 server/egress_probe_settings_test.go diff --git a/README.md b/README.md index fcace3e..4face3c 100644 --- a/README.md +++ b/README.md @@ -392,6 +392,12 @@ server { 本版历史记录通过漏洞详情和会话查看,暂未纳入漏洞报告导出或任务归档包,也未自动关联流量包。演示模式只生成明确标注的模拟记录,不请求真实目标。 +### 自定义 IP 探测服务 + +在「系统配置 → 流量与安全」展开「自定义 IP 探测服务(可选)」,填写可访问的 HTTP / HTTPS 回显服务后点击「保存探测服务」。服务需返回纯文本 IP 或 `{"ip":"1.2.3.4"}` 格式的 JSON。支持内网地址,保留对本机、链路本地地址及跨主机重定向的限制。 + +保存后,顶栏出口 IP、代理测试、代理池健康检查和自动重连均使用该地址,刷新页面或重启后仍生效;自定义服务失败时不会再尝试默认公网服务。清空地址并保存可恢复默认的 api.ipify.org / ifconfig.me。修改后也可先点击「测试代理」临时验证,测试不会保存地址。 + ### 本地运行与测试 ```bash diff --git a/server/egress_probe.go b/server/egress_probe.go index d4ae9f3..a848d7d 100644 --- a/server/egress_probe.go +++ b/server/egress_probe.go @@ -26,12 +26,30 @@ import ( "github.com/Autumn-27/artex/traffic" ) -// defaultEgressProbes 按顺序尝试,第一个成功即停;probe_url 可覆盖,供隔离网使用。 +const settingEgressProbeURL = "egress_probe_url" + +// defaultEgressProbes 按顺序尝试,第一个成功即停;自定义地址为空时使用。 var defaultEgressProbes = []string{ "https://api.ipify.org?format=json", // 返回 {"ip":"1.2.3.4"} "https://ifconfig.me/ip", // 返回纯文本 1.2.3.4 } +func (m *Manager) EgressProbeURL() string { + if m == nil || m.pg == nil { + return "" + } + raw, _, _ := m.pg.GetSetting(settingEgressProbeURL) + return strings.TrimSpace(raw) +} + +// egressProbes reads settings for every probe so saves take effect without a restart. +func (m *Manager) egressProbes() []string { + if raw := m.EgressProbeURL(); raw != "" { + return []string{raw} + } + return defaultEgressProbes +} + // validateProbeURL 只放行指向"非本机、非链路本地"的绝对 http(s) 地址。 // // 这个接口要登录,但仍要挡:agent 可能被目标页面诱导去调它,而"本机"在容器里就是 ARTEX @@ -339,9 +357,9 @@ func egressTarget(cfg string, capture, pending bool, agentProxy, agentCA string) // 空 = 测直连、永远返回 200 用 ok 表达结果。 func (s *Server) testEgressProxy(w http.ResponseWriter, r *http.Request) { var req struct { - GlobalProxy string `json:"global_proxy"` - ProbeURL string `json:"probe_url"` // 可选:覆盖默认探测地址 - Resume bool `json:"resume"` // manual settings probe may release paused_by_proxy tasks + GlobalProxy string `json:"global_proxy"` + ProbeURL *string `json:"probe_url"` // 省略=已保存地址;空串=默认;非空=临时覆盖 + Resume bool `json:"resume"` // manual settings probe may release paused_by_proxy tasks } // 请求体只有一个地址,给个上限,免得被塞大 payload。 r.Body = http.MaxBytesReader(w, r.Body, 64<<10) @@ -351,14 +369,17 @@ func (s *Server) testEgressProxy(w http.ResponseWriter, r *http.Request) { return } - probes := defaultEgressProbes - if u := strings.TrimSpace(req.ProbeURL); u != "" { - checked, err := validateProbeURL(u) - if err != nil { - writeErr(w, 400, err.Error()) - return + probes := s.m.egressProbes() + if req.ProbeURL != nil { + probes = defaultEgressProbes + if u := strings.TrimSpace(*req.ProbeURL); u != "" { + checked, err := validateProbeURL(u) + if err != nil { + writeErr(w, 400, err.Error()) + return + } + probes = []string{checked} } - probes = []string{checked} } // 这三个调用决定 agent 到底会走哪条路。 @@ -391,7 +412,7 @@ func (s *Server) testEgressProxy(w http.ResponseWriter, r *http.Request) { rep.Checks = append(rep.Checks, egressCheck{ Key: "direct", Label: "① 直连 IP(不经过代理)", - Result: probeEgress(ctx, "", "", probes, 10*time.Second), + Result: s.runProxyProbe(ctx, "", "", probes, 10*time.Second), }) if configuredProxy != "" { @@ -405,7 +426,7 @@ func (s *Server) testEgressProxy(w http.ResponseWriter, r *http.Request) { Label: "② 经全局代理的出口 IP", // Path 是实际探测地址(经 MITM 时是 8788),排查时看这个。 Path: maskSecrets(probeAddr), - Result: probeEgress(ctx, probeAddr, probeCA, probes, timeout), + Result: s.runProxyProbe(ctx, probeAddr, probeCA, probes, timeout), }) } else { skipReason := "输入框为空,未配置代理" diff --git a/server/egress_probe_settings_test.go b/server/egress_probe_settings_test.go new file mode 100644 index 0000000..99b43c3 --- /dev/null +++ b/server/egress_probe_settings_test.go @@ -0,0 +1,182 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" +) + +func preserveEgressProbeSetting(t *testing.T, m *Manager) { + t.Helper() + old, existed, err := m.pg.GetSetting(settingEgressProbeURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if existed { + if err := m.pg.SetSetting(settingEgressProbeURL, old); err != nil { + t.Error(err) + } + } else if _, err := m.pg.Exec("DELETE FROM settings WHERE key=$1", settingEgressProbeURL); err != nil { + t.Error(err) + } + }) +} + +func TestEgressProbeSettingsPersistence(t *testing.T) { + m := proxyTestManager(t) + preserveEgressProbeSetting(t, m) + if _, err := m.pg.Exec("DELETE FROM settings WHERE key=$1", settingEgressProbeURL); err != nil { + t.Fatal(err) + } + dir := t.TempDir() + ctx, cancel := context.WithCancel(context.Background()) + s := New(ctx, m, dir, dir, dir) + defer func() { + cancel() + s.Close() + }() + token, err := signJWT(s.jwtKey) + if err != nil { + t.Fatal(err) + } + request := func(method, body string, authenticated bool) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, "/api/settings", strings.NewReader(body)) + if authenticated { + req.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, req) + return w + } + for _, method := range []string{http.MethodGet, http.MethodPut} { + if w := request(method, `{"egress_probe_url":"http://10.0.0.5/ip"}`, false); w.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated %s: %d", method, w.Code) + } + } + assertResponse := func(method, body, want string) { + t.Helper() + w := request(method, body, true) + if w.Code != http.StatusOK { + t.Fatalf("%s: status=%d body=%s", method, w.Code, w.Body.String()) + } + var payload map[string]json.RawMessage + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + var got string + if err := json.Unmarshal(payload[settingEgressProbeURL], &got); err != nil || got != want { + t.Fatalf("egress_probe_url=%q want=%q err=%v", got, want, err) + } + } + assertResponse(http.MethodGet, "", "") + if got := m.egressProbes(); !reflect.DeepEqual(got, defaultEgressProbes) { + t.Fatalf("default probes=%v", got) + } + const custom = "http://192.168.132.130:19000/ip" + assertResponse(http.MethodPut, `{"egress_probe_url":" `+custom+` "}`, custom) + assertResponse(http.MethodGet, "", custom) + assertResponse(http.MethodPut, `{}`, custom) + assertResponse(http.MethodPut, `{"egress_probe_url":null}`, custom) + for _, invalid := range []string{"ftp://example.com/ip", "/relative", "http://127.0.0.1:8787", "http://169.254.169.254/latest", "http://[::1]/"} { + body, _ := json.Marshal(map[string]string{settingEgressProbeURL: invalid}) + if w := request(http.MethodPut, string(body), true); w.Code != http.StatusBadRequest { + t.Fatalf("invalid URL %q accepted: %d %s", invalid, w.Code, w.Body.String()) + } + assertResponse(http.MethodGet, "", custom) + } + // A fresh manager must read the persisted value, without sharing runtime state. + reloaded := proxyTestManager(t) + if got := reloaded.egressProbes(); !reflect.DeepEqual(got, []string{custom}) { + t.Fatalf("reloaded probes=%v", got) + } + assertResponse(http.MethodPut, `{"egress_probe_url":" "}`, "") + if got := reloaded.egressProbes(); !reflect.DeepEqual(got, defaultEgressProbes) { + t.Fatalf("cleared probes=%v", got) + } +} + +func TestEgressProbeSavedAndTemporaryURLs(t *testing.T) { + m := proxyTestManager(t) + preserveEgressProbeSetting(t, m) + const saved = "http://10.0.0.5/ip" + if err := m.pg.SetSetting(settingEgressProbeURL, saved); err != nil { + t.Fatal(err) + } + s := &Server{m: m} + for _, tc := range []struct { + name string + body string + want []string + }{ + {"navbar uses saved", `{}`, []string{saved}}, + {"null uses saved", `{"probe_url":null}`, []string{saved}}, + {"unsaved override", `{"probe_url":" https://example.com/ip "}`, []string{"https://example.com/ip"}}, + {"empty previews defaults", `{"probe_url":""}`, defaultEgressProbes}, + {"whitespace previews defaults", `{"probe_url":" "}`, defaultEgressProbes}, + {"global proxy uses saved", `{"global_proxy":"http://10.0.0.9:8080"}`, []string{saved}}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + s.proxyProbe = func(_ context.Context, _, _ string, probes []string, _ time.Duration) egressProbeResult { + calls++ + if !reflect.DeepEqual(probes, tc.want) { + t.Fatalf("probes=%v want=%v", probes, tc.want) + } + return egressProbeResult{OK: true, ExitIP: "203.0.113.7", Probes: []egressAttempt{}} + } + w := httptest.NewRecorder() + s.testEgressProxy(w, httptest.NewRequest(http.MethodPost, "/api/settings/proxy/test", strings.NewReader(tc.body))) + wantCalls := 1 + if strings.Contains(tc.body, "global_proxy") { + wantCalls = 2 + } + if w.Code != http.StatusOK || calls != wantCalls { + t.Fatalf("status=%d calls=%d body=%s", w.Code, calls, w.Body.String()) + } + if got := m.EgressProbeURL(); got != saved { + t.Fatalf("temporary probe changed saved setting: %q", got) + } + }) + } + s.proxyProbe = func(context.Context, string, string, []string, time.Duration) egressProbeResult { + t.Fatal("invalid URL must not send a probe") + return egressProbeResult{} + } + w := httptest.NewRecorder() + s.testEgressProxy(w, httptest.NewRequest(http.MethodPost, "/api/settings/proxy/test", strings.NewReader(`{"probe_url":"http://127.0.0.1"}`))) + if w.Code != http.StatusBadRequest { + t.Fatalf("invalid probe status=%d", w.Code) + } +} + +func TestProxyPoolHealthUsesSavedEgressProbe(t *testing.T) { + m := proxyTestManager(t) + preserveEgressProbeSetting(t, m) + const custom = "http://10.0.0.5/ip" + if err := m.pg.SetSetting(settingEgressProbeURL, custom); err != nil { + t.Fatal(err) + } + m.globalProxy = "http://proxy.example:8080" + m.proxyPool = &proxyPoolState{active: "a", entries: []proxyPoolEntry{{ + ProxyPoolEntry: ProxyPoolEntry{ID: "a", Enabled: true}, RawURL: m.globalProxy, + }}} + s := &Server{m: m, ctx: context.Background(), proxyPoolProbeSem: make(chan struct{}, proxyPoolMaxProbeConcurrency)} + calls := 0 + s.proxyProbe = func(_ context.Context, _, _ string, probes []string, _ time.Duration) egressProbeResult { + calls++ + if !reflect.DeepEqual(probes, []string{custom}) { + t.Errorf("health probes=%v want=%s", probes, custom) + } + return egressProbeResult{Error: "offline test"} + } + s.proxyPoolTickLocked(true) + if calls != 1 { + t.Fatalf("health probe calls=%d", calls) + } +} diff --git a/server/proxy_autoresume.go b/server/proxy_autoresume.go index e68e220..6a972d7 100644 --- a/server/proxy_autoresume.go +++ b/server/proxy_autoresume.go @@ -145,7 +145,7 @@ func (s *Server) handleProxyFailure(taskID string, cause error) { if s.handleProxyPoolFailure(cause) { return } - if probe := s.runProxyProbe(s.ctx, s.m.GlobalProxy(), "", defaultEgressProbes, 10*time.Second); probe.OK { + if probe := s.runProxyProbe(s.ctx, s.m.GlobalProxy(), "", s.m.egressProbes(), 10*time.Second); probe.OK { return } s.pauseProxyTask(taskID, cause) @@ -200,7 +200,7 @@ func (s *Server) handleProxyTrafficFailure(cause error) { } // Confirm the saved global proxy itself before pausing. MITM RequestError can // also represent a target-side failure, and those must not pause a task. - if probe := probeEgress(context.Background(), s.m.GlobalProxy(), "", defaultEgressProbes, 10*time.Second); probe.OK { + if probe := probeEgress(context.Background(), s.m.GlobalProxy(), "", s.m.egressProbes(), 10*time.Second); probe.OK { return } for _, t := range s.m.List() { @@ -219,7 +219,7 @@ func (s *Server) handleProxyToolFailure(ctx context.Context, cause error) { if !isGlobalProxyDialFailure(cause) || strings.TrimSpace(s.m.GlobalProxy()) == "" { return } - if probe := probeEgress(context.Background(), s.m.GlobalProxy(), "", defaultEgressProbes, 10*time.Second); probe.OK { + if probe := probeEgress(context.Background(), s.m.GlobalProxy(), "", s.m.egressProbes(), 10*time.Second); probe.OK { return } if info := agent.RunInfoFrom(ctx); info.TaskID > 0 { @@ -302,7 +302,7 @@ func (s *Server) proxyReconnectTick() { if !claimed { continue } - res := s.runProxyProbe(context.Background(), s.m.GlobalProxy(), "", defaultEgressProbes, 10*time.Second) + res := s.runProxyProbe(context.Background(), s.m.GlobalProxy(), "", s.m.egressProbes(), 10*time.Second) if res.OK { s.proxyReconnectSuccessWithGuards(map[string]proxyReconnectGuard{ t.ID: {generation: round, attempt: attempt, checkAttempt: true}, @@ -431,7 +431,7 @@ func (s *Server) testTaskProxy(w http.ResponseWriter, r *http.Request) { s.proxyReconnectSuccessWithGuards(guards, nil) return } - res := s.runProxyProbe(r.Context(), proxy, "", defaultEgressProbes, 10*time.Second) + res := s.runProxyProbe(r.Context(), proxy, "", s.m.egressProbes(), 10*time.Second) if res.OK { s.proxyReconnectSuccessWithGuards(guards, nil) } diff --git a/server/proxy_pool.go b/server/proxy_pool.go index 7fadf0c..b65f640 100644 --- a/server/proxy_pool.go +++ b/server/proxy_pool.go @@ -578,7 +578,7 @@ func (s *Server) proxyPoolTickLocked(forceHealth bool) proxyPoolTickOutcome { return } defer func() { <-sem }() - res := s.runProxyProbe(serverContext(s), e.RawURL, "", defaultEgressProbes, proxyPoolProbeTimeout) + res := s.runProxyProbe(serverContext(s), e.RawURL, "", s.m.egressProbes(), proxyPoolProbeTimeout) s.m.markProxyProbe(e.ID, res) probeMu.Lock() probeResults[e.ID] = res.OK @@ -695,7 +695,7 @@ func (s *Server) handleProxyPoolFailure(cause error) bool { if cur == "" { return false } - res := s.runProxyProbe(serverContext(s), cur, "", defaultEgressProbes, proxyPoolProbeTimeout) + res := s.runProxyProbe(serverContext(s), cur, "", s.m.egressProbes(), proxyPoolProbeTimeout) if res.OK { return false } @@ -1134,7 +1134,7 @@ func (s *Server) testProxyPoolEntry(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") for _, e := range s.m.proxyPoolEntries() { if e.ID == id && e.Enabled && strings.TrimSpace(e.RawURL) != "" { - res := s.runProxyProbe(r.Context(), e.RawURL, "", defaultEgressProbes, proxyPoolProbeTimeout) + res := s.runProxyProbe(r.Context(), e.RawURL, "", s.m.egressProbes(), proxyPoolProbeTimeout) s.m.markProxyProbe(id, res) if res.OK { s.reconcileProxyPoolAfterProbe(id == s.m.proxyPoolActiveID()) diff --git a/server/server.go b/server/server.go index 114f0e9..230bade 100644 --- a/server/server.go +++ b/server/server.go @@ -3699,6 +3699,7 @@ func (s *Server) settingsPayload() map[string]any { "proxy_reconnect_max_attempts": func() int { n, _ := s.proxyReconnectConfig(); return n }(), "proxy_reconnect_interval_seconds": func() int { _, d := s.proxyReconnectConfig(); return int(d / time.Second) }(), "show_egress_ip": s.m.pg.GetBool(settingShowEgressIP, false), + "egress_probe_url": s.m.EgressProbeURL(), "python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测) "workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效 "worker_pools": s.m.WorkerPools(), // 非空时按池顺序绑定 worker 会话 @@ -3791,6 +3792,7 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { ProxyReconnectMax *int `json:"proxy_reconnect_max_attempts"` ProxyReconnectInterval *int `json:"proxy_reconnect_interval_seconds"` ShowEgressIP *bool `json:"show_egress_ip"` // 在顶栏显示直连/全局代理出口 IP;默认关 + EgressProbeURL *string `json:"egress_probe_url"` // null=不改;空串=默认公共探测服务 PythonInterp *string `json:"python_interpreter"` // 自定义脚本工具的 python 解释器路径 Workers *int `json:"workers"` // 并发工作 agent 数(>0);对之后启动的任务生效 WorkerPools *[]WorkerPool `json:"worker_pools"` @@ -3816,6 +3818,18 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { writeErr(w, 400, err.Error()) return } + if req.EgressProbeURL != nil { + value := strings.TrimSpace(*req.EgressProbeURL) + if value != "" { + checked, err := validateProbeURL(value) + if err != nil { + writeErr(w, 400, err.Error()) + return + } + value = checked + } + req.EgressProbeURL = &value + } if req.ConstraintsInjectPlanner != nil { if err := s.m.pg.SetBool(settingConstraintsInjectPlanner, *req.ConstraintsInjectPlanner); err != nil { writeErr(w, 500, err.Error()) @@ -4060,6 +4074,12 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { default: } } + if req.EgressProbeURL != nil { + if err := s.m.pg.SetSetting(settingEgressProbeURL, *req.EgressProbeURL); err != nil { + writeErr(w, 500, err.Error()) + return + } + } if req.ShowEgressIP != nil { if err := s.m.pg.SetBool(settingShowEgressIP, *req.ShowEgressIP); err != nil { writeErr(w, 500, err.Error()) diff --git a/web/src/app/(main)/system/settings/page.tsx b/web/src/app/(main)/system/settings/page.tsx index 4690afa..1260cd0 100644 --- a/web/src/app/(main)/system/settings/page.tsx +++ b/web/src/app/(main)/system/settings/page.tsx @@ -85,9 +85,11 @@ export default function SystemSettingsPage() { const clearEgressReport = () => { egressSeq.current += 1; setEgressReport(null); + setEgressTesting(false); }; // 探测地址:留空用公网回显;隔离网填内网回显服务。 const [egressProbeUrl, setEgressProbeUrl] = React.useState(""); + const [savingEgressProbeUrl, setSavingEgressProbeUrl] = React.useState(false); const [testing, setTesting] = React.useState(false); const [loaded, setLoaded] = React.useState(false); const [settingsLoaded, setSettingsLoaded] = React.useState(false); @@ -143,6 +145,7 @@ export default function SystemSettingsPage() { setProxyPoolLogs(s.proxy_pool_switch_log ?? []); setProxyReconnectMax(String(s.proxy_reconnect_max_attempts ?? 5)); setProxyReconnectInterval(String(s.proxy_reconnect_interval_seconds ?? 30)); + setEgressProbeUrl(s.egress_probe_url ?? ""); setPyInterp(s.python_interpreter ?? ""); setWorkers(String(s.workers ?? 3)); setWorkerPools(s.worker_pools ?? []); @@ -567,6 +570,21 @@ export default function SystemSettingsPage() { .catch((e) => toast.error("保存失败:" + (e as Error).message)); }; + const saveEgressProbeUrl = async () => { + setSavingEgressProbeUrl(true); + try { + const settings = await api.setSettings({ egress_probe_url: egressProbeUrl.trim() }); + setEgressProbeUrl(settings.egress_probe_url ?? ""); + clearEgressReport(); + toast.success(settings.egress_probe_url ? "已保存 IP 探测服务" : "已恢复默认 IP 探测服务"); + if (showEgressIp) void probeEgress().catch(() => undefined); + } catch (e) { + toast.error(`保存 IP 探测服务失败:${(e as Error).message}`); + } finally { + setSavingEgressProbeUrl(false); + } + }; + // 测表单里当前的值,不要求先保存;后端返回直连 / 经代理两条。 const testEgress = () => { const seq = ++egressSeq.current; @@ -576,8 +594,7 @@ export default function SystemSettingsPage() { .testEgressProxy({ global_proxy: globalProxyInput.trim(), resume: true, - - ...(egressProbeUrl.trim() ? { probe_url: egressProbeUrl.trim() } : {}), + probe_url: egressProbeUrl.trim(), }) .then((rep) => { if (seq !== egressSeq.current) return; // 期间改过配置,这份结果已经过期 @@ -952,8 +969,8 @@ export default function SystemSettingsPage() {
出口 IP 测试说明

- 测试会访问 IP 回显服务(默认 api.ipify.org,失败时改用 ifconfig.me)。结果中的直连 IP 与代理 IP - 可用于确认代理是否生效。修改地址后无需保存即可测试。 + 测试会访问 IP 回显服务。未设置自定义地址时,默认访问 api.ipify.org,失败时改用 ifconfig.me。 + 配置自定义地址后仅使用该服务。结果中的直连 IP 与代理 IP 可用于确认代理是否生效。修改地址后无需保存即可测试。

{/* 内网里公网回显不可达,两行都会失败——不是代理坏了。 */} @@ -961,16 +978,32 @@ export default function SystemSettingsPage() { 自定义 IP 探测服务(可选) -
- { - setEgressProbeUrl(e.target.value); - clearEgressReport(); - }} - /> +
+ +
+ { + setEgressProbeUrl(e.target.value); + clearEgressReport(); + }} + /> + +
+ + 保存后用于顶栏出口 IP、代理测试、健康检查和自动重连,刷新页面或重启后仍生效。清空地址并保存可恢复默认服务。 + 自定义 IP 探测服务需返回纯文本 IP 或 {'{"ip":"1.2.3.4"}'} 格式的 JSON。 若内网环境下无法访问公网,或本机和内网代理共用同一个公网出口, 则可替换为内网 IP 探测服务,此时 ① diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2282fb2..fa44933 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1000,7 +1000,7 @@ export const api = { deepseek_search_api_key?: string; }) => post<{ ok: boolean; error?: string; count?: number; backend?: string }>(`/settings/web-search/test`, patch), - // global_proxy 传空 = 测直连;probe_url 可覆盖默认探测地址(隔离网用)。 + // global_proxy 传空=测直连;probe_url 省略=已保存地址,空串=默认,非空=临时覆盖。 testEgressProxy: (patch: { global_proxy?: string; probe_url?: string; resume?: boolean }) => post(`/settings/proxy/test`, patch), proxyPool: () => diff --git a/web/src/lib/mock/data.ts b/web/src/lib/mock/data.ts index bd56282..687d044 100644 --- a/web/src/lib/mock/data.ts +++ b/web/src/lib/mock/data.ts @@ -3361,6 +3361,7 @@ export const settings: Settings = { proxy_health_cooldown_seconds: 60, proxy_health_check_interval_seconds: 30, show_egress_ip: false, + egress_probe_url: "", python_interpreter: "/usr/bin/python3", workers: 3, worker_pools: [], diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 5fd92ef..199ed6a 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1019,6 +1019,7 @@ export interface Settings { proxy_reconnect_max_attempts?: number; proxy_reconnect_interval_seconds?: number; show_egress_ip?: boolean; // 在顶栏显示直连 / 全局代理出口 IP;默认关闭 + egress_probe_url?: string; // 自定义 IP 探测服务;空=默认公共回显服务 python_interpreter?: string; // 自定义脚本工具的 python 解释器路径(空=运行时检测) workers?: number; // 并发工作 agent 数(默认3);对之后启动的任务生效 worker_pools: WorkerPool[];