继续完善按服务器管理SSH密钥

This commit is contained in:
xuans 2026-09-27 15:04:53 +08:00
parent 38c3e2a389
commit 8211ac4973
8 changed files with 149 additions and 12 deletions

View File

@ -154,7 +154,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
RetryInterval: time.Duration(cfg.ACME.RenewalRetryIntervalMins) * time.Minute,
}, management)
server = httpapi.New(httpapi.Dependencies{
Config: cfg, ConfigPath: configPath, Logger: logger, Repository: repo, Servers: serverRepo, Users: userRepo, SSHKeys: sshKeys,
Config: cfg, ConfigPath: configPath, Logger: logger, Repository: repo, Servers: serverRepo, Users: userRepo, SSHKeys: sshKeys, DeploymentFactory: deployment.SSHFactory{Secrets: secretStore},
SetupDNS: dnsVerification, Secrets: secretStore, Firewall: firewall.NewManager(), AliyunChecker: aliyunAuthChecker,
IssueCertificate: workflow.IssueForHTTP,
IssueSetupCertificate: func(ctx context.Context, request certificate.SetupCertificateRequest) (certificate.SavedResource, error) {

View File

@ -119,7 +119,7 @@ func shouldSkipMySQLStatement(ctx context.Context, db *sql.DB, statement string)
}
func migrationName(version int) string {
switch version {`n`tcase 9:`n`t return "server_ssh_key_status"
switch version {
case 1:
return "initial"
case 2:
@ -136,6 +136,8 @@ func migrationName(version int) string {
return "server_password_auth"
case 8:
return "user_groups"
case 9:
return "server_ssh_key_status"
default:
return "unknown"
}

View File

@ -0,0 +1,5 @@
ALTER TABLE servers ADD COLUMN ssh_key_status VARCHAR(32) NOT NULL DEFAULT 'not_configured';
ALTER TABLE servers ADD COLUMN ssh_key_fingerprint VARCHAR(255) NOT NULL DEFAULT '';
ALTER TABLE servers ADD COLUMN ssh_key_deployed_at VARCHAR(64) NOT NULL DEFAULT '';
ALTER TABLE servers ADD COLUMN ssh_key_tested_at VARCHAR(64) NOT NULL DEFAULT '';
ALTER TABLE servers ADD COLUMN ssh_key_error TEXT NOT NULL;

View File

@ -0,0 +1,5 @@
ALTER TABLE servers ADD COLUMN ssh_key_status TEXT NOT NULL DEFAULT 'not_configured';
ALTER TABLE servers ADD COLUMN ssh_key_fingerprint TEXT NOT NULL DEFAULT '';
ALTER TABLE servers ADD COLUMN ssh_key_deployed_at TEXT NOT NULL DEFAULT '';
ALTER TABLE servers ADD COLUMN ssh_key_tested_at TEXT NOT NULL DEFAULT '';
ALTER TABLE servers ADD COLUMN ssh_key_error TEXT NOT NULL DEFAULT '';

View File

@ -0,0 +1,5 @@
ALTER TABLE servers ADD COLUMN ssh_key_status TEXT NOT NULL DEFAULT 'not_configured';
ALTER TABLE servers ADD COLUMN ssh_key_fingerprint TEXT NOT NULL DEFAULT '';
ALTER TABLE servers ADD COLUMN ssh_key_deployed_at TEXT NOT NULL DEFAULT '';
ALTER TABLE servers ADD COLUMN ssh_key_tested_at TEXT NOT NULL DEFAULT '';
ALTER TABLE servers ADD COLUMN ssh_key_error TEXT NOT NULL DEFAULT '';

View File

@ -49,6 +49,7 @@ type Dependencies struct {
Servers repository.ServerRepository
Users repository.UserRepository
SSHKeys *sshkey.Service
DeploymentFactory deployment.ClientFactory
SetupDNS setupDNSVerifier
Secrets setupSecretWriter
Firewall firewall.Manager
@ -79,6 +80,7 @@ type Server struct {
servers repository.ServerRepository
users repository.UserRepository
sshKeys *sshkey.Service
deploymentFactory deployment.ClientFactory
setupDNS setupDNSVerifier
secrets setupSecretWriter
firewall firewall.Manager
@ -138,6 +140,7 @@ func New(deps Dependencies) *Server {
servers: deps.Servers,
users: deps.Users,
sshKeys: deps.SSHKeys,
deploymentFactory: deps.DeploymentFactory,
issueCertificate: deps.IssueCertificate,
issueSetupCertificate: deps.IssueSetupCertificate,
rollbackSetupCertificate: deps.RollbackSetupCertificate,
@ -551,6 +554,20 @@ func (s *Server) createDomain(c *gin.Context) {
s.writeError(c, http.StatusInternalServerError, "保存域名失败", err)
return
}
item, provisionErr := s.provisionServerSSHKey(c.Request.Context(), item)
if provisionErr != nil {
if updateErr := s.servers.UpdateServer(c.Request.Context(), item); updateErr != nil && s.logger != nil {
`s.logger.Warn("保存 SSH 密钥部署失败状态失败", "error", updateErr)
}
c.JSON(http.StatusBadGateway, gin.H{"error": item.SSHKeyError, "server": item})
return
}
if err := s.servers.UpdateServer(c.Request.Context(), item); err != nil {
s.writeError(c, http.StatusInternalServerError, "保存 SSH 密钥部署状态失败", err)
return
}
item, provisionErr := s.provisionServerSSHKey(c.Request.Context(), item) if provisionErr != nil { if updateErr := s.servers.UpdateServer(c.Request.Context(), item); updateErr != nil && s.logger != nil { s.logger.Warn("保存 SSH 密钥部署失败状态失败", "error", updateErr) } c.JSON(http.StatusBadGateway, gin.H{"error": item.SSHKeyError, "server": item}) return } if err := s.servers.UpdateServer(c.Request.Context(), item); err != nil { s.writeError(c, http.StatusInternalServerError, "保存 SSH 密钥部署状态失败", err) return }
item, provisionErr := s.provisionServerSSHKey(c.Request.Context(), item) if provisionErr != nil { if updateErr := s.servers.UpdateServer(c.Request.Context(), item); updateErr != nil && s.logger != nil { s.logger.Warn("保存 SSH 密钥部署失败状态失败", "error", updateErr) } c.JSON(http.StatusBadGateway, gin.H{"error": item.SSHKeyError, "server": item}) return } if err := s.servers.UpdateServer(c.Request.Context(), item); err != nil { s.writeError(c, http.StatusInternalServerError, "保存 SSH 密钥部署状态失败", err) return }
c.JSON(http.StatusCreated, item)
}
@ -750,6 +767,18 @@ func (s *Server) updateServer(c *gin.Context) {
s.writeError(c, serverErrorStatus(err), "更新服务器失败", err)
return
}
item, provisionErr := s.provisionServerSSHKey(c.Request.Context(), item)
if provisionErr != nil {
if updateErr := s.servers.UpdateServer(c.Request.Context(), item); updateErr != nil && s.logger != nil {
`s.logger.Warn("保存 SSH 密钥部署失败状态失败", "error", updateErr)
}
c.JSON(http.StatusBadGateway, gin.H{"error": item.SSHKeyError, "server": item})
return
}
if err := s.servers.UpdateServer(c.Request.Context(), item); err != nil {
s.writeError(c, http.StatusInternalServerError, "保存 SSH 密钥部署状态失败", err)
return
}
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)
@ -783,11 +812,11 @@ func (s *Server) prepareServerCredential(ctx context.Context, item deployment.Se
}
if password == "" && existing != nil && existing.PasswordRef != "" {
item.PasswordRef = existing.PasswordRef
return item, cleanup, nil
}
if password == "" {
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)
@ -795,9 +824,78 @@ func (s *Server) prepareServerCredential(ctx context.Context, item deployment.Se
cleanup = func() {
_ = s.secrets.Delete(context.Background(), item.PasswordRef)
}
}
if item.PrivateKeyRef == "" && s.sshKeys != nil && s.deploymentFactory != nil {
metadata, _, err := s.sshKeys.CreateGenerated(ctx, "服务器 "+item.Name+" SSH密钥")
if err != nil {
return deployment.Server{}, cleanup, fmt.Errorf("生成 SSH 密钥失败:%w", err)
}
item.PrivateKeyRef = metadata.Reference
item.SSHKeyFingerprint = metadata.Fingerprint
item.SSHKeyStatus = deployment.SSHKeyStatusPending
}
return item, cleanup, nil
}
func (s *Server) provisionServerSSHKey(ctx context.Context, item deployment.Server) (deployment.Server, error) {
item = item.Normalize()
if item.AuthMethod != deployment.SSHAuthPassword || item.PrivateKeyRef == "" || s.sshKeys == nil || s.deploymentFactory == nil {
return item, nil
}
publicKey, err := s.sshKeys.AuthorizedPublicKey(ctx, item.PrivateKeyRef)
if err != nil {
item.SSHKeyStatus = deployment.SSHKeyStatusFailed
item.SSHKeyError = "自动部署 SSH 公钥失败"
return item, err
}
item.SSHKeyStatus = deployment.SSHKeyStatusPending
item.SSHKeyError = ""
passwordClient, err := s.deploymentFactory.Connect(ctx, item)
if err != nil {
item.SSHKeyStatus = deployment.SSHKeyStatusFailed
item.SSHKeyError = "自动部署 SSH 公钥失败"
return item, err
}
if err := passwordClient.InstallAuthorizedKey(ctx, publicKey); err != nil {
_ = passwordClient.Close()
item.SSHKeyStatus = deployment.SSHKeyStatusFailed
item.SSHKeyError = "自动部署 SSH 公钥失败"
return item, err
}
if err := passwordClient.Close(); err != nil {
item.SSHKeyStatus = deployment.SSHKeyStatusFailed
item.SSHKeyError = "自动部署 SSH 公钥失败"
return item, err
}
privateItem := item
privateItem.AuthMethod = deployment.SSHAuthPrivateKey
privateClient, err := s.deploymentFactory.Connect(ctx, privateItem)
if err != nil {
item.SSHKeyStatus = deployment.SSHKeyStatusFailed
item.SSHKeyError = "自动验证 SSH 密钥登录失败"
return item, err
}
defer privateClient.Close()
result, err := privateClient.Run(ctx, []string{"true"})
if err != nil {
item.SSHKeyStatus = deployment.SSHKeyStatusFailed
item.SSHKeyError = "自动验证 SSH 密钥登录失败"
return item, err
}
if result.ExitCode != 0 {
item.SSHKeyStatus = deployment.SSHKeyStatusFailed
item.SSHKeyError = "自动验证 SSH 密钥登录失败"
return item, fmt.Errorf("远程验证退出码:%d", result.ExitCode)
}
now := time.Now().UTC().Format(time.RFC3339)
item.AuthMethod = deployment.SSHAuthPrivateKey
item.SSHKeyStatus = deployment.SSHKeyStatusDeployed
item.SSHKeyDeployedAt = now
item.SSHKeyTestedAt = now
item.SSHKeyError = ""
return item, nil
}
func (s *Server) deleteServer(c *gin.Context) {
if s.servers == nil {
s.writeError(c, http.StatusNotImplemented, "服务器管理接口尚未配置", errors.New("服务器仓储未配置"))

View File

@ -58,9 +58,9 @@ func (r *SQLServerRepository) CreateServer(ctx context.Context, server deploymen
updatedAt = createdAt
}
_, err = r.db.ExecContext(ctx, r.query(`INSERT INTO servers
(id, name, address, port, username, auth_method, private_key_ref, password_ref, host_key_fingerprint, certificate_path, private_key_path, certificate_chain_path, web_server, validation_command_json, reload_command_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), server.ID, server.Name, server.Address, server.Port, server.Username, server.AuthMethod, server.PrivateKeyRef, server.PasswordRef,
server.HostKeyFingerprint, server.CertificatePath, server.PrivateKeyPath, server.CertificateChainPath, server.WebServer, string(validationCommand), string(command), createdAt, updatedAt)
(id, name, address, port, username, auth_method, private_key_ref, password_ref, host_key_fingerprint, ssh_key_status, ssh_key_fingerprint, ssh_key_deployed_at, ssh_key_tested_at, ssh_key_error, certificate_path, private_key_path, certificate_chain_path, web_server, validation_command_json, reload_command_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), server.ID, server.Name, server.Address, server.Port, server.Username, server.AuthMethod, server.PrivateKeyRef, server.PasswordRef,
server.HostKeyFingerprint, server.SSHKeyStatus, server.SSHKeyFingerprint, server.SSHKeyDeployedAt, server.SSHKeyTestedAt, server.SSHKeyError, server.CertificatePath, server.PrivateKeyPath, server.CertificateChainPath, server.WebServer, string(validationCommand), string(command), createdAt, updatedAt)
if err != nil {
return fmt.Errorf("保存服务器失败:%w", err)
}
@ -84,8 +84,8 @@ func (r *SQLServerRepository) UpdateServer(ctx context.Context, server deploymen
if updatedAt.IsZero() {
updatedAt = time.Now().UTC()
}
result, err := r.db.ExecContext(ctx, r.query(`UPDATE servers SET name = ?, address = ?, port = ?, username = ?, auth_method = ?, private_key_ref = ?, password_ref = ?, host_key_fingerprint = ?, certificate_path = ?, private_key_path = ?, certificate_chain_path = ?, web_server = ?, validation_command_json = ?, reload_command_json = ?, updated_at = ? WHERE id = ?`),
server.Name, server.Address, server.Port, server.Username, server.AuthMethod, server.PrivateKeyRef, server.PasswordRef, server.HostKeyFingerprint, server.CertificatePath, server.PrivateKeyPath, server.CertificateChainPath, server.WebServer, string(validationCommand), string(command), updatedAt, server.ID)
result, err := r.db.ExecContext(ctx, r.query(`UPDATE servers SET name = ?, address = ?, port = ?, username = ?, auth_method = ?, private_key_ref = ?, password_ref = ?, host_key_fingerprint = ?, ssh_key_status = ?, ssh_key_fingerprint = ?, ssh_key_deployed_at = ?, ssh_key_tested_at = ?, ssh_key_error = ?, certificate_path = ?, private_key_path = ?, certificate_chain_path = ?, web_server = ?, validation_command_json = ?, reload_command_json = ?, updated_at = ? WHERE id = ?`),
server.Name, server.Address, server.Port, server.Username, server.AuthMethod, server.PrivateKeyRef, server.PasswordRef, server.HostKeyFingerprint, server.SSHKeyStatus, server.SSHKeyFingerprint, server.SSHKeyDeployedAt, server.SSHKeyTestedAt, server.SSHKeyError, server.CertificatePath, server.PrivateKeyPath, server.CertificateChainPath, server.WebServer, string(validationCommand), string(command), updatedAt, server.ID)
if err != nil {
return fmt.Errorf("更新服务器失败:%w", err)
}
@ -235,8 +235,8 @@ func (r *SQLServerRepository) ReplaceDomainServers(ctx context.Context, domainID
return nil
}
const serverSelect = `SELECT id, name, address, port, username, auth_method, private_key_ref, password_ref, host_key_fingerprint, certificate_path, private_key_path, certificate_chain_path, web_server, validation_command_json, reload_command_json, created_at, updated_at FROM servers`
const serverSelectQualified = `SELECT servers.id, servers.name, servers.address, servers.port, servers.username, servers.auth_method, servers.private_key_ref, servers.password_ref, servers.host_key_fingerprint, servers.certificate_path, servers.private_key_path, servers.certificate_chain_path, servers.web_server, servers.validation_command_json, servers.reload_command_json, servers.created_at, servers.updated_at FROM servers`
const serverSelect = `SELECT id, name, address, port, username, auth_method, private_key_ref, password_ref, host_key_fingerprint, ssh_key_status, ssh_key_fingerprint, ssh_key_deployed_at, ssh_key_tested_at, ssh_key_error, certificate_path, private_key_path, certificate_chain_path, web_server, validation_command_json, reload_command_json, created_at, updated_at FROM servers`
const serverSelectQualified = `SELECT servers.id, servers.name, servers.address, servers.port, servers.username, servers.auth_method, servers.private_key_ref, servers.password_ref, servers.host_key_fingerprint, servers.ssh_key_status, servers.ssh_key_fingerprint, servers.ssh_key_deployed_at, servers.ssh_key_tested_at, servers.ssh_key_error, servers.certificate_path, servers.private_key_path, servers.certificate_chain_path, servers.web_server, servers.validation_command_json, servers.reload_command_json, servers.created_at, servers.updated_at FROM servers`
type rowScanner interface {
Scan(dest ...any) error
@ -248,7 +248,7 @@ func scanServer(row rowScanner) (deployment.Server, error) {
var authMethod string
var validationCommandJSON string
var commandJSON string
if err := row.Scan(&server.ID, &server.Name, &server.Address, &server.Port, &server.Username, &authMethod, &server.PrivateKeyRef, &server.PasswordRef, &server.HostKeyFingerprint, &server.CertificatePath, &server.PrivateKeyPath, &server.CertificateChainPath, &webServer, &validationCommandJSON, &commandJSON, &server.CreatedAt, &server.UpdatedAt); err != nil {
if err := row.Scan(&server.ID, &server.Name, &server.Address, &server.Port, &server.Username, &authMethod, &server.PrivateKeyRef, &server.PasswordRef, &server.HostKeyFingerprint, &server.SSHKeyStatus, &server.SSHKeyFingerprint, &server.SSHKeyDeployedAt, &server.SSHKeyTestedAt, &server.SSHKeyError, &server.CertificatePath, &server.PrivateKeyPath, &server.CertificateChainPath, &webServer, &validationCommandJSON, &commandJSON, &server.CreatedAt, &server.UpdatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return deployment.Server{}, sql.ErrNoRows
}

View File

@ -119,6 +119,28 @@ func (s *Service) CreateGenerated(ctx context.Context, name string) (Metadata, s
}
return metadata, strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPublicKey))), nil
}
func (s *Service) AuthorizedPublicKey(ctx context.Context, reference string) (string, error) {
if s == nil || s.store == nil {
return "", errors.New("SSH 私钥存储未初始化")
}
metadata, err := s.metadata(ctx, strings.TrimSpace(reference), time.Time{}, true)
if err != nil {
return "", err
}
payload, err := s.store.Get(ctx, metadata.Reference)
if err != nil {
return "", fmt.Errorf("读取 SSH 私钥失败:%w", err)
}
signer, err := parsePrivateKey(payload["private_key"], payload["passphrase"])
if err != nil {
return "", err
}
publicKey, err := ssh.NewPublicKey(signer.PublicKey())
if err != nil {
return "", fmt.Errorf("编码 SSH 公钥失败:%w", err)
}
return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(publicKey))), nil
}
func (s *Service) List(ctx context.Context) ([]Metadata, error) {
if s == nil || s.store == nil {
return nil, errors.New("SSH 私钥存储未初始化")