package bootstrap import ( "context" "encoding/json" "errors" "io" "mime" "net/http" "strings" "time" "git.pcux.cn/xuans/Scert/backend/internal/dnsprovider" "git.pcux.cn/xuans/Scert/backend/internal/setup" ) const ( bootstrapSessionRequestLimit = 4 * 1024 bootstrapRequestLimit = 64 * 1024 databaseVerificationTimeout = 20 * time.Second bootstrapDatabaseDisplaySQLite = "SQLite" ) type bootstrapDatabaseVerifier interface { Verify(context.Context, DatabaseInput) (DatabaseVerificationResult, error) } type bootstrapDNSVerifier interface { Verify(context.Context, setup.DNSVerificationInput) (setup.DNSVerificationResult, error) } // Router 只承载首次初始化接口,不注册正式管理接口。 type Router struct { sessions *SessionManager database bootstrapDatabaseVerifier dns bootstrapDNSVerifier } type bootstrapSessionRequest struct { Token string `json:"token"` } type bootstrapDNSTestRequest struct { Provider string `json:"provider"` TestDomain string `json:"test_domain"` Credentials map[string]string `json:"credentials"` } // NewRouter 创建与正式管理服务完全分离的首次初始化路由。 func NewRouter(sessions *SessionManager, database bootstrapDatabaseVerifier, dns bootstrapDNSVerifier) *Router { return &Router{sessions: sessions, database: database, dns: dns} } func (router *Router) ServeHTTP(writer http.ResponseWriter, request *http.Request) { setBootstrapSecurityHeaders(writer) if request == nil { writeBootstrapError(writer, http.StatusBadRequest, "请求无效") return } switch request.URL.Path { case "/api/v1/bootstrap/status": if !requireBootstrapMethod(writer, request, http.MethodGet) { return } router.status(writer) case "/api/v1/bootstrap/session": if !requireBootstrapMethod(writer, request, http.MethodPost) { return } router.exchangeSession(writer, request) case "/api/v1/bootstrap/database/test": if !requireBootstrapMethod(writer, request, http.MethodPost) || !router.authenticate(writer, request) { return } router.testDatabase(writer, request) case "/api/v1/bootstrap/dns/providers": if !requireBootstrapMethod(writer, request, http.MethodGet) || !router.authenticate(writer, request) { return } router.listDNSProviders(writer) case "/api/v1/bootstrap/dns/test": if !requireBootstrapMethod(writer, request, http.MethodPost) || !router.authenticate(writer, request) { return } router.testDNS(writer, request) default: writeBootstrapError(writer, http.StatusNotFound, "接口不存在") } } func (router *Router) status(writer http.ResponseWriter) { writeBootstrapJSON(writer, http.StatusOK, map[string]any{ "mode": "bootstrap", "session_required": true, "database_test_available": router != nil && router.database != nil, "dns_test_available": router != nil && router.dns != nil, "database_drivers": []map[string]string{ {"id": "sqlite", "name": bootstrapDatabaseDisplaySQLite}, {"id": "mysql", "name": "MySQL"}, {"id": "postgres", "name": "PostgreSQL"}, }, "dns_provider": dnsprovider.Aliyun, }) } func (router *Router) exchangeSession(writer http.ResponseWriter, request *http.Request) { if router == nil || router.sessions == nil { writeBootstrapError(writer, http.StatusServiceUnavailable, "初始化会话服务尚未就绪") return } var input bootstrapSessionRequest if err := decodeBootstrapJSON(writer, request, &input, bootstrapSessionRequestLimit); err != nil { writeBootstrapError(writer, http.StatusBadRequest, "初始化令牌请求无效") return } if strings.TrimSpace(input.Token) == "" || len(input.Token) > 1024 { writeBootstrapError(writer, http.StatusBadRequest, "初始化令牌格式无效") return } session, err := router.sessions.Exchange(input.Token, request.RemoteAddr) if err != nil { switch { case errors.Is(err, ErrBootstrapRateLimited): writeBootstrapError(writer, http.StatusTooManyRequests, "初始化验证尝试过多,请稍后重试") case errors.Is(err, ErrBootstrapBusy), errors.Is(err, ErrBootstrapSessionFull): writeBootstrapError(writer, http.StatusServiceUnavailable, "初始化会话服务繁忙,请稍后重试") default: writeBootstrapError(writer, http.StatusUnauthorized, "初始化令牌无效或已经失效") } return } http.SetCookie(writer, BootstrapSessionCookie(session.Token, session.ExpiresAt)) writeBootstrapJSON(writer, http.StatusOK, map[string]any{ "authenticated": true, "expires_at": session.ExpiresAt, }) } func (router *Router) testDatabase(writer http.ResponseWriter, request *http.Request) { if router == nil || router.database == nil { writeBootstrapError(writer, http.StatusServiceUnavailable, "数据库验证服务尚未就绪") return } var input DatabaseInput if err := decodeBootstrapJSON(writer, request, &input, bootstrapRequestLimit); err != nil { writeBootstrapError(writer, http.StatusBadRequest, "数据库测试请求参数无效") return } ctx, cancel := context.WithTimeout(request.Context(), databaseVerificationTimeout) defer cancel() result, err := router.database.Verify(ctx, input) if err != nil { writeBootstrapError(writer, http.StatusUnprocessableEntity, "数据库连接或写权限验证失败,请检查连接参数、TLS 配置和账号权限") return } writeBootstrapJSON(writer, http.StatusOK, map[string]any{ "verified": true, "driver": result.Config.Driver, "verification_token": result.VerificationToken, "expires_at": result.ExpiresAt, "verification_timeout_secs": int(databaseVerificationTimeout.Seconds()), "message": "数据库连接和写权限验证通过。", }) } func (router *Router) listDNSProviders(writer http.ResponseWriter) { descriptor, err := dnsprovider.GetDescriptor(dnsprovider.Aliyun) if err != nil { writeBootstrapError(writer, http.StatusServiceUnavailable, "阿里云 DNS 配置尚未就绪") return } writeBootstrapJSON(writer, http.StatusOK, map[string]any{"items": []dnsprovider.Descriptor{descriptor}}) } func (router *Router) testDNS(writer http.ResponseWriter, request *http.Request) { if router == nil || router.dns == nil { writeBootstrapError(writer, http.StatusServiceUnavailable, "DNS 在线验证服务尚未就绪") return } var input bootstrapDNSTestRequest if err := decodeBootstrapJSON(writer, request, &input, bootstrapRequestLimit); err != nil { writeBootstrapError(writer, http.StatusBadRequest, "DNS 测试请求参数无效") return } if !strings.EqualFold(strings.TrimSpace(input.Provider), dnsprovider.Aliyun) { writeBootstrapError(writer, http.StatusBadRequest, "首次初始化仅支持阿里云 DNS") return } timeout := dnsprovider.PermissionVerificationTimeoutFor(dnsprovider.Aliyun) ctx, cancel := context.WithTimeout(request.Context(), timeout) defer cancel() result, err := router.dns.Verify(ctx, setup.DNSVerificationInput{ ProviderID: dnsprovider.Aliyun, TestDomain: input.TestDomain, Credentials: input.Credentials, }) if err != nil { report := redactDNSReport(result.TestReport, input.Credentials) var verificationErr *setup.DNSVerificationError if errors.As(err, &verificationErr) { report = redactDNSReport(verificationErr.Report, input.Credentials) } writeBootstrapJSON(writer, http.StatusUnprocessableEntity, map[string]any{ "error": "阿里云 DNS 在线验证失败,请根据验证步骤检查凭据、域名和 RAM 权限", "test_report": report, "verification_timeout_secs": int(timeout.Seconds()), }) return } writeBootstrapJSON(writer, http.StatusOK, map[string]any{ "verified": true, "verification_token": result.VerificationToken, "expires_at": result.ExpiresAt, "test_report": redactDNSReport(result.TestReport, input.Credentials), "verification_timeout_secs": int(timeout.Seconds()), "message": "阿里云 DNS 创建、查询和清理验证通过。", }) } func redactDNSReport(report setup.DNSVerificationReport, credentials map[string]string) setup.DNSVerificationReport { redacted := report redacted.RecordName = redactCredentialValues(redacted.RecordName, credentials) redacted.Stages = append([]setup.DNSVerificationStage(nil), report.Stages...) for index := range redacted.Stages { redacted.Stages[index].Name = redactCredentialValues(redacted.Stages[index].Name, credentials) redacted.Stages[index].Status = redactCredentialValues(redacted.Stages[index].Status, credentials) redacted.Stages[index].Message = redactCredentialValues(redacted.Stages[index].Message, credentials) } return redacted } func redactCredentialValues(value string, credentials map[string]string) string { for _, credential := range credentials { credential = strings.TrimSpace(credential) if credential != "" { value = strings.ReplaceAll(value, credential, "[已隐藏]") } } return value } func (router *Router) authenticate(writer http.ResponseWriter, request *http.Request) bool { if router == nil || router.sessions == nil { writeBootstrapError(writer, http.StatusServiceUnavailable, "初始化会话服务尚未就绪") return false } cookie, err := request.Cookie(SessionCookieName) if err != nil || !router.sessions.Authenticate(cookie.Value) { http.SetCookie(writer, ClearBootstrapSessionCookie()) writeBootstrapError(writer, http.StatusUnauthorized, "初始化会话无效或已经过期") return false } return true } func decodeBootstrapJSON(writer http.ResponseWriter, request *http.Request, target any, limit int64) error { if request == nil || request.Body == nil { return errors.New("请求体为空") } contentType := strings.TrimSpace(request.Header.Get("Content-Type")) if contentType != "" { mediaType, _, err := mime.ParseMediaType(contentType) if err != nil || mediaType != "application/json" { return errors.New("请求体必须使用 application/json") } } request.Body = http.MaxBytesReader(writer, request.Body, limit) decoder := json.NewDecoder(request.Body) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { return err } if err := decoder.Decode(&struct{}{}); err != io.EOF { if err == nil { return errors.New("请求体包含多段 JSON 数据") } return err } return nil } func requireBootstrapMethod(writer http.ResponseWriter, request *http.Request, method string) bool { if request.Method == method { return true } writer.Header().Set("Allow", method) writeBootstrapError(writer, http.StatusMethodNotAllowed, "请求方法不受支持") return false } func setBootstrapSecurityHeaders(writer http.ResponseWriter) { writer.Header().Set("Cache-Control", "no-store") writer.Header().Set("Referrer-Policy", "no-referrer") writer.Header().Set("X-Content-Type-Options", "nosniff") writer.Header().Set("X-Frame-Options", "DENY") } func writeBootstrapError(writer http.ResponseWriter, status int, message string) { writeBootstrapJSON(writer, status, map[string]string{"error": message}) } func writeBootstrapJSON(writer http.ResponseWriter, status int, value any) { writer.Header().Set("Content-Type", "application/json; charset=utf-8") writer.WriteHeader(status) _ = json.NewEncoder(writer).Encode(value) }