Scert/backend/internal/httpapi/setup.go

273 lines
9.6 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 httpapi
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
"git.pcux.cn/xuans/Scert/backend/internal/firewall"
"git.pcux.cn/xuans/Scert/backend/internal/setup"
)
const setupRequestLimit = 64 * 1024
type setupDNSVerifier interface {
Verify(ctx context.Context, input setup.DNSVerificationInput) (setup.DNSVerificationResult, error)
Check(token, providerID, credentialRef, testDomain string) error
Consume(token, providerID, credentialRef, testDomain string) error
}
type setupRequest struct {
AdminUsername string `json:"admin_username"`
AdminPassword string `json:"admin_password"`
ServerPort int `json:"server_port"`
ResetServicePort int `json:"reset_service_port"`
EmailProvider string `json:"email_provider"`
EmailEndpoint string `json:"email_endpoint"`
Recipients []string `json:"recipients"`
DNSProvider string `json:"dns_provider"`
DNSCredentialRef string `json:"dns_credential_ref"`
DNSVerificationToken string `json:"dns_verification_token"`
DNSTestDomain string `json:"dns_test_domain"`
CertificateRoot string `json:"certificate_root"`
WebHostname string `json:"web_hostname"`
WebPublicIP string `json:"web_public_ip"`
WebEnabled *bool `json:"web_enabled"`
HTTPSMode string `json:"https_mode"`
}
type dnsTestRequest struct {
ProviderID string `json:"provider"`
TestDomain string `json:"test_domain"`
Credentials map[string]string `json:"credentials"`
}
func (s *Server) registerSetupRoutes() {
s.router.GET("/api/v1/setup/status", s.setupStatus)
s.router.GET("/api/v1/setup/dns/providers", s.setupDNSProviders)
s.router.POST("/api/v1/setup/dns/test", s.testSetupDNS)
s.router.POST("/api/v1/setup", s.applySetup)
}
func (s *Server) setupStatus(c *gin.Context) {
s.configMu.RLock()
cfg := s.config
s.configMu.RUnlock()
if !cfg.SetupReady && !isLoopbackRequest(c.Request) {
s.writeError(c, http.StatusForbidden, "首次初始化接口仅允许从本机访问", nil)
return
}
phase := "ready"
blockers := []string{}
if !cfg.SetupReady {
phase = "configuration_required"
blockers = append(blockers, "需要完成首次初始化")
if cfg.DNS.VerificationState != "verified" {
blockers = append(blockers, "DNS API 尚未验证")
}
}
c.JSON(http.StatusOK, gin.H{
"initialized": cfg.SetupReady,
"phase": phase,
"blockers": blockers,
"server_port": cfg.Server.Port,
"reset_service_port": cfg.Admin.ResetServicePort,
"https_mode": cfg.Server.HTTPSMode,
"web_enabled": cfg.Web.Enabled,
"web_hostname": cfg.Web.Hostname,
"web_public_ip": cfg.Web.PublicIP,
"certificate_root": cfg.Paths.Certificate,
"dns_provider": cfg.DNS.Provider,
"dns_verification_state": cfg.DNS.VerificationState,
"dns_test_available": s.setupDNS != nil,
})
}
func (s *Server) setupDNSProviders(c *gin.Context) {
if !s.allowSetupRequest(c) {
return
}
c.JSON(http.StatusOK, gin.H{"items": dnsprovider.Descriptors()})
}
func (s *Server) testSetupDNS(c *gin.Context) {
if !s.allowSetupRequest(c) {
return
}
if s.setupDNS == nil {
s.writeError(c, http.StatusServiceUnavailable, "DNS 在线验证服务尚未初始化", nil)
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, setupRequestLimit)
var request dnsTestRequest
if err := c.ShouldBindJSON(&request); err != nil {
s.writeError(c, http.StatusBadRequest, "DNS 测试请求参数无效", err)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 45*time.Second)
defer cancel()
result, err := s.setupDNS.Verify(ctx, setup.DNSVerificationInput{
ProviderID: request.ProviderID, TestDomain: request.TestDomain, Credentials: request.Credentials,
})
if err != nil {
if errors.Is(err, dnsprovider.ErrProviderUnavailable) {
s.writeError(c, http.StatusNotImplemented, "该 DNS 服务商在线验证适配器尚未开放", nil)
return
}
if s.logger != nil {
s.logger.Error("DNS API 在线验证失败", "provider", request.ProviderID, "test_domain", request.TestDomain)
}
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "DNS API 在线验证失败,请检查凭据、测试域名和 DNS 权限"})
return
}
c.JSON(http.StatusOK, gin.H{
"verified": true,
"credential_ref": result.CredentialRef,
"verification_token": result.VerificationToken,
"expires_at": result.ExpiresAt,
"message": "DNS API 在线验证通过,凭据已加密保存。",
})
}
func (s *Server) applySetup(c *gin.Context) {
s.configMu.Lock()
defer s.configMu.Unlock()
if !isLoopbackRequest(c.Request) {
s.writeError(c, http.StatusForbidden, "首次初始化接口仅允许从本机访问", nil)
return
}
if s.config.SetupReady {
s.writeError(c, http.StatusConflict, "初始化已经完成,不能重复提交", nil)
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, setupRequestLimit)
var request setupRequest
if err := c.ShouldBindJSON(&request); err != nil {
s.writeError(c, http.StatusBadRequest, "初始化请求参数无效", err)
return
}
if s.setupDNS == nil {
s.writeError(c, http.StatusServiceUnavailable, "DNS 在线验证服务尚未初始化", nil)
return
}
testDomain := strings.TrimSpace(request.DNSTestDomain)
if testDomain == "" {
testDomain = strings.TrimSpace(request.WebHostname)
}
if err := s.setupDNS.Check(request.DNSVerificationToken, request.DNSProvider, request.DNSCredentialRef, testDomain); err != nil {
s.writeError(c, http.StatusBadRequest, err.Error(), nil)
return
}
webEnabled := true
if request.WebEnabled != nil {
webEnabled = *request.WebEnabled
}
updated := s.config
err := setup.Apply(&updated, setup.Input{
AdminUsername: request.AdminUsername,
AdminPassword: request.AdminPassword,
ServerPort: request.ServerPort,
ResetServicePort: request.ResetServicePort,
EmailProvider: request.EmailProvider,
EmailEndpoint: request.EmailEndpoint,
EmailRecipients: request.Recipients,
DNSProvider: request.DNSProvider,
DNSCredentialRef: request.DNSCredentialRef,
DNSVerified: true,
CertificateRoot: request.CertificateRoot,
WebHostname: request.WebHostname,
WebPublicIP: request.WebPublicIP,
WebEnabled: webEnabled,
HTTPSMode: request.HTTPSMode,
})
if err != nil {
s.writeError(c, http.StatusBadRequest, err.Error(), err)
return
}
if strings.TrimSpace(s.configPath) == "" {
s.writeError(c, http.StatusInternalServerError, "服务未配置配置文件路径,无法保存初始化结果", nil)
return
}
firewallResult := firewall.Result{
Backend: firewall.BackendNone, Port: updated.Server.Port,
Message: "未配置防火墙管理器,已跳过本机规则创建",
}
if s.firewall != nil {
firewallContext, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
firewallResult, err = s.firewall.OpenTCP(firewallContext, updated.Server.Port)
cancel()
if err != nil {
s.writeError(c, http.StatusInternalServerError, "自动配置本机防火墙失败,请使用管理员权限重新运行初始化程序", err)
return
}
}
if err := updated.Save(s.configPath); err != nil {
if firewallResult.Created && s.firewall != nil {
rollbackContext, cancel := context.WithTimeout(context.Background(), 30*time.Second)
rollbackErr := s.firewall.Rollback(rollbackContext, firewallResult)
cancel()
if rollbackErr != nil {
if s.logger != nil {
s.logger.Error("保存初始化配置失败,且回滚本机防火墙规则失败", "error", rollbackErr, "rule", firewallResult.RuleName)
}
s.writeError(c, http.StatusInternalServerError, "保存初始化配置失败,且自动创建的防火墙规则回滚失败,请手动检查本机防火墙", err)
return
}
}
s.writeError(c, http.StatusInternalServerError, "保存初始化配置失败", err)
return
}
if err := s.setupDNS.Consume(request.DNSVerificationToken, request.DNSProvider, request.DNSCredentialRef, testDomain); err != nil && s.logger != nil {
s.logger.Warn("初始化配置已保存,但清理 DNS 验证令牌失败", "error", err)
}
s.config = updated
c.JSON(http.StatusOK, gin.H{
"initialized": true,
"message": fmt.Sprintf(
"初始化配置已保存,DNS API 已完成在线验证且凭据已加密保存。%s。请确认云安全组已放行主服务端口 %d;Scert-admin 仅允许本机访问。证书申请和 HTTPS 切换将在后续任务中完成。",
firewallResult.Message, updated.Server.Port,
),
"status": gin.H{
"server_port": updated.Server.Port,
"reset_service_port": updated.Admin.ResetServicePort,
"web_enabled": updated.Web.Enabled,
"https_mode": updated.Server.HTTPSMode,
"firewall": firewallResult,
},
})
}
func (s *Server) allowSetupRequest(c *gin.Context) bool {
s.configMu.RLock()
initialized := s.config.SetupReady
s.configMu.RUnlock()
if initialized {
s.writeError(c, http.StatusConflict, "初始化已经完成,不能重复执行初始化 DNS 测试", nil)
return false
}
if !isLoopbackRequest(c.Request) {
s.writeError(c, http.StatusForbidden, "首次初始化接口仅允许从本机访问", nil)
return false
}
return true
}
func isLoopbackRequest(request *http.Request) bool {
if request == nil {
return false
}
host, _, err := net.SplitHostPort(request.RemoteAddr)
if err != nil {
host = request.RemoteAddr
}
ip := net.ParseIP(strings.TrimSpace(host))
return ip != nil && ip.IsLoopback()
}