Scert/backend/internal/bootstrap/router.go

307 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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