Scert/backend/internal/deployment/model.go
xuans 7284958819 完善域名服务器项目归属与数据库关联约束
保存资源项目、所有者和操作人,接口从会话记录归属,普通更新保留创建信息。
新增三库第十一版迁移、同项目绑定约束和 SQLite 触发器保护,补充迁移回滚重试、仓储、接口及备份恢复测试。
本次整理验证:后端构建、静态检查、关联改造针对性测试和 CLI 包测试通过。
范围说明:项目成员权限及其他资源归属尚未完成;本次未重新执行外部数据库实库、独立程序及生产部署验收。
2026-09-28 10:45:00 +08:00

295 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 deployment
import (
"errors"
"fmt"
"net"
"path"
"regexp"
"strings"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
)
type WebServer string
type SSHAuthMethod string
const (
SSHAuthPrivateKey SSHAuthMethod = "private_key"
SSHAuthPassword SSHAuthMethod = "password"
)
const (
SSHKeyStatusNotConfigured SSHKeyStatus = "not_configured"
SSHKeyStatusPending SSHKeyStatus = "pending"
SSHKeyStatusDeployed SSHKeyStatus = "deployed"
SSHKeyStatusManual SSHKeyStatus = "manual"
SSHKeyStatusFailed SSHKeyStatus = "failed"
)
type SSHKeyStatus string
const (
WebServerNginx WebServer = "nginx"
WebServerCaddy WebServer = "caddy"
WebServerApache WebServer = "apache"
WebServerCustom WebServer = "custom"
)
const (
DefaultCertificatePath = "/ssl/{域名}/{证书类型}/cert.pem"
DefaultCertificateChainPath = "/ssl/{域名}/{证书类型}/fullchain.pem"
DefaultPrivateKeyPath = "/ssl/{域名}/{证书类型}/key.pem"
)
var serverPathPlaceholderPattern = regexp.MustCompile(`\{[^{}]+\}`)
type Server struct {
domain.ResourceScope
ID string `json:"id"`
Name string `json:"name"`
Address string `json:"address"`
Port int `json:"port"`
Username string `json:"username"`
AuthMethod SSHAuthMethod `json:"auth_method"`
PrivateKeyRef string `json:"private_key_ref"`
PasswordRef string `json:"-"`
PasswordConfigured bool `json:"password_configured"`
SSHKeyStatus SSHKeyStatus `json:"ssh_key_status"`
SSHKeyFingerprint string `json:"ssh_key_fingerprint,omitempty"`
SSHKeyDeployedAt string `json:"ssh_key_deployed_at,omitempty"`
SSHKeyTestedAt string `json:"ssh_key_tested_at,omitempty"`
SSHKeyError string `json:"ssh_key_error,omitempty"`
HostKeyFingerprint string `json:"host_key_fingerprint"`
CertificatePath string `json:"certificate_path"`
PrivateKeyPath string `json:"private_key_path"`
CertificateChainPath string `json:"certificate_chain_path"`
WebServer WebServer `json:"web_server"`
ValidationCommand []string `json:"validation_command,omitempty"`
ReloadCommand []string `json:"reload_command,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (s Server) Normalize() Server {
s.ResourceScope = s.ResourceScope.Normalize()
s.ID = strings.TrimSpace(s.ID)
s.Name = strings.TrimSpace(s.Name)
s.Address = strings.TrimSpace(s.Address)
s.Username = strings.TrimSpace(s.Username)
s.AuthMethod = SSHAuthMethod(strings.ToLower(strings.TrimSpace(string(s.AuthMethod))))
s.PrivateKeyRef = strings.TrimSpace(s.PrivateKeyRef)
s.PasswordRef = strings.TrimSpace(s.PasswordRef)
s.PasswordConfigured = s.PasswordRef != ""
s.SSHKeyStatus = SSHKeyStatus(strings.ToLower(strings.TrimSpace(string(s.SSHKeyStatus))))
s.SSHKeyFingerprint = strings.TrimSpace(s.SSHKeyFingerprint)
s.SSHKeyDeployedAt = strings.TrimSpace(s.SSHKeyDeployedAt)
s.SSHKeyTestedAt = strings.TrimSpace(s.SSHKeyTestedAt)
s.SSHKeyError = strings.TrimSpace(s.SSHKeyError)
if s.AuthMethod == "" {
if s.PasswordRef != "" {
s.AuthMethod = SSHAuthPassword
} else {
s.AuthMethod = SSHAuthPrivateKey
}
}
if s.SSHKeyStatus == "" {
s.SSHKeyStatus = SSHKeyStatusNotConfigured
if s.PrivateKeyRef != "" {
s.SSHKeyStatus = SSHKeyStatusManual
}
}
s.HostKeyFingerprint = strings.TrimSpace(s.HostKeyFingerprint)
s.CertificatePath = strings.TrimSpace(s.CertificatePath)
s.PrivateKeyPath = strings.TrimSpace(s.PrivateKeyPath)
s.CertificateChainPath = strings.TrimSpace(s.CertificateChainPath)
if s.CertificatePath == "" {
s.CertificatePath = DefaultCertificatePath
}
if s.CertificateChainPath == "" {
s.CertificateChainPath = DefaultCertificateChainPath
}
if s.PrivateKeyPath == "" {
s.PrivateKeyPath = DefaultPrivateKeyPath
}
s.WebServer = WebServer(strings.ToLower(strings.TrimSpace(string(s.WebServer))))
if s.Port == 0 {
s.Port = 22
}
if len(s.ReloadCommand) == 0 {
s.ReloadCommand = DefaultReloadCommand(s.WebServer)
}
if len(s.ValidationCommand) == 0 {
s.ValidationCommand = DefaultValidationCommand(s.WebServer)
}
for index := range s.ValidationCommand {
s.ValidationCommand[index] = strings.TrimSpace(s.ValidationCommand[index])
}
for index := range s.ReloadCommand {
s.ReloadCommand[index] = strings.TrimSpace(s.ReloadCommand[index])
}
return s
}
func ResolveServerPaths(server Server, domainName, certificateType string) (Server, error) {
server = server.Normalize()
domainDirectory, err := normalizeDomainDirectory(domainName)
if err != nil {
return Server{}, err
}
certificateType = strings.ToLower(strings.TrimSpace(certificateType))
switch certificateType {
case "single", "wildcard", "san":
default:
return Server{}, fmt.Errorf("证书类型无法用于远程路径:%s", certificateType)
}
replacer := strings.NewReplacer(
"{域名}", domainDirectory,
"{domain}", domainDirectory,
"{证书类型}", certificateType,
"{certificate_type}", certificateType,
)
server.CertificatePath, err = resolveServerPathTemplate("远程证书路径", server.CertificatePath, replacer)
if err != nil {
return Server{}, err
}
server.CertificateChainPath, err = resolveServerPathTemplate("远程完整证书链路径", server.CertificateChainPath, replacer)
if err != nil {
return Server{}, err
}
server.PrivateKeyPath, err = resolveServerPathTemplate("远程私钥路径", server.PrivateKeyPath, replacer)
if err != nil {
return Server{}, err
}
if err := validateDeploymentPaths([]remoteFile{
{path: server.CertificatePath},
{path: server.CertificateChainPath},
{path: server.PrivateKeyPath},
}); err != nil {
return Server{}, err
}
return server, nil
}
func normalizeDomainDirectory(value string) (string, error) {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.TrimPrefix(value, "*.")
if value == "" || value == "." || value == ".." || strings.ContainsAny(value, "/\\\x00\r\n") {
return "", fmt.Errorf("域名无法用于远程路径:%s", value)
}
return value, nil
}
func resolveServerPathTemplate(label, value string, replacer *strings.Replacer) (string, error) {
resolved := replacer.Replace(strings.TrimSpace(value))
if placeholder := serverPathPlaceholderPattern.FindString(resolved); placeholder != "" {
return "", fmt.Errorf("%s包含不支持的占位符:%s", label, placeholder)
}
cleaned := RemotePath(resolved)
if cleaned == "" || cleaned == "." {
return "", fmt.Errorf("%s无效", label)
}
if !strings.HasPrefix(cleaned, "/") {
return "", fmt.Errorf("%s必须是绝对路径", label)
}
return cleaned, nil
}
func (s Server) Validate() error {
s = s.Normalize()
if s.ID == "" {
return errors.New("服务器 ID 不能为空")
}
if s.Name == "" {
return errors.New("服务器名称不能为空")
}
if s.Address == "" || net.ParseIP(s.Address) == nil && strings.ContainsAny(s.Address, " /\\") {
return errors.New("服务器地址无效")
}
if s.Port < 1 || s.Port > 65535 {
return errors.New("SSH 端口无效")
}
if s.Username == "" {
return errors.New("SSH 用户不能为空")
}
switch s.AuthMethod {
case SSHAuthPrivateKey:
if s.PrivateKeyRef == "" {
return errors.New("SSH \u79c1\u94a5\u5f15\u7528\u4e0d\u80fd\u4e3a\u7a7a")
}
case SSHAuthPassword:
if s.PasswordRef == "" {
return errors.New("SSH \u5bc6\u7801\u5f15\u7528\u4e0d\u80fd\u4e3a\u7a7a")
}
default:
return fmt.Errorf("SSH \u8ba4\u8bc1\u65b9\u5f0f\u65e0\u6548\uff1a%s", s.AuthMethod)
}
if s.HostKeyFingerprint == "" {
return errors.New("SSH 主机指纹不能为空")
}
if s.CertificatePath == "" || s.PrivateKeyPath == "" || s.CertificateChainPath == "" {
return errors.New("远程证书路径不能为空")
}
if _, err := ResolveServerPaths(s, "example.com", "single"); err != nil {
return err
}
if s.WebServer != WebServerNginx && s.WebServer != WebServerCaddy && s.WebServer != WebServerApache && s.WebServer != WebServerCustom {
return fmt.Errorf("网站服务类型无效:%s", s.WebServer)
}
if err := validateServerCommand("配置校验", s.ValidationCommand); err != nil {
return err
}
if err := validateServerCommand("reload", s.ReloadCommand); err != nil {
return err
}
return nil
}
func DefaultValidationCommand(webServer WebServer) []string {
switch WebServer(strings.ToLower(strings.TrimSpace(string(webServer)))) {
case WebServerNginx:
return []string{"nginx", "-t"}
case WebServerCaddy:
return []string{"caddy", "validate", "--config", "/etc/caddy/Caddyfile"}
case WebServerApache:
return []string{"apachectl", "configtest"}
default:
return nil
}
}
func DefaultReloadCommand(webServer WebServer) []string {
switch WebServer(strings.ToLower(strings.TrimSpace(string(webServer)))) {
case WebServerNginx:
return []string{"nginx", "-s", "reload"}
case WebServerCaddy:
return []string{"caddy", "reload"}
case WebServerApache:
return []string{"apachectl", "-k", "graceful"}
default:
return nil
}
}
func validateServerCommand(label string, command []string) error {
if len(command) == 0 || command[0] == "" {
return fmt.Errorf("%s命令不能为空", label)
}
for _, argument := range command {
if argument == "" || strings.ContainsAny(argument, "\x00\r\n") {
return fmt.Errorf("%s命令参数无效", label)
}
}
return nil
}
func RemotePath(value string) string {
value = strings.TrimSpace(value)
if strings.ContainsAny(value, "\x00\r\n") {
return ""
}
return path.Clean(strings.ReplaceAll(value, "\\", "/"))
}