diff --git a/go-backend/internal/http/handler/control_plane.go b/go-backend/internal/http/handler/control_plane.go index 17b7c23..a91bdc9 100644 --- a/go-backend/internal/http/handler/control_plane.go +++ b/go-backend/internal/http/handler/control_plane.go @@ -311,7 +311,7 @@ func (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interfa } msg := strings.ToLower(strings.TrimSpace(err.Error())) if tolerateExists { - if strings.Contains(msg, "exists") || strings.Contains(msg, "already") || strings.Contains(msg, "已存在") { + if isAlreadyExistsMessage(msg) { return result, nil } } @@ -907,6 +907,17 @@ func isNotFoundError(err error) bool { return strings.Contains(msg, "not found") || strings.Contains(msg, "不存在") } +func isAlreadyExistsMessage(message string) bool { + msg := strings.ToLower(strings.TrimSpace(message)) + if msg == "" { + return false + } + if strings.Contains(msg, "address already in use") { + return false + } + return strings.Contains(msg, "already exists") || strings.Contains(msg, "已存在") +} + func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} { protocols := []string{"tcp", "udp"} services := make([]map[string]interface{}, 0, 2) diff --git a/go-backend/internal/http/handler/control_plane_test.go b/go-backend/internal/http/handler/control_plane_test.go index 91c3a9b..963d7a9 100644 --- a/go-backend/internal/http/handler/control_plane_test.go +++ b/go-backend/internal/http/handler/control_plane_test.go @@ -53,3 +53,15 @@ func TestShouldTryLegacySingleService(t *testing.T) { t.Fatalf("DeleteService should not require legacy fallback") } } + +func TestIsAlreadyExistsMessage(t *testing.T) { + if !isAlreadyExistsMessage("service demo already exists") { + t.Fatalf("expected already exists message to be tolerated") + } + if !isAlreadyExistsMessage("服务已存在") { + t.Fatalf("expected Chinese already exists message to be tolerated") + } + if isAlreadyExistsMessage("listen tcp [::]:10001: bind: address already in use") { + t.Fatalf("address already in use must not be treated as already exists") + } +} diff --git a/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go index 0759b37..8755f17 100644 --- a/go-backend/internal/http/handler/handler.go +++ b/go-backend/internal/http/handler/handler.go @@ -989,10 +989,27 @@ func (h *Handler) captchaEnabled() (bool, error) { if err != nil { return false, err } - if cfg == nil { + if cfg == nil || !strings.EqualFold(strings.TrimSpace(cfg.Value), "true") { return false, nil } - return strings.EqualFold(cfg.Value, "true"), nil + + siteCfg, err := h.repo.GetConfigByName("cloudflare_site_key") + if err != nil { + return false, err + } + if siteCfg == nil || strings.TrimSpace(siteCfg.Value) == "" { + return false, nil + } + + secretCfg, err := h.repo.GetConfigByName("cloudflare_secret_key") + if err != nil { + return false, err + } + if secretCfg == nil || strings.TrimSpace(secretCfg.Value) == "" { + return false, nil + } + + return true, nil } func (h *Handler) apiClientCaptchaBypassEnabled(r *http.Request) bool { diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index eb4b99b..5b09d74 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -1183,7 +1183,7 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.Err(-2, err.Error())) return } - if err := h.syncForwardServices(createdForward, "AddService", false); err != nil { + if err := h.syncForwardServices(createdForward, "UpdateService", true); err != nil { _ = h.deleteForwardByID(forwardID) response.WriteJSON(w, response.ErrDefault(err.Error())) return diff --git a/go-backend/tests/contract/forward_contract_test.go b/go-backend/tests/contract/forward_contract_test.go index 490440d..b4d1c4a 100644 --- a/go-backend/tests/contract/forward_contract_test.go +++ b/go-backend/tests/contract/forward_contract_test.go @@ -610,6 +610,96 @@ func TestForwardSpeedIDWriteAndClearContracts(t *testing.T) { } } +func TestForwardCreateThenPauseResumeContract(t *testing.T) { + secret := "contract-jwt-secret" + router, repo := setupContractRouter(t, secret) + + adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate admin token: %v", err) + } + + now := time.Now().UnixMilli() + if err := repo.DB().Exec(` + INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, "forward-toggle-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil { + t.Fatalf("insert tunnel: %v", err) + } + tunnelID := mustLastInsertID(t, repo, "forward-toggle-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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, "forward-toggle-node", "forward-toggle-secret", "10.31.0.1", "10.31.0.1", "", "41000-41010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil { + t.Fatalf("insert node: %v", err) + } + nodeID := mustLastInsertID(t, repo, "forward-toggle-node") + + if err := repo.DB().Exec(` + INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) + VALUES(?, 1, ?, 41001, 'round', 1, 'tls') + `, tunnelID, nodeID).Error; err != nil { + t.Fatalf("insert chain_tunnel: %v", err) + } + + server := httptest.NewServer(router) + defer server.Close() + stopNode := startMockNodeSession(t, server.URL, "forward-toggle-secret") + defer stopNode() + + createPayload := map[string]interface{}{ + "name": "forward-toggle-target", + "tunnelId": tunnelID, + "remoteAddr": "1.1.1.1:443", + "strategy": "fifo", + } + createBody, err := json.Marshal(createPayload) + if err != nil { + t.Fatalf("marshal create payload: %v", err) + } + 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) + + forwardID := mustLastInsertID(t, repo, "forward-toggle-target") + + pauseBody, err := json.Marshal(map[string]interface{}{"id": forwardID}) + if err != nil { + t.Fatalf("marshal pause payload: %v", err) + } + pauseReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/pause", bytes.NewReader(pauseBody)) + pauseReq.Header.Set("Authorization", adminToken) + pauseReq.Header.Set("Content-Type", "application/json") + pauseRes := httptest.NewRecorder() + router.ServeHTTP(pauseRes, pauseReq) + assertCode(t, pauseRes, 0) + + pausedStatus := mustQueryInt(t, repo, `SELECT status FROM forward WHERE id = ?`, forwardID) + if pausedStatus != 0 { + t.Fatalf("expected status=0 after pause, got %d", pausedStatus) + } + + resumeBody, err := json.Marshal(map[string]interface{}{"id": forwardID}) + if err != nil { + t.Fatalf("marshal resume payload: %v", err) + } + resumeReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/resume", bytes.NewReader(resumeBody)) + resumeReq.Header.Set("Authorization", adminToken) + resumeReq.Header.Set("Content-Type", "application/json") + resumeRes := httptest.NewRecorder() + router.ServeHTTP(resumeRes, resumeReq) + assertCode(t, resumeRes, 0) + + resumedStatus := mustQueryInt(t, repo, `SELECT status FROM forward WHERE id = ?`, forwardID) + if resumedStatus != 1 { + t.Fatalf("expected status=1 after resume, got %d", resumedStatus) + } +} + func jsonNumber(v int64) string { return strconv.FormatInt(v, 10) } diff --git a/go-backend/tests/contract/limiter_sync_failure_contract_test.go b/go-backend/tests/contract/limiter_sync_failure_contract_test.go index 72694ff..568b5d9 100644 --- a/go-backend/tests/contract/limiter_sync_failure_contract_test.go +++ b/go-backend/tests/contract/limiter_sync_failure_contract_test.go @@ -99,6 +99,77 @@ func TestForwardCreateRollbackWhenLimiterDispatchFailsContract(t *testing.T) { } } +func TestForwardCreateRollbackWhenServiceDispatchReturnsAddressInUseContract(t *testing.T) { + secret := "contract-jwt-secret" + router, r := setupContractRouter(t, secret) + server := httptest.NewServer(router) + defer server.Close() + + adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate admin token: %v", err) + } + + now := time.Now().UnixMilli() + if err := r.DB().Exec(` + INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, "addr-in-use-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil { + t.Fatalf("insert tunnel: %v", err) + } + tunnelID := mustLastInsertID(t, r, "addr-in-use-tunnel") + + if err := r.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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, "addr-in-use-node", "addr-in-use-secret", "10.20.0.11", "10.20.0.11", "", "32100-32110", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil { + t.Fatalf("insert node: %v", err) + } + nodeID := mustLastInsertID(t, r, "addr-in-use-node") + + if err := r.DB().Exec(` + INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) + VALUES(?, 1, ?, 32101, 'round', 1, 'tls') + `, tunnelID, nodeID).Error; err != nil { + t.Fatalf("insert chain_tunnel: %v", err) + } + + stopNode := startMockNodeSessionWithCommandFailures(t, server.URL, "addr-in-use-secret", map[string]string{ + "updateservice": "listen tcp [::]:32101: bind: address already in use", + "addservice": "listen tcp [::]:32101: bind: address already in use", + }) + defer stopNode() + + payload := map[string]interface{}{ + "name": "addr-in-use-forward", + "tunnelId": tunnelID, + "remoteAddr": "1.1.1.1:443", + "strategy": "fifo", + } + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(body)) + req.Header.Set("Authorization", adminToken) + 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 create failure on address-in-use service dispatch, got code=0") + } + + forwardCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM forward WHERE name = ?`, "addr-in-use-forward") + if forwardCount != 0 { + t.Fatalf("expected forward rollback delete on address-in-use failure, got count=%d", forwardCount) + } +} + func TestBatchAssignRollbackWhenLimiterDispatchFailsContract(t *testing.T) { secret := "contract-jwt-secret" router, r := setupContractRouter(t, secret) diff --git a/go-backend/tests/contract/migration_contract_test.go b/go-backend/tests/contract/migration_contract_test.go index 02201fe..9391cd5 100644 --- a/go-backend/tests/contract/migration_contract_test.go +++ b/go-backend/tests/contract/migration_contract_test.go @@ -25,6 +25,7 @@ import ( func TestCaptchaVerifyLoginContract(t *testing.T) { secret := "contract-jwt-secret" router, r := setupContractRouter(t, secret) + verifiedToken := "" if err := r.DB().Exec(` INSERT INTO vite_config(name, value, time) @@ -34,6 +35,55 @@ func TestCaptchaVerifyLoginContract(t *testing.T) { t.Fatalf("enable captcha: %v", err) } + t.Run("login allowed when cloudflare keys are missing", func(t *testing.T) { + body := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":""}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + assertCode(t, resp, 0) + }) + + t.Run("captcha verify remains compatible without cloudflare secret", func(t *testing.T) { + verifyReq := httptest.NewRequest(http.MethodPost, "/api/v1/captcha/verify", bytes.NewBufferString(`{"id":"captcha-token-1","data":"ok"}`)) + verifyReq.Header.Set("Content-Type", "application/json") + verifyResp := httptest.NewRecorder() + + router.ServeHTTP(verifyResp, verifyReq) + + var verifyOut struct { + Success bool `json:"success"` + Data struct { + ValidToken string `json:"validToken"` + } `json:"data"` + } + if err := json.NewDecoder(verifyResp.Body).Decode(&verifyOut); err != nil { + t.Fatalf("decode captcha verify response: %v", err) + } + if !verifyOut.Success || verifyOut.Data.ValidToken != "captcha-token-1" { + t.Fatalf("unexpected captcha verify payload: success=%v token=%q", verifyOut.Success, verifyOut.Data.ValidToken) + } + + verifiedToken = verifyOut.Data.ValidToken + }) + + if err := r.DB().Exec(` + INSERT INTO vite_config(name, value, time) + VALUES(?, ?, ?) + ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time + `, "cloudflare_site_key", "test-site-key", time.Now().UnixMilli()).Error; err != nil { + t.Fatalf("set cloudflare site key: %v", err) + } + if err := r.DB().Exec(` + INSERT INTO vite_config(name, value, time) + VALUES(?, ?, ?) + ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time + `, "cloudflare_secret_key", "test-secret-key", time.Now().UnixMilli()).Error; err != nil { + t.Fatalf("set cloudflare secret key: %v", err) + } + t.Run("login denied without verified captcha token", func(t *testing.T) { body := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":""}`) req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body) @@ -58,33 +108,18 @@ func TestCaptchaVerifyLoginContract(t *testing.T) { }) t.Run("captcha token is one-time and consumed by login", func(t *testing.T) { - verifyReq := httptest.NewRequest(http.MethodPost, "/api/v1/captcha/verify", bytes.NewBufferString(`{"id":"captcha-token-1","data":"ok"}`)) - verifyReq.Header.Set("Content-Type", "application/json") - verifyResp := httptest.NewRecorder() - - router.ServeHTTP(verifyResp, verifyReq) - - var verifyOut struct { - Success bool `json:"success"` - Data struct { - ValidToken string `json:"validToken"` - } `json:"data"` - } - if err := json.NewDecoder(verifyResp.Body).Decode(&verifyOut); err != nil { - t.Fatalf("decode captcha verify response: %v", err) - } - if !verifyOut.Success || verifyOut.Data.ValidToken != "captcha-token-1" { - t.Fatalf("unexpected captcha verify payload: success=%v token=%q", verifyOut.Success, verifyOut.Data.ValidToken) + if strings.TrimSpace(verifiedToken) == "" { + t.Fatalf("expected verified token from compatibility captcha verify") } - loginBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"captcha-token-1"}`) + loginBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"` + verifiedToken + `"}`) loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", loginBody) loginReq.Header.Set("Content-Type", "application/json") loginResp := httptest.NewRecorder() router.ServeHTTP(loginResp, loginReq) assertCode(t, loginResp, 0) - replayBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"captcha-token-1"}`) + replayBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"` + verifiedToken + `"}`) replayReq := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", replayBody) replayReq.Header.Set("Content-Type", "application/json") replayResp := httptest.NewRecorder()