Scert/backend/internal/deployment/model.go
xuans 48fc5eb395 关闭生产 SSH 私钥认证流程
生产服务器认证统一使用 SSH 密码,移除私钥管理路由、自动部署和私钥认证流程。
2026-10-02 02:47:13 +08:00

291 lines
9.5 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 仅为历史数据库兼容保留,禁止进入 API 响应和生产认证流程。
PrivateKeyRef string `json:"-"`
PasswordRef string `json:"-"`
PasswordConfigured bool `json:"password_configured"`
// SSH 密钥状态仅为历史数据兼容保留,不对外暴露。
SSHKeyStatus SSHKeyStatus `json:"-"`
SSHKeyFingerprint string `json:"-"`
SSHKeyDeployedAt string `json:"-"`
SSHKeyTestedAt string `json:"-"`
SSHKeyError string `json:"-"`
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 == "" || s.AuthMethod == SSHAuthPrivateKey {
s.AuthMethod = SSHAuthPassword
}
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 用户不能为空")
}
// 历史内存模型仍允许读取旧私钥记录,实际 SSHFactory 会在连接前拒绝该认证方式。
if s.PrivateKeyRef != "" && s.PasswordRef == "" {
return nil
}
if s.AuthMethod != SSHAuthPassword {
return errors.New("服务器仅支持 SSH 密码认证")
}
if s.PasswordRef == "" {
return errors.New("SSH 密码引用不能为空")
}
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, "\\", "/"))
}