From 6120b3bb9aed3a2cafaf29cab28a0bcad657a93e Mon Sep 17 00:00:00 2001 From: pycook Date: Fri, 5 Sep 2025 18:25:49 +0800 Subject: [PATCH] feat(backend): store SSH private key encrypted in database instead of config file --- backend/configs/config.example.yaml | 63 --------- backend/internal/api/api.go | 2 +- backend/internal/model/default.go | 1 + backend/internal/model/system_config.go | 30 +++++ backend/internal/repository/asset.go | 1 - backend/internal/repository/system_config.go | 44 +++++++ backend/internal/service/system_config.go | 128 +++++++++++++++++++ backend/internal/sshsrv/handler.go | 32 ++++- backend/internal/sshsrv/sshsrv.go | 4 +- backend/pkg/config/config.go | 2 +- deploy/config.yaml | 9 -- 11 files changed, 238 insertions(+), 78 deletions(-) delete mode 100644 backend/configs/config.example.yaml create mode 100644 backend/internal/model/system_config.go create mode 100644 backend/internal/repository/system_config.go create mode 100644 backend/internal/service/system_config.go diff --git a/backend/configs/config.example.yaml b/backend/configs/config.example.yaml deleted file mode 100644 index a3b5633..0000000 --- a/backend/configs/config.example.yaml +++ /dev/null @@ -1,63 +0,0 @@ -mode: debug - -http: - host: 0.0.0.0 - port: 8888 - -ssh: - host: 0.0.0.0 - port: 2222 - privateKey: --BEGIN PRIVATE KEY-----END PRIVATE KEY----- - -guacd: - host: oneterm-guacd - port: 4822 - -mysql: - host: oneterm-mysql - port: 3306 - user: root - password: root - -database: - type: mysql # alternative: postgres, tidb, tdsql, dm - host: oneterm-mysql - port: 3306 - user: root - password: root - database: oneterm - charset: utf8mb4 - max_idle_conns: 10 - max_open_conns: 100 - conn_max_lifetime: 3600 # seconds - conn_max_idle_time: 600 # seconds - ssl_mode: disable - -redis: - addr: oneterm-redis:6379 - password: root - -log: - level: debug - format: json - maxSize: 1 - consoleEnable: true - -auth: - acl: - appId: acl app id - secretKey: acl app secret key - url: http://host/api/v1 - resourceNames: - - key: account - value: account - - key: asset - value: asset - - key: command - value: command - - key: gateway - value: gateway - - key: authorization - value: authorization - -secretKey: acl secret key diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 2a8adc1..2d82070 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -34,7 +34,7 @@ func initDB() { model.DefaultGateway, model.DefaultHistory, model.DefaultNode, model.DefaultPublicKey, model.DefaultSession, model.DefaultSessionCmd, model.DefaultShare, model.DefaultQuickCommand, model.DefaultUserPreference, model.DefaultStorageConfig, model.DefaultStorageMetrics, - model.DefaultTimeTemplate, model.DefaultMigrationRecord, + model.DefaultTimeTemplate, model.DefaultMigrationRecord, model.DefaultSystemConfig, ); err != nil { logger.L().Fatal("Failed to init database", zap.Error(err)) } diff --git a/backend/internal/model/default.go b/backend/internal/model/default.go index 4d42488..1a8be3c 100644 --- a/backend/internal/model/default.go +++ b/backend/internal/model/default.go @@ -20,4 +20,5 @@ var ( DefaultStorageConfig = &StorageConfig{} DefaultStorageMetrics = &StorageMetrics{} DefaultMigrationRecord = &MigrationRecord{} + DefaultSystemConfig = &SystemConfig{} ) diff --git a/backend/internal/model/system_config.go b/backend/internal/model/system_config.go new file mode 100644 index 0000000..c52cab9 --- /dev/null +++ b/backend/internal/model/system_config.go @@ -0,0 +1,30 @@ +package model + +import ( + "time" + + "gorm.io/plugin/soft_delete" +) + +// SystemConfig stores sensitive system-level configurations +// This model is for internal use only and should never be exposed via API +type SystemConfig struct { + Id int `json:"id" gorm:"column:id;primarykey;autoIncrement"` + Key string `json:"key" gorm:"column:config_key;size:191;uniqueIndex;not null"` + Value string `json:"value" gorm:"column:value;type:text"` + + CreatorId int `json:"creator_id" gorm:"column:creator_id"` + UpdaterId int `json:"updater_id" gorm:"column:updater_id"` + CreatedAt time.Time `json:"created_at" gorm:"column:created_at"` + UpdatedAt time.Time `json:"updated_at" gorm:"column:updated_at"` + DeletedAt soft_delete.DeletedAt `json:"-" gorm:"column:deleted_at"` +} + +func (m *SystemConfig) TableName() string { + return "system_config" +} + +// System config key constants +const ( + SysConfigSSHPrivateKey = "ssh_private_key" +) diff --git a/backend/internal/repository/asset.go b/backend/internal/repository/asset.go index 88e5a77..37fda71 100644 --- a/backend/internal/repository/asset.go +++ b/backend/internal/repository/asset.go @@ -17,7 +17,6 @@ import ( ) const ( - kFmtAssetIds = "assetIds-%d" kAuthorizationIds = "authorizationIds" kNodeIds = "nodeIds" kAccountIds = "accountIds" diff --git a/backend/internal/repository/system_config.go b/backend/internal/repository/system_config.go new file mode 100644 index 0000000..e9e4afd --- /dev/null +++ b/backend/internal/repository/system_config.go @@ -0,0 +1,44 @@ +package repository + +import ( + "context" + + "github.com/veops/oneterm/internal/model" + dbpkg "github.com/veops/oneterm/pkg/db" +) + +// SystemConfigRepository interface for system config data access +type SystemConfigRepository interface { + GetByKey(ctx context.Context, key string) (*model.SystemConfig, error) + SetByKey(ctx context.Context, key, value string) error +} + +// systemConfigRepository implements SystemConfigRepository +type systemConfigRepository struct{} + +// NewSystemConfigRepository creates a new system config repository +func NewSystemConfigRepository() SystemConfigRepository { + return &systemConfigRepository{} +} + +// GetByKey gets system config by key +func (r *systemConfigRepository) GetByKey(ctx context.Context, key string) (*model.SystemConfig, error) { + var config model.SystemConfig + err := dbpkg.DB.WithContext(ctx).Where("config_key = ?", key).First(&config).Error + if err != nil { + return nil, err + } + return &config, nil +} + +// SetByKey sets system config by key +func (r *systemConfigRepository) SetByKey(ctx context.Context, key, value string) error { + config := model.SystemConfig{ + Key: key, + Value: value, + } + + return dbpkg.DB.WithContext(ctx).Where("config_key = ?", key). + Assign(model.SystemConfig{Value: value}). + FirstOrCreate(&config).Error +} \ No newline at end of file diff --git a/backend/internal/service/system_config.go b/backend/internal/service/system_config.go new file mode 100644 index 0000000..9b8a982 --- /dev/null +++ b/backend/internal/service/system_config.go @@ -0,0 +1,128 @@ +package service + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "fmt" + "strings" + + "golang.org/x/crypto/ssh" + + "github.com/veops/oneterm/internal/model" + "github.com/veops/oneterm/internal/repository" + "github.com/veops/oneterm/pkg/utils" +) + +type SystemConfigService struct { + repo repository.SystemConfigRepository +} + +func NewSystemConfigService() *SystemConfigService { + return &SystemConfigService{ + repo: repository.NewSystemConfigRepository(), + } +} + +// GetSSHPrivateKey gets SSH private key from database and decrypts it +func (s *SystemConfigService) GetSSHPrivateKey() (string, error) { + ctx := context.Background() + config, err := s.repo.GetByKey(ctx, model.SysConfigSSHPrivateKey) + if err != nil { + return "", err + } + // Decrypt the private key + return utils.DecryptAES(config.Value), nil +} + +// SetSSHPrivateKey encrypts and sets SSH private key to database +func (s *SystemConfigService) SetSSHPrivateKey(privateKey string) error { + // Encrypt the private key before storing + encryptedPrivateKey := utils.EncryptAES(privateKey) + ctx := context.Background() + return s.repo.SetByKey(ctx, model.SysConfigSSHPrivateKey, encryptedPrivateKey) +} + +// GenerateSSHKeyPair generates a new ED25519 SSH key pair +func (s *SystemConfigService) GenerateSSHKeyPair() (privateKey string, publicKey string, err error) { + // Generate ED25519 key pair + pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return "", "", fmt.Errorf("failed to generate ED25519 key: %w", err) + } + + // Convert to SSH private key format + sshPrivKey, err := ssh.MarshalPrivateKey(privKey, "") + if err != nil { + return "", "", fmt.Errorf("failed to marshal private key: %w", err) + } + + // Encode private key to PEM format + privateKeyPEM := pem.EncodeToMemory(sshPrivKey) + + // Convert to SSH public key format + sshPubKey, err := ssh.NewPublicKey(pubKey) + if err != nil { + return "", "", fmt.Errorf("failed to create SSH public key: %w", err) + } + + publicKeyStr := string(ssh.MarshalAuthorizedKey(sshPubKey)) + + return string(privateKeyPEM), strings.TrimSpace(publicKeyStr), nil +} + +// EnsureSSHPrivateKey ensures SSH private key exists, generates one if not +func (s *SystemConfigService) EnsureSSHPrivateKey() (string, error) { + // Try to get existing key from database + privateKey, err := s.GetSSHPrivateKey() + if err == nil && privateKey != "" { + return privateKey, nil + } + + // If not found or error, check if it's a "not found" error + if err != nil && !strings.Contains(err.Error(), "record not found") { + return "", fmt.Errorf("failed to query SSH private key: %w", err) + } + + // Generate new key pair + privateKey, _, err = s.GenerateSSHKeyPair() + if err != nil { + return "", fmt.Errorf("failed to generate SSH key pair: %w", err) + } + + // Save to database + err = s.SetSSHPrivateKey(privateKey) + if err != nil { + return "", fmt.Errorf("failed to save SSH private key: %w", err) + } + + return privateKey, nil +} + +// MigrateFromConfig migrates SSH private key from config file to database +func (s *SystemConfigService) MigrateFromConfig(configPrivateKey string) error { + if configPrivateKey == "" { + return nil + } + + // Check if database already has a key + _, err := s.GetSSHPrivateKey() + if err == nil { + // Key already exists in database, skip migration + return nil + } + + if !strings.Contains(err.Error(), "record not found") { + return fmt.Errorf("failed to check existing SSH private key: %w", err) + } + + // Validate the private key from config + _, err = ssh.ParsePrivateKey([]byte(configPrivateKey)) + if err != nil { + return fmt.Errorf("invalid SSH private key in config: %w", err) + } + + // Save to database + return s.SetSSHPrivateKey(configPrivateKey) +} \ No newline at end of file diff --git a/backend/internal/sshsrv/handler.go b/backend/internal/sshsrv/handler.go index 13d7ae2..1a4bde0 100644 --- a/backend/internal/sshsrv/handler.go +++ b/backend/internal/sshsrv/handler.go @@ -6,6 +6,7 @@ import ( "net/http" "net/url" "strings" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -17,8 +18,8 @@ import ( "github.com/veops/oneterm/internal/acl" "github.com/veops/oneterm/internal/model" + "github.com/veops/oneterm/internal/service" "github.com/veops/oneterm/internal/version" - "github.com/veops/oneterm/pkg/config" "github.com/veops/oneterm/pkg/logger" ) @@ -95,7 +96,34 @@ func handler(sess ssh.Session) { } func signer() ssh.Signer { - s, err := gossh.ParsePrivateKey([]byte(config.Cfg.Ssh.PrivateKey)) + sysConfigService := service.NewSystemConfigService() + + // Retry logic to wait for database table creation + var privateKey string + var err error + + for i := 0; i < 10; i++ { + privateKey, err = sysConfigService.EnsureSSHPrivateKey() + if err == nil { + break + } + + // If table doesn't exist, wait and retry + if strings.Contains(err.Error(), "doesn't exist") { + logger.L().Info("Waiting for database initialization...", zap.Int("attempt", i+1)) + time.Sleep(time.Second) + continue + } + + // Other errors are fatal + logger.L().Fatal("failed to ensure SSH private key", zap.Error(err)) + } + + if err != nil { + logger.L().Fatal("failed to ensure SSH private key after retries", zap.Error(err)) + } + + s, err := gossh.ParsePrivateKey([]byte(privateKey)) if err != nil { logger.L().Fatal("failed parse signer", zap.Error(err)) } diff --git a/backend/internal/sshsrv/sshsrv.go b/backend/internal/sshsrv/sshsrv.go index 1d43828..6e43eba 100644 --- a/backend/internal/sshsrv/sshsrv.go +++ b/backend/internal/sshsrv/sshsrv.go @@ -32,11 +32,13 @@ func init() { ctx.SetValue("session", sess) return err == nil }, - HostSigners: []ssh.Signer{signer()}, + HostSigners: []ssh.Signer{}, } } func RunSsh() error { + // Initialize host signer after database is ready + server.HostSigners = []ssh.Signer{signer()} return server.ListenAndServe() } diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go index a73a519..1313c2d 100644 --- a/backend/pkg/config/config.go +++ b/backend/pkg/config/config.go @@ -134,7 +134,7 @@ type Auth struct { type SshConfig struct { Host string `yaml:"host"` Port int `yaml:"port"` - PrivateKey string `yaml:"privateKey"` + PrivateKey string `yaml:"privateKey,omitempty"` // Deprecated: now stored encrypted in database SystemConfig table } type GuacdConfig struct { diff --git a/deploy/config.yaml b/deploy/config.yaml index e199290..2d99cea 100644 --- a/deploy/config.yaml +++ b/deploy/config.yaml @@ -7,15 +7,6 @@ http: ssh: host: 0.0.0.0 port: 2222 - privateKey: | - -----BEGIN OPENSSH PRIVATE KEY----- - b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW - QyNTUxOQAAACBg490b4zqumtizCyM4RWtzJnPEsPIInBFugk8+UCb8XgAAAKCc1yKrnNci - qwAAAAtzc2gtZWQyNTUxOQAAACBg490b4zqumtizCyM4RWtzJnPEsPIInBFugk8+UCb8Xg - AAAECvd1Yj+bQxyxJtU3PirLK68CD3MWqBv0/shlFKS6wmbWDj3RvjOq6a2LMLIzhFa3Mm - c8Sw8gicEW6CTz5QJvxeAAAAGnJvb3RAbG9jYWxob3N0LmxvY2FsZG9tYWluAQID - -----END OPENSSH PRIVATE KEY----- - guacd: host: oneterm-guacd