feat(backend): store SSH private key encrypted in database instead of config file

This commit is contained in:
pycook
2025-09-05 18:25:49 +08:00
parent 510ee1c243
commit 6120b3bb9a
11 changed files with 238 additions and 78 deletions

View File

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

View File

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

View File

@@ -20,4 +20,5 @@ var (
DefaultStorageConfig = &StorageConfig{}
DefaultStorageMetrics = &StorageMetrics{}
DefaultMigrationRecord = &MigrationRecord{}
DefaultSystemConfig = &SystemConfig{}
)

View File

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

View File

@@ -17,7 +17,6 @@ import (
)
const (
kFmtAssetIds = "assetIds-%d"
kAuthorizationIds = "authorizationIds"
kNodeIds = "nodeIds"
kAccountIds = "accountIds"

View File

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

View File

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

View File

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

View File

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

View File

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