diff --git a/backend/internal/bootstrap/router.go b/backend/internal/bootstrap/router.go index 810a732..d8ce26f 100644 --- a/backend/internal/bootstrap/router.go +++ b/backend/internal/bootstrap/router.go @@ -1,6 +1,7 @@ package bootstrap import ( + "bytes" "context" "encoding/json" "errors" @@ -18,6 +19,7 @@ const ( bootstrapSessionRequestLimit = 4 * 1024 bootstrapRequestLimit = 64 * 1024 databaseVerificationTimeout = 20 * time.Second + bootstrapCompletionTimeout = 12 * time.Minute bootstrapDatabaseDisplaySQLite = "SQLite" ) @@ -29,11 +31,16 @@ type bootstrapDNSVerifier interface { Verify(context.Context, setup.DNSVerificationInput) (setup.DNSVerificationResult, error) } +type bootstrapCompleter interface { + Complete(context.Context, CompleteRequest) (CompleteResult, error) +} + // Router 只承载首次初始化接口,不注册正式管理接口。 type Router struct { - sessions *SessionManager - database bootstrapDatabaseVerifier - dns bootstrapDNSVerifier + sessions *SessionManager + database bootstrapDatabaseVerifier + dns bootstrapDNSVerifier + completer bootstrapCompleter } type bootstrapSessionRequest struct { @@ -47,8 +54,8 @@ type bootstrapDNSTestRequest struct { } // NewRouter 创建与正式管理服务完全分离的首次初始化路由。 -func NewRouter(sessions *SessionManager, database bootstrapDatabaseVerifier, dns bootstrapDNSVerifier) *Router { - return &Router{sessions: sessions, database: database, dns: dns} +func NewRouter(sessions *SessionManager, database bootstrapDatabaseVerifier, dns bootstrapDNSVerifier, completer bootstrapCompleter) *Router { + return &Router{sessions: sessions, database: database, dns: dns, completer: completer} } func (router *Router) ServeHTTP(writer http.ResponseWriter, request *http.Request) { @@ -83,6 +90,11 @@ func (router *Router) ServeHTTP(writer http.ResponseWriter, request *http.Reques return } router.testDNS(writer, request) + case "/api/v1/bootstrap/complete": + if !requireBootstrapMethod(writer, request, http.MethodPost) || !router.authenticate(writer, request) { + return + } + router.complete(writer, request) default: writeBootstrapError(writer, http.StatusNotFound, "接口不存在") } @@ -94,6 +106,7 @@ func (router *Router) status(writer http.ResponseWriter) { "session_required": true, "database_test_available": router != nil && router.database != nil, "dns_test_available": router != nil && router.dns != nil, + "complete_available": router != nil && router.completer != nil, "database_drivers": []map[string]string{ {"id": "sqlite", "name": bootstrapDatabaseDisplaySQLite}, {"id": "mysql", "name": "MySQL"}, @@ -217,6 +230,56 @@ func (router *Router) testDNS(writer http.ResponseWriter, request *http.Request) }) } +func (router *Router) complete(writer http.ResponseWriter, request *http.Request) { + if router == nil || router.completer == nil { + writeBootstrapError(writer, http.StatusServiceUnavailable, "初始化提交服务尚未就绪") + return + } + var input CompleteRequest + if err := decodeBootstrapJSON(writer, request, &input, bootstrapRequestLimit); err != nil { + writeBootstrapError(writer, http.StatusBadRequest, "初始化提交请求参数无效") + return + } + ctx, cancel := context.WithTimeout(request.Context(), bootstrapCompletionTimeout) + defer cancel() + result, err := router.completer.Complete(ctx, input) + if err != nil { + writeBootstrapCompletionError(writer, err) + return + } + if !result.Completed || strings.TrimSpace(result.ManagementURL) == "" { + writeBootstrapError(writer, http.StatusInternalServerError, "初始化提交结果无效,请检查服务状态") + return + } + router.sessions.RevokeAll() + http.SetCookie(writer, ClearBootstrapSessionCookie()) + writeBootstrapJSON(writer, http.StatusOK, result) +} + +func writeBootstrapCompletionError(writer http.ResponseWriter, err error) { + var completionErr *CompleteError + switch { + case errors.Is(err, ErrCompleteBusy): + writeBootstrapError(writer, http.StatusConflict, "初始化正在执行,请等待当前请求完成") + case errors.Is(err, ErrCompleteDone): + writeBootstrapError(writer, http.StatusConflict, "初始化已经完成,请使用正式管理入口") + case errors.As(err, &completionErr): + // 不转发底层错误或可注入的说明,防止密码、凭据引用及验证令牌泄露。 + switch completionErr.Kind { + case CompleteErrorInput: + writeBootstrapError(writer, http.StatusBadRequest, "初始化参数无效,请检查管理员、管理入口和服务配置") + case CompleteErrorVerification: + writeBootstrapError(writer, http.StatusUnprocessableEntity, "数据库或 DNS 验证结果无效、过期或不匹配,请重新验证") + case CompleteErrorDependency: + writeBootstrapError(writer, http.StatusBadGateway, "初始化依赖操作失败,请检查数据库、证书签发和防火墙状态后重新验证") + default: + writeBootstrapError(writer, http.StatusInternalServerError, "初始化提交失败,请检查服务器状态后重新验证") + } + default: + writeBootstrapError(writer, http.StatusInternalServerError, "初始化提交失败,请检查服务器状态后重新验证") + } +} + func redactDNSReport(report setup.DNSVerificationReport, credentials map[string]string) setup.DNSVerificationReport { redacted := report redacted.RecordName = redactCredentialValues(redacted.RecordName, credentials) @@ -265,7 +328,15 @@ func decodeBootstrapJSON(writer http.ResponseWriter, request *http.Request, targ } } request.Body = http.MaxBytesReader(writer, request.Body, limit) - decoder := json.NewDecoder(request.Body) + data, err := io.ReadAll(request.Body) + if err != nil { + return err + } + data = bytes.TrimSpace(data) + if len(data) == 0 || data[0] != '{' { + return errors.New("请求体必须是 JSON 对象") + } + decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { return err diff --git a/backend/internal/bootstrap/router_complete_test.go b/backend/internal/bootstrap/router_complete_test.go new file mode 100644 index 0000000..b4769e2 --- /dev/null +++ b/backend/internal/bootstrap/router_complete_test.go @@ -0,0 +1,168 @@ +package bootstrap + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/config" +) + +type fakeBootstrapCompleter struct { + result CompleteResult + err error + calls int + input CompleteRequest +} + +func (completer *fakeBootstrapCompleter) Complete(ctx context.Context, input CompleteRequest) (CompleteResult, error) { + completer.calls++ + completer.input = input + if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > bootstrapCompletionTimeout { + return CompleteResult{}, errors.New("missing completion deadline") + } + return completer.result, completer.err +} + +func TestBootstrapCompleteRequiresSessionAndPost(t *testing.T) { + sessions, cookie := authenticatedBootstrapSession(t) + completer := &fakeBootstrapCompleter{} + router := NewRouter(sessions, nil, nil, completer) + for _, item := range []struct { + method string + cookie *http.Cookie + status int + }{ + {http.MethodPost, nil, http.StatusUnauthorized}, + {http.MethodPost, &http.Cookie{Name: SessionCookieName, Value: "invalid"}, http.StatusUnauthorized}, + {http.MethodGet, cookie, http.StatusMethodNotAllowed}, + {http.MethodPut, cookie, http.StatusMethodNotAllowed}, + } { + var cookies []*http.Cookie + if item.cookie != nil { + cookies = append(cookies, item.cookie) + } + response := performBootstrapRequest(router, item.method, "/api/v1/bootstrap/complete", `{}`, nil, "192.0.2.1:5000", cookies...) + if response.Code != item.status || completer.calls != 0 { + t.Fatalf("非法请求进入提交服务:状态=%d,调用=%d", response.Code, completer.calls) + } + if item.status == http.StatusMethodNotAllowed && response.Header().Get("Allow") != http.MethodPost { + t.Fatal("方法错误应明确允许 POST") + } + assertBootstrapSecurityHeaders(t, response) + } +} + +func TestBootstrapCompleteRejectsInvalidBodiesBeforeSideEffects(t *testing.T) { + for _, item := range []struct { + name, body, contentType string + }{ + {"unknown", `{"extra":true}`, "application/json"}, + {"trailing", `{} {}`, "application/json"}, + {"trailing_null", `{} null`, "application/json"}, + {"null", `null`, "application/json"}, + {"array", `[]`, "application/json"}, + {"empty", ``, "application/json"}, + {"wrong_type", `{"server_port":"8443"}`, "application/json"}, + {"oversized", `{"admin_password":"` + strings.Repeat("x", bootstrapRequestLimit) + `"}`, "application/json"}, + {"media_type", `{}`, "text/plain"}, + } { + t.Run(item.name, func(t *testing.T) { + sessions, cookie := authenticatedBootstrapSession(t) + completer := &fakeBootstrapCompleter{} + response := performBootstrapRequest(NewRouter(sessions, nil, nil, completer), http.MethodPost, + "/api/v1/bootstrap/complete", item.body, map[string]string{"Content-Type": item.contentType}, "192.0.2.1:5000", cookie) + if response.Code != http.StatusBadRequest || completer.calls != 0 { + t.Fatalf("无效正文未提前拒绝:状态=%d,调用=%d", response.Code, completer.calls) + } + }) + } +} + +func TestBootstrapCompleteMapsErrorsWithoutLeakingSecrets(t *testing.T) { + secret := "sensitive-password-token-reference" + for _, item := range []struct { + name string + err error + status int + }{ + {"busy", ErrCompleteBusy, http.StatusConflict}, + {"done", ErrCompleteDone, http.StatusConflict}, + {"input", completeError(CompleteErrorInput, secret, errors.New(secret)), http.StatusBadRequest}, + {"verification", completeError(CompleteErrorVerification, secret, errors.New(secret)), http.StatusUnprocessableEntity}, + {"dependency", completeError(CompleteErrorDependency, secret, errors.New(secret)), http.StatusBadGateway}, + {"internal", completeError(CompleteErrorInternal, secret, errors.New(secret)), http.StatusInternalServerError}, + {"unknown", errors.New(secret), http.StatusInternalServerError}, + } { + t.Run(item.name, func(t *testing.T) { + sessions, cookie := authenticatedBootstrapSession(t) + completer := &fakeBootstrapCompleter{err: item.err} + response := performBootstrapRequest(NewRouter(sessions, nil, nil, completer), http.MethodPost, + "/api/v1/bootstrap/complete", `{}`, nil, "192.0.2.1:5000", cookie) + if response.Code != item.status || completer.calls != 1 { + t.Fatalf("错误映射不正确:%d,%s", response.Code, response.Body.String()) + } + assertResponseExcludes(t, response, secret) + if !sessions.Authenticate(cookie.Value) { + t.Fatal("提交失败不能撤销可供重新验证的初始化会话") + } + }) + } +} + +func TestBootstrapCompleteUnavailableAndInvalidResult(t *testing.T) { + for _, available := range []bool{false, true} { + sessions, cookie := authenticatedBootstrapSession(t) + var completer bootstrapCompleter + status := http.StatusServiceUnavailable + if available { + completer = &fakeBootstrapCompleter{} + status = http.StatusInternalServerError + } + response := performBootstrapRequest(NewRouter(sessions, nil, nil, completer), http.MethodPost, + "/api/v1/bootstrap/complete", `{}`, nil, "192.0.2.1:5000", cookie) + if response.Code != status || !sessions.Authenticate(cookie.Value) { + t.Fatalf("未提交状态不应误报成功或撤销会话:%d", response.Code) + } + } +} + +func TestBootstrapCompleteCommitsAndClearsCookie(t *testing.T) { + fixture := newCompletionFixture(t, "admin.example.test") + request := fixture.request("admin.example.test") + body, err := json.Marshal(request) + if err != nil { + t.Fatal(err) + } + router := NewRouter(fixture.sessions, fixture.database, fixture.dns, fixture.completer(nil)) + cookie := BootstrapSessionCookie(fixture.sessionCookie, time.Now().Add(time.Hour)) + response := performBootstrapRequest(router, http.MethodPost, "/api/v1/bootstrap/complete", string(body), + map[string]string{"Content-Type": "application/json"}, "192.0.2.1:5000", cookie) + if response.Code != http.StatusOK { + t.Fatalf("初始化 HTTP 提交失败:%d,%s", response.Code, response.Body.String()) + } + var result CompleteResult + if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil || !result.Completed || result.ManagementURL != "https://admin.example.test:8443/" { + t.Fatalf("管理入口响应不正确:%+v,%v", result, err) + } + assertResponseExcludes(t, response, request.AdminPassword, request.DatabaseVerificationToken, request.DNSVerificationToken, "secret://", "test-secret") + cookies := response.Result().Cookies() + if len(cookies) != 1 || cookies[0].Name != SessionCookieName || cookies[0].Value != "" || cookies[0].MaxAge != -1 || + !cookies[0].HttpOnly || !cookies[0].Secure || cookies[0].Path != "/api/v1/bootstrap" || cookies[0].SameSite != http.SameSiteStrictMode { + t.Fatalf("初始化成功未安全清除 Cookie:%+v", cookies) + } + stored, err := config.Load(fixture.configPath) + if err != nil || !stored.SetupReady || !fixture.store.has(fixture.dnsRef) { + t.Fatalf("HTTP 提交未保留完整正式配置和凭据:%v", err) + } + for _, path := range []string{"/api/v1/bootstrap/complete", "/api/v1/bootstrap/database/test", "/api/v1/bootstrap/dns/test"} { + replayed := performBootstrapRequest(router, http.MethodPost, path, `{}`, nil, "192.0.2.1:5000", cookie) + if replayed.Code != http.StatusUnauthorized { + t.Fatalf("提交后旧会话仍可调用 %s:%d", path, replayed.Code) + } + } +} diff --git a/backend/internal/bootstrap/router_test.go b/backend/internal/bootstrap/router_test.go index 717d09b..407ce37 100644 --- a/backend/internal/bootstrap/router_test.go +++ b/backend/internal/bootstrap/router_test.go @@ -48,7 +48,7 @@ func TestBootstrapRouterRequiresSessionForProtectedAPIs(t *testing.T) { } database := &fakeBootstrapDatabaseVerifier{} dns := &fakeBootstrapDNSVerifier{} - router := NewRouter(sessions, database, dns) + router := NewRouter(sessions, database, dns, nil) for _, item := range []struct { method string @@ -58,6 +58,7 @@ func TestBootstrapRouterRequiresSessionForProtectedAPIs(t *testing.T) { {http.MethodPost, "/api/v1/bootstrap/database/test", `{"driver":"sqlite"}`}, {http.MethodGet, "/api/v1/bootstrap/dns/providers", ""}, {http.MethodPost, "/api/v1/bootstrap/dns/test", `{"provider":"aliyun"}`}, + {http.MethodPost, "/api/v1/bootstrap/complete", `{}`}, } { response := performBootstrapRequest(router, item.method, item.path, item.body, nil, "192.0.2.10:50000") if response.Code != http.StatusUnauthorized { @@ -75,7 +76,7 @@ func TestBootstrapRouterExchangesTokenOnceAndSetsSecureCookie(t *testing.T) { if err != nil { t.Fatal(err) } - router := NewRouter(sessions, nil, nil) + router := NewRouter(sessions, nil, nil, nil) body := fmt.Sprintf(`{"token":%q}`, token) first := performBootstrapRequest(router, http.MethodPost, "/api/v1/bootstrap/session", body, map[string]string{ "Content-Type": "application/json", @@ -109,7 +110,7 @@ func TestBootstrapRouterIgnoresForwardedHeadersForRateLimit(t *testing.T) { if err != nil { t.Fatal(err) } - router := NewRouter(sessions, nil, nil) + router := NewRouter(sessions, nil, nil, nil) for index := 0; index < bootstrapSourceAttemptLimit; index++ { response := performBootstrapRequest(router, http.MethodPost, "/api/v1/bootstrap/session", `{"token":"wrong"}`, map[string]string{ "Content-Type": "application/json", @@ -147,7 +148,7 @@ func TestBootstrapRouterRejectsInvalidJSONBodies(t *testing.T) { if err != nil { t.Fatal(err) } - response := performBootstrapRequest(NewRouter(sessions, nil, nil), http.MethodPost, "/api/v1/bootstrap/session", item.body, map[string]string{ + response := performBootstrapRequest(NewRouter(sessions, nil, nil, nil), http.MethodPost, "/api/v1/bootstrap/session", item.body, map[string]string{ "Content-Type": item.mediaType, }, "192.0.2.50:53000") if response.Code != item.wantStatus { @@ -160,7 +161,7 @@ func TestBootstrapRouterRejectsInvalidJSONBodies(t *testing.T) { func TestBootstrapRouterOnlyListsAndAcceptsAliyun(t *testing.T) { sessions, cookie := authenticatedBootstrapSession(t) dns := &fakeBootstrapDNSVerifier{} - router := NewRouter(sessions, nil, dns) + router := NewRouter(sessions, nil, dns, nil) providers := performBootstrapRequest(router, http.MethodGet, "/api/v1/bootstrap/dns/providers", "", nil, "192.0.2.60:54000", cookie) if providers.Code != http.StatusOK { @@ -206,7 +207,7 @@ func TestBootstrapRouterResponsesDoNotLeakDatabaseOrDNSCredentials(t *testing.T) {Name: "查询记录", Status: "completed", Message: "已完成安全验证。"}, }}, }} - router := NewRouter(sessions, database, dns) + router := NewRouter(sessions, database, dns, nil) databaseResponse := performBootstrapRequest(router, http.MethodPost, "/api/v1/bootstrap/database/test", `{ "driver":"mysql", @@ -248,7 +249,7 @@ func TestBootstrapRouterRedactsSensitiveUnderlyingErrors(t *testing.T) { Report: report, Err: errors.New("aliyun rejected " + dnsSecret), }} - router := NewRouter(sessions, database, dns) + router := NewRouter(sessions, database, dns, nil) databaseResponse := performBootstrapRequest(router, http.MethodPost, "/api/v1/bootstrap/database/test", fmt.Sprintf(`{ "driver":"mysql","host":"db.example.test","database":"scert", @@ -270,7 +271,7 @@ func TestBootstrapRouterRedactsSensitiveUnderlyingErrors(t *testing.T) { } func TestBootstrapRouterStatusAndUnknownAPIAreSafeJSON(t *testing.T) { - router := NewRouter(nil, nil, nil) + router := NewRouter(nil, nil, nil, nil) status := performBootstrapRequest(router, http.MethodGet, "/api/v1/bootstrap/status", "", nil, "192.0.2.90:57000") if status.Code != http.StatusOK || status.Header().Get("Content-Type") != "application/json; charset=utf-8" { t.Fatalf("Bootstrap 状态响应不正确:%d headers=%v", status.Code, status.Header())