记录SSH私钥安全整改中间状态
提交密码认证收敛、历史字段清理迁移及相关测试调整,后续继续修复测试并完成复审。
This commit is contained in:
parent
d78e8e679e
commit
5af752b25a
@ -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},
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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', '历史审计')`,
|
||||
} {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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 = '';
|
||||
@ -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 = '';
|
||||
@ -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 = '';
|
||||
@ -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`
|
||||
|
||||
@ -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 密码认证")
|
||||
}
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user