diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 3a5822f..a4be770 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -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) { diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go index c37e696..ad4c231 100644 --- a/backend/internal/database/migrate.go +++ b/backend/internal/database/migrate.go @@ -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" } diff --git a/backend/internal/database/migrations/mysql/0009_server_ssh_key_status.sql b/backend/internal/database/migrations/mysql/0009_server_ssh_key_status.sql new file mode 100644 index 0000000..9fb9547 --- /dev/null +++ b/backend/internal/database/migrations/mysql/0009_server_ssh_key_status.sql @@ -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; diff --git a/backend/internal/database/migrations/postgres/0009_server_ssh_key_status.sql b/backend/internal/database/migrations/postgres/0009_server_ssh_key_status.sql new file mode 100644 index 0000000..62b5bfc --- /dev/null +++ b/backend/internal/database/migrations/postgres/0009_server_ssh_key_status.sql @@ -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 ''; diff --git a/backend/internal/database/migrations/sqlite/0009_server_ssh_key_status.sql b/backend/internal/database/migrations/sqlite/0009_server_ssh_key_status.sql new file mode 100644 index 0000000..62b5bfc --- /dev/null +++ b/backend/internal/database/migrations/sqlite/0009_server_ssh_key_status.sql @@ -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 ''; diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 0a3b392..008d526 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -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("服务器仓储未配置")) diff --git a/backend/internal/repository/server_repository.go b/backend/internal/repository/server_repository.go index 3fe5b4c..8e390cf 100644 --- a/backend/internal/repository/server_repository.go +++ b/backend/internal/repository/server_repository.go @@ -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 } diff --git a/backend/internal/sshkey/service.go b/backend/internal/sshkey/service.go index b27b701..6daf637 100644 --- a/backend/internal/sshkey/service.go +++ b/backend/internal/sshkey/service.go @@ -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 私钥存储未初始化")