接入首次初始化完成接口并严格校验请求

This commit is contained in:
xuans 2026-09-16 19:07:45 +08:00
parent 5f3d3a0a22
commit 8615296565
3 changed files with 254 additions and 14 deletions

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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())