From 799bb66fe5d78f5bac6eb9179b964245f3a733d2 Mon Sep 17 00:00:00 2001 From: sagit <36596628+Sagit-chu@users.noreply.github.com> Date: Sun, 26 Apr 2026 16:30:05 +0800 Subject: [PATCH] feat: add local remote address toggle (#472) --- .../2026-04-26-allow-local-remote-addr.md | 171 ++++++++++++++ go-backend/internal/http/handler/handler.go | 14 ++ go-backend/internal/http/handler/mutations.go | 6 +- ...forward_local_remote_addr_contract_test.go | 219 ++++++++++++++++++ vite-frontend/src/pages/config.tsx | 8 + 5 files changed, 416 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-26-allow-local-remote-addr.md create mode 100644 go-backend/tests/contract/forward_local_remote_addr_contract_test.go diff --git a/docs/superpowers/plans/2026-04-26-allow-local-remote-addr.md b/docs/superpowers/plans/2026-04-26-allow-local-remote-addr.md new file mode 100644 index 0000000..86d9c5b --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-allow-local-remote-addr.md @@ -0,0 +1,171 @@ +# Allow Local Remote Address Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a global settings toggle that allows non-admin forward rules to target local/private addresses when explicitly enabled. + +**Architecture:** Keep the existing remote-address safety validator as the default path for non-admin rule changes, but gate its use behind a single backend config lookup in forward create/update handlers. Surface the toggle through the existing `vite_config` settings page and prove behavior with backend contract tests first. + +**Tech Stack:** Go `net/http` + GORM backend, React + TypeScript frontend settings page, Go contract tests. + +--- + +### Task 1: Backend Contract Coverage + +**Files:** +- Modify: `go-backend/tests/contract/forward_contract_test.go` + +- [ ] **Step 1: Write the failing tests** + +Add contract tests that prove the desired behavior: + +```go +t.Run("local remote address is rejected when toggle is off", func(t *testing.T) { + createPayload := map[string]interface{}{ + "name": "deny-local-remote", + "tunnelId": tunnelID, + "remoteAddr": "127.0.0.1:8080", + "strategy": "fifo", + } + createBody, _ := json.Marshal(createPayload) + createReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody)) + createReq.Header.Set("Authorization", adminToken) + createReq.Header.Set("Content-Type", "application/json") + createRes := httptest.NewRecorder() + router.ServeHTTP(createRes, createReq) + + var out response.R + _ = json.NewDecoder(createRes.Body).Decode(&out) + if out.Code == 0 { + t.Fatalf("expected local remote address to be rejected when toggle is off") + } +}) + +t.Run("local remote address is allowed when toggle is on", func(t *testing.T) { + if err := repo.DB().Exec(` + INSERT INTO vite_config(name, value, time) + VALUES(?, ?, ?) + ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time + `, "allow_local_remote_addr", "1", time.Now().UnixMilli()).Error; err != nil { + t.Fatalf("enable allow_local_remote_addr: %v", err) + } + + createPayload := map[string]interface{}{ + "name": "allow-local-remote", + "tunnelId": tunnelID, + "remoteAddr": "127.0.0.1:8080", + "strategy": "fifo", + } + createBody, _ := json.Marshal(createPayload) + createReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody)) + createReq.Header.Set("Authorization", adminToken) + createReq.Header.Set("Content-Type", "application/json") + createRes := httptest.NewRecorder() + router.ServeHTTP(createRes, createReq) + assertCode(t, createRes, 0) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./tests/contract/... -run 'TestForwardContracts|local remote address'` +Expected: FAIL because backend still rejects local/private addresses unconditionally. + +- [ ] **Step 3: Commit** + +Do not commit yet; combine with Task 2 after implementation passes. + +### Task 2: Backend Toggle Implementation + +**Files:** +- Modify: `go-backend/internal/http/handler/mutations.go` + +- [ ] **Step 1: Add a tiny config helper** + +Add a helper near other handler helpers: + +```go +func (h *Handler) allowLocalRemoteAddr() bool { + if h == nil || h.repo == nil { + return false + } + cfg, err := h.repo.GetConfigByName("allow_local_remote_addr") + if err != nil || cfg == nil { + return false + } + return strings.TrimSpace(cfg.Value) == "1" +} +``` + +- [ ] **Step 2: Gate create/update validation behind the helper** + +Replace the unconditional checks with: + +```go +if !h.allowLocalRemoteAddr() { + if err := IsSafeRemoteAddr(remoteAddr); err != nil { + response.WriteJSON(w, response.Err(403, err.Error())) + return + } +} +``` + +- [ ] **Step 3: Run contract tests to verify they pass** + +Run: `go test ./tests/contract/... -run 'TestForwardContracts|local remote address'` +Expected: PASS + +- [ ] **Step 4: Run full backend tests** + +Run: `go test ./...` +Expected: PASS + +### Task 3: Settings Page Toggle + +**Files:** +- Modify: `vite-frontend/src/pages/config.tsx` + +- [ ] **Step 1: Add the config item to the settings schema** + +Add a switch-style item for `allow_local_remote_addr` with warning copy about reduced safety. + +- [ ] **Step 2: Ensure the key is included in config loading/saving paths** + +Add `allow_local_remote_addr` anywhere the page enumerates config keys or groups persisted config values. + +- [ ] **Step 3: Run frontend build** + +Run: `pnpm run build` +Expected: PASS + +- [ ] **Step 4: Run frontend lint** + +Run: `pnpm run lint` +Expected: 0 errors; existing warnings may remain. + +### Task 4: Final Verification + +**Files:** +- Verify only + +- [ ] **Step 1: Re-run backend contracts for the toggle** + +Run: `go test ./tests/contract/... -run 'TestForwardContracts|local remote address'` +Expected: PASS + +- [ ] **Step 2: Re-run full backend tests** + +Run: `go test ./...` +Expected: PASS + +- [ ] **Step 3: Re-run frontend build/lint** + +Run: `pnpm run build && pnpm run lint` +Expected: Build passes, lint has no errors. + +- [ ] **Step 4: Commit** + +```bash +git add go-backend/internal/http/handler/mutations.go go-backend/tests/contract/forward_contract_test.go vite-frontend/src/pages/config.tsx docs/superpowers/specs/2026-04-26-allow-local-remote-addr-design.md docs/superpowers/plans/2026-04-26-allow-local-remote-addr.md +git commit -m "feat: add allow-local-remote-address toggle" +``` diff --git a/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go index c9262d7..357b5a0 100644 --- a/go-backend/internal/http/handler/handler.go +++ b/go-backend/internal/http/handler/handler.go @@ -50,6 +50,7 @@ type Handler struct { } const monitorTunnelQualityEnabledConfigKey = "monitor_tunnel_quality_enabled" +const allowLocalRemoteAddrConfigKey = "allow_local_remote_addr" type loginRequest struct { Username string `json:"username"` @@ -1041,6 +1042,19 @@ func (h *Handler) isTunnelQualityMonitoringEnabled() bool { return strings.TrimSpace(strings.ToLower(cfg.Value)) != "false" } +func (h *Handler) allowLocalRemoteAddr() bool { + if h == nil || h.repo == nil { + return false + } + + cfg, err := h.repo.GetConfigByName(allowLocalRemoteAddrConfigKey) + if err != nil || cfg == nil { + return false + } + + return strings.TrimSpace(strings.ToLower(cfg.Value)) == "true" +} + func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { response.WriteJSON(w, response.ErrDefault("请求失败")) diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 2c5f112..6d041a1 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -1726,11 +1726,13 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.ErrDefault("转发名称和目标地址不能为空")) return } - if roleID != 0 { + if roleID != 0 && !h.allowLocalRemoteAddr() { if err := IsSafeRemoteAddr(remoteAddr); err != nil { response.WriteJSON(w, response.Err(403, err.Error())) return } + } + if roleID != 0 { if speedIDVal, ok := req["speedId"]; ok && speedIDVal != nil { response.WriteJSON(w, response.Err(-1, "普通用户无法设置限速规则")) return @@ -1853,7 +1855,7 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) { if remoteAddr == "" { remoteAddr = forward.RemoteAddr } - if actorRole != 0 { + if actorRole != 0 && !h.allowLocalRemoteAddr() { if err := IsSafeRemoteAddr(remoteAddr); err != nil { response.WriteJSON(w, response.Err(403, err.Error())) return diff --git a/go-backend/tests/contract/forward_local_remote_addr_contract_test.go b/go-backend/tests/contract/forward_local_remote_addr_contract_test.go new file mode 100644 index 0000000..3b6d934 --- /dev/null +++ b/go-backend/tests/contract/forward_local_remote_addr_contract_test.go @@ -0,0 +1,219 @@ +package contract_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "go-backend/internal/auth" + "go-backend/internal/http/handler" + "go-backend/internal/http/response" +) + +func TestForwardLocalRemoteAddrToggleContracts(t *testing.T) { + handler.DisableSafeRemoteAddrCheckForTesting = false + t.Cleanup(func() { + handler.DisableSafeRemoteAddrCheckForTesting = true + }) + + secret := "contract-jwt-secret" + router, repo := setupContractRouter(t, secret) + server := httptest.NewServer(router) + defer server.Close() + + now := time.Now().UnixMilli() + if err := repo.DB().Exec(` + INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) + VALUES(2, 'local_remote_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1) + `, now, now).Error; err != nil { + t.Fatalf("insert user: %v", err) + } + + if err := repo.DB().Exec(` + INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, "local-remote-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil { + t.Fatalf("insert tunnel: %v", err) + } + tunnelID := mustLastInsertID(t, repo, "local-remote-tunnel") + + if err := repo.DB().Exec(` + INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, "local-remote-entry", "local-remote-secret", "10.60.0.1", "10.60.0.1", "", "31000-31010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil { + t.Fatalf("insert node: %v", err) + } + entryNodeID := mustLastInsertID(t, repo, "local-remote-entry") + + if err := repo.DB().Exec(` + INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) + VALUES(?, 1, ?, 31001, 'round', 1, 'tls') + `, tunnelID, entryNodeID).Error; err != nil { + t.Fatalf("insert chain_tunnel: %v", err) + } + + if err := repo.DB().Exec(` + INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) + VALUES(601, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1) + `, tunnelID).Error; err != nil { + t.Fatalf("insert user_tunnel: %v", err) + } + + userToken, err := auth.GenerateToken(2, "local_remote_user", 1, secret) + if err != nil { + t.Fatalf("generate user token: %v", err) + } + + stopNode := startMockNodeSession(t, server.URL, "local-remote-secret") + defer stopNode() + waitNodeStatus(t, repo, entryNodeID, 1) + + t.Run("local remote address is rejected on create when toggle is off", func(t *testing.T) { + createPayload := map[string]interface{}{ + "name": "deny-local-create", + "tunnelId": tunnelID, + "remoteAddr": "127.0.0.1:8080", + "strategy": "fifo", + } + createBody, err := json.Marshal(createPayload) + if err != nil { + t.Fatalf("marshal create payload: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody)) + req.Header.Set("Authorization", userToken) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code == 0 { + t.Fatalf("expected local remote address to be rejected when toggle is off") + } + if !strings.Contains(out.Msg, "internal IP") && !strings.Contains(out.Msg, "内部") { + t.Fatalf("expected internal IP error, got code=%d msg=%q", out.Code, out.Msg) + } + }) + + t.Run("local remote address is allowed on create when toggle is on", func(t *testing.T) { + if err := repo.DB().Exec(` + INSERT INTO vite_config(name, value, time) + VALUES(?, ?, ?) + ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time + `, "allow_local_remote_addr", "true", time.Now().UnixMilli()).Error; err != nil { + t.Fatalf("enable allow_local_remote_addr: %v", err) + } + + createPayload := map[string]interface{}{ + "name": "allow-local-create", + "tunnelId": tunnelID, + "remoteAddr": "127.0.0.1:8080", + "strategy": "fifo", + } + createBody, err := json.Marshal(createPayload) + if err != nil { + t.Fatalf("marshal create payload: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody)) + req.Header.Set("Authorization", userToken) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + assertCode(t, res, 0) + }) + + t.Run("local remote address is rejected on update when toggle is off", func(t *testing.T) { + if err := repo.DB().Exec(` + INSERT INTO vite_config(name, value, time) + VALUES(?, ?, ?) + ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time + `, "allow_local_remote_addr", "false", time.Now().UnixMilli()).Error; err != nil { + t.Fatalf("disable allow_local_remote_addr: %v", err) + } + + createPayload := map[string]interface{}{ + "name": "safe-remote-before-update", + "tunnelId": tunnelID, + "remoteAddr": "8.8.8.8:53", + "strategy": "fifo", + } + createBody, err := json.Marshal(createPayload) + if err != nil { + t.Fatalf("marshal safe create payload: %v", err) + } + createReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody)) + createReq.Header.Set("Authorization", userToken) + createReq.Header.Set("Content-Type", "application/json") + createRes := httptest.NewRecorder() + router.ServeHTTP(createRes, createReq) + assertCode(t, createRes, 0) + + forwardID := mustLastInsertID(t, repo, "safe-remote-before-update") + updatePayload := map[string]interface{}{ + "id": forwardID, + "name": "safe-remote-before-update", + "tunnelId": tunnelID, + "remoteAddr": "127.0.0.1:8081", + "strategy": "fifo", + } + updateBody, err := json.Marshal(updatePayload) + if err != nil { + t.Fatalf("marshal update payload: %v", err) + } + updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody)) + updateReq.Header.Set("Authorization", userToken) + updateReq.Header.Set("Content-Type", "application/json") + updateRes := httptest.NewRecorder() + router.ServeHTTP(updateRes, updateReq) + + var out response.R + if err := json.NewDecoder(updateRes.Body).Decode(&out); err != nil { + t.Fatalf("decode update response: %v", err) + } + if out.Code == 0 { + t.Fatalf("expected local remote address to be rejected on update when toggle is off") + } + if !strings.Contains(out.Msg, "internal IP") && !strings.Contains(out.Msg, "内部") { + t.Fatalf("expected internal IP error on update, got code=%d msg=%q", out.Code, out.Msg) + } + }) + + t.Run("local remote address is allowed on update when toggle is on", func(t *testing.T) { + if err := repo.DB().Exec(` + INSERT INTO vite_config(name, value, time) + VALUES(?, ?, ?) + ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time + `, "allow_local_remote_addr", "true", time.Now().UnixMilli()).Error; err != nil { + t.Fatalf("enable allow_local_remote_addr: %v", err) + } + + var forwardID int64 + if err := repo.DB().Raw(`SELECT id FROM forward WHERE name = ? ORDER BY id DESC LIMIT 1`, "safe-remote-before-update").Row().Scan(&forwardID); err != nil { + t.Fatalf("query forward id: %v", err) + } + + updatePayload := map[string]interface{}{ + "id": forwardID, + "name": "safe-remote-before-update", + "tunnelId": tunnelID, + "remoteAddr": "127.0.0.1:8081", + "strategy": "fifo", + } + updateBody, err := json.Marshal(updatePayload) + if err != nil { + t.Fatalf("marshal update payload: %v", err) + } + updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody)) + updateReq.Header.Set("Authorization", userToken) + updateReq.Header.Set("Content-Type", "application/json") + updateRes := httptest.NewRecorder() + router.ServeHTTP(updateRes, updateReq) + assertCode(t, updateRes, 0) + }) +} diff --git a/vite-frontend/src/pages/config.tsx b/vite-frontend/src/pages/config.tsx index d21f7ca..ad80b39 100644 --- a/vite-frontend/src/pages/config.tsx +++ b/vite-frontend/src/pages/config.tsx @@ -185,6 +185,13 @@ const CONFIG_ITEMS: ConfigItem[] = [ dependsOn: "github_proxy_enabled", dependsValue: "true", }, + { + key: "allow_local_remote_addr", + label: "允许转发到本地地址", + description: + "开启后,普通用户创建或编辑规则时可将目标地址指向 127.0.0.1、10.x.x.x、172.16-31.x.x、192.168.x.x 等本地或内网地址。默认关闭以降低开放代理风险。", + type: "switch", + }, ]; const BACKUP_TYPE_OPTIONS = [ @@ -219,6 +226,7 @@ const getInitialConfigs = (): Record => { "app_favicon", "github_proxy_enabled", "github_proxy_url", + "allow_local_remote_addr", ]; const initialConfigs: Record = {};