Compare commits

..

2 Commits

Author SHA1 Message Date
MengMengCode bb0c4f999d release: v1.1.12 2026-06-09 23:25:29 +08:00
MengMengCode 0c9f420474 添加新建虚拟机/重装系统 预设SSH密码以及KEY Auth功能 2026-06-09 23:25:19 +08:00
15 changed files with 715 additions and 94 deletions
+5 -22
View File
@@ -2,12 +2,10 @@ package api
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode"
"clicd/internal/config" "clicd/internal/config"
"clicd/internal/lxc" "clicd/internal/lxc"
@@ -267,6 +265,10 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return return
} }
if err := validateCreateSSHAuth(cfg); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
if cfg.ExpiresAt != "" { if cfg.ExpiresAt != "" {
expiresAt, ok := lxc.ParseExpiration(cfg.ExpiresAt) expiresAt, ok := lxc.ParseExpiration(cfg.ExpiresAt)
if !ok { if !ok {
@@ -536,26 +538,7 @@ func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
} }
func validateSSHPassword(password string) error { func validateSSHPassword(password string) error {
if len(password) < 8 || len(password) > 64 { return lxc.ValidateCustomSSHPassword(password)
return fmt.Errorf("密码长度必须为 8-64 位")
}
hasLetter := false
hasDigit := false
for _, r := range password {
if unicode.IsSpace(r) {
return fmt.Errorf("密码不能包含空白字符")
}
if unicode.IsLetter(r) {
hasLetter = true
}
if unicode.IsDigit(r) {
hasDigit = true
}
}
if !hasLetter || !hasDigit {
return fmt.Errorf("密码至少需要包含字母和数字")
}
return nil
} }
func addPortMapping(w http.ResponseWriter, r *http.Request, id int) { func addPortMapping(w http.ResponseWriter, r *http.Request, id int) {
+23 -3
View File
@@ -38,6 +38,26 @@ func createByRuntime(cfg lxc.ContainerConfig) error {
return lxcManager.CreateContainer(cfg) return lxcManager.CreateContainer(cfg)
} }
func validateCreateSSHAuth(cfg lxc.ContainerConfig) error {
if cfg.Virtualization == config.VirtualizationKVM && kvm.IsWindowsImage(cfg.TemplateID) {
return nil
}
_, err := lxc.ResolveCreateSSHAccess(cfg)
return err
}
func validateReinstallSSHAuth(c *config.Container, templateID string, cfg lxc.ContainerConfig) error {
if c != nil && c.IsKVM() && kvm.IsWindowsImage(templateID) {
return nil
}
currentPassword := ""
if c != nil {
currentPassword = c.SSHPassword
}
_, err := lxc.ResolveReinstallSSHAccess(currentPassword, cfg)
return err
}
func startByRuntime(id int) error { func startByRuntime(id int) error {
c := config.FindContainer(id) c := config.FindContainer(id)
if c != nil && c.IsKVM() { if c != nil && c.IsKVM() {
@@ -70,12 +90,12 @@ func destroyByRuntime(id int) error {
return lxcManager.DestroyContainer(id) return lxcManager.DestroyContainer(id)
} }
func reinstallByRuntime(id int, templateID string) error { func reinstallByRuntime(id int, templateID string, authConfig ...lxc.ContainerConfig) error {
c := config.FindContainer(id) c := config.FindContainer(id)
if c != nil && c.IsKVM() { if c != nil && c.IsKVM() {
return kvmManager.ReinstallContainer(id, templateID) return kvmManager.ReinstallContainer(id, templateID, authConfig...)
} }
return lxcManager.ReinstallContainer(id, templateID) return lxcManager.ReinstallContainer(id, templateID, authConfig...)
} }
func resetPasswordByRuntime(id int, password string) (string, error) { func resetPasswordByRuntime(id int, password string) (string, error) {
+71 -7
View File
@@ -75,6 +75,10 @@ func (q *TaskQueue) enqueueTask(task *Task) {
} }
func (q *TaskQueue) Enqueue(containerID int, containerName string, taskType TaskType, templateID string, cfg *lxc.ContainerConfig) []string { func (q *TaskQueue) Enqueue(containerID int, containerName string, taskType TaskType, templateID string, cfg *lxc.ContainerConfig) []string {
return q.EnqueueWithAudit(containerID, containerName, taskType, templateID, cfg, "admin", "", "")
}
func (q *TaskQueue) EnqueueWithAudit(containerID int, containerName string, taskType TaskType, templateID string, cfg *lxc.ContainerConfig, user string, ip string, userAgent string) []string {
q.mu.Lock() q.mu.Lock()
defer q.mu.Unlock() defer q.mu.Unlock()
@@ -88,6 +92,9 @@ func (q *TaskQueue) Enqueue(containerID int, containerName string, taskType Task
Status: "pending", Status: "pending",
CreatedAt: time.Now().Format("2006-01-02 15:04:05"), CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
TemplateID: templateID, TemplateID: templateID,
User: user,
IP: ip,
UserAgent: userAgent,
} }
if cfg != nil { if cfg != nil {
task.Config = *cfg task.Config = *cfg
@@ -343,7 +350,11 @@ func (q *TaskQueue) opWorker() {
} }
} }
case TaskReinstall: case TaskReinstall:
err = reinstallByRuntime(task.ContainerID, task.TemplateID) if lxc.HasSSHAuthOptions(task.Config) {
err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config)
} else {
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
}
} }
} }
@@ -472,6 +483,7 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
var taskType TaskType var taskType TaskType
var templateID string var templateID string
var taskConfig *lxc.ContainerConfig
switch action { switch action {
case "start": case "start":
taskType = TaskStart taskType = TaskStart
@@ -483,7 +495,10 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
taskType = TaskDelete taskType = TaskDelete
case "reinstall": case "reinstall":
var req struct { var req struct {
TemplateID string `json:"template_id"` TemplateID string `json:"template_id"`
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
SSHPassword string `json:"ssh_password,omitempty"`
SSHPublicKey string `json:"ssh_public_key,omitempty"`
} }
json.NewDecoder(r.Body).Decode(&req) json.NewDecoder(r.Body).Decode(&req)
templateID = req.TemplateID templateID = req.TemplateID
@@ -501,13 +516,26 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return return
} }
authCfg := lxc.ContainerConfig{
TemplateID: templateID,
SSHAuthMode: req.SSHAuthMode,
SSHPassword: req.SSHPassword,
SSHPublicKey: req.SSHPublicKey,
}
if lxc.HasSSHAuthOptions(authCfg) {
if err := validateReinstallSSHAuth(c, templateID, authCfg); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
taskConfig = &authCfg
}
taskType = TaskReinstall taskType = TaskReinstall
default: default:
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
return return
} }
ids := globalQueue.EnqueueBatchWithAudit(taskType, []int{id}, templateID, user, ip, userAgent) ids := globalQueue.EnqueueWithAudit(id, name, taskType, templateID, taskConfig, user, ip, userAgent)
jsonResponse(w, http.StatusAccepted, APIResponse{ jsonResponse(w, http.StatusAccepted, APIResponse{
Success: true, Success: true,
Message: "Task queued", Message: "Task queued",
@@ -614,6 +642,10 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
return return
} }
if err := validateCreateSSHAuth(req.Containers[i]); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
return
}
requestNames[name] = true requestNames[name] = true
} }
ids := globalQueue.EnqueueBatchCreateWithAudit(req.Containers, requestActor(r), clientIP(r), r.UserAgent()) ids := globalQueue.EnqueueBatchCreateWithAudit(req.Containers, requestActor(r), clientIP(r), r.UserAgent())
@@ -631,9 +663,12 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
return return
} }
var req struct { var req struct {
Action string `json:"action"` Action string `json:"action"`
Containers []int `json:"containers"` Containers []int `json:"containers"`
TemplateID string `json:"template_id,omitempty"` TemplateID string `json:"template_id,omitempty"`
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
SSHPassword string `json:"ssh_password,omitempty"`
SSHPublicKey string `json:"ssh_public_key,omitempty"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
@@ -642,6 +677,7 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
var taskType TaskType var taskType TaskType
var requiredScope string var requiredScope string
var taskConfig *lxc.ContainerConfig
switch req.Action { switch req.Action {
case "start": case "start":
taskType = TaskStart taskType = TaskStart
@@ -664,6 +700,15 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return return
} }
authCfg := lxc.ContainerConfig{
TemplateID: req.TemplateID,
SSHAuthMode: req.SSHAuthMode,
SSHPassword: req.SSHPassword,
SSHPublicKey: req.SSHPublicKey,
}
if lxc.HasSSHAuthOptions(authCfg) {
taskConfig = &authCfg
}
taskType = TaskReinstall taskType = TaskReinstall
requiredScope = "container:reinstall" requiredScope = "container:reinstall"
default: default:
@@ -679,9 +724,28 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to one or more containers"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to one or more containers"})
return return
} }
if taskConfig != nil {
if err := validateReinstallSSHAuth(c, req.TemplateID, *taskConfig); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: c.Name + ": " + err.Error()})
return
}
}
} }
ids := globalQueue.EnqueueBatchWithAudit(taskType, req.Containers, req.TemplateID, requestActor(r), clientIP(r), r.UserAgent()) var ids []string
if taskConfig != nil {
for _, id := range req.Containers {
c := config.FindContainer(id)
name := ""
if c != nil {
name = c.Name
}
queued := globalQueue.EnqueueWithAudit(id, name, taskType, req.TemplateID, taskConfig, requestActor(r), clientIP(r), r.UserAgent())
ids = append(ids, queued...)
}
} else {
ids = globalQueue.EnqueueBatchWithAudit(taskType, req.Containers, req.TemplateID, requestActor(r), clientIP(r), r.UserAgent())
}
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids}) jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
} }
+20 -5
View File
@@ -43,6 +43,9 @@ type savedTaskConfig struct {
AssignIPv6 bool `json:"assign_ipv6"` AssignIPv6 bool `json:"assign_ipv6"`
IPv6Count int `json:"ipv6_count,omitempty"` IPv6Count int `json:"ipv6_count,omitempty"`
IPv6Addresses []string `json:"ipv6_addresses,omitempty"` IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
SSHPassword string `json:"ssh_password,omitempty"`
SSHPublicKey string `json:"ssh_public_key,omitempty"`
ExpiresAt string `json:"expires_at"` ExpiresAt string `json:"expires_at"`
} }
@@ -285,6 +288,9 @@ func ensureSchema() error {
cfg_assign_ipv6 INTEGER, cfg_assign_ipv6 INTEGER,
cfg_ipv6_count INTEGER, cfg_ipv6_count INTEGER,
cfg_ipv6_addresses TEXT, cfg_ipv6_addresses TEXT,
cfg_ssh_auth_mode TEXT,
cfg_ssh_password TEXT,
cfg_ssh_public_key TEXT,
cfg_expires_at TEXT cfg_expires_at TEXT
)`, )`,
`CREATE TABLE IF NOT EXISTS task_extra_ports ( `CREATE TABLE IF NOT EXISTS task_extra_ports (
@@ -344,6 +350,9 @@ func ensureSchemaMigrations() error {
{"tasks", "cfg_assign_nat", "INTEGER"}, {"tasks", "cfg_assign_nat", "INTEGER"},
{"tasks", "cfg_ipv6_count", "INTEGER"}, {"tasks", "cfg_ipv6_count", "INTEGER"},
{"tasks", "cfg_ipv6_addresses", "TEXT"}, {"tasks", "cfg_ipv6_addresses", "TEXT"},
{"tasks", "cfg_ssh_auth_mode", "TEXT"},
{"tasks", "cfg_ssh_password", "TEXT"},
{"tasks", "cfg_ssh_public_key", "TEXT"},
{"port_mappings", "host_ip", "TEXT"}, {"port_mappings", "host_ip", "TEXT"},
{"container_public_ipv4s", "prefix_len", "INTEGER"}, {"container_public_ipv4s", "prefix_len", "INTEGER"},
{"container_public_ipv4s", "gateway", "TEXT"}, {"container_public_ipv4s", "gateway", "TEXT"},
@@ -660,14 +669,15 @@ func saveTasksDB(tx *sql.Tx) error {
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb, cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit, cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses, cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
cfg_expires_at cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent, task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent,
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB, cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB, cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.SnapshotLimit, cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.SnapshotLimit,
boolInt(cfg.AssignIPv4), cfg.IPv4Count, encodeStringSlice(cfg.PublicIPv4s), boolInt(cfg.AssignIPv4), cfg.IPv4Count, encodeStringSlice(cfg.PublicIPv4s),
boolInt(cfg.AssignIPv6), cfg.IPv6Count, encodeStringSlice(cfg.IPv6Addresses), cfg.ExpiresAt, boolInt(cfg.AssignIPv6), cfg.IPv6Count, encodeStringSlice(cfg.IPv6Addresses),
cfg.SSHAuthMode, cfg.SSHPassword, cfg.SSHPublicKey, cfg.ExpiresAt,
); err != nil { ); err != nil {
return err return err
} }
@@ -939,7 +949,7 @@ func loadTasks() ([]SavedTask, error) {
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb, cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit, cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses, cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
cfg_expires_at cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_expires_at
FROM tasks ORDER BY created_at, id`) FROM tasks ORDER BY created_at, id`)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -952,13 +962,15 @@ func loadTasks() ([]SavedTask, error) {
var cfg savedTaskConfig var cfg savedTaskConfig
var assignIPv4, assignIPv6 int var assignIPv4, assignIPv6 int
var ip, userAgent, publicIPv4s, ipv6Addresses sql.NullString var ip, userAgent, publicIPv4s, ipv6Addresses sql.NullString
var sshAuthMode, sshPassword, sshPublicKey sql.NullString
var assignNAT, ipv4Count, ipv6Count sql.NullInt64 var assignNAT, ipv4Count, ipv6Count sql.NullInt64
if err := rows.Scan( if err := rows.Scan(
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent, &t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent,
&cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB, &cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
&cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB, &cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &assignNAT, &cfg.SnapshotLimit, &cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &assignNAT, &cfg.SnapshotLimit,
&assignIPv4, &ipv4Count, &publicIPv4s, &assignIPv6, &ipv6Count, &ipv6Addresses, &cfg.ExpiresAt, &assignIPv4, &ipv4Count, &publicIPv4s, &assignIPv6, &ipv6Count, &ipv6Addresses,
&sshAuthMode, &sshPassword, &sshPublicKey, &cfg.ExpiresAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -978,6 +990,9 @@ func loadTasks() ([]SavedTask, error) {
cfg.IPv6Count = int(ipv6Count.Int64) cfg.IPv6Count = int(ipv6Count.Int64)
} }
cfg.IPv6Addresses = decodeStringSlice(ipv6Addresses.String) cfg.IPv6Addresses = decodeStringSlice(ipv6Addresses.String)
cfg.SSHAuthMode = sshAuthMode.String
cfg.SSHPassword = sshPassword.String
cfg.SSHPublicKey = sshPublicKey.String
result = append(result, t) result = append(result, t)
configs = append(configs, cfg) configs = append(configs, cfg)
} }
+55 -7
View File
@@ -406,6 +406,15 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
seedPath := filepath.Join(m.instanceDir(vmName), "seed.iso") seedPath := filepath.Join(m.instanceDir(vmName), "seed.iso")
mac := randomMAC() mac := randomMAC()
sshPassword := generateRandomString(16) sshPassword := generateRandomString(16)
sshPublicKey := ""
if !IsWindowsImage(image.ID) {
sshAccess, err := lxc.ResolveCreateSSHAccess(cfg)
if err != nil {
return nil, err
}
sshPassword = sshAccess.Password
sshPublicKey = sshAccess.PublicKey
}
publicIPv4s, err := lxc.AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4) publicIPv4s, err := lxc.AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -455,7 +464,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil { if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil {
return nil, err return nil, err
} }
if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, mac, ipv6List, *image); err != nil { if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, sshPublicKey, mac, ipv6List, *image); err != nil {
return nil, err return nil, err
} }
xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps, image.Desktop != "") xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps, image.Desktop != "")
@@ -677,7 +686,7 @@ func (m *Manager) DestroyContainer(id int) error {
return nil return nil
} }
func (m *Manager) ReinstallContainer(id int, templateID string) error { func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...lxc.ContainerConfig) error {
c := config.FindContainer(id) c := config.FindContainer(id)
if c == nil { if c == nil {
return fmt.Errorf("container not found: %d", id) return fmt.Errorf("container not found: %d", id)
@@ -709,6 +718,19 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
SnapshotLimit: c.SnapshotLimit, SnapshotLimit: c.SnapshotLimit,
ExpiresAt: c.ExpiresAt, ExpiresAt: c.ExpiresAt,
} }
if len(authConfig) > 0 && lxc.HasSSHAuthOptions(authConfig[0]) && !IsWindowsImage(templateID) {
sshAccess, err := lxc.ResolveReinstallSSHAccess(c.SSHPassword, authConfig[0])
if err != nil {
return err
}
if sshAccess.PublicKey != "" {
cfg.SSHAuthMode = lxc.SSHAuthKey
cfg.SSHPublicKey = sshAccess.PublicKey
} else {
cfg.SSHAuthMode = lxc.SSHAuthPassword
}
cfg.SSHPassword = sshAccess.Password
}
next, err := m.defineContainer(id, name, cfg, false) next, err := m.defineContainer(id, name, cfg, false)
if err != nil { if err != nil {
return err return err
@@ -1788,8 +1810,8 @@ func shellQuoteWindows(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"` return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
} }
func createSeedISO(seedPath, instanceID, hostname, password, mac string, ipv6s []string, image Image) error { func createSeedISO(seedPath, instanceID, hostname, password, publicKey, mac string, ipv6s []string, image Image) error {
guestSetup := kvmSSHSetupScript(password) guestSetup := kvmSSHSetupScript(password, publicKey)
if desktopSetup := kvmDesktopSetupScript(image); desktopSetup != "" { if desktopSetup := kvmDesktopSetupScript(image); desktopSetup != "" {
guestSetup += "\n" + desktopSetup guestSetup += "\n" + desktopSetup
} }
@@ -1797,6 +1819,12 @@ func createSeedISO(seedPath, instanceID, hostname, password, mac string, ipv6s [
if len(ipv6s) > 0 { if len(ipv6s) > 0 {
guestSetup += "\n" + kvmIPv6SetupScript(ipv6s) guestSetup += "\n" + kvmIPv6SetupScript(ipv6s)
} }
authorizedKeys := ""
if publicKey != "" {
authorizedKeys = fmt.Sprintf(`
ssh_authorized_keys:
- %s`, yamlSingleQuote(publicKey))
}
setupScript := indentScript(guestSetup, 4) setupScript := indentScript(guestSetup, 4)
userData := fmt.Sprintf(`#cloud-config userData := fmt.Sprintf(`#cloud-config
preserve_hostname: false preserve_hostname: false
@@ -1812,11 +1840,11 @@ chpasswd:
type: text type: text
users: users:
- name: root - name: root
lock_passwd: false lock_passwd: false%s
runcmd: runcmd:
- | - |
%s %s
`, hostname, password, setupScript) `, hostname, password, authorizedKeys, setupScript)
metaData := fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", instanceID, hostname) metaData := fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", instanceID, hostname)
ipv6Block := "" ipv6Block := ""
if len(ipv6s) > 0 { if len(ipv6s) > 0 {
@@ -1907,6 +1935,10 @@ func indentScript(script string, spaces int) string {
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
func yamlSingleQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}
func isKVMDesktopTemplate(templateID string) bool { func isKVMDesktopTemplate(templateID string) bool {
image := FindImage(templateID) image := FindImage(templateID)
return image != nil && image.Desktop != "" return image != nil && image.Desktop != ""
@@ -2368,9 +2400,14 @@ func runKVMSSHScript(client *ssh.Client, script string, description string, time
} }
} }
func kvmSSHSetupScript(password string) string { func kvmSSHSetupScript(password string, publicKeys ...string) string {
publicKey := ""
if len(publicKeys) > 0 {
publicKey = strings.TrimSpace(publicKeys[0])
}
return `set -u return `set -u
ROOT_PASSWORD=` + shellQuote(password) + ` ROOT_PASSWORD=` + shellQuote(password) + `
SSH_PUBLIC_KEY=` + shellQuote(publicKey) + `
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
if command -v apt-get >/dev/null 2>&1; then if command -v apt-get >/dev/null 2>&1; then
if ! command -v sshd >/dev/null 2>&1 || ! command -v qemu-ga >/dev/null 2>&1; then if ! command -v sshd >/dev/null 2>&1 || ! command -v qemu-ga >/dev/null 2>&1; then
@@ -2397,6 +2434,7 @@ fi
mkdir -p /etc/ssh/sshd_config.d mkdir -p /etc/ssh/sshd_config.d
cat > /etc/ssh/sshd_config.d/99-clicd-root.conf <<'EOF' cat > /etc/ssh/sshd_config.d/99-clicd-root.conf <<'EOF'
PermitRootLogin yes PermitRootLogin yes
PubkeyAuthentication yes
PasswordAuthentication yes PasswordAuthentication yes
KbdInteractiveAuthentication yes KbdInteractiveAuthentication yes
ChallengeResponseAuthentication yes ChallengeResponseAuthentication yes
@@ -2404,11 +2442,21 @@ EOF
if [ -f /etc/ssh/sshd_config ]; then if [ -f /etc/ssh/sshd_config ]; then
grep -q '^PermitRootLogin ' /etc/ssh/sshd_config && sed -i 's/^PermitRootLogin .*/PermitRootLogin yes/' /etc/ssh/sshd_config || printf '\nPermitRootLogin yes\n' >> /etc/ssh/sshd_config grep -q '^PermitRootLogin ' /etc/ssh/sshd_config && sed -i 's/^PermitRootLogin .*/PermitRootLogin yes/' /etc/ssh/sshd_config || printf '\nPermitRootLogin yes\n' >> /etc/ssh/sshd_config
grep -q '^#PermitRootLogin ' /etc/ssh/sshd_config && sed -i 's/^#PermitRootLogin .*/PermitRootLogin yes/' /etc/ssh/sshd_config || true grep -q '^#PermitRootLogin ' /etc/ssh/sshd_config && sed -i 's/^#PermitRootLogin .*/PermitRootLogin yes/' /etc/ssh/sshd_config || true
grep -q '^PubkeyAuthentication ' /etc/ssh/sshd_config && sed -i 's/^PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config || printf '\nPubkeyAuthentication yes\n' >> /etc/ssh/sshd_config
grep -q '^#PubkeyAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config || true
grep -q '^PasswordAuthentication ' /etc/ssh/sshd_config && sed -i 's/^PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config || printf '\nPasswordAuthentication yes\n' >> /etc/ssh/sshd_config grep -q '^PasswordAuthentication ' /etc/ssh/sshd_config && sed -i 's/^PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config || printf '\nPasswordAuthentication yes\n' >> /etc/ssh/sshd_config
grep -q '^#PasswordAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config || true grep -q '^#PasswordAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config || true
grep -q '^KbdInteractiveAuthentication ' /etc/ssh/sshd_config && sed -i 's/^KbdInteractiveAuthentication .*/KbdInteractiveAuthentication yes/' /etc/ssh/sshd_config || printf '\nKbdInteractiveAuthentication yes\n' >> /etc/ssh/sshd_config grep -q '^KbdInteractiveAuthentication ' /etc/ssh/sshd_config && sed -i 's/^KbdInteractiveAuthentication .*/KbdInteractiveAuthentication yes/' /etc/ssh/sshd_config || printf '\nKbdInteractiveAuthentication yes\n' >> /etc/ssh/sshd_config
grep -q '^#KbdInteractiveAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#KbdInteractiveAuthentication .*/KbdInteractiveAuthentication yes/' /etc/ssh/sshd_config || true grep -q '^#KbdInteractiveAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#KbdInteractiveAuthentication .*/KbdInteractiveAuthentication yes/' /etc/ssh/sshd_config || true
fi fi
if [ -n "$SSH_PUBLIC_KEY" ]; then
mkdir -p /root/.ssh
touch /root/.ssh/authorized_keys
grep -qxF "$SSH_PUBLIC_KEY" /root/.ssh/authorized_keys 2>/dev/null || printf '%s\n' "$SSH_PUBLIC_KEY" >> /root/.ssh/authorized_keys
chmod 700 /root/.ssh
chmod 600 /root/.ssh/authorized_keys
chown -R root:root /root/.ssh 2>/dev/null || true
fi
if command -v chpasswd >/dev/null 2>&1; then if command -v chpasswd >/dev/null 2>&1; then
printf 'root:%s\n' "$ROOT_PASSWORD" | chpasswd || true printf 'root:%s\n' "$ROOT_PASSWORD" | chpasswd || true
fi fi
+71 -5
View File
@@ -241,6 +241,9 @@ type ContainerConfig struct {
AssignIPv6 bool `json:"assign_ipv6"` AssignIPv6 bool `json:"assign_ipv6"`
IPv6Count int `json:"ipv6_count,omitempty"` IPv6Count int `json:"ipv6_count,omitempty"`
IPv6Addresses []string `json:"ipv6_addresses,omitempty"` IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
SSHPassword string `json:"ssh_password,omitempty"`
SSHPublicKey string `json:"ssh_public_key,omitempty"`
ExpiresAt string `json:"expires_at"` ExpiresAt string `json:"expires_at"`
} }
@@ -270,6 +273,10 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
if config.FindContainerByName(cfg.Name) != nil { if config.FindContainerByName(cfg.Name) != nil {
return fmt.Errorf("container name already exists: %s", cfg.Name) return fmt.Errorf("container name already exists: %s", cfg.Name)
} }
sshAccess, err := ResolveCreateSSHAccess(cfg)
if err != nil {
return err
}
// Allocate ID and build LXC name // Allocate ID and build LXC name
id := config.AllocateContainerID() id := config.AllocateContainerID()
@@ -329,7 +336,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
} }
} }
sshPassword := generateRandomString(16) sshPassword := sshAccess.Password
sshPort := 0 sshPort := 0
portMappings := []config.PortMapping{} portMappings := []config.PortMapping{}
@@ -420,6 +427,13 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
if err := m.preconfigureSSH(rootfsPath, cfg.TemplateID); err != nil { if err := m.preconfigureSSH(rootfsPath, cfg.TemplateID); err != nil {
fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err) fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err)
} }
if sshAccess.PublicKey != "" {
if err := m.installRootAuthorizedKey(rootfsPath, sshAccess.PublicKey); err != nil {
_ = m.cleanupContainerStorage(lxcName)
config.RemoveContainer(id)
return fmt.Errorf("failed to install SSH public key: %v", err)
}
}
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil { if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
_ = m.cleanupContainerStorage(lxcName) _ = m.cleanupContainerStorage(lxcName)
@@ -1931,6 +1945,7 @@ ssh-keygen -A >/dev/null 2>&1 || true
cat >/etc/ssh/sshd_config.d/99-clicd.conf <<'EOF' cat >/etc/ssh/sshd_config.d/99-clicd.conf <<'EOF'
PermitRootLogin yes PermitRootLogin yes
PubkeyAuthentication yes
PasswordAuthentication yes PasswordAuthentication yes
KbdInteractiveAuthentication no KbdInteractiveAuthentication no
ChallengeResponseAuthentication no ChallengeResponseAuthentication no
@@ -1938,6 +1953,7 @@ UsePAM no
EOF EOF
set_sshd_option PermitRootLogin yes set_sshd_option PermitRootLogin yes
set_sshd_option PubkeyAuthentication yes
set_sshd_option PasswordAuthentication yes set_sshd_option PasswordAuthentication yes
set_sshd_option KbdInteractiveAuthentication no set_sshd_option KbdInteractiveAuthentication no
set_sshd_option ChallengeResponseAuthentication no set_sshd_option ChallengeResponseAuthentication no
@@ -2102,6 +2118,45 @@ func (m *Manager) setRootfsPassword(rootfsPath, password string) error {
return nil return nil
} }
func (m *Manager) installRootAuthorizedKey(rootfsPath, publicKey string) error {
key, err := NormalizeSSHPublicKey(publicKey)
if err != nil {
return err
}
if key == "" {
return nil
}
sshDir := filepath.Join(rootfsPath, "root", ".ssh")
if err := os.MkdirAll(sshDir, 0700); err != nil {
return err
}
authPath := filepath.Join(sshDir, "authorized_keys")
existing, _ := os.ReadFile(authPath)
lines := strings.Split(string(existing), "\n")
for _, line := range lines {
if strings.TrimSpace(line) == key {
_ = os.Chmod(sshDir, 0700)
_ = os.Chmod(authPath, 0600)
_ = os.Chown(sshDir, 0, 0)
_ = os.Chown(authPath, 0, 0)
return nil
}
}
content := strings.TrimRight(string(existing), "\r\n")
if content != "" {
content += "\n"
}
content += key + "\n"
if err := os.WriteFile(authPath, []byte(content), 0600); err != nil {
return err
}
_ = os.Chmod(sshDir, 0700)
_ = os.Chmod(authPath, 0600)
_ = os.Chown(sshDir, 0, 0)
_ = os.Chown(authPath, 0, 0)
return nil
}
func safeRootfsCommandArgs(args []string) ([]string, error) { func safeRootfsCommandArgs(args []string) ([]string, error) {
if len(args) == 0 { if len(args) == 0 {
return nil, fmt.Errorf("empty rootfs command") return nil, fmt.Errorf("empty rootfs command")
@@ -2499,7 +2554,7 @@ func copyRootfsContents(src, dst string) error {
} }
// ReinstallContainer reinstalls the container OS // ReinstallContainer reinstalls the container OS
func (m *Manager) ReinstallContainer(id int, templateID string) error { func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...ContainerConfig) error {
c := config.FindContainer(id) c := config.FindContainer(id)
if c == nil { if c == nil {
return fmt.Errorf("container not found: %d", id) return fmt.Errorf("container not found: %d", id)
@@ -2509,6 +2564,14 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
if tmpl == nil { if tmpl == nil {
return fmt.Errorf("template not found: %s", templateID) return fmt.Errorf("template not found: %s", templateID)
} }
authCfg := ContainerConfig{SSHAuthMode: SSHAuthKeep}
if len(authConfig) > 0 {
authCfg = authConfig[0]
}
sshAccess, err := ResolveReinstallSSHAccess(c.SSHPassword, authCfg)
if err != nil {
return err
}
lxcName := c.LxcName() lxcName := c.LxcName()
@@ -2562,12 +2625,15 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
fmt.Printf("Warning: failed to install IPv6 init in %s after reinstall: %v\n", lxcName, err) fmt.Printf("Warning: failed to install IPv6 init in %s after reinstall: %v\n", lxcName, err)
} }
} }
if c.SSHPassword == "" { c.SSHPassword = sshAccess.Password
c.SSHPassword = generateRandomString(16)
}
if err := m.preconfigureSSH(rootfsPath, templateID); err != nil { if err := m.preconfigureSSH(rootfsPath, templateID); err != nil {
fmt.Printf("Warning: failed to pre-configure SSH in %s after reinstall: %v\n", lxcName, err) fmt.Printf("Warning: failed to pre-configure SSH in %s after reinstall: %v\n", lxcName, err)
} }
if sshAccess.PublicKey != "" {
if err := m.installRootAuthorizedKey(rootfsPath, sshAccess.PublicKey); err != nil {
return fmt.Errorf("failed to install SSH public key: %v", err)
}
}
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil { if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
return err return err
} }
+221
View File
@@ -0,0 +1,221 @@
package lxc
import (
"fmt"
"strings"
"unicode"
"golang.org/x/crypto/ssh"
)
const (
SSHAuthAutoPassword = "auto_password"
SSHAuthPassword = "password"
SSHAuthKey = "key"
SSHAuthKeep = "keep"
)
type SSHAccess struct {
Mode string
Password string
PublicKey string
}
func HasSSHAuthOptions(cfg ContainerConfig) bool {
return strings.TrimSpace(cfg.SSHAuthMode) != "" ||
strings.TrimSpace(cfg.SSHPassword) != "" ||
strings.TrimSpace(cfg.SSHPublicKey) != ""
}
func ResolveCreateSSHAccess(cfg ContainerConfig) (SSHAccess, error) {
mode, err := resolveSSHAuthMode(cfg.SSHAuthMode, cfg.SSHPassword, cfg.SSHPublicKey, SSHAuthAutoPassword)
if err != nil {
return SSHAccess{}, err
}
if mode == SSHAuthKeep {
mode = SSHAuthAutoPassword
}
switch mode {
case SSHAuthAutoPassword:
return SSHAccess{Mode: mode, Password: generateRandomString(16)}, nil
case SSHAuthPassword:
password := strings.TrimSpace(cfg.SSHPassword)
if password == "" {
return SSHAccess{}, fmt.Errorf("请填写自定义 SSH 密码")
}
if err := ValidateCustomSSHPassword(password); err != nil {
return SSHAccess{}, err
}
return SSHAccess{Mode: mode, Password: password}, nil
case SSHAuthKey:
publicKey, err := NormalizeSSHPublicKey(cfg.SSHPublicKey)
if err != nil {
return SSHAccess{}, err
}
if publicKey == "" {
return SSHAccess{}, fmt.Errorf("请填写 SSH 公钥")
}
password := strings.TrimSpace(cfg.SSHPassword)
if password == "" {
password = generateRandomString(16)
} else if err := ValidateCustomSSHPassword(password); err != nil {
return SSHAccess{}, err
}
return SSHAccess{Mode: mode, Password: password, PublicKey: publicKey}, nil
default:
return SSHAccess{}, fmt.Errorf("不支持的 SSH 登录方式: %s", mode)
}
}
func ResolveReinstallSSHAccess(currentPassword string, cfg ContainerConfig) (SSHAccess, error) {
mode, err := resolveSSHAuthMode(cfg.SSHAuthMode, cfg.SSHPassword, cfg.SSHPublicKey, SSHAuthKeep)
if err != nil {
return SSHAccess{}, err
}
switch mode {
case SSHAuthKeep:
password := strings.TrimSpace(currentPassword)
if password == "" {
password = generateRandomString(16)
}
if err := validateRootPassword(password); err != nil {
return SSHAccess{}, err
}
return SSHAccess{Mode: mode, Password: password}, nil
case SSHAuthAutoPassword:
return SSHAccess{Mode: mode, Password: generateRandomString(16)}, nil
case SSHAuthPassword:
password := strings.TrimSpace(cfg.SSHPassword)
if password == "" {
return SSHAccess{}, fmt.Errorf("请填写自定义 SSH 密码")
}
if err := ValidateCustomSSHPassword(password); err != nil {
return SSHAccess{}, err
}
return SSHAccess{Mode: mode, Password: password}, nil
case SSHAuthKey:
publicKey, err := NormalizeSSHPublicKey(cfg.SSHPublicKey)
if err != nil {
return SSHAccess{}, err
}
if publicKey == "" {
return SSHAccess{}, fmt.Errorf("请填写 SSH 公钥")
}
password := strings.TrimSpace(cfg.SSHPassword)
if password != "" {
if err := ValidateCustomSSHPassword(password); err != nil {
return SSHAccess{}, err
}
} else {
password = strings.TrimSpace(currentPassword)
if password == "" {
password = generateRandomString(16)
}
}
if err := validateRootPassword(password); err != nil {
return SSHAccess{}, err
}
return SSHAccess{Mode: mode, Password: password, PublicKey: publicKey}, nil
default:
return SSHAccess{}, fmt.Errorf("不支持的 SSH 登录方式: %s", mode)
}
}
func ValidateCustomSSHPassword(password string) error {
if len(password) < 8 || len(password) > 64 {
return fmt.Errorf("密码长度必须为 8-64 位")
}
hasLetter := false
hasDigit := false
for _, r := range password {
if unicode.IsSpace(r) {
return fmt.Errorf("密码不能包含空白字符")
}
if unicode.IsLetter(r) {
hasLetter = true
}
if unicode.IsDigit(r) {
hasDigit = true
}
}
if !hasLetter || !hasDigit {
return fmt.Errorf("密码至少需要包含字母和数字")
}
return validateRootPassword(password)
}
func NormalizeSSHPublicKey(publicKey string) (string, error) {
key := strings.TrimSpace(publicKey)
if key == "" {
return "", nil
}
if len(key) > 8192 {
return "", fmt.Errorf("SSH 公钥长度不能超过 8192 字符")
}
if strings.ContainsAny(key, "\r\n") || strings.ContainsRune(key, '\x00') {
return "", fmt.Errorf("SSH 公钥只能填写一行")
}
fields := strings.Fields(key)
if len(fields) < 2 {
return "", fmt.Errorf("SSH 公钥格式不正确")
}
if !isSupportedSSHKeyType(fields[0]) {
return "", fmt.Errorf("不支持的 SSH 公钥类型: %s", fields[0])
}
parsed, _, _, rest, err := ssh.ParseAuthorizedKey([]byte(key))
if err != nil {
return "", fmt.Errorf("SSH 公钥格式不正确")
}
if strings.TrimSpace(string(rest)) != "" {
return "", fmt.Errorf("一次只能填写一个 SSH 公钥")
}
if !isSupportedSSHKeyType(parsed.Type()) {
return "", fmt.Errorf("不支持的 SSH 公钥类型: %s", parsed.Type())
}
return key, nil
}
func resolveSSHAuthMode(rawMode, password, publicKey, defaultMode string) (string, error) {
mode := strings.ToLower(strings.TrimSpace(rawMode))
mode = strings.ReplaceAll(mode, "-", "_")
if mode == "" {
if strings.TrimSpace(publicKey) != "" {
return SSHAuthKey, nil
}
if strings.TrimSpace(password) != "" {
return SSHAuthPassword, nil
}
return defaultMode, nil
}
switch mode {
case "auto", "auto_password", "generated", "generate":
return SSHAuthAutoPassword, nil
case "password", "custom_password":
return SSHAuthPassword, nil
case "key", "ssh_key", "public_key":
return SSHAuthKey, nil
case "keep", "retain", "keep_password":
return SSHAuthKeep, nil
default:
return "", fmt.Errorf("不支持的 SSH 登录方式: %s", rawMode)
}
}
func isSupportedSSHKeyType(keyType string) bool {
switch keyType {
case "ssh-ed25519",
"ssh-rsa",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
"sk-ssh-ed25519@openssh.com",
"sk-ecdsa-sha2-nistp256@openssh.com":
return true
default:
return false
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
package version package version
var ( var (
Version = "1.1.11" Version = "1.1.12"
Repo = "MengMengCode/CLICD" Repo = "MengMengCode/CLICD"
) )
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "clicd-frontend", "name": "clicd-frontend",
"private": true, "private": true,
"version": "1.1.11", "version": "1.1.12",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react' import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { CalendarClock, X } from 'lucide-react' import { CalendarClock, RefreshCw, X } from 'lucide-react'
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, CreateContainerRequest, HostInfo, IPv6Status, Template } from '../services/api' import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, CreateContainerRequest, HostInfo, IPv6Status, Template } from '../services/api'
import { useDialog } from './Dialog' import { useDialog } from './Dialog'
import { useLanguage, type Language } from '../contexts/LanguageContext' import { useLanguage, type Language } from '../contexts/LanguageContext'
import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type SSHAuthMode } from '../utils/sshAuth'
interface CreateContainerModalProps { interface CreateContainerModalProps {
isOpen: boolean isOpen: boolean
@@ -35,6 +36,9 @@ const defaultForm: CreateContainerRequest = {
assign_ipv6: false, assign_ipv6: false,
ipv6_count: 1, ipv6_count: 1,
ipv6_addresses: [], ipv6_addresses: [],
ssh_auth_mode: 'auto_password',
ssh_password: '',
ssh_public_key: '',
expires_at: '', expires_at: '',
} }
@@ -94,6 +98,8 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB) const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
const natEnabled = form.assign_nat !== false const natEnabled = form.assign_nat !== false
const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2) : 0 const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2) : 0
const linuxTemplate = !isWindowsTemplate(form.template_id)
const sshAuthMode = (form.ssh_auth_mode || 'auto_password') as SSHAuthMode
const autoPorts = useMemo(() => { const autoPorts = useMemo(() => {
if (!natEnabled) return [] if (!natEnabled) return []
@@ -148,6 +154,12 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
return return
} }
const authError = validateSSHAuthInputs(form)
if (authError) {
dialog.alert('登录方式有误', authError)
return
}
const boundedForm = normalizeCreateForm(form) const boundedForm = normalizeCreateForm(form)
const wantsNAT = boundedForm.assign_nat !== false const wantsNAT = boundedForm.assign_nat !== false
@@ -254,6 +266,55 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</Field> </Field>
{linuxTemplate && (
<div className="rounded-md border border-gray-200 bg-white px-3 py-3 text-sm">
<div className="mb-2 font-medium text-gray-800"></div>
<div className="grid grid-cols-3 gap-2">
{([
['auto_password', '自动生成密码'],
['password', '自定义密码'],
['key', 'SSH Key'],
] as Array<[SSHAuthMode, string]>).map(([mode, label]) => (
<button
key={mode}
type="button"
onClick={() => setForm({ ...form, ssh_auth_mode: mode })}
className={`rounded-md border px-3 py-2 text-xs font-medium transition-colors ${sshAuthMode === mode ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
>
{label}
</button>
))}
</div>
{sshAuthMode === 'password' && (
<div className="mt-3 flex gap-2">
<input
type="text"
value={form.ssh_password || ''}
onChange={(event) => setForm({ ...form, ssh_password: event.target.value })}
className={inputClass}
placeholder="RootPass123"
/>
<button
type="button"
onClick={() => setForm({ ...form, ssh_password: generateSSHPassword() })}
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-gray-300 text-gray-600 hover:bg-gray-50"
title="生成密码"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
)}
{sshAuthMode === 'key' && (
<textarea
value={form.ssh_public_key || ''}
onChange={(event) => setForm({ ...form, ssh_public_key: event.target.value })}
className={`${inputClass} mt-3 min-h-20 resize-y font-mono text-xs`}
placeholder="ssh-ed25519 AAAA..."
/>
)}
</div>
)}
<div className={`rounded-md border px-3 py-2 text-sm ${ipv4Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}> <div className={`rounded-md border px-3 py-2 text-sm ${ipv4Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
<label className="flex items-start gap-3"> <label className="flex items-start gap-3">
<input <input
@@ -620,6 +681,8 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
const wantsNAT = normalized.assign_nat !== false const wantsNAT = normalized.assign_nat !== false
const wantsIPv4 = !!normalized.assign_ipv4 const wantsIPv4 = !!normalized.assign_ipv4
const wantsIPv6 = !!normalized.assign_ipv6 const wantsIPv6 = !!normalized.assign_ipv6
const linuxTemplate = !isWindowsTemplate(normalized.template_id)
const sshAuthMode = linuxTemplate ? (normalized.ssh_auth_mode || 'auto_password') : 'auto_password'
return { return {
...normalized, ...normalized,
vcpu: normalized.virtualization === 'kvm' ? Math.round(normalized.vcpu) : normalizeLXCvCPU(normalized.vcpu), vcpu: normalized.virtualization === 'kvm' ? Math.round(normalized.vcpu) : normalizeLXCvCPU(normalized.vcpu),
@@ -633,10 +696,22 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
assign_ipv6: wantsIPv6, assign_ipv6: wantsIPv6,
ipv6_count: wantsIPv6 ? clampInt(normalized.ipv6_count || 1, 1, 64, 1) : 0, ipv6_count: wantsIPv6 ? clampInt(normalized.ipv6_count || 1, 1, 64, 1) : 0,
ipv6_addresses: wantsIPv6 ? (normalized.ipv6_addresses || []) : [], ipv6_addresses: wantsIPv6 ? (normalized.ipv6_addresses || []) : [],
ssh_auth_mode: sshAuthMode,
ssh_password: linuxTemplate && sshAuthMode === 'password' ? (normalized.ssh_password || '').trim() : '',
ssh_public_key: linuxTemplate && sshAuthMode === 'key' ? (normalized.ssh_public_key || '').trim() : '',
snapshot_limit: clampInt(normalized.snapshot_limit, 1, undefined, 3), snapshot_limit: clampInt(normalized.snapshot_limit, 1, undefined, 3),
} }
} }
function validateSSHAuthInputs(form: CreateContainerRequest) {
if (isWindowsTemplate(form.template_id)) return ''
const mode = form.ssh_auth_mode || 'auto_password'
if (mode === 'password') return sshPasswordError((form.ssh_password || '').trim())
if (mode === 'key') return sshPublicKeyError(form.ssh_public_key || '')
if (mode !== 'auto_password') return '请选择登录方式'
return ''
}
function applyTemplateDefaults(form: CreateContainerRequest): CreateContainerRequest { function applyTemplateDefaults(form: CreateContainerRequest): CreateContainerRequest {
if (!isWindowsTemplate(form.template_id)) return form if (!isWindowsTemplate(form.template_id)) return form
return { return {
+11 -1
View File
@@ -734,9 +734,17 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
port_mapping_count: 2, port_mapping_count: 2,
snapshot_limit: 1, snapshot_limit: 1,
assign_ipv6: true, assign_ipv6: true,
ssh_auth_mode: 'auto_password',
ssh_password: '',
ssh_public_key: '',
expires_at: '', expires_at: '',
}, },
'POST /api/v1/containers/{id}/reinstall': { template_id: 'debian-bookworm' }, 'POST /api/v1/containers/{id}/reinstall': {
template_id: 'debian-bookworm',
ssh_auth_mode: 'keep',
ssh_password: '',
ssh_public_key: '',
},
'PUT /api/v1/containers/{id}/traffic-limit': { 'PUT /api/v1/containers/{id}/traffic-limit': {
traffic_mode: 'total', traffic_mode: 'total',
monthly_traffic_gb: 100, monthly_traffic_gb: 100,
@@ -788,6 +796,8 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
port_mapping_count: 2, port_mapping_count: 2,
snapshot_limit: 1, snapshot_limit: 1,
assign_ipv6: true, assign_ipv6: true,
ssh_auth_mode: 'key',
ssh_public_key: 'ssh-ed25519 AAAA... user@example',
}, },
], ],
}, },
+81 -38
View File
@@ -78,6 +78,7 @@ import ResourceStatsPanel, {
StatsRangeKey, StatsRangeKey,
statsRanges, statsRanges,
} from '../components/ResourceStatsPanel' } from '../components/ResourceStatsPanel'
import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type ReinstallSSHAuthMode } from '../utils/sshAuth'
const PUBLIC_HOST = window.location.hostname const PUBLIC_HOST = window.location.hostname
const inputClass = 'w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black' const inputClass = 'w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black'
@@ -135,6 +136,9 @@ export default function ContainerDetail() {
const [showReinstall, setShowReinstall] = useState(false) const [showReinstall, setShowReinstall] = useState(false)
const [templates, setTemplates] = useState<Template[]>([]) const [templates, setTemplates] = useState<Template[]>([])
const [selectedTemplate, setSelectedTemplate] = useState('') const [selectedTemplate, setSelectedTemplate] = useState('')
const [reinstallAuthMode, setReinstallAuthMode] = useState<ReinstallSSHAuthMode>('keep')
const [reinstallPasswordDraft, setReinstallPasswordDraft] = useState('')
const [reinstallPublicKeyDraft, setReinstallPublicKeyDraft] = useState('')
const [reinstalling, setReinstalling] = useState(false) const [reinstalling, setReinstalling] = useState(false)
const [traffic, setTraffic] = useState<TrafficInfo | null>(null) const [traffic, setTraffic] = useState<TrafficInfo | null>(null)
const [subUser, setSubUser] = useState<SubUser | null>(null) const [subUser, setSubUser] = useState<SubUser | null>(null)
@@ -413,6 +417,9 @@ export default function ContainerDetail() {
setTemplates(res.data.data) setTemplates(res.data.data)
setSelectedTemplate(res.data.data[0]?.id || '') setSelectedTemplate(res.data.data[0]?.id || '')
} }
setReinstallAuthMode('keep')
setReinstallPasswordDraft('')
setReinstallPublicKeyDraft('')
setShowReinstall(true) setShowReinstall(true)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@@ -434,9 +441,28 @@ export default function ContainerDetail() {
const handleReinstall = async () => { const handleReinstall = async () => {
if (!containerIdentifier || !selectedTemplate) return if (!containerIdentifier || !selectedTemplate) return
const linuxTemplate = !isWindowsTemplate(selectedTemplate)
if (linuxTemplate && reinstallAuthMode === 'password') {
const validationError = sshPasswordError(reinstallPasswordDraft.trim())
if (validationError) {
await dialog.alert('密码格式不正确', validationError)
return
}
}
if (linuxTemplate && reinstallAuthMode === 'key') {
const validationError = sshPublicKeyError(reinstallPublicKeyDraft)
if (validationError) {
await dialog.alert('SSH Key 格式不正确', validationError)
return
}
}
setReinstalling(true) setReinstalling(true)
try { try {
await reinstallContainer(containerIdentifier, selectedTemplate) await reinstallContainer(containerIdentifier, selectedTemplate, linuxTemplate ? {
ssh_auth_mode: reinstallAuthMode,
ssh_password: reinstallAuthMode === 'password' ? reinstallPasswordDraft.trim() : '',
ssh_public_key: reinstallAuthMode === 'key' ? reinstallPublicKeyDraft.trim() : '',
} : undefined)
setShowReinstall(false) setShowReinstall(false)
setShowSSH(false) setShowSSH(false)
setShowVNC(false) setShowVNC(false)
@@ -450,23 +476,12 @@ export default function ContainerDetail() {
} }
const generateResetPassword = () => { const generateResetPassword = () => {
const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' setResetPasswordDraft(generateSSHPassword())
const digits = '23456789'
const symbols = '!@#$%*-_+='
const all = letters + digits + symbols
const pick = (chars: string) => chars[secureRandomInt(chars.length)]
let password = pick(letters) + pick(digits)
while (password.length < 16) password += pick(all)
setResetPasswordDraft(secureShuffle(password.split('')).join(''))
setResetPasswordResult('') setResetPasswordResult('')
} }
const resetPasswordError = (password: string) => { const resetPasswordError = (password: string) => {
if (password.length < 8 || password.length > 64) return '密码长度必须为 8-64 位' return sshPasswordError(password)
if (/\s/.test(password)) return '密码不能包含空白字符'
if (!/[A-Za-z]/.test(password)) return '密码至少需要包含字母'
if (!/\d/.test(password)) return '密码至少需要包含数字'
return ''
} }
const handleResetPassword = async () => { const handleResetPassword = async () => {
@@ -740,6 +755,7 @@ export default function ContainerDetail() {
const isRunning = container.status === 'running' const isRunning = container.status === 'running'
const isKVM = (container.virtualization || 'lxc') === 'kvm' const isKVM = (container.virtualization || 'lxc') === 'kvm'
const isWindows = container.template?.includes('windows') const isWindows = container.template?.includes('windows')
const reinstallLinuxTemplate = !isWindowsTemplate(selectedTemplate)
const canOpenVNC = isKVM && isRunning const canOpenVNC = isKVM && isRunning
const isExpired = container.expires_at ? new Date(container.expires_at) < new Date() : false const isExpired = container.expires_at ? new Date(container.expires_at) < new Date() : false
const isPolicyBlocked = !!container.policy_blocked const isPolicyBlocked = !!container.policy_blocked
@@ -1484,6 +1500,55 @@ export default function ContainerDetail() {
))} ))}
</select> </select>
</Field> </Field>
{reinstallLinuxTemplate && (
<div className="rounded-md border border-gray-200 bg-white px-3 py-3 text-sm">
<div className="mb-2 font-medium text-gray-800"></div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{([
['keep', '保留当前密码'],
['auto_password', '生成新密码'],
['password', '自定义密码'],
['key', 'SSH Key'],
] as Array<[ReinstallSSHAuthMode, string]>).map(([mode, label]) => (
<button
key={mode}
type="button"
onClick={() => setReinstallAuthMode(mode)}
className={`rounded-md border px-3 py-2 text-xs font-medium transition-colors ${reinstallAuthMode === mode ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
>
{label}
</button>
))}
</div>
{reinstallAuthMode === 'password' && (
<div className="mt-3 flex gap-2">
<input
type="text"
value={reinstallPasswordDraft}
onChange={(event) => setReinstallPasswordDraft(event.target.value)}
className={inputClass}
placeholder="RootPass123"
/>
<button
type="button"
onClick={() => setReinstallPasswordDraft(generateSSHPassword())}
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-gray-300 text-gray-600 hover:bg-gray-50"
title="生成密码"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
)}
{reinstallAuthMode === 'key' && (
<textarea
value={reinstallPublicKeyDraft}
onChange={(event) => setReinstallPublicKeyDraft(event.target.value)}
className={`${inputClass} mt-3 min-h-20 resize-y font-mono text-xs`}
placeholder="ssh-ed25519 AAAA..."
/>
)}
</div>
)}
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button onClick={() => setShowReinstall(false)} className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md"></button> <button onClick={() => setShowReinstall(false)} className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md"></button>
<button onClick={handleReinstall} disabled={reinstalling} className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50"> <button onClick={handleReinstall} disabled={reinstalling} className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50">
@@ -2135,30 +2200,8 @@ function TrafficBar({ container }: { container: Container }) {
) )
} }
function secureRandomInt(maxExclusive: number) { function isWindowsTemplate(templateID: string) {
if (!Number.isSafeInteger(maxExclusive) || maxExclusive <= 0) { return templateID.toLowerCase().includes('windows')
throw new Error('invalid random range')
}
const values = new Uint32Array(1)
const maxUint32 = 0x100000000
const limit = Math.floor(maxUint32 / maxExclusive) * maxExclusive
let value = 0
do {
crypto.getRandomValues(values)
value = values[0]
} while (value >= limit)
return value % maxExclusive
}
function secureShuffle<T>(items: T[]) {
const next = [...items]
for (let i = next.length - 1; i > 0; i--) {
const j = secureRandomInt(i + 1)
const value = next[i]
next[i] = next[j]
next[j] = value
}
return next
} }
function getTemplateIcon(id: string): ReactNode { function getTemplateIcon(id: string): ReactNode {
+1 -1
View File
@@ -128,7 +128,7 @@ export default function Login() {
</form> </form>
</div> </div>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.11</p> <p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.12</p>
</div> </div>
</div> </div>
) )
+11 -2
View File
@@ -138,9 +138,18 @@ export interface CreateContainerRequest {
assign_ipv6: boolean assign_ipv6: boolean
ipv6_count?: number ipv6_count?: number
ipv6_addresses?: string[] ipv6_addresses?: string[]
ssh_auth_mode?: string
ssh_password?: string
ssh_public_key?: string
expires_at: string expires_at: string
} }
export interface ReinstallContainerOptions {
ssh_auth_mode?: string
ssh_password?: string
ssh_public_key?: string
}
export interface IPv6PrefixInfo { export interface IPv6PrefixInfo {
interface: string interface: string
address: string address: string
@@ -426,8 +435,8 @@ export const stopContainer = (id: ContainerIdentifier) =>
export const restartContainer = (id: ContainerIdentifier) => export const restartContainer = (id: ContainerIdentifier) =>
api.post<APIResponse>(`/containers/${id}/restart`) api.post<APIResponse>(`/containers/${id}/restart`)
export const reinstallContainer = (id: ContainerIdentifier, templateId: string) => export const reinstallContainer = (id: ContainerIdentifier, templateId: string, options?: ReinstallContainerOptions) =>
api.post<APIResponse>(`/containers/${id}/reinstall`, { template_id: templateId }) api.post<APIResponse>(`/containers/${id}/reinstall`, { template_id: templateId, ...(options || {}) })
export const resetSSHPassword = (id: ContainerIdentifier, password?: string) => export const resetSSHPassword = (id: ContainerIdentifier, password?: string) =>
api.post<APIResponse<{ password: string }>>(`/containers/${id}/reset-password`, password ? { password } : {}) api.post<APIResponse<{ password: string }>>(`/containers/${id}/reset-password`, password ? { password } : {})
+67
View File
@@ -0,0 +1,67 @@
export type SSHAuthMode = 'auto_password' | 'password' | 'key'
export type ReinstallSSHAuthMode = SSHAuthMode | 'keep'
const supportedKeyTypes = new Set([
'ssh-ed25519',
'ssh-rsa',
'ecdsa-sha2-nistp256',
'ecdsa-sha2-nistp384',
'ecdsa-sha2-nistp521',
'sk-ssh-ed25519@openssh.com',
'sk-ecdsa-sha2-nistp256@openssh.com',
])
export function generateSSHPassword() {
const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
const digits = '23456789'
const symbols = '!@#$%*-_+='
const all = letters + digits + symbols
const pick = (chars: string) => chars[secureRandomInt(chars.length)]
let password = pick(letters) + pick(digits)
while (password.length < 16) password += pick(all)
return secureShuffle(password.split('')).join('')
}
export function sshPasswordError(password: string) {
if (password.length < 8 || password.length > 64) return '密码长度必须为 8-64 位'
if (/\s/.test(password)) return '密码不能包含空白字符'
if (!/[A-Za-z]/.test(password)) return '密码至少需要包含字母'
if (!/\d/.test(password)) return '密码至少需要包含数字'
return ''
}
export function sshPublicKeyError(publicKey: string) {
const key = publicKey.trim()
if (!key) return '请填写 SSH 公钥'
if (key.length > 8192) return 'SSH 公钥长度不能超过 8192 字符'
if (/[\r\n]/.test(key)) return 'SSH 公钥只能填写一行'
const parts = key.split(/\s+/)
if (parts.length < 2 || !supportedKeyTypes.has(parts[0])) return 'SSH 公钥格式不正确'
return ''
}
function secureRandomInt(maxExclusive: number) {
if (!Number.isSafeInteger(maxExclusive) || maxExclusive <= 0) {
throw new Error('invalid random range')
}
const values = new Uint32Array(1)
const maxUint32 = 0x100000000
const limit = Math.floor(maxUint32 / maxExclusive) * maxExclusive
let value = 0
do {
crypto.getRandomValues(values)
value = values[0]
} while (value >= limit)
return value % maxExclusive
}
function secureShuffle<T>(items: T[]) {
const next = [...items]
for (let i = next.length - 1; i > 0; i--) {
const j = secureRandomInt(i + 1)
const value = next[i]
next[i] = next[j]
next[j] = value
}
return next
}