From 38c3e2a3898a5e4672c02574605f2949c7c3bc56 Mon Sep 17 00:00:00 2001 From: xuans Date: Sun, 27 Sep 2026 14:47:41 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E5=AE=9E=E7=8E=B0=E6=8C=89?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=E7=AE=A1=E7=90=86SSH=E5=AF=86?= =?UTF-8?q?=E9=92=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充服务器SSH密钥状态模型、密钥生成及公钥部署接口,为自动部署流程继续开发做检查点。 --- backend/internal/database/migrate.go | 6 ++--- backend/internal/deployment/client.go | 1 + backend/internal/deployment/model.go | 26 ++++++++++++++++++ backend/internal/deployment/service_test.go | 2 ++ backend/internal/deployment/ssh.go | 26 ++++++++++++++++++ backend/internal/sshkey/service.go | 29 +++++++++++++++++++++ 6 files changed, 87 insertions(+), 3 deletions(-) diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go index 803ac04..c37e696 100644 --- a/backend/internal/database/migrate.go +++ b/backend/internal/database/migrate.go @@ -9,7 +9,7 @@ import ( "strings" ) -//go:embed migrations/sqlite/0001_initial.sql migrations/sqlite/0002_domain_options.sql migrations/sqlite/0003_deployment.sql migrations/sqlite/0004_task_attempts.sql migrations/sqlite/0005_users.sql migrations/sqlite/0006_server_validation.sql migrations/sqlite/0007_server_password_auth.sql migrations/mysql/0001_initial.sql migrations/mysql/0002_domain_options.sql migrations/mysql/0003_deployment.sql migrations/mysql/0004_task_attempts.sql migrations/mysql/0005_users.sql migrations/mysql/0006_server_validation.sql migrations/mysql/0007_server_password_auth.sql migrations/postgres/0001_initial.sql migrations/postgres/0002_domain_options.sql migrations/postgres/0003_deployment.sql migrations/postgres/0004_task_attempts.sql migrations/postgres/0005_users.sql migrations/postgres/0006_server_validation.sql migrations/postgres/0007_server_password_auth.sql +//go:embed migrations/sqlite/0001_initial.sql migrations/sqlite/0002_domain_options.sql migrations/sqlite/0003_deployment.sql migrations/sqlite/0004_task_attempts.sql migrations/sqlite/0005_users.sql migrations/sqlite/0006_server_validation.sql migrations/sqlite/0007_server_password_auth.sql migrations/sqlite/0009_server_ssh_key_status.sql migrations/mysql/0001_initial.sql migrations/mysql/0002_domain_options.sql migrations/mysql/0003_deployment.sql migrations/mysql/0004_task_attempts.sql migrations/mysql/0005_users.sql migrations/mysql/0006_server_validation.sql migrations/mysql/0007_server_password_auth.sql migrations/mysql/0009_server_ssh_key_status.sql migrations/postgres/0001_initial.sql migrations/postgres/0002_domain_options.sql migrations/postgres/0003_deployment.sql migrations/postgres/0004_task_attempts.sql migrations/postgres/0005_users.sql migrations/postgres/0006_server_validation.sql migrations/postgres/0007_server_password_auth.sql migrations/postgres/0009_server_ssh_key_status.sql //go:embed migrations/sqlite/0008_user_groups.sql migrations/mysql/0008_user_groups.sql migrations/postgres/0008_user_groups.sql var migrationFiles embed.FS @@ -39,7 +39,7 @@ func Migrate(ctx context.Context, db *sql.DB, driver string) error { return nil } -const latestMigrationVersion = 8 +const latestMigrationVersion = 9 func applyMigration(ctx context.Context, db *sql.DB, driver string, version int) error { path := fmt.Sprintf("migrations/%s/%04d_%s.sql", driver, version, migrationName(version)) @@ -119,7 +119,7 @@ func shouldSkipMySQLStatement(ctx context.Context, db *sql.DB, statement string) } func migrationName(version int) string { - switch version { + switch version {`n`tcase 9:`n`t return "server_ssh_key_status" case 1: return "initial" case 2: diff --git a/backend/internal/deployment/client.go b/backend/internal/deployment/client.go index 505361b..13a8d8b 100644 --- a/backend/internal/deployment/client.go +++ b/backend/internal/deployment/client.go @@ -17,6 +17,7 @@ type Client interface { Rename(ctx context.Context, source, target string) error Remove(ctx context.Context, path string) error Run(ctx context.Context, command []string) (CommandResult, error) + InstallAuthorizedKey(ctx context.Context, publicKey string) error Close() error } diff --git a/backend/internal/deployment/model.go b/backend/internal/deployment/model.go index 5c987d5..32361cb 100644 --- a/backend/internal/deployment/model.go +++ b/backend/internal/deployment/model.go @@ -19,6 +19,16 @@ const ( 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" @@ -44,6 +54,11 @@ type Server struct { 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"` @@ -64,6 +79,11 @@ func (s Server) Normalize() Server { 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 @@ -71,6 +91,12 @@ func (s Server) Normalize() Server { 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) diff --git a/backend/internal/deployment/service_test.go b/backend/internal/deployment/service_test.go index 179d5e6..f096b1f 100644 --- a/backend/internal/deployment/service_test.go +++ b/backend/internal/deployment/service_test.go @@ -120,6 +120,8 @@ func (c *memoryClient) Run(_ context.Context, command []string) (CommandResult, return result, err } +func (c *memoryClient) InstallAuthorizedKey(context.Context, string) error { return nil } + func (c *memoryClient) Close() error { return nil } type memoryFactory struct { diff --git a/backend/internal/deployment/ssh.go b/backend/internal/deployment/ssh.go index 076afcb..531b772 100644 --- a/backend/internal/deployment/ssh.go +++ b/backend/internal/deployment/ssh.go @@ -2,6 +2,7 @@ package deployment import ( "context" + "encoding/base64" "errors" "fmt" "io" @@ -332,6 +333,31 @@ func (c *sshClientAdapter) Remove(ctx context.Context, path string) error { }) } +func (c *sshClientAdapter) InstallAuthorizedKey(ctx context.Context, publicKey string) error { + publicKey = strings.TrimSpace(publicKey) + key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(publicKey)) + if err != nil { + return fmt.Errorf("SSH 公钥格式无效:%w", err) + } + encoded := base64.StdEncoding.EncodeToString(ssh.MarshalAuthorizedKey(key)) + script := fmt.Sprintf(`set -eu +umask 077 +install -d -m 700 "$HOME/.ssh" +touch "$HOME/.ssh/authorized_keys" +chmod 600 "$HOME/.ssh/authorized_keys" +decoded="$(printf '%s' '%s' | base64 -d)" +if ! grep -Fqx -- "$decoded" "$HOME/.ssh/authorized_keys"; then + printf '%%s\\n' "$decoded" >> "$HOME/.ssh/authorized_keys" +fi`, "%s", encoded) + result, err := c.Run(ctx, []string{"sh", "-c", script}) + if err != nil { + return fmt.Errorf("部署 SSH 公钥失败:%w", err) + } + if result.ExitCode != 0 { + return fmt.Errorf("部署 SSH 公钥失败,远程退出码:%d", result.ExitCode) + } + return nil +} func (c *sshClientAdapter) Run(ctx context.Context, command []string) (CommandResult, error) { if len(command) == 0 { return CommandResult{}, errors.New("远程命令不能为空") diff --git a/backend/internal/sshkey/service.go b/backend/internal/sshkey/service.go index a939299..b27b701 100644 --- a/backend/internal/sshkey/service.go +++ b/backend/internal/sshkey/service.go @@ -2,6 +2,9 @@ package sshkey import ( "context" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" "errors" "fmt" "strings" @@ -90,6 +93,32 @@ func (s *Service) Create(ctx context.Context, name, privateKey, passphrase strin }, nil } +func (s *Service) CreateGenerated(ctx context.Context, name string) (Metadata, string, error) { + if s == nil || s.store == nil { + return Metadata{}, "", errors.New("SSH 私钥存储未初始化") + } + name = strings.TrimSpace(name) + if name == "" { + return Metadata{}, "", errors.New("SSH 私钥名称不能为空") + } + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return Metadata{}, "", fmt.Errorf("生成 SSH 密钥失败:%w", err) + } + privatePEM, err := ssh.MarshalPrivateKey(privateKey, "scert-deploy") + if err != nil { + return Metadata{}, "", fmt.Errorf("编码 SSH 私钥失败:%w", err) + } + metadata, err := s.Create(ctx, name, string(pem.EncodeToMemory(privatePEM)), "") + if err != nil { + return Metadata{}, "", err + } + sshPublicKey, err := ssh.NewPublicKey(publicKey) + if err != nil { + return Metadata{}, "", fmt.Errorf("编码 SSH 公钥失败:%w", err) + } + return metadata, strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPublicKey))), nil +} func (s *Service) List(ctx context.Context) ([]Metadata, error) { if s == nil || s.store == nil { return nil, errors.New("SSH 私钥存储未初始化")