508 lines
17 KiB
Go
508 lines
17 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"git.pcux.cn/xuans/Scert/backend/internal/admin"
|
|
"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/domain"
|
|
"git.pcux.cn/xuans/Scert/backend/internal/firewall"
|
|
"git.pcux.cn/xuans/Scert/backend/internal/logging"
|
|
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
|
"git.pcux.cn/xuans/Scert/backend/internal/sshkey"
|
|
)
|
|
|
|
type Dependencies struct {
|
|
Config config.Config
|
|
ConfigPath string
|
|
Logger *logging.Logger
|
|
Repository repository.DomainRepository
|
|
Servers repository.ServerRepository
|
|
SSHKeys *sshkey.Service
|
|
SetupDNS setupDNSVerifier
|
|
Firewall firewall.Manager
|
|
IssueCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
|
|
ResetStatePath string
|
|
Audit repository.AuditRepository
|
|
}
|
|
|
|
type Server struct {
|
|
configMu sync.RWMutex
|
|
config config.Config
|
|
configPath string
|
|
logger *logging.Logger
|
|
repository repository.DomainRepository
|
|
servers repository.ServerRepository
|
|
sshKeys *sshkey.Service
|
|
setupDNS setupDNSVerifier
|
|
firewall firewall.Manager
|
|
issueCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
|
|
resetStatePath string
|
|
audit repository.AuditRepository
|
|
resetMu sync.Mutex
|
|
resetClosedHash string
|
|
resetLimiter *resetRateLimiter
|
|
authSessions map[string]authSession
|
|
authMu sync.RWMutex
|
|
loginAttempts map[string]loginAttempt
|
|
loginMu sync.Mutex
|
|
now func() time.Time
|
|
httpServer *http.Server
|
|
router *gin.Engine
|
|
}
|
|
|
|
func New(deps Dependencies) *Server {
|
|
gin.SetMode(gin.ReleaseMode)
|
|
router := gin.New()
|
|
router.Use(gin.Recovery())
|
|
server := &Server{
|
|
config: deps.Config, configPath: deps.ConfigPath, logger: deps.Logger,
|
|
repository: deps.Repository, setupDNS: deps.SetupDNS, firewall: deps.Firewall, router: router,
|
|
servers: deps.Servers,
|
|
sshKeys: deps.SSHKeys,
|
|
issueCertificate: deps.IssueCertificate,
|
|
resetStatePath: deps.ResetStatePath,
|
|
audit: deps.Audit,
|
|
resetLimiter: newResetRateLimiter(),
|
|
authSessions: make(map[string]authSession),
|
|
loginAttempts: make(map[string]loginAttempt),
|
|
now: time.Now,
|
|
}
|
|
server.registerRoutes()
|
|
server.registerSetupRoutes()
|
|
return server
|
|
}
|
|
|
|
func (s *Server) registerRoutes() {
|
|
// 主路由保持独立,初始化路由由专用模块注册。
|
|
s.router.GET("/healthz", s.health)
|
|
s.router.GET(admin.ResetPath, s.resetPage)
|
|
s.router.POST(admin.ResetPath, s.resetSubmit)
|
|
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.authLogin)
|
|
s.router.POST("/api/v1/auth/logout", s.authLogout)
|
|
s.router.GET("/api/v1/domains", s.listDomains)
|
|
s.router.POST("/api/v1/domains", s.createDomain)
|
|
s.router.PUT("/api/v1/domains/:id", s.updateDomain)
|
|
s.router.DELETE("/api/v1/domains/:id", s.deleteDomain)
|
|
s.router.POST("/api/v1/domains/:id/issue", s.issueDomain)
|
|
s.router.GET("/api/v1/domains/:id/servers", s.listDomainServers)
|
|
s.router.PUT("/api/v1/domains/:id/servers", s.replaceDomainServers)
|
|
s.router.GET("/api/v1/servers", s.listServers)
|
|
s.router.POST("/api/v1/servers", s.createServer)
|
|
s.router.GET("/api/v1/servers/:id", s.getServer)
|
|
s.router.PUT("/api/v1/servers/:id", s.updateServer)
|
|
s.router.DELETE("/api/v1/servers/:id", s.deleteServer)
|
|
s.router.GET("/api/v1/ssh-keys", s.listSSHKeys)
|
|
s.router.POST("/api/v1/ssh-keys", s.createSSHKey)
|
|
s.router.DELETE("/api/v1/ssh-keys/:id", s.deleteSSHKey)
|
|
}
|
|
|
|
func (s *Server) Handler() http.Handler {
|
|
return s.router
|
|
}
|
|
|
|
func (s *Server) ListenAndServe() error {
|
|
s.configMu.RLock()
|
|
cfg := s.config
|
|
s.configMu.RUnlock()
|
|
s.httpServer = &http.Server{
|
|
Addr: net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port)),
|
|
Handler: s.router,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
ReadTimeout: 30 * time.Second,
|
|
WriteTimeout: 30 * time.Second,
|
|
IdleTimeout: 60 * time.Second,
|
|
}
|
|
if s.logger != nil {
|
|
s.logger.Info("HTTP 服务启动", "address", s.httpServer.Addr, "https_mode", cfg.Server.HTTPSMode)
|
|
}
|
|
return s.httpServer.ListenAndServe()
|
|
}
|
|
|
|
func (s *Server) Shutdown(ctx context.Context) error {
|
|
if s.httpServer == nil {
|
|
return nil
|
|
}
|
|
err := s.httpServer.Shutdown(ctx)
|
|
if errors.Is(err, http.ErrServerClosed) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
type domainRequest struct {
|
|
Name string `json:"name" binding:"required"`
|
|
CertificateType string `json:"certificate_type" binding:"required"`
|
|
SANs []string `json:"sans"`
|
|
AutoRenew *bool `json:"auto_renew"`
|
|
ACMEEnvironment string `json:"acme_environment"`
|
|
DNSProvider string `json:"dns_provider"`
|
|
}
|
|
|
|
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"`
|
|
PrivateKeyRef string `json:"private_key_ref" binding:"required"`
|
|
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"`
|
|
ReloadCommand []string `json:"reload_command"`
|
|
}
|
|
|
|
type domainServersRequest struct {
|
|
ServerIDs []string `json:"server_ids"`
|
|
}
|
|
|
|
func (s *Server) health(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok", "service": "scert"})
|
|
}
|
|
|
|
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()
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"service": "scert",
|
|
"version": buildinfo.Version,
|
|
"config_version": cfg.Version,
|
|
"setup_ready": cfg.SetupReady,
|
|
"https_mode": cfg.Server.HTTPSMode,
|
|
"database_driver": cfg.Database.Driver,
|
|
"reset_url": cfg.ResetURL(),
|
|
})
|
|
}
|
|
|
|
func (s *Server) listDomains(c *gin.Context) {
|
|
items, err := s.repository.List(c.Request.Context())
|
|
if err != nil {
|
|
s.writeError(c, http.StatusInternalServerError, "查询域名失败", err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
|
}
|
|
|
|
func (s *Server) createDomain(c *gin.Context) {
|
|
var request domainRequest
|
|
if err := c.ShouldBindJSON(&request); err != nil {
|
|
s.writeError(c, http.StatusBadRequest, "请求参数无效", err)
|
|
return
|
|
}
|
|
item, err := newDomainFromRequest(request)
|
|
if err != nil {
|
|
s.writeError(c, http.StatusBadRequest, "域名配置无效", err)
|
|
return
|
|
}
|
|
if err := s.repository.Create(c.Request.Context(), item); err != nil {
|
|
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) {
|
|
var request domainRequest
|
|
if err := c.ShouldBindJSON(&request); err != nil {
|
|
s.writeError(c, http.StatusBadRequest, "请求参数无效", err)
|
|
return
|
|
}
|
|
existing, err := s.repository.GetByID(c.Request.Context(), c.Param("id"))
|
|
if err != nil {
|
|
if repository.IsNotFound(err) {
|
|
s.writeError(c, http.StatusNotFound, "域名不存在", err)
|
|
return
|
|
}
|
|
s.writeError(c, http.StatusInternalServerError, "读取域名失败", err)
|
|
return
|
|
}
|
|
item, err := newDomainFromRequest(request)
|
|
if err != nil {
|
|
s.writeError(c, http.StatusBadRequest, "域名配置无效", err)
|
|
return
|
|
}
|
|
item.ID = existing.ID
|
|
item.CreatedAt = existing.CreatedAt
|
|
item.Status = "pending"
|
|
item.ExpiresAt = nil
|
|
if err := s.repository.Update(c.Request.Context(), item); err != nil {
|
|
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
|
|
}
|
|
return domain.NewDomainWithOptions(request.Name, domain.CertificateType(request.CertificateType), request.SANs, autoRenew, acmeEnvironment, request.DNSProvider)
|
|
}
|
|
|
|
func (s *Server) deleteDomain(c *gin.Context) {
|
|
if err := s.repository.Delete(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
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) issueDomain(c *gin.Context) {
|
|
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": deploymentReport})
|
|
}
|
|
|
|
func (s *Server) listServers(c *gin.Context) {
|
|
if s.servers == nil {
|
|
s.writeError(c, http.StatusNotImplemented, "服务器管理接口尚未配置", errors.New("服务器仓储未配置"))
|
|
return
|
|
}
|
|
items, err := s.servers.ListServers(c.Request.Context())
|
|
if err != nil {
|
|
s.writeError(c, http.StatusInternalServerError, "查询服务器失败", err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
|
}
|
|
|
|
func (s *Server) getServer(c *gin.Context) {
|
|
if s.servers == nil {
|
|
s.writeError(c, http.StatusNotImplemented, "服务器管理接口尚未配置", errors.New("服务器仓储未配置"))
|
|
return
|
|
}
|
|
item, err := s.servers.GetServerByID(c.Request.Context(), c.Param("id"))
|
|
if err != nil {
|
|
if repository.IsNotFound(err) {
|
|
s.writeError(c, http.StatusNotFound, "服务器不存在", err)
|
|
return
|
|
}
|
|
s.writeError(c, http.StatusInternalServerError, "读取服务器失败", 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 := c.ShouldBindJSON(&request); err != nil {
|
|
s.writeError(c, http.StatusBadRequest, "请求参数无效", err)
|
|
return
|
|
}
|
|
item := serverFromRequest(request)
|
|
item = item.Normalize()
|
|
if err := s.validateSSHKeyReference(c.Request.Context(), item.PrivateKeyRef); err != nil {
|
|
s.writeError(c, http.StatusBadRequest, "SSH 私钥引用无效", err)
|
|
return
|
|
}
|
|
if err := s.servers.CreateServer(c.Request.Context(), item); err != nil {
|
|
s.writeError(c, serverErrorStatus(err), "保存服务器失败", err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, item)
|
|
}
|
|
|
|
func (s *Server) updateServer(c *gin.Context) {
|
|
if s.servers == nil {
|
|
s.writeError(c, http.StatusNotImplemented, "服务器管理接口尚未配置", errors.New("服务器仓储未配置"))
|
|
return
|
|
}
|
|
var request serverRequest
|
|
if err := c.ShouldBindJSON(&request); err != nil {
|
|
s.writeError(c, http.StatusBadRequest, "请求参数无效", err)
|
|
return
|
|
}
|
|
item := serverFromRequest(request)
|
|
item.ID = c.Param("id")
|
|
item = item.Normalize()
|
|
existing, err := s.servers.GetServerByID(c.Request.Context(), item.ID)
|
|
if err != nil {
|
|
if repository.IsNotFound(err) {
|
|
s.writeError(c, http.StatusNotFound, "服务器不存在", err)
|
|
return
|
|
}
|
|
s.writeError(c, http.StatusInternalServerError, "读取服务器失败", err)
|
|
return
|
|
}
|
|
item.CreatedAt = existing.CreatedAt
|
|
if err := s.validateSSHKeyReference(c.Request.Context(), item.PrivateKeyRef); err != nil {
|
|
s.writeError(c, http.StatusBadRequest, "SSH 私钥引用无效", err)
|
|
return
|
|
}
|
|
if err := s.servers.UpdateServer(c.Request.Context(), item); err != nil {
|
|
s.writeError(c, serverErrorStatus(err), "更新服务器失败", err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, item)
|
|
}
|
|
|
|
func (s *Server) validateSSHKeyReference(ctx context.Context, reference string) error {
|
|
if s.sshKeys == nil {
|
|
return errors.New("SSH 私钥管理服务未配置")
|
|
}
|
|
_, err := s.sshKeys.ValidateReference(ctx, reference)
|
|
return err
|
|
}
|
|
|
|
func (s *Server) deleteServer(c *gin.Context) {
|
|
if s.servers == nil {
|
|
s.writeError(c, http.StatusNotImplemented, "服务器管理接口尚未配置", errors.New("服务器仓储未配置"))
|
|
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
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) listDomainServers(c *gin.Context) {
|
|
if s.servers == nil {
|
|
s.writeError(c, http.StatusNotImplemented, "服务器管理接口尚未配置", errors.New("服务器仓储未配置"))
|
|
return
|
|
}
|
|
if _, err := s.repository.GetByID(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
|
|
}
|
|
items, err := s.servers.ListServersByDomainID(c.Request.Context(), c.Param("id"))
|
|
if err != nil {
|
|
s.writeError(c, http.StatusInternalServerError, "查询域名部署服务器失败", err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
|
}
|
|
|
|
func (s *Server) replaceDomainServers(c *gin.Context) {
|
|
if s.servers == nil {
|
|
s.writeError(c, http.StatusNotImplemented, "服务器管理接口尚未配置", errors.New("服务器仓储未配置"))
|
|
return
|
|
}
|
|
if _, err := s.repository.GetByID(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
|
|
}
|
|
var request domainServersRequest
|
|
if err := c.ShouldBindJSON(&request); err != nil {
|
|
s.writeError(c, http.StatusBadRequest, "请求参数无效", err)
|
|
return
|
|
}
|
|
if err := s.servers.ReplaceDomainServers(c.Request.Context(), c.Param("id"), request.ServerIDs); err != nil {
|
|
s.writeError(c, serverErrorStatus(err), "保存域名部署服务器失败", err)
|
|
return
|
|
}
|
|
items, err := s.servers.ListServersByDomainID(c.Request.Context(), c.Param("id"))
|
|
if err != nil {
|
|
s.writeError(c, http.StatusInternalServerError, "读取域名部署服务器失败", 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, PrivateKeyRef: request.PrivateKeyRef, HostKeyFingerprint: request.HostKeyFingerprint,
|
|
CertificatePath: request.CertificatePath, PrivateKeyPath: request.PrivateKeyPath,
|
|
CertificateChainPath: request.CertificateChainPath, WebServer: deployment.WebServer(request.WebServer),
|
|
ReloadCommand: request.ReloadCommand,
|
|
}
|
|
}
|
|
|
|
func serverErrorStatus(err error) int {
|
|
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})
|
|
}
|