196 lines
6.2 KiB
Go
196 lines
6.2 KiB
Go
package deployment
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"net"
|
||
"path"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
type WebServer 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 {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Address string `json:"address"`
|
||
Port int `json:"port"`
|
||
Username string `json:"username"`
|
||
PrivateKeyRef string `json:"private_key_ref"`
|
||
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"`
|
||
ReloadCommand []string `json:"reload_command,omitempty"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
func (s Server) Normalize() Server {
|
||
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.PrivateKeyRef = strings.TrimSpace(s.PrivateKeyRef)
|
||
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)
|
||
}
|
||
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
|
||
}
|
||
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)
|
||
}
|
||
return resolved, 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 用户不能为空")
|
||
}
|
||
if s.PrivateKeyRef == "" {
|
||
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 len(s.ReloadCommand) == 0 || s.ReloadCommand[0] == "" {
|
||
return errors.New("reload 命令不能为空")
|
||
}
|
||
for _, argument := range s.ReloadCommand {
|
||
if argument == "" || strings.ContainsAny(argument, "\x00\r\n") {
|
||
return errors.New("reload 命令参数无效")
|
||
}
|
||
}
|
||
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 RemotePath(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
if strings.ContainsAny(value, "\x00\r\n") {
|
||
return ""
|
||
}
|
||
return path.Clean(strings.ReplaceAll(value, "\\", "/"))
|
||
}
|