273 lines
9.6 KiB
Go
273 lines
9.6 KiB
Go
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"`
|
||
AdminEmail string `json:"admin_email"`
|
||
AdminPassword string `json:"admin_password"`
|
||
ServerPort int `json:"server_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_url": cfg.ResetURL(),
|
||
"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,
|
||
AdminEmail: request.AdminEmail,
|
||
AdminPassword: request.AdminPassword,
|
||
ServerPort: request.ServerPort,
|
||
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 默认关闭,启动后通过 %s 使用临时重置入口。证书申请和 HTTPS 切换将在后续任务中完成。",
|
||
firewallResult.Message, updated.Server.Port, updated.ResetURL(),
|
||
),
|
||
"status": gin.H{
|
||
"server_port": updated.Server.Port,
|
||
"reset_url": updated.ResetURL(),
|
||
"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()
|
||
}
|