Scert/backend/internal/httpapi/server.go
xuans 73511d6fcb 统一多级域名泛域名生成与预览
集中处理泛域名识别、生成、编辑回显和多级子域名预览,补充前后端测试覆盖。
2026-10-16 02:41:16 +08:00

1111 lines
40 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"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
"git.pcux.cn/xuans/Scert/backend/internal/buildinfo"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/firewall"
"git.pcux.cn/xuans/Scert/backend/internal/httpchallenge"
"git.pcux.cn/xuans/Scert/backend/internal/logging"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
type setupSecretWriter interface {
Put(context.Context, string, map[string]string) error
Delete(context.Context, string) error
}
type readinessSecretReader interface {
Get(context.Context, string) (map[string]string, error)
}
type Dependencies struct {
HTTPChallenges *httpchallenge.Store
Config config.Config
ConfigPath string
FrontendDir string
Logger *logging.Logger
Repository repository.DomainRepository
Servers repository.ServerRepository
Users repository.UserRepository
ProjectMembers repository.ProjectMemberRepository
ProjectDirectory repository.ProjectDirectoryRepository
ProjectLifecycle repository.ProjectLifecycleRepository
ProjectResources repository.ProjectResourceReader
ProjectBindings repository.ProjectBindingRepository
ProjectDomains repository.ProjectDomainRepository
DeploymentFactory deployment.ClientFactory
RecoveryInspection localRecoveryReader
SetupDNS setupDNSVerifier
DNSAccountVerifier dnsAccountVerifier
Secrets setupSecretWriter
Firewall firewall.Manager
IssueCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
IssueSetupCertificate func(context.Context, certificate.SetupCertificateRequest) (certificate.SavedResource, error)
RollbackSetupCertificate func(context.Context, certificate.SavedResource) error
ImportCertificate func(context.Context, certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error)
// ImportCertificateTask 只执行导入流程,复用 HTTP 层已经创建的任务。
ImportCertificateTask func(context.Context, certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error)
DeployCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
// DeployCertificateTask 执行已有任务,不得重复创建任务或尝试记录。
DeployCertificateTask func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
RestoreCertificateVersion func(context.Context, string, string) (certificate.IssueResult, error)
RestoreCertificateVersionTask func(context.Context, string, string) (certificate.IssueResult, error)
ValidateCertificate func(context.Context, string) (certificate.IssueResult, certificate.ValidationResult, error)
// ValidateCertificateTask 直接校验当前证书,不重复创建任务。
ValidateCertificateTask func(context.Context, string) (certificate.IssueResult, certificate.ValidationResult, error)
RunRenewal func(context.Context) (certificate.RenewalReport, error)
UpdateRenewalPolicy func(certificate.RenewalPolicy)
CurrentCertificate certificateReader
ResetStatePath string
Audit repository.AuditRepository
Tasks repository.TaskRepository
ReadinessCheck func(context.Context) error
AliyunChecker aliyunScopeChecker
}
type Server struct {
httpChallenges *httpchallenge.Store
configMu sync.RWMutex
config config.Config
configPath string
frontendDir string
logger *logging.Logger
repository repository.DomainRepository
servers repository.ServerRepository
users repository.UserRepository
projectMembers repository.ProjectMemberRepository
projectDirectory repository.ProjectDirectoryRepository
projectLifecycle repository.ProjectLifecycleRepository
projectResources repository.ProjectResourceReader
projectBindings repository.ProjectBindingRepository
projectDomains repository.ProjectDomainRepository
deploymentFactory deployment.ClientFactory
recoveryInspection localRecoveryReader
setupDNS setupDNSVerifier
dnsAccountVerifier dnsAccountVerifier
secrets setupSecretWriter
firewall firewall.Manager
issueCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
issueSetupCertificate func(context.Context, certificate.SetupCertificateRequest) (certificate.SavedResource, error)
rollbackSetupCertificate func(context.Context, certificate.SavedResource) error
importCertificate func(context.Context, certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error)
importCertificateTask func(context.Context, certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error)
deployCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
deployCertificateTask func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
restoreVersion func(context.Context, string, string) (certificate.IssueResult, error)
restoreVersionTask func(context.Context, string, string) (certificate.IssueResult, error)
validateCertificateWorkflow func(context.Context, string) (certificate.IssueResult, certificate.ValidationResult, error)
validateCertificateTask func(context.Context, string) (certificate.IssueResult, certificate.ValidationResult, error)
runRenewal func(context.Context) (certificate.RenewalReport, error)
updateRenewalPolicy func(certificate.RenewalPolicy)
currentCertificate certificateReader
resetStatePath string
audit repository.AuditRepository
tasks repository.TaskRepository
readinessCheck func(context.Context) error
aliyunChecker aliyunScopeChecker
taskMu sync.Mutex
taskCancels map[string]context.CancelFunc
taskWorkers sync.WaitGroup
taskWorkersStopped bool
asyncTasks map[string]bool
taskSlots chan struct{}
issueTaskMu sync.Mutex
setupMu sync.Mutex
resetMu sync.Mutex
resetClosedHash string
resetLimiter *resetRateLimiter
authSessions map[string]authSession
authMu sync.RWMutex
loginAttempts map[string]loginAttempt
loginMu sync.Mutex
loginSources map[string]loginSource
loginPending map[string]int
loginInFlight int
loginPrunedAt time.Time
now func() time.Time
httpMu sync.Mutex
httpStopped bool
httpServer *http.Server
httpListening chan struct{}
tlsMu sync.Mutex
tlsCertificate atomic.Pointer[tls.Certificate]
tlsRoots *x509.CertPool
router *gin.Engine
}
func New(deps Dependencies) *Server {
gin.SetMode(gin.ReleaseMode)
router := gin.New()
router.Use(gin.Recovery())
frontendDir := strings.TrimSpace(deps.FrontendDir)
if frontendDir == "" {
frontendDir = filepath.Join(deps.Config.Paths.Install, "web")
}
server := &Server{
httpChallenges: deps.HTTPChallenges,
httpListening: make(chan struct{}),
config: deps.Config, configPath: deps.ConfigPath, frontendDir: filepath.Clean(frontendDir), logger: deps.Logger,
repository: deps.Repository, setupDNS: deps.SetupDNS, secrets: deps.Secrets, firewall: deps.Firewall, router: router,
servers: deps.Servers,
users: deps.Users,
projectMembers: deps.ProjectMembers,
dnsAccountVerifier: deps.DNSAccountVerifier,
projectDirectory: deps.ProjectDirectory,
projectLifecycle: deps.ProjectLifecycle,
projectResources: deps.ProjectResources,
projectBindings: deps.ProjectBindings,
projectDomains: deps.ProjectDomains,
deploymentFactory: deps.DeploymentFactory,
recoveryInspection: deps.RecoveryInspection,
issueCertificate: deps.IssueCertificate,
issueSetupCertificate: deps.IssueSetupCertificate,
rollbackSetupCertificate: deps.RollbackSetupCertificate,
importCertificate: deps.ImportCertificate,
importCertificateTask: deps.ImportCertificateTask,
deployCertificate: deps.DeployCertificate,
deployCertificateTask: deps.DeployCertificateTask,
restoreVersion: deps.RestoreCertificateVersion,
restoreVersionTask: deps.RestoreCertificateVersionTask,
validateCertificateWorkflow: deps.ValidateCertificate,
validateCertificateTask: deps.ValidateCertificateTask,
runRenewal: deps.RunRenewal,
updateRenewalPolicy: deps.UpdateRenewalPolicy,
currentCertificate: deps.CurrentCertificate,
resetStatePath: deps.ResetStatePath,
audit: deps.Audit,
tasks: deps.Tasks,
readinessCheck: deps.ReadinessCheck,
aliyunChecker: deps.AliyunChecker,
taskCancels: make(map[string]context.CancelFunc),
resetLimiter: newResetRateLimiter(),
authSessions: make(map[string]authSession),
loginAttempts: make(map[string]loginAttempt),
loginSources: make(map[string]loginSource),
loginPending: make(map[string]int),
now: time.Now,
}
router.Use(server.requestMiddleware())
server.registerRoutes()
server.registerSetupRoutes()
return server
}
func (s *Server) registerRoutes() {
// 主路由保持独立,初始化路由由专用模块注册。
s.router.GET("/healthz", s.health)
s.router.GET("/readyz", s.ready)
s.router.GET("/api/v1/status", s.status)
s.router.GET("/api/v1/auth/session", s.authSession)
s.router.POST("/api/v1/auth/login", s.requireSameOrigin, s.authLogin)
s.router.POST("/api/v1/auth/logout", s.requireSameOrigin, s.authLogout)
s.router.POST("/api/v1/auth/password", s.requireAuthenticated, s.changePassword)
s.router.POST("/api/v1/password-reset/resolve", s.showPasswordResetGuidance)
adminAPI := s.router.Group("/api/v1", s.requireAdmin)
userAPI := s.router.Group("/api/v1/users", s.requireAuthenticated)
userAPI.GET("", s.listUsers)
userAPI.POST("", s.requireSuperAdmin, s.createUser)
userAPI.GET("/:id", s.getUser)
userAPI.PUT("/:id", s.requireSuperAdmin, s.updateUser)
userAPI.DELETE("/:id", s.requireSuperAdmin, s.deleteUser)
s.registerProjectMemberRoutes()
s.registerProjectLifecycleRoutes()
s.registerProjectResourceRoutes()
s.registerProjectBindingRoutes()
s.router.GET("/api/v1/projects", privateProjectResponse, s.requireAuthenticated, s.listVisibleProjects)
domainAPI := s.router.Group("/api/v1", privateProjectResponse, s.requireAuthenticated)
domainAPI.POST("/domains", s.dnsOperation, s.createDomain)
domainAPI.PUT("/domains/:id", s.dnsOperation, s.updateDomain)
domainAPI.DELETE("/domains/:id", s.deleteDomain)
domainAPI.POST("/domains/:id/issue", s.issueDomain)
api := s.router.Group("/api/v1", privateProjectResponse, s.requireAuthenticated)
api.GET("/tasks", s.listTasks)
api.GET("/tasks/:id", s.getTask)
api.DELETE("/tasks/:id", s.cancelTask)
api.POST("/tasks/:id/retry", s.retryTask)
api.GET("/tasks/:id/attempts", s.listTaskAttempts)
api.GET("/tasks/:id/deployment-results", s.listTaskDeploymentResults)
api.GET("/tasks/:id/validation-results", s.listTaskValidationResults)
s.router.GET("/api/v1/settings", privateProjectResponse, s.requireSuperAdminSystemConfig, s.getSettings)
s.router.PUT("/api/v1/settings", privateProjectResponse, s.requireSuperAdminSystemConfig, s.updateSettings)
adminAPI.POST("/dns/test", s.requireSuperAdminSystemConfig, s.dnsOperation, s.testAdminDNS)
adminAPI.POST("/dns/aliyun/check", s.requireSuperAdminSystemConfig, s.dnsOperation, s.checkAliyunDNSDomain)
adminAPI.POST("/renewal/run", s.requireSuperAdmin, s.runRenewalNow)
domainAPI.POST("/domains/:id/certificate/import", s.longOperation(certificateTaskTimeout), s.importCertificateFiles)
domainAPI.POST("/domains/:id/certificate/validate", s.longOperation(certificateTaskTimeout), s.validateCertificate)
domainAPI.POST("/domains/:id/deploy", s.deployCertificateCurrent)
domainAPI.POST("/domains/:id/certificate/versions/:version/restore", s.longOperation(certificateTaskTimeout), s.restoreCertificateVersion)
api.POST("/servers", s.createServer)
api.PUT("/servers/:id", s.updateServer)
api.DELETE("/servers/:id", s.deleteServer)
s.router.NoRoute(s.frontend)
}
func (s *Server) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 在路由纠正、访问日志及登录检查前接管挑战,未知令牌不能落入前端页面。
if strings.HasPrefix(r.URL.Path, strings.TrimSuffix(httpchallenge.PathPrefix, "/")) {
s.httpChallenges.ServeHTTP(w, r)
return
}
s.router.ServeHTTP(w, r)
})
}
func (s *Server) ListenAndServe() error {
server, err := s.prepareHTTPServer()
if err != nil {
return err
}
listener, err := net.Listen("tcp", server.Addr)
if err != nil {
return err
}
defer listener.Close()
return server.ServeTLS(&servingListener{Listener: listener, ready: s.httpListening}, "", "")
}
// Listening 仅在 TLS 服务进入接收循环后关闭,不代表数据库或代理入口已经就绪。
func (s *Server) Listening() <-chan struct{} {
return s.httpListening
}
type servingListener struct {
net.Listener
once sync.Once
ready chan struct{}
}
func (l *servingListener) Accept() (net.Conn, error) {
l.once.Do(func() { close(l.ready) })
return l.Listener.Accept()
}
func (s *Server) prepareHTTPServer() (*http.Server, error) {
s.tlsMu.Lock()
defer s.tlsMu.Unlock()
s.httpMu.Lock()
stopped, started := s.httpStopped, s.httpServer != nil
s.httpMu.Unlock()
if stopped {
return nil, http.ErrServerClosed
}
if started {
return nil, errors.New("HTTPS 服务已经启动")
}
s.configMu.RLock()
cfg := s.config
s.configMu.RUnlock()
if !cfg.SetupReady {
return nil, errors.New("初始化未完成,请使用独立 HTTPS 初始化服务")
}
address, err := listenAddress(cfg)
if err != nil {
return nil, err
}
certPath := strings.TrimSpace(cfg.Server.TLSCert)
keyPath := strings.TrimSpace(cfg.Server.TLSKey)
if certPath == "" || keyPath == "" {
return nil, fmt.Errorf("初始化已完成,但 HTTPS 证书或私钥路径未配置")
}
if _, err := os.Stat(filepath.Clean(certPath)); err != nil {
return nil, fmt.Errorf("读取 HTTPS 证书失败:%w", err)
}
if _, err := os.Stat(filepath.Clean(keyPath)); err != nil {
return nil, fmt.Errorf("读取 HTTPS 私钥失败:%w", err)
}
pair, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return nil, fmt.Errorf("加载 HTTPS 证书与私钥失败:%w", err)
}
server := newHTTPServer(address, s.Handler())
server.TLSConfig.GetCertificate = s.getTLSCertificate
s.httpMu.Lock()
if s.httpStopped {
s.httpMu.Unlock()
return nil, http.ErrServerClosed
}
if s.httpServer != nil {
s.httpMu.Unlock()
return nil, errors.New("HTTPS 服务已经启动")
}
s.tlsCertificate.Store(&pair)
s.httpServer = server
s.httpMu.Unlock()
if s.logger != nil {
s.logger.Info("HTTPS 服务启动", "address", server.Addr, "https_mode", cfg.Server.HTTPSMode)
}
return server, nil
}
func newHTTPServer(address string, handler http.Handler) *http.Server {
return &http.Server{
Addr: address,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
}
}
// listenAddress 返回当前 HTTPS 模式实际使用的监听地址。
// 高级模式的核心服务只允许通过本机回环地址提供内部 HTTPS,避免误绑定到公网网卡。
func listenAddress(cfg config.Config) (string, error) {
host := strings.TrimSpace(cfg.Server.Host)
if host == "" {
return "", fmt.Errorf("服务监听地址不能为空")
}
if cfg.Server.Port < 1 || cfg.Server.Port > 65535 {
return "", fmt.Errorf("服务监听端口无效:%d", cfg.Server.Port)
}
if cfg.Server.HTTPSMode == "proxy" && !isLoopbackHost(host) {
host = "127.0.0.1"
}
return net.JoinHostPort(strings.Trim(host, "[]"), strconv.Itoa(cfg.Server.Port)), nil
}
func isLoopbackHost(host string) bool {
host = strings.Trim(strings.TrimSpace(host), "[]")
if strings.EqualFold(host, "localhost") {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
func (s *Server) Shutdown(ctx context.Context) error {
s.stopTaskWorkers()
s.httpMu.Lock()
s.httpStopped = true
server := s.httpServer
s.httpMu.Unlock()
if server == nil {
return s.waitTaskWorkers(ctx)
}
err := server.Shutdown(ctx)
workerErr := s.waitTaskWorkers(ctx)
if errors.Is(err, http.ErrServerClosed) {
return workerErr
}
return errors.Join(err, workerErr)
}
type domainRequest struct {
Name string `json:"name" binding:"required"`
CertificateType string `json:"certificate_type" binding:"required"`
SANs []string `json:"sans"`
IncludeWildcard bool `json:"include_wildcard"`
AutoRenew *bool `json:"auto_renew"`
ACMEEnvironment string `json:"acme_environment"`
DNSProvider string `json:"dns_provider"`
DNSAccountID string `json:"dns_account_id"`
DNSAccountRevision int64 `json:"dns_account_revision"`
ChallengeType *string `json:"challenge_type"`
}
type serverRequest struct {
Name string `json:"name" binding:"required"`
Address string `json:"address" binding:"required"`
Port int `json:"port"`
Username string `json:"username" binding:"required"`
Password string `json:"password"`
HostKeyFingerprint string `json:"host_key_fingerprint" binding:"required"`
CertificatePath string `json:"certificate_path"`
PrivateKeyPath string `json:"private_key_path"`
CertificateChainPath string `json:"certificate_chain_path"`
WebServer string `json:"web_server" binding:"required"`
ValidationCommand []string `json:"validation_command"`
ReloadCommand []string `json:"reload_command"`
ProjectID string `json:"project_id"`
}
func (s *Server) health(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok", "service": "scert"})
}
func (s *Server) ready(c *gin.Context) {
s.configMu.RLock()
cfg := s.config
s.configMu.RUnlock()
if !cfg.SetupReady {
s.writeReadinessFailure(c, "setup_incomplete")
return
}
dnsReference := strings.TrimSpace(cfg.DNS.CredentialRef)
if dnsReference == "" {
s.writeReadinessFailure(c, "dns_credential_missing")
return
}
references := []string{
dnsReference,
strings.TrimSpace(cfg.Notification.HTTPCredentialRef),
strings.TrimSpace(cfg.Notification.SMTPCredentialRef),
}
reader, ok := s.secrets.(readinessSecretReader)
if !ok {
s.writeReadinessFailure(c, "secret_store_unavailable")
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer cancel()
checked := make(map[string]struct{}, len(references))
for _, reference := range references {
if reference == "" {
continue
}
if _, exists := checked[reference]; exists {
continue
}
checked[reference] = struct{}{}
credentials, err := reader.Get(ctx, reference)
if err != nil {
s.writeReadinessFailure(c, "credential_unavailable")
return
}
if reference == dnsReference {
if _, err := dnsprovider.ValidateCredentials(cfg.DNS.Provider, credentials); err != nil {
s.writeReadinessFailure(c, "dns_credential_invalid")
return
}
}
}
for _, path := range []string{cfg.Server.TLSCert, cfg.Server.TLSKey} {
info, err := os.Lstat(filepath.Clean(strings.TrimSpace(path)))
if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
s.writeReadinessFailure(c, "tls_material_unavailable")
return
}
}
if s.readinessCheck != nil {
if err := s.readinessCheck(ctx); err != nil {
s.writeReadinessFailure(c, "database_unavailable")
return
}
}
c.JSON(http.StatusOK, gin.H{"status": "ready", "service": "scert"})
}
func (s *Server) writeReadinessFailure(c *gin.Context, reason string) {
c.JSON(http.StatusServiceUnavailable, gin.H{
"status": "not_ready",
"service": "scert",
"reason": reason,
})
}
func (s *Server) resetPage(c *gin.Context) {
s.resetMu.Lock()
defer s.resetMu.Unlock()
session, ok := s.currentResetSession()
if !ok {
c.Status(http.StatusNotFound)
return
}
token, err := newCSRFToken()
if err != nil {
s.writeResetFailure(c, http.StatusInternalServerError, "暂时无法生成安全校验信息,请稍后重试")
return
}
s.setResetCSRFCookie(c, token, session.ExpiresAt)
s.renderResetPage(c, http.StatusOK, token, "")
}
func (s *Server) status(c *gin.Context) {
s.configMu.RLock()
cfg := s.config
s.configMu.RUnlock()
payload := gin.H{
"service": "scert",
"version": buildinfo.Version,
"config_version": cfg.Version,
"setup_ready": cfg.SetupReady,
}
if _, authenticated := s.currentAuthSession(c); authenticated {
payload["https_mode"] = cfg.Server.HTTPSMode
payload["database_driver"] = cfg.Database.Driver
payload["reset_method"] = "contact_administrator"
}
c.JSON(http.StatusOK, payload)
}
func (s *Server) requireDomainRepository(c *gin.Context) bool {
if s.repository != nil {
return true
}
s.writeError(c, http.StatusNotImplemented, "域名管理接口尚未配置", errors.New("域名仓储未配置"))
return false
}
func (s *Server) requireProjectDomainRepository(c *gin.Context) bool {
if s.projectDomains != nil {
return true
}
s.writeError(c, http.StatusServiceUnavailable, "项目域名服务尚未就绪", nil)
return false
}
func (s *Server) authorizeDomainMutation(c *gin.Context, projectID string) bool {
authorizer, ok := s.projectDomains.(repository.ProjectAuthorizer)
if !ok {
s.writeError(c, http.StatusServiceUnavailable, "项目权限服务尚未就绪", nil)
return false
}
if err := authorizer.AuthorizeProject(c.Request.Context(), c.GetString(authenticatedActorKey), projectID, domain.PermissionCertificateManage); err != nil {
if errors.Is(err, repository.ErrProjectAccessDenied) {
s.writeError(c, http.StatusForbidden, "无权管理此项目的域名", nil)
} else {
s.writeError(c, http.StatusInternalServerError, "检查项目域名权限失败", err)
}
return false
}
return true
}
func (s *Server) authorizeServerMutation(c *gin.Context, projectID string) bool {
authorizer, ok := s.projectDomains.(repository.ProjectAuthorizer)
if !ok {
if fallback, fallbackOK := s.projectMembers.(repository.ProjectAuthorizer); fallbackOK {
authorizer = fallback
ok = true
}
}
if !ok {
s.writeError(c, http.StatusServiceUnavailable, "项目权限服务尚未就绪", nil)
return false
}
if err := authorizer.AuthorizeProject(c.Request.Context(), c.GetString(authenticatedActorKey), projectID, domain.PermissionServerManage); err != nil {
if errors.Is(err, repository.ErrProjectAccessDenied) {
s.writeError(c, http.StatusForbidden, "无权管理此项目的服务器", nil)
} else {
s.writeError(c, http.StatusInternalServerError, "检查项目服务器权限失败", err)
}
return false
}
return true
}
func (s *Server) currentDNSProvider() string {
s.configMu.RLock()
provider := strings.ToLower(strings.TrimSpace(s.config.DNS.Provider))
s.configMu.RUnlock()
return provider
}
func (s *Server) listDomains(c *gin.Context) {
if !s.requireProjectResourceReader(c) {
return
}
items, err := s.projectResources.ListVisibleDomains(c.Request.Context(), c.GetString(authenticatedActorKey))
if err != nil {
s.writeProjectResourceError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (s *Server) createDomain(c *gin.Context) {
if !s.requireProjectDomainRepository(c) {
return
}
var request domainRequest
if err := bindJSON(c, &request, jsonRequestBodyLimit); err != nil {
s.writeError(c, http.StatusBadRequest, "请求参数无效", err)
return
}
item, err := newDomainFromRequest(request)
if err != nil {
s.writeError(c, http.StatusBadRequest, "域名配置无效", err)
return
}
item.ResourceScope = s.newResourceScope(c)
if request.DNSAccountID != "" {
if !domain.ValidProjectID(request.DNSAccountID) || request.DNSAccountRevision < 1 || item.ChallengeType != domain.ChallengeDNS01 {
s.writeError(c, http.StatusBadRequest, "初始 DNS 账户参数无效", nil)
return
}
item.DNSAccountID, item.DNSBindingID, item.InitialDNSRevision = request.DNSAccountID, domain.NewID(), request.DNSAccountRevision
} else if request.DNSAccountRevision != 0 {
s.writeError(c, http.StatusBadRequest, "初始 DNS 账户参数不完整", nil)
return
}
if !s.authorizeDomainMutation(c, item.ProjectID) {
return
}
if !s.authorizeDomainChallenge(c, item) {
return
}
if err := s.projectDomains.CreateProjectDomain(c.Request.Context(), c.GetString(authenticatedActorKey), item); err != nil {
if errors.Is(err, repository.ErrDNSAccountConflict) || errors.Is(err, repository.ErrDNSBindingInvalid) {
s.writeDomainDNSAccountError(c, err)
return
}
if errors.Is(err, repository.ErrProjectAccessDenied) {
s.writeError(c, http.StatusForbidden, "无权管理此项目的域名", nil)
return
}
if repository.IsConflict(err) {
s.writeError(c, http.StatusConflict, "域名已经存在", err)
return
}
s.writeError(c, http.StatusInternalServerError, "保存域名失败", err)
return
}
c.JSON(http.StatusCreated, item)
}
func (s *Server) updateDomain(c *gin.Context) {
if !s.requireProjectDomainRepository(c) || !s.requireProjectResourceReader(c) {
return
}
var request domainRequest
if err := bindJSON(c, &request, jsonRequestBodyLimit); err != nil {
s.writeError(c, http.StatusBadRequest, "请求参数无效", err)
return
}
if request.DNSAccountID != "" || request.DNSAccountRevision != 0 {
s.writeError(c, http.StatusBadRequest, "编辑 DNS 账户须使用独立绑定接口", nil)
return
}
existing, err := s.projectResources.GetVisibleDomain(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id"))
if err != nil {
s.writeProjectResourceError(c, err)
return
}
if !s.authorizeDomainMutation(c, existing.ProjectID) {
return
}
if request.ChallengeType == nil {
request.ChallengeType = &existing.ChallengeType
}
item, err := newDomainFromRequest(request)
if err != nil {
s.writeError(c, http.StatusBadRequest, "域名配置无效", err)
return
}
item.ResourceScope = existing.ResourceScope
if !s.authorizeDomainChallenge(c, item) {
return
}
item.ID = existing.ID
item.ResourceScope = s.updatedResourceScope(c, existing.ResourceScope)
item.CreatedAt = existing.CreatedAt
item.Status = "pending"
item.ExpiresAt = nil
if err := s.projectDomains.UpdateProjectDomain(c.Request.Context(), c.GetString(authenticatedActorKey), item); err != nil {
if errors.Is(err, repository.ErrDNSAccountBusy) {
s.writeError(c, http.StatusConflict, repository.ErrDNSAccountBusy.Error(), nil)
return
}
if errors.Is(err, repository.ErrProjectAccessDenied) {
s.writeError(c, http.StatusForbidden, "无权管理此项目的域名", nil)
return
}
if errors.Is(err, deployment.ErrSharedPathConflict) {
s.writeError(c, http.StatusConflict, deployment.ErrSharedPathConflict.Error(), nil)
return
}
if repository.IsNotFound(err) {
s.writeError(c, http.StatusNotFound, "域名不存在", err)
return
}
if repository.IsConflict(err) {
s.writeError(c, http.StatusConflict, "域名已经存在", err)
return
}
s.writeError(c, http.StatusInternalServerError, "更新域名失败", err)
return
}
c.JSON(http.StatusOK, item)
}
func newDomainFromRequest(request domainRequest) (domain.Domain, error) {
autoRenew := true
if request.AutoRenew != nil {
autoRenew = *request.AutoRenew
}
acmeEnvironment := request.ACMEEnvironment
if acmeEnvironment == "" {
acmeEnvironment = domain.ACMEEnvironmentStaging
}
certificateType := domain.CertificateType(request.CertificateType)
sans := append([]string(nil), request.SANs...)
if request.IncludeWildcard {
name := strings.TrimSpace(request.Name)
if domain.IsWildcardName(name) {
return domain.Domain{}, errors.New("同时申请泛域名时,主域名不能以 *. 开头")
}
if certificateType != domain.CertificateSingle {
return domain.Domain{}, errors.New("同时申请泛域名仅适用于单域名证书")
}
wildcardName, err := domain.WildcardName(name)
if err != nil {
return domain.Domain{}, err
}
sans = append(sans, wildcardName)
certificateType = domain.CertificateSAN
}
item, err := domain.NewDomainWithOptions(request.Name, certificateType, sans, autoRenew, acmeEnvironment, request.DNSProvider)
if err != nil {
return domain.Domain{}, err
}
value := ""
if request.ChallengeType != nil {
value = *request.ChallengeType
}
item.ChallengeType, err = domain.NormalizeChallengeType(value, append([]string{item.Name}, item.SANs...))
if item.ChallengeType == domain.ChallengeHTTP01 {
item.DNSProvider = ""
}
return item, err
}
func (s *Server) deleteDomain(c *gin.Context) {
if !s.requireProjectDomainRepository(c) {
return
}
domainID := strings.TrimSpace(c.Param("id"))
if err := s.projectDomains.DeleteProjectDomain(c.Request.Context(), c.GetString(authenticatedActorKey), domainID); err != nil {
if errors.Is(err, repository.ErrDNSAccountBusy) {
s.writeError(c, http.StatusConflict, repository.ErrDNSAccountBusy.Error(), nil)
return
}
if errors.Is(err, repository.ErrProjectAccessDenied) {
s.writeError(c, http.StatusForbidden, "无权管理此项目的域名", nil)
return
}
if errors.Is(err, repository.ErrProjectDomainBusy) {
s.writeError(c, http.StatusConflict, err.Error(), nil)
return
}
if repository.IsNotFound(err) {
s.writeError(c, http.StatusNotFound, "域名不存在", err)
return
}
s.writeError(c, http.StatusInternalServerError, "删除域名失败", err)
return
}
c.Status(http.StatusNoContent)
}
func (s *Server) authorizeSynchronousCertificateIssue(c *gin.Context) bool {
if !s.authorizeVisibleDomainPermissions(c, domain.PermissionCertificateManage, domain.PermissionDeploymentExecute) {
return false
}
resource, err := s.projectResources.GetVisibleDomain(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id"))
if err != nil {
s.writeProjectResourceError(c, err)
return false
}
if !s.authorizeIssueChallenge(c, resource) {
return false
}
c.Request = c.Request.WithContext(s.withCertificateIssueAuthorization(c.Request.Context(), c.GetString(authenticatedActorKey), resource.ID, resource.ResourceScope.Normalize().ProjectID, false))
return true
}
func (s *Server) issueDomain(c *gin.Context) {
if s.tasks != nil {
s.issueDomainAsTask(c)
return
}
if !s.authorizeSynchronousCertificateIssue(c) {
return
}
if s.issueCertificate == nil {
s.writeError(c, http.StatusNotImplemented, "证书申请接口尚未配置", errors.New("证书申请工作流未配置"))
return
}
certificateResult, deploymentReport, err := s.issueCertificate(c.Request.Context(), c.Param("id"))
if err != nil {
s.writeError(c, http.StatusBadGateway, "证书申请或部署失败", err)
return
}
c.JSON(http.StatusOK, gin.H{"certificate": certificateResult, "deployment": sanitizeDeploymentReport(deploymentReport)})
}
func (s *Server) listServers(c *gin.Context) {
if !s.requireProjectResourceReader(c) {
return
}
items, err := s.projectResources.ListVisibleServers(c.Request.Context(), c.GetString(authenticatedActorKey))
if err != nil {
s.writeProjectResourceError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (s *Server) getServer(c *gin.Context) {
if !s.requireProjectResourceReader(c) {
return
}
item, err := s.projectResources.GetVisibleServer(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id"))
if err != nil {
s.writeProjectResourceError(c, err)
return
}
c.JSON(http.StatusOK, item)
}
func (s *Server) createServer(c *gin.Context) {
if s.servers == nil {
s.writeError(c, http.StatusNotImplemented, "服务器管理接口尚未配置", errors.New("服务器仓储未配置"))
return
}
var request serverRequest
if err := bindJSON(c, &request, jsonRequestBodyLimit); err != nil {
s.writeError(c, http.StatusBadRequest, "请求参数无效", err)
return
}
item := serverFromRequest(request)
item.ResourceScope = s.newResourceScope(c)
if strings.TrimSpace(request.ProjectID) != "" {
item.ProjectID = strings.TrimSpace(request.ProjectID)
}
item.ResourceScope = item.ResourceScope.Normalize()
if !s.authorizeServerMutation(c, item.ProjectID) {
return
}
item = item.Normalize()
now := time.Now().UTC()
item.CreatedAt = now
item.UpdatedAt = now
item, cleanup, err := s.prepareServerCredential(c.Request.Context(), item, request.Password, nil)
if err != nil {
s.writeError(c, http.StatusBadRequest, "SSH 认证信息无效", err)
return
}
if err := s.servers.CreateServer(c.Request.Context(), item); err != nil {
cleanup()
s.writeError(c, serverErrorStatus(err), "保存服务器失败", err)
return
}
s.recordServerHostKeyAudit(c, "server_host_key_fingerprint_register", item.ID)
c.JSON(http.StatusCreated, item)
}
func (s *Server) updateServer(c *gin.Context) {
if s.servers == nil {
s.writeError(c, http.StatusNotImplemented, "服务器管理接口尚未配置", errors.New("服务器仓储未配置"))
return
}
if !s.requireProjectResourceReader(c) {
return
}
var request serverRequest
if err := bindJSON(c, &request, jsonRequestBodyLimit); err != nil {
s.writeError(c, http.StatusBadRequest, "请求参数无效", err)
return
}
item := serverFromRequest(request)
item.ID = c.Param("id")
item = item.Normalize()
existing, err := s.projectResources.GetVisibleServer(c.Request.Context(), c.GetString(authenticatedActorKey), item.ID)
if err != nil {
if errors.Is(err, repository.ErrProjectAccessDenied) {
s.writeProjectResourceError(c, err)
return
}
if repository.IsNotFound(err) {
s.writeError(c, http.StatusNotFound, "服务器不存在", err)
return
}
s.writeError(c, http.StatusInternalServerError, "读取服务器失败", err)
return
}
if !s.authorizeServerMutation(c, existing.ProjectID) {
return
}
item.CreatedAt = existing.CreatedAt
item.ResourceScope = s.updatedResourceScope(c, existing.ResourceScope)
item.UpdatedAt = time.Now().UTC()
item, cleanup, err := s.prepareServerCredential(c.Request.Context(), item, request.Password, &existing)
if err != nil {
s.writeError(c, http.StatusBadRequest, "SSH 认证信息无效", err)
return
}
if err := s.servers.UpdateServer(c.Request.Context(), item); err != nil {
cleanup()
if errors.Is(err, deployment.ErrSharedPathConflict) {
s.writeError(c, http.StatusConflict, deployment.ErrSharedPathConflict.Error(), nil)
return
}
s.writeError(c, serverErrorStatus(err), "更新服务器失败", err)
return
}
if existing.HostKeyFingerprint != item.HostKeyFingerprint {
s.recordServerHostKeyAudit(c, "server_host_key_fingerprint_update", item.ID)
}
if existing.PasswordRef != "" && existing.PasswordRef != item.PasswordRef && s.secrets != nil {
if err := s.secrets.Delete(c.Request.Context(), existing.PasswordRef); err != nil && s.logger != nil {
s.logger.Warn("删除旧 SSH 密码失败", "error", err)
}
}
c.JSON(http.StatusOK, item)
}
func (s *Server) prepareServerCredential(ctx context.Context, item deployment.Server, password string, existing *deployment.Server) (deployment.Server, func(), error) {
item = item.Normalize()
cleanup := func() {}
item.AuthMethod = deployment.SSHAuthPassword
if s.secrets == nil {
return deployment.Server{}, cleanup, errors.New("SSH \u5bc6\u7801\u5b58\u50a8\u672a\u521d\u59cb\u5316")
}
if password == "" && existing != nil && existing.PasswordRef != "" {
item.PasswordRef = existing.PasswordRef
}
if item.PasswordRef == "" && password == "" {
return deployment.Server{}, cleanup, errors.New("SSH \u5bc6\u7801\u4e0d\u80fd\u4e3a\u7a7a")
}
if item.PasswordRef == "" {
item.PasswordRef = "secret://ssh/password/" + domain.NewID()
if err := s.secrets.Put(ctx, item.PasswordRef, map[string]string{"kind": "ssh_password", "password": password}); err != nil {
return deployment.Server{}, cleanup, fmt.Errorf("\u4fdd\u5b58 SSH \u5bc6\u7801\u5931\u8d25\uff1a%w", err)
}
cleanup = func() {
_ = s.secrets.Delete(context.Background(), item.PasswordRef)
}
}
return item, cleanup, nil
}
func (s *Server) deleteServer(c *gin.Context) {
if s.servers == nil {
s.writeError(c, http.StatusNotImplemented, "服务器管理接口尚未配置", errors.New("服务器仓储未配置"))
return
}
if !s.requireProjectResourceReader(c) {
return
}
item, err := s.projectResources.GetVisibleServer(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id"))
if err != nil {
if errors.Is(err, repository.ErrProjectAccessDenied) {
s.writeProjectResourceError(c, err)
return
}
if repository.IsNotFound(err) {
s.writeError(c, http.StatusNotFound, "服务器不存在", err)
return
}
s.writeError(c, http.StatusInternalServerError, "读取服务器失败", err)
return
}
if !s.authorizeServerMutation(c, item.ProjectID) {
return
}
if err := s.servers.DeleteServer(c.Request.Context(), c.Param("id")); err != nil {
if repository.IsNotFound(err) {
s.writeError(c, http.StatusNotFound, "服务器不存在", err)
return
}
s.writeError(c, http.StatusInternalServerError, "删除服务器失败", err)
return
}
if item.PasswordRef != "" && s.secrets != nil {
if err := s.secrets.Delete(c.Request.Context(), item.PasswordRef); err != nil && s.logger != nil {
s.logger.Warn("删除 SSH 密码失败", "error", err)
}
}
c.Status(http.StatusNoContent)
}
func (s *Server) listDomainServers(c *gin.Context) {
if !s.requireProjectResourceReader(c) {
return
}
items, err := s.projectResources.ListVisibleDomainServers(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id"))
if err != nil {
s.writeProjectResourceError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (s *Server) listServerDomains(c *gin.Context) {
if !s.requireProjectResourceReader(c) {
return
}
items, err := s.projectResources.ListVisibleServerDomains(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id"))
if err != nil {
s.writeProjectResourceError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func serverFromRequest(request serverRequest) deployment.Server {
return deployment.Server{
ID: domain.NewID(), Name: request.Name, Address: request.Address, Port: request.Port,
Username: request.Username, AuthMethod: deployment.SSHAuthPassword, HostKeyFingerprint: request.HostKeyFingerprint,
CertificatePath: request.CertificatePath, PrivateKeyPath: request.PrivateKeyPath,
CertificateChainPath: request.CertificateChainPath, WebServer: deployment.WebServer(request.WebServer),
ValidationCommand: request.ValidationCommand, ReloadCommand: request.ReloadCommand,
}
}
func serverErrorStatus(err error) int {
if errors.Is(err, deployment.ErrSharedPathConflict) {
return http.StatusConflict
}
if repository.IsConflict(err) {
return http.StatusConflict
}
if repository.IsNotFound(err) {
return http.StatusNotFound
}
if strings.Contains(err.Error(), "不存在") || strings.Contains(err.Error(), "不能为空") || strings.Contains(err.Error(), "无效") {
return http.StatusBadRequest
}
return http.StatusInternalServerError
}
func (s *Server) writeError(c *gin.Context, status int, message string, err error) {
if s.logger != nil {
s.logger.Error(message, "error", err)
}
c.JSON(status, gin.H{"error": message})
}
func (s *Server) recordServerHostKeyAudit(c *gin.Context, action, serverID string) {
if s.audit == nil {
return
}
if err := s.audit.CreateAuditLog(c.Request.Context(), repository.AuditLog{
OperatorID: s.currentOperatorID(c),
Action: action, ResourceType: "server", ResourceID: serverID, Status: "success",
Message: "已登记服务器主机指纹变更",
}); err != nil && s.logger != nil {
s.logger.Warn("保存服务器主机指纹审计日志失败", "server_id", serverID, "error", err)
}
}