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