继续实现按服务器管理SSH密钥

补充服务器SSH密钥状态模型、密钥生成及公钥部署接口,为自动部署流程继续开发做检查点。
This commit is contained in:
xuans 2026-09-27 14:47:41 +08:00
parent 5edf0fb092
commit 38c3e2a389
6 changed files with 87 additions and 3 deletions

View File

@ -9,7 +9,7 @@ import (
"strings" "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 //go:embed migrations/sqlite/0008_user_groups.sql migrations/mysql/0008_user_groups.sql migrations/postgres/0008_user_groups.sql
var migrationFiles embed.FS var migrationFiles embed.FS
@ -39,7 +39,7 @@ func Migrate(ctx context.Context, db *sql.DB, driver string) error {
return nil return nil
} }
const latestMigrationVersion = 8 const latestMigrationVersion = 9
func applyMigration(ctx context.Context, db *sql.DB, driver string, version int) error { 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)) 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 { func migrationName(version int) string {
switch version { switch version {`n`tcase 9:`n`t return "server_ssh_key_status"
case 1: case 1:
return "initial" return "initial"
case 2: case 2:

View File

@ -17,6 +17,7 @@ type Client interface {
Rename(ctx context.Context, source, target string) error Rename(ctx context.Context, source, target string) error
Remove(ctx context.Context, path string) error Remove(ctx context.Context, path string) error
Run(ctx context.Context, command []string) (CommandResult, error) Run(ctx context.Context, command []string) (CommandResult, error)
InstallAuthorizedKey(ctx context.Context, publicKey string) error
Close() error Close() error
} }

View File

@ -19,6 +19,16 @@ const (
SSHAuthPassword SSHAuthMethod = "password" SSHAuthPassword SSHAuthMethod = "password"
) )
const (
SSHKeyStatusNotConfigured SSHKeyStatus = "not_configured"
SSHKeyStatusPending SSHKeyStatus = "pending"
SSHKeyStatusDeployed SSHKeyStatus = "deployed"
SSHKeyStatusManual SSHKeyStatus = "manual"
SSHKeyStatusFailed SSHKeyStatus = "failed"
)
type SSHKeyStatus string
const ( const (
WebServerNginx WebServer = "nginx" WebServerNginx WebServer = "nginx"
WebServerCaddy WebServer = "caddy" WebServerCaddy WebServer = "caddy"
@ -44,6 +54,11 @@ type Server struct {
PrivateKeyRef string `json:"private_key_ref"` PrivateKeyRef string `json:"private_key_ref"`
PasswordRef string `json:"-"` PasswordRef string `json:"-"`
PasswordConfigured bool `json:"password_configured"` 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"` HostKeyFingerprint string `json:"host_key_fingerprint"`
CertificatePath string `json:"certificate_path"` CertificatePath string `json:"certificate_path"`
PrivateKeyPath string `json:"private_key_path"` PrivateKeyPath string `json:"private_key_path"`
@ -64,6 +79,11 @@ func (s Server) Normalize() Server {
s.PrivateKeyRef = strings.TrimSpace(s.PrivateKeyRef) s.PrivateKeyRef = strings.TrimSpace(s.PrivateKeyRef)
s.PasswordRef = strings.TrimSpace(s.PasswordRef) s.PasswordRef = strings.TrimSpace(s.PasswordRef)
s.PasswordConfigured = 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.AuthMethod == "" {
if s.PasswordRef != "" { if s.PasswordRef != "" {
s.AuthMethod = SSHAuthPassword s.AuthMethod = SSHAuthPassword
@ -71,6 +91,12 @@ func (s Server) Normalize() Server {
s.AuthMethod = SSHAuthPrivateKey s.AuthMethod = SSHAuthPrivateKey
} }
} }
if s.SSHKeyStatus == "" {
s.SSHKeyStatus = SSHKeyStatusNotConfigured
if s.PrivateKeyRef != "" {
s.SSHKeyStatus = SSHKeyStatusManual
}
}
s.HostKeyFingerprint = strings.TrimSpace(s.HostKeyFingerprint) s.HostKeyFingerprint = strings.TrimSpace(s.HostKeyFingerprint)
s.CertificatePath = strings.TrimSpace(s.CertificatePath) s.CertificatePath = strings.TrimSpace(s.CertificatePath)
s.PrivateKeyPath = strings.TrimSpace(s.PrivateKeyPath) s.PrivateKeyPath = strings.TrimSpace(s.PrivateKeyPath)

View File

@ -120,6 +120,8 @@ func (c *memoryClient) Run(_ context.Context, command []string) (CommandResult,
return result, err return result, err
} }
func (c *memoryClient) InstallAuthorizedKey(context.Context, string) error { return nil }
func (c *memoryClient) Close() error { return nil } func (c *memoryClient) Close() error { return nil }
type memoryFactory struct { type memoryFactory struct {

View File

@ -2,6 +2,7 @@ package deployment
import ( import (
"context" "context"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"io" "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) { func (c *sshClientAdapter) Run(ctx context.Context, command []string) (CommandResult, error) {
if len(command) == 0 { if len(command) == 0 {
return CommandResult{}, errors.New("远程命令不能为空") return CommandResult{}, errors.New("远程命令不能为空")

View File

@ -2,6 +2,9 @@ package sshkey
import ( import (
"context" "context"
"crypto/ed25519"
"crypto/rand"
"encoding/pem"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@ -90,6 +93,32 @@ func (s *Service) Create(ctx context.Context, name, privateKey, passphrase strin
}, nil }, 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) { func (s *Service) List(ctx context.Context) ([]Metadata, error) {
if s == nil || s.store == nil { if s == nil || s.store == nil {
return nil, errors.New("SSH 私钥存储未初始化") return nil, errors.New("SSH 私钥存储未初始化")