继续实现按服务器管理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"
)
//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:

View File

@ -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
}

View File

@ -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)

View File

@ -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 {

View File

@ -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("远程命令不能为空")

View File

@ -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 私钥存储未初始化")