记录SSH私钥安全整改中间状态

提交密码认证收敛、历史字段清理迁移及相关测试调整,后续继续修复测试并完成复审。
This commit is contained in:
xuans 2026-10-02 05:03:31 +08:00
parent d78e8e679e
commit 5af752b25a
16 changed files with 64 additions and 120 deletions

View File

@ -20,7 +20,6 @@ import (
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
"golang.org/x/crypto/bcrypt"
"golang.org/x/crypto/ssh"
)
type sqliteBusinessFixture struct {
@ -54,17 +53,8 @@ func newSQLiteBusinessAssets(t *testing.T) sqliteBusinessFixture {
if err := cfg.Save(fixture.path); err != nil {
t.Fatal(err)
}
sshKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
sshBlock, err := ssh.MarshalPrivateKey(sshKey, "隔离恢复测试")
if err != nil {
t.Fatal(err)
}
fixture.secrets = map[string]map[string]string{
cfg.DNS.CredentialRef: {"access_key_id": rand.Text(), "access_key_secret": rand.Text()},
"secret://ssh/business": {"private_key": string(pem.EncodeToMemory(sshBlock))},
cfg.DNS.CredentialRef: {"access_key_id": rand.Text(), "access_key_secret": rand.Text()},
}
store, err := secretstore.Open(cfg.Paths.Config)
if err != nil {
@ -103,7 +93,7 @@ func newSQLiteBusinessFixture(t *testing.T) sqliteBusinessFixture {
{`INSERT INTO users (id, username, email, password_hash, role, groups_json) VALUES ('owner', 'recovery-owner', 'owner@example.test', ?, 'super_admin', '[]')`, []any{string(hash)}},
{`INSERT INTO users (id, username, email, password_hash, role, groups_json) VALUES ('member', 'recovery-member', 'member@example.test', ?, 'user', '["测试分组"]')`, []any{string(hash)}},
{`INSERT INTO domains (id, name, certificate_type, sans_json, status, expires_at, auto_renew, acme_environment, dns_provider) VALUES ('domain', 'example.test', 'single', '["example.test"]', 'issued', ?, 1, 'staging', 'alidns')`, []any{fixture.certificate.NotAfter}},
{`INSERT INTO servers (id, name, address, port, username, private_key_ref, host_key_fingerprint, certificate_path, private_key_path, certificate_chain_path, web_server, reload_command_json, validation_command_json) VALUES ('server', '恢复测试服务器', '192.0.2.10', 22, 'deploy', 'secret://ssh/business', 'test-only-fingerprint', '/test/cert.pem', '/test/key.pem', '/test/chain.pem', 'nginx', '["nginx","-s","reload"]', '["nginx","-t"]')`, nil},
{`INSERT INTO servers (id, name, address, port, username, private_key_ref, host_key_fingerprint, certificate_path, private_key_path, certificate_chain_path, web_server, reload_command_json, validation_command_json) VALUES ('server', '恢复测试服务器', '192.0.2.10', 22, 'deploy', '', 'test-only-fingerprint', '/test/cert.pem', '/test/key.pem', '/test/chain.pem', 'nginx', '["nginx","-s","reload"]', '["nginx","-t"]')`, nil},
{`INSERT INTO domain_servers (domain_id, server_id) VALUES ('domain', 'server')`, nil},
{`INSERT INTO tasks (id, domain_id, task_type, status, stage, message, operator_id, certificate_version_id, idempotency_key) VALUES ('task', 'domain', 'deploy', 'success', 'completed', '部署完成', 'owner', ?, 'recovery-idempotency')`, []any{fixture.certificate.Version}},
{`INSERT INTO task_attempts (id, task_id, attempt_number, stage, status, message) VALUES ('attempt', 'task', 1, 'deploy', 'success', '证书已推送')`, nil},

View File

@ -10,10 +10,10 @@ import (
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
"golang.org/x/crypto/bcrypt"
"golang.org/x/crypto/ssh"
)
func TestSQLiteBusinessBackupRestore(t *testing.T) {
@ -124,11 +124,6 @@ func assertSQLiteBusinessUsable(t *testing.T, fixture sqliteBusinessFixture) {
if err != nil || !reflect.DeepEqual(actual, want) {
t.Fatalf("恢复后的加密凭据无法正确读取:%v", err)
}
if key := actual["private_key"]; key != "" {
if _, err := ssh.ParsePrivateKey([]byte(key)); err != nil {
t.Fatalf("恢复后的 SSH 私钥无法解析:%v", err)
}
}
}
if _, err := tls.LoadX509KeyPair(fixture.certificate.CertificatePath, fixture.certificate.PrivateKeyPath); err != nil {
t.Fatalf("恢复后的证书与私钥不可用:%v", err)
@ -157,8 +152,8 @@ func assertSQLiteBusinessUsable(t *testing.T, fixture sqliteBusinessFixture) {
}
servers := repository.NewSQLServerRepository(db.DB)
server, err := servers.GetServerByID(t.Context(), "server")
if err != nil || server.PrivateKeyRef != "secret://ssh/business" {
t.Fatalf("服务器凭据引用未恢复:%v", err)
if err != nil || server.AuthMethod != deployment.SSHAuthPassword || server.PasswordRef != "" {
t.Fatalf("服务器认证方式未按密码模式恢复:%v", err)
}
tasks := repository.NewSQLTaskRepository(db.DB)
task, err := tasks.GetTask(t.Context(), "task")

View File

@ -68,7 +68,7 @@ func TestSQLiteBusinessUpgradeStandaloneCLI(t *testing.T) {
`INSERT INTO settings (key, value) VALUES ('historical', '历史设置')`,
`INSERT INTO domains (id, name, certificate_type, sans_json, status, dns_provider) VALUES ('domain', 'example.test', 'single', '["example.test"]', 'issued', 'alidns')`,
`INSERT INTO tasks (id, domain_id, task_type, status, message) VALUES ('task', 'domain', 'deploy', 'success', '历史任务')`,
`INSERT INTO servers (id, name, address, port, username, private_key_ref, host_key_fingerprint, certificate_path, private_key_path, certificate_chain_path, web_server, reload_command_json) VALUES ('server', '历史服务器', '192.0.2.10', 22, 'deploy', 'secret://ssh/business', 'test-only', '/test/cert.pem', '/test/key.pem', '/test/chain.pem', 'nginx', '["nginx","-s","reload"]')`,
`INSERT INTO servers (id, name, address, port, username, private_key_ref, host_key_fingerprint, certificate_path, private_key_path, certificate_chain_path, web_server, reload_command_json) VALUES ('server', '历史服务器', '192.0.2.10', 22, 'deploy', '', 'test-only', '/test/cert.pem', '/test/key.pem', '/test/chain.pem', 'nginx', '["nginx","-s","reload"]')`,
`INSERT INTO domain_servers (domain_id, server_id) VALUES ('domain', 'server')`,
`INSERT INTO audit_logs (action, resource_type, resource_id, status, message) VALUES ('deploy', 'domain', 'domain', 'success', '历史审计')`,
} {

View File

@ -19,7 +19,7 @@ import (
//go:embed migrations/sqlite/0015_task_idempotency.sql migrations/mysql/0015_task_idempotency.sql migrations/postgres/0015_task_idempotency.sql
//go:embed migrations/sqlite/0016_task_deployment_results.sql migrations/mysql/0016_task_deployment_results.sql migrations/postgres/0016_task_deployment_results.sql
//go:embed migrations/sqlite/0017_deployment_transactions.sql migrations/mysql/0017_deployment_transactions.sql migrations/postgres/0017_deployment_transactions.sql
//go:embed migrations/sqlite/0018_recovery_evidence.sql migrations/mysql/0018_recovery_evidence.sql migrations/postgres/0018_recovery_evidence.sql
//go:embed migrations/sqlite/0018_recovery_evidence.sql migrations/mysql/0018_recovery_evidence.sql migrations/postgres/0018_recovery_evidence.sql migrations/sqlite/0019_password_auth_only.sql migrations/mysql/0019_password_auth_only.sql migrations/postgres/0019_password_auth_only.sql
var migrationFiles embed.FS
func Migrate(ctx context.Context, db *sql.DB, driver string) error {
@ -44,7 +44,7 @@ func Migrate(ctx context.Context, db *sql.DB, driver string) error {
return nil
}
const latestMigrationVersion = 18
const latestMigrationVersion = 19
// currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。
func currentMigrationVersion(ctx context.Context, db *sql.DB) (int, error) {
@ -235,6 +235,8 @@ func migrationName(version int) string {
return "deployment_transactions"
case 18:
return "recovery_evidence"
case 19:
return "password_auth_only"
default:
return "unknown"
}

View File

@ -0,0 +1 @@
UPDATE servers SET private_key_ref = '', auth_method = 'password', ssh_key_status = 'not_configured', ssh_key_fingerprint = '', ssh_key_deployed_at = '', ssh_key_tested_at = '', ssh_key_error = '';

View File

@ -0,0 +1 @@
UPDATE servers SET private_key_ref = '', auth_method = 'password', ssh_key_status = 'not_configured', ssh_key_fingerprint = '', ssh_key_deployed_at = '', ssh_key_tested_at = '', ssh_key_error = '';

View File

@ -0,0 +1 @@
UPDATE servers SET private_key_ref = '', auth_method = 'password', ssh_key_status = 'not_configured', ssh_key_fingerprint = '', ssh_key_deployed_at = '', ssh_key_tested_at = '', ssh_key_error = '';

View File

@ -98,7 +98,7 @@ func createSQLiteHistoricalBusiness(t *testing.T, db *sql.DB, version int) {
}
if version >= 3 {
statements = append(statements,
`INSERT INTO servers (id, name, address, port, username, private_key_ref, host_key_fingerprint, certificate_path, private_key_path, certificate_chain_path, web_server, reload_command_json) VALUES ('server', '历史部署服务器', '192.0.2.10', 22, 'deploy', 'secret://ssh/historical', 'test-only', '/test/cert.pem', '/test/key.pem', '/test/chain.pem', 'nginx', '["nginx","-s","reload"]')`,
`INSERT INTO servers (id, name, address, port, username, private_key_ref, host_key_fingerprint, certificate_path, private_key_path, certificate_chain_path, web_server, reload_command_json) VALUES ('server', '历史部署服务器', '192.0.2.10', 22, 'deploy', '', 'test-only', '/test/cert.pem', '/test/key.pem', '/test/chain.pem', 'nginx', '["nginx","-s","reload"]')`,
`INSERT INTO domain_servers (domain_id, server_id) VALUES ('domain', 'server')`)
}
if version >= 4 {
@ -155,7 +155,17 @@ func snapshotSQLiteHistoricalBusiness(t *testing.T, db *sql.DB) map[string]sqlit
if err := rows.Close(); err != nil {
t.Fatal(err)
}
for index, column := range columns {
if name == "servers" {
filtered := make([]string, 0, len(columns))
for _, column := range columns {
switch column {
case "auth_method", "private_key_ref", "ssh_key_status", "ssh_key_fingerprint", "ssh_key_deployed_at", "ssh_key_tested_at", "ssh_key_error":
continue
}
filtered = append(filtered, column)
}
columns = filtered
} for index, column := range columns {
columns[index] = `"` + strings.ReplaceAll(column, `"`, `""`) + `"`
}
query := "SELECT " + strings.Join(columns, ",") + ` FROM "` + name + `" ORDER BY 1`

View File

@ -17,20 +17,9 @@ type WebServer string
type SSHAuthMethod string
const (
SSHAuthPrivateKey SSHAuthMethod = "private_key"
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 (
WebServerNginx WebServer = "nginx"
WebServerCaddy WebServer = "caddy"
@ -48,31 +37,23 @@ var serverPathPlaceholderPattern = regexp.MustCompile(`\{[^{}]+\}`)
type Server struct {
domain.ResourceScope
ID string `json:"id"`
Name string `json:"name"`
Address string `json:"address"`
Port int `json:"port"`
Username string `json:"username"`
AuthMethod SSHAuthMethod `json:"auth_method"`
// PrivateKeyRef 仅为历史数据库兼容保留,禁止进入 API 响应和生产认证流程。
PrivateKeyRef string `json:"-"`
PasswordRef string `json:"-"`
PasswordConfigured bool `json:"password_configured"`
// SSH 密钥状态仅为历史数据兼容保留,不对外暴露。
SSHKeyStatus SSHKeyStatus `json:"-"`
SSHKeyFingerprint string `json:"-"`
SSHKeyDeployedAt string `json:"-"`
SSHKeyTestedAt string `json:"-"`
SSHKeyError string `json:"-"`
HostKeyFingerprint string `json:"host_key_fingerprint"`
CertificatePath string `json:"certificate_path"`
PrivateKeyPath string `json:"private_key_path"`
CertificateChainPath string `json:"certificate_chain_path"`
WebServer WebServer `json:"web_server"`
ValidationCommand []string `json:"validation_command,omitempty"`
ReloadCommand []string `json:"reload_command,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
Name string `json:"name"`
Address string `json:"address"`
Port int `json:"port"`
Username string `json:"username"`
AuthMethod SSHAuthMethod `json:"auth_method"`
PasswordRef string `json:"-"`
PasswordConfigured bool `json:"password_configured"`
HostKeyFingerprint string `json:"host_key_fingerprint"`
CertificatePath string `json:"certificate_path"`
PrivateKeyPath string `json:"private_key_path"`
CertificateChainPath string `json:"certificate_chain_path"`
WebServer WebServer `json:"web_server"`
ValidationCommand []string `json:"validation_command,omitempty"`
ReloadCommand []string `json:"reload_command,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (s Server) Normalize() Server {
@ -82,23 +63,11 @@ func (s Server) Normalize() Server {
s.Address = strings.TrimSpace(s.Address)
s.Username = strings.TrimSpace(s.Username)
s.AuthMethod = SSHAuthMethod(strings.ToLower(strings.TrimSpace(string(s.AuthMethod))))
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 == "" || s.AuthMethod == SSHAuthPrivateKey {
if s.AuthMethod == "" {
s.AuthMethod = SSHAuthPassword
}
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)
@ -212,10 +181,6 @@ func (s Server) Validate() error {
if s.Username == "" {
return errors.New("SSH 用户不能为空")
}
// 历史内存模型仍允许读取旧私钥记录,实际 SSHFactory 会在连接前拒绝该认证方式。
if s.PrivateKeyRef != "" {
return errors.New("\u670d\u52a1\u5668\u4e0d\u5141\u8bb8\u4f7f\u7528 SSH \u79c1\u94a5\u8ba4\u8bc1")
}
if s.AuthMethod != SSHAuthPassword {
return errors.New("服务器仅支持 SSH 密码认证")
}

View File

@ -121,9 +121,9 @@ func TestServerRejectsPasswordStyleOrUnsafeConfiguration(t *testing.T) {
t.Fatal("missing SSH password reference should reject server configuration")
}
server = testServer("one")
server.PrivateKeyRef = "secret://ssh/private-key"
server.AuthMethod = SSHAuthMethod("unsupported")
if err := server.Validate(); err == nil {
t.Fatal("SSH private key reference should reject server configuration")
t.Fatal("unsupported SSH authentication should reject server configuration")
}
server = testServer("one")
server.ReloadCommand = []string{"nginx\nrm", "-s", "reload"}

View File

@ -44,9 +44,6 @@ func (f SSHFactory) connect(ctx context.Context, server Server, requireSFTP bool
if f.Secrets == nil {
return nil, errors.New("SSH 敏感信息存储未初始化")
}
if server.AuthMethod != SSHAuthPassword || server.PrivateKeyRef != "" {
return nil, errors.New("服务器仅支持 SSH 密码认证,不能使用 SSH 私钥")
}
secrets, err := f.Secrets.Get(ctx, server.PasswordRef)
if err != nil {
return nil, fmt.Errorf("读取 SSH 认证信息失败:%w", err)

View File

@ -13,19 +13,18 @@ func (s staticSecretReader) Get(_ context.Context, reference string) (map[string
return s[reference], nil
}
func TestSSHFactoryRejectsPrivateKeyAuthentication(t *testing.T) {
func TestSSHFactoryRejectsUnsupportedAuthentication(t *testing.T) {
server := Server{
ID: "server-test", Name: "test server", Address: "127.0.0.1", Port: 1,
Username: "deploy", PrivateKeyRef: "secret://ssh/test", HostKeyFingerprint: "SHA256:test",
Username: "deploy", AuthMethod: SSHAuthMethod("unsupported"), HostKeyFingerprint: "SHA256:test",
CertificatePath: "/tmp/cert.pem", PrivateKeyPath: "/tmp/key.pem",
CertificateChainPath: "/tmp/fullchain.pem", WebServer: WebServerNginx,
}
factory := SSHFactory{
Secrets: staticSecretReader{server.PrivateKeyRef: {"private_key": "must-not-be-read"}},
Timeout: 100 * time.Millisecond,
}
if _, err := factory.Connect(t.Context(), server); err == nil || !strings.Contains(err.Error(), "SSH") {
t.Fatalf("private key authentication should be rejected before reading secrets: %v", err)
t.Fatalf("unsupported authentication should be rejected before reading secrets: %v", err)
}
}

View File

@ -886,9 +886,6 @@ func (s *Server) updateServer(c *gin.Context) {
func (s *Server) prepareServerCredential(ctx context.Context, item deployment.Server, password string, existing *deployment.Server) (deployment.Server, func(), error) {
item = item.Normalize()
cleanup := func() {}
if item.PrivateKeyRef != "" {
return deployment.Server{}, cleanup, errors.New("服务器仅支持 SSH 密码认证,不能使用 SSH 私钥")
}
item.AuthMethod = deployment.SSHAuthPassword
if s.secrets == nil {
return deployment.Server{}, cleanup, errors.New("SSH \u5bc6\u7801\u5b58\u50a8\u672a\u521d\u59cb\u5316")

View File

@ -16,17 +16,16 @@ func (f reloadTestFactory) Connect(ctx context.Context, item deployment.Server)
return f.connect(ctx, item)
}
func TestSSHAfterReloadRejectsPrivateKeyAuthentication(t *testing.T) {
func TestSSHAfterReloadRejectsUnsupportedAuthentication(t *testing.T) {
server := &Server{deploymentFactory: reloadTestFactory{connect: func(context.Context, deployment.Server) (deployment.Client, error) {
t.Fatal("private key authentication must not reach the deployment factory")
t.Fatal("unsupported authentication must not reach the deployment factory")
return nil, nil
}}}
client, err := server.connectSSHAfterReload(t.Context(), deployment.Server{
AuthMethod: deployment.SSHAuthPrivateKey,
PrivateKeyRef: "secret://ssh/private-key",
AuthMethod: deployment.SSHAuthMethod("unsupported"),
HostKeyFingerprint: "SHA256:registered",
})
if client != nil || err == nil || !strings.Contains(err.Error(), "SSH") {
t.Fatalf("private key authentication should be rejected: client=%v err=%v", client, err)
t.Fatalf("unsupported authentication should be rejected: client=%v err=%v", client, err)
}
}

View File

@ -20,7 +20,6 @@ type ServerRepository interface {
GetServerByID(ctx context.Context, id string) (deployment.Server, error)
ListServers(ctx context.Context) ([]deployment.Server, error)
DeleteServer(ctx context.Context, id string) error
IsPrivateKeyRefUsed(ctx context.Context, reference string) (bool, error)
ListServersByDomainID(ctx context.Context, domainID string) ([]deployment.Server, error)
ReplaceDomainServers(ctx context.Context, domainID string, serverIDs []string) error
}
@ -60,9 +59,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, 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, project_id, owner_id, created_by, updated_by)
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,
(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, project_id, owner_id, created_by, updated_by)
VALUES (?, ?, ?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), server.ID, server.Name, server.Address, server.Port, server.Username, server.AuthMethod, server.PasswordRef,
server.HostKeyFingerprint, server.CertificatePath, server.PrivateKeyPath, server.CertificateChainPath, server.WebServer, string(validationCommand), string(command), createdAt, updatedAt,
server.ProjectID, server.OwnerID, server.CreatedBy, server.UpdatedBy)
if err != nil {
return fmt.Errorf("保存服务器失败:%w", err)
@ -102,8 +101,8 @@ func (r *SQLServerRepository) UpdateServer(ctx context.Context, server deploymen
if err := validateServerDomainPaths(ctx, tx, r.driver, server, nil); err != nil {
return err
}
_, err = tx.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 = ?, updated_by = ? WHERE id = ? AND project_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.UpdatedBy, server.ID, server.ProjectID)
_, err = tx.ExecContext(ctx, r.query(`UPDATE servers SET name = ?, address = ?, port = ?, username = ?, auth_method = ?, password_ref = ?, host_key_fingerprint = ?, certificate_path = ?, private_key_path = ?, certificate_chain_path = ?, web_server = ?, validation_command_json = ?, reload_command_json = ?, updated_at = ?, updated_by = ? WHERE id = ? AND project_id = ?`),
server.Name, server.Address, server.Port, server.Username, server.AuthMethod, server.PasswordRef, server.HostKeyFingerprint, server.CertificatePath, server.PrivateKeyPath, server.CertificateChainPath, server.WebServer, string(validationCommand), string(command), updatedAt, server.UpdatedBy, server.ID, server.ProjectID)
if err != nil {
return fmt.Errorf("更新服务器失败:%w", err)
}
@ -168,18 +167,6 @@ func (r *SQLServerRepository) DeleteServer(ctx context.Context, id string) error
return nil
}
func (r *SQLServerRepository) IsPrivateKeyRefUsed(ctx context.Context, reference string) (bool, error) {
reference = strings.TrimSpace(reference)
if reference == "" {
return false, errors.New("SSH 私钥引用不能为空")
}
var count int
if err := r.db.QueryRowContext(ctx, r.query("SELECT COUNT(*) FROM servers WHERE private_key_ref = ?"), reference).Scan(&count); err != nil {
return false, fmt.Errorf("检查 SSH 私钥引用占用状态失败:%w", err)
}
return count > 0, nil
}
func (r *SQLServerRepository) ListServersByDomainID(ctx context.Context, domainID string) ([]deployment.Server, error) {
rows, err := r.db.QueryContext(ctx, r.query(serverSelectQualified+` INNER JOIN domain_servers ds ON ds.server_id = servers.id WHERE ds.domain_id = ? ORDER BY servers.name, servers.id`), strings.TrimSpace(domainID))
if err != nil {
@ -254,8 +241,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, 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, project_id, owner_id, created_by, updated_by 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, servers.project_id, servers.owner_id, servers.created_by, servers.updated_by FROM servers`
const serverSelect = `SELECT id, name, address, port, username, auth_method, 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, project_id, owner_id, created_by, updated_by FROM servers`
const serverSelectQualified = `SELECT servers.id, servers.name, servers.address, servers.port, servers.username, servers.auth_method, 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, servers.project_id, servers.owner_id, servers.created_by, servers.updated_by FROM servers`
type rowScanner interface {
Scan(dest ...any) error
@ -267,7 +254,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.SSHKeyStatus, &server.SSHKeyFingerprint, &server.SSHKeyDeployedAt, &server.SSHKeyTestedAt, &server.SSHKeyError, &server.CertificatePath, &server.PrivateKeyPath, &server.CertificateChainPath, &webServer, &validationCommandJSON, &commandJSON, &server.CreatedAt, &server.UpdatedAt, &server.ProjectID, &server.OwnerID, &server.CreatedBy, &server.UpdatedBy); err != nil {
if err := row.Scan(&server.ID, &server.Name, &server.Address, &server.Port, &server.Username, &authMethod, &server.PasswordRef, &server.HostKeyFingerprint, &server.CertificatePath, &server.PrivateKeyPath, &server.CertificateChainPath, &webServer, &validationCommandJSON, &commandJSON, &server.CreatedAt, &server.UpdatedAt, &server.ProjectID, &server.OwnerID, &server.CreatedBy, &server.UpdatedBy); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return deployment.Server{}, sql.ErrNoRows
}

View File

@ -100,15 +100,15 @@ func TestServerRepositoryRejectsInvalidServer(t *testing.T) {
}
}
func TestServerRepositoryRejectsPrivateKeyReference(t *testing.T) {
func TestServerRepositoryRejectsUnsupportedAuthentication(t *testing.T) {
db := openServerTestDatabase(t)
defer db.Close()
repo := NewSQLServerRepository(db.DB)
server := validTestServer()
server.ID = "server-private-key-rejected"
server.PrivateKeyRef = "secret://ssh/private-key"
server.AuthMethod = deployment.SSHAuthMethod("unsupported")
if err := repo.CreateServer(t.Context(), server); err == nil {
t.Fatal("server repository must reject SSH private key references")
t.Fatal("server repository must reject unsupported authentication")
}
}