记录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/database"
|
||||||
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
|
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type sqliteBusinessFixture struct {
|
type sqliteBusinessFixture struct {
|
||||||
@ -54,17 +53,8 @@ func newSQLiteBusinessAssets(t *testing.T) sqliteBusinessFixture {
|
|||||||
if err := cfg.Save(fixture.path); err != nil {
|
if err := cfg.Save(fixture.path); err != nil {
|
||||||
t.Fatal(err)
|
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{
|
fixture.secrets = map[string]map[string]string{
|
||||||
cfg.DNS.CredentialRef: {"access_key_id": rand.Text(), "access_key_secret": rand.Text()},
|
cfg.DNS.CredentialRef: {"access_key_id": rand.Text(), "access_key_secret": rand.Text()},
|
||||||
"secret://ssh/business": {"private_key": string(pem.EncodeToMemory(sshBlock))},
|
|
||||||
}
|
}
|
||||||
store, err := secretstore.Open(cfg.Paths.Config)
|
store, err := secretstore.Open(cfg.Paths.Config)
|
||||||
if err != nil {
|
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 ('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 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 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 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 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},
|
{`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/config"
|
||||||
"git.pcux.cn/xuans/Scert/backend/internal/database"
|
"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/repository"
|
||||||
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
|
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSQLiteBusinessBackupRestore(t *testing.T) {
|
func TestSQLiteBusinessBackupRestore(t *testing.T) {
|
||||||
@ -124,11 +124,6 @@ func assertSQLiteBusinessUsable(t *testing.T, fixture sqliteBusinessFixture) {
|
|||||||
if err != nil || !reflect.DeepEqual(actual, want) {
|
if err != nil || !reflect.DeepEqual(actual, want) {
|
||||||
t.Fatalf("恢复后的加密凭据无法正确读取:%v", err)
|
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 {
|
if _, err := tls.LoadX509KeyPair(fixture.certificate.CertificatePath, fixture.certificate.PrivateKeyPath); err != nil {
|
||||||
t.Fatalf("恢复后的证书与私钥不可用:%v", err)
|
t.Fatalf("恢复后的证书与私钥不可用:%v", err)
|
||||||
@ -157,8 +152,8 @@ func assertSQLiteBusinessUsable(t *testing.T, fixture sqliteBusinessFixture) {
|
|||||||
}
|
}
|
||||||
servers := repository.NewSQLServerRepository(db.DB)
|
servers := repository.NewSQLServerRepository(db.DB)
|
||||||
server, err := servers.GetServerByID(t.Context(), "server")
|
server, err := servers.GetServerByID(t.Context(), "server")
|
||||||
if err != nil || server.PrivateKeyRef != "secret://ssh/business" {
|
if err != nil || server.AuthMethod != deployment.SSHAuthPassword || server.PasswordRef != "" {
|
||||||
t.Fatalf("服务器凭据引用未恢复:%v", err)
|
t.Fatalf("服务器认证方式未按密码模式恢复:%v", err)
|
||||||
}
|
}
|
||||||
tasks := repository.NewSQLTaskRepository(db.DB)
|
tasks := repository.NewSQLTaskRepository(db.DB)
|
||||||
task, err := tasks.GetTask(t.Context(), "task")
|
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 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 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 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 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', '历史审计')`,
|
`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/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/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/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
|
var migrationFiles embed.FS
|
||||||
|
|
||||||
func Migrate(ctx context.Context, db *sql.DB, driver string) error {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestMigrationVersion = 18
|
const latestMigrationVersion = 19
|
||||||
|
|
||||||
// currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。
|
// currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。
|
||||||
func currentMigrationVersion(ctx context.Context, db *sql.DB) (int, error) {
|
func currentMigrationVersion(ctx context.Context, db *sql.DB) (int, error) {
|
||||||
@ -235,6 +235,8 @@ func migrationName(version int) string {
|
|||||||
return "deployment_transactions"
|
return "deployment_transactions"
|
||||||
case 18:
|
case 18:
|
||||||
return "recovery_evidence"
|
return "recovery_evidence"
|
||||||
|
case 19:
|
||||||
|
return "password_auth_only"
|
||||||
default:
|
default:
|
||||||
return "unknown"
|
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 {
|
if version >= 3 {
|
||||||
statements = append(statements,
|
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')`)
|
`INSERT INTO domain_servers (domain_id, server_id) VALUES ('domain', 'server')`)
|
||||||
}
|
}
|
||||||
if version >= 4 {
|
if version >= 4 {
|
||||||
@ -155,7 +155,17 @@ func snapshotSQLiteHistoricalBusiness(t *testing.T, db *sql.DB) map[string]sqlit
|
|||||||
if err := rows.Close(); err != nil {
|
if err := rows.Close(); err != nil {
|
||||||
t.Fatal(err)
|
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, `"`, `""`) + `"`
|
columns[index] = `"` + strings.ReplaceAll(column, `"`, `""`) + `"`
|
||||||
}
|
}
|
||||||
query := "SELECT " + strings.Join(columns, ",") + ` FROM "` + name + `" ORDER BY 1`
|
query := "SELECT " + strings.Join(columns, ",") + ` FROM "` + name + `" ORDER BY 1`
|
||||||
|
|||||||
@ -17,20 +17,9 @@ type WebServer string
|
|||||||
type SSHAuthMethod string
|
type SSHAuthMethod string
|
||||||
|
|
||||||
const (
|
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 (
|
const (
|
||||||
WebServerNginx WebServer = "nginx"
|
WebServerNginx WebServer = "nginx"
|
||||||
WebServerCaddy WebServer = "caddy"
|
WebServerCaddy WebServer = "caddy"
|
||||||
@ -48,31 +37,23 @@ var serverPathPlaceholderPattern = regexp.MustCompile(`\{[^{}]+\}`)
|
|||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
domain.ResourceScope
|
domain.ResourceScope
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
AuthMethod SSHAuthMethod `json:"auth_method"`
|
AuthMethod SSHAuthMethod `json:"auth_method"`
|
||||||
// PrivateKeyRef 仅为历史数据库兼容保留,禁止进入 API 响应和生产认证流程。
|
PasswordRef string `json:"-"`
|
||||||
PrivateKeyRef string `json:"-"`
|
PasswordConfigured bool `json:"password_configured"`
|
||||||
PasswordRef string `json:"-"`
|
HostKeyFingerprint string `json:"host_key_fingerprint"`
|
||||||
PasswordConfigured bool `json:"password_configured"`
|
CertificatePath string `json:"certificate_path"`
|
||||||
// SSH 密钥状态仅为历史数据兼容保留,不对外暴露。
|
PrivateKeyPath string `json:"private_key_path"`
|
||||||
SSHKeyStatus SSHKeyStatus `json:"-"`
|
CertificateChainPath string `json:"certificate_chain_path"`
|
||||||
SSHKeyFingerprint string `json:"-"`
|
WebServer WebServer `json:"web_server"`
|
||||||
SSHKeyDeployedAt string `json:"-"`
|
ValidationCommand []string `json:"validation_command,omitempty"`
|
||||||
SSHKeyTestedAt string `json:"-"`
|
ReloadCommand []string `json:"reload_command,omitempty"`
|
||||||
SSHKeyError string `json:"-"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
HostKeyFingerprint string `json:"host_key_fingerprint"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
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 {
|
func (s Server) Normalize() Server {
|
||||||
@ -82,23 +63,11 @@ func (s Server) Normalize() Server {
|
|||||||
s.Address = strings.TrimSpace(s.Address)
|
s.Address = strings.TrimSpace(s.Address)
|
||||||
s.Username = strings.TrimSpace(s.Username)
|
s.Username = strings.TrimSpace(s.Username)
|
||||||
s.AuthMethod = SSHAuthMethod(strings.ToLower(strings.TrimSpace(string(s.AuthMethod))))
|
s.AuthMethod = SSHAuthMethod(strings.ToLower(strings.TrimSpace(string(s.AuthMethod))))
|
||||||
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))))
|
if s.AuthMethod == "" {
|
||||||
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 {
|
|
||||||
s.AuthMethod = SSHAuthPassword
|
s.AuthMethod = SSHAuthPassword
|
||||||
}
|
}
|
||||||
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)
|
||||||
@ -212,10 +181,6 @@ func (s Server) Validate() error {
|
|||||||
if s.Username == "" {
|
if s.Username == "" {
|
||||||
return errors.New("SSH 用户不能为空")
|
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 {
|
if s.AuthMethod != SSHAuthPassword {
|
||||||
return errors.New("服务器仅支持 SSH 密码认证")
|
return errors.New("服务器仅支持 SSH 密码认证")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -121,9 +121,9 @@ func TestServerRejectsPasswordStyleOrUnsafeConfiguration(t *testing.T) {
|
|||||||
t.Fatal("missing SSH password reference should reject server configuration")
|
t.Fatal("missing SSH password reference should reject server configuration")
|
||||||
}
|
}
|
||||||
server = testServer("one")
|
server = testServer("one")
|
||||||
server.PrivateKeyRef = "secret://ssh/private-key"
|
server.AuthMethod = SSHAuthMethod("unsupported")
|
||||||
if err := server.Validate(); err == nil {
|
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 = testServer("one")
|
||||||
server.ReloadCommand = []string{"nginx\nrm", "-s", "reload"}
|
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 {
|
if f.Secrets == nil {
|
||||||
return nil, errors.New("SSH 敏感信息存储未初始化")
|
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)
|
secrets, err := f.Secrets.Get(ctx, server.PasswordRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("读取 SSH 认证信息失败:%w", err)
|
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
|
return s[reference], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSSHFactoryRejectsPrivateKeyAuthentication(t *testing.T) {
|
func TestSSHFactoryRejectsUnsupportedAuthentication(t *testing.T) {
|
||||||
server := Server{
|
server := Server{
|
||||||
ID: "server-test", Name: "test server", Address: "127.0.0.1", Port: 1,
|
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",
|
CertificatePath: "/tmp/cert.pem", PrivateKeyPath: "/tmp/key.pem",
|
||||||
CertificateChainPath: "/tmp/fullchain.pem", WebServer: WebServerNginx,
|
CertificateChainPath: "/tmp/fullchain.pem", WebServer: WebServerNginx,
|
||||||
}
|
}
|
||||||
factory := SSHFactory{
|
factory := SSHFactory{
|
||||||
Secrets: staticSecretReader{server.PrivateKeyRef: {"private_key": "must-not-be-read"}},
|
|
||||||
Timeout: 100 * time.Millisecond,
|
Timeout: 100 * time.Millisecond,
|
||||||
}
|
}
|
||||||
if _, err := factory.Connect(t.Context(), server); err == nil || !strings.Contains(err.Error(), "SSH") {
|
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) {
|
func (s *Server) prepareServerCredential(ctx context.Context, item deployment.Server, password string, existing *deployment.Server) (deployment.Server, func(), error) {
|
||||||
item = item.Normalize()
|
item = item.Normalize()
|
||||||
cleanup := func() {}
|
cleanup := func() {}
|
||||||
if item.PrivateKeyRef != "" {
|
|
||||||
return deployment.Server{}, cleanup, errors.New("服务器仅支持 SSH 密码认证,不能使用 SSH 私钥")
|
|
||||||
}
|
|
||||||
item.AuthMethod = deployment.SSHAuthPassword
|
item.AuthMethod = deployment.SSHAuthPassword
|
||||||
if s.secrets == nil {
|
if s.secrets == nil {
|
||||||
return deployment.Server{}, cleanup, errors.New("SSH \u5bc6\u7801\u5b58\u50a8\u672a\u521d\u59cb\u5316")
|
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)
|
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) {
|
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
|
return nil, nil
|
||||||
}}}
|
}}}
|
||||||
client, err := server.connectSSHAfterReload(t.Context(), deployment.Server{
|
client, err := server.connectSSHAfterReload(t.Context(), deployment.Server{
|
||||||
AuthMethod: deployment.SSHAuthPrivateKey,
|
AuthMethod: deployment.SSHAuthMethod("unsupported"),
|
||||||
PrivateKeyRef: "secret://ssh/private-key",
|
|
||||||
HostKeyFingerprint: "SHA256:registered",
|
HostKeyFingerprint: "SHA256:registered",
|
||||||
})
|
})
|
||||||
if client != nil || err == nil || !strings.Contains(err.Error(), "SSH") {
|
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)
|
GetServerByID(ctx context.Context, id string) (deployment.Server, error)
|
||||||
ListServers(ctx context.Context) ([]deployment.Server, error)
|
ListServers(ctx context.Context) ([]deployment.Server, error)
|
||||||
DeleteServer(ctx context.Context, id string) 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)
|
ListServersByDomainID(ctx context.Context, domainID string) ([]deployment.Server, error)
|
||||||
ReplaceDomainServers(ctx context.Context, domainID string, serverIDs []string) 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
|
updatedAt = createdAt
|
||||||
}
|
}
|
||||||
_, err = r.db.ExecContext(ctx, r.query(`INSERT INTO servers
|
_, 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)
|
(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.PrivateKeyRef, server.PasswordRef,
|
VALUES (?, ?, ?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), server.ID, server.Name, server.Address, server.Port, server.Username, server.AuthMethod, 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,
|
server.HostKeyFingerprint, server.CertificatePath, server.PrivateKeyPath, server.CertificateChainPath, server.WebServer, string(validationCommand), string(command), createdAt, updatedAt,
|
||||||
server.ProjectID, server.OwnerID, server.CreatedBy, server.UpdatedBy)
|
server.ProjectID, server.OwnerID, server.CreatedBy, server.UpdatedBy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("保存服务器失败:%w", err)
|
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 {
|
if err := validateServerDomainPaths(ctx, tx, r.driver, server, nil); err != nil {
|
||||||
return err
|
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 = ?`),
|
_, 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.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)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("更新服务器失败:%w", err)
|
return fmt.Errorf("更新服务器失败:%w", err)
|
||||||
}
|
}
|
||||||
@ -168,18 +167,6 @@ func (r *SQLServerRepository) DeleteServer(ctx context.Context, id string) error
|
|||||||
return nil
|
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) {
|
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))
|
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 {
|
if err != nil {
|
||||||
@ -254,8 +241,8 @@ func (r *SQLServerRepository) ReplaceDomainServers(ctx context.Context, domainID
|
|||||||
return nil
|
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 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.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 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 {
|
type rowScanner interface {
|
||||||
Scan(dest ...any) error
|
Scan(dest ...any) error
|
||||||
@ -267,7 +254,7 @@ func scanServer(row rowScanner) (deployment.Server, error) {
|
|||||||
var authMethod string
|
var authMethod string
|
||||||
var validationCommandJSON string
|
var validationCommandJSON string
|
||||||
var commandJSON 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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return deployment.Server{}, 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)
|
db := openServerTestDatabase(t)
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
repo := NewSQLServerRepository(db.DB)
|
repo := NewSQLServerRepository(db.DB)
|
||||||
server := validTestServer()
|
server := validTestServer()
|
||||||
server.ID = "server-private-key-rejected"
|
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 {
|
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