mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
添加新建虚拟机/重装系统 预设SSH密码以及KEY Auth功能
This commit is contained in:
@@ -2,12 +2,10 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"clicd/internal/config"
|
||||
"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()})
|
||||
return
|
||||
}
|
||||
if err := validateCreateSSHAuth(cfg); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if cfg.ExpiresAt != "" {
|
||||
expiresAt, ok := lxc.ParseExpiration(cfg.ExpiresAt)
|
||||
if !ok {
|
||||
@@ -536,26 +538,7 @@ func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
|
||||
}
|
||||
|
||||
func validateSSHPassword(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 nil
|
||||
return lxc.ValidateCustomSSHPassword(password)
|
||||
}
|
||||
|
||||
func addPortMapping(w http.ResponseWriter, r *http.Request, id int) {
|
||||
|
||||
@@ -38,6 +38,26 @@ func createByRuntime(cfg lxc.ContainerConfig) error {
|
||||
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 {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
@@ -70,12 +90,12 @@ func destroyByRuntime(id int) error {
|
||||
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)
|
||||
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) {
|
||||
|
||||
@@ -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 {
|
||||
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()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
@@ -88,6 +92,9 @@ func (q *TaskQueue) Enqueue(containerID int, containerName string, taskType Task
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
TemplateID: templateID,
|
||||
User: user,
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
}
|
||||
if cfg != nil {
|
||||
task.Config = *cfg
|
||||
@@ -343,9 +350,13 @@ func (q *TaskQueue) opWorker() {
|
||||
}
|
||||
}
|
||||
case TaskReinstall:
|
||||
if lxc.HasSSHAuthOptions(task.Config) {
|
||||
err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config)
|
||||
} else {
|
||||
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
q.mu.Lock()
|
||||
auditUser := task.User
|
||||
@@ -472,6 +483,7 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
|
||||
|
||||
var taskType TaskType
|
||||
var templateID string
|
||||
var taskConfig *lxc.ContainerConfig
|
||||
switch action {
|
||||
case "start":
|
||||
taskType = TaskStart
|
||||
@@ -484,6 +496,9 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
|
||||
case "reinstall":
|
||||
var req struct {
|
||||
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)
|
||||
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"})
|
||||
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
|
||||
default:
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
|
||||
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{
|
||||
Success: true,
|
||||
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()})
|
||||
return
|
||||
}
|
||||
if err := validateCreateSSHAuth(req.Containers[i]); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
||||
return
|
||||
}
|
||||
requestNames[name] = true
|
||||
}
|
||||
ids := globalQueue.EnqueueBatchCreateWithAudit(req.Containers, requestActor(r), clientIP(r), r.UserAgent())
|
||||
@@ -634,6 +666,9 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
|
||||
Action string `json:"action"`
|
||||
Containers []int `json:"containers"`
|
||||
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 {
|
||||
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 requiredScope string
|
||||
var taskConfig *lxc.ContainerConfig
|
||||
switch req.Action {
|
||||
case "start":
|
||||
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"})
|
||||
return
|
||||
}
|
||||
authCfg := lxc.ContainerConfig{
|
||||
TemplateID: req.TemplateID,
|
||||
SSHAuthMode: req.SSHAuthMode,
|
||||
SSHPassword: req.SSHPassword,
|
||||
SSHPublicKey: req.SSHPublicKey,
|
||||
}
|
||||
if lxc.HasSSHAuthOptions(authCfg) {
|
||||
taskConfig = &authCfg
|
||||
}
|
||||
taskType = TaskReinstall
|
||||
requiredScope = "container:reinstall"
|
||||
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"})
|
||||
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})
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,9 @@ type savedTaskConfig struct {
|
||||
AssignIPv6 bool `json:"assign_ipv6"`
|
||||
IPv6Count int `json:"ipv6_count,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"`
|
||||
}
|
||||
|
||||
@@ -285,6 +288,9 @@ func ensureSchema() error {
|
||||
cfg_assign_ipv6 INTEGER,
|
||||
cfg_ipv6_count INTEGER,
|
||||
cfg_ipv6_addresses TEXT,
|
||||
cfg_ssh_auth_mode TEXT,
|
||||
cfg_ssh_password TEXT,
|
||||
cfg_ssh_public_key TEXT,
|
||||
cfg_expires_at TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS task_extra_ports (
|
||||
@@ -344,6 +350,9 @@ func ensureSchemaMigrations() error {
|
||||
{"tasks", "cfg_assign_nat", "INTEGER"},
|
||||
{"tasks", "cfg_ipv6_count", "INTEGER"},
|
||||
{"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"},
|
||||
{"container_public_ipv4s", "prefix_len", "INTEGER"},
|
||||
{"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_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_expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
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.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
|
||||
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.SnapshotLimit,
|
||||
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 {
|
||||
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_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_expires_at
|
||||
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_expires_at
|
||||
FROM tasks ORDER BY created_at, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -952,13 +962,15 @@ func loadTasks() ([]SavedTask, error) {
|
||||
var cfg savedTaskConfig
|
||||
var assignIPv4, assignIPv6 int
|
||||
var ip, userAgent, publicIPv4s, ipv6Addresses sql.NullString
|
||||
var sshAuthMode, sshPassword, sshPublicKey sql.NullString
|
||||
var assignNAT, ipv4Count, ipv6Count sql.NullInt64
|
||||
if err := rows.Scan(
|
||||
&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.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
|
||||
&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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -978,6 +990,9 @@ func loadTasks() ([]SavedTask, error) {
|
||||
cfg.IPv6Count = int(ipv6Count.Int64)
|
||||
}
|
||||
cfg.IPv6Addresses = decodeStringSlice(ipv6Addresses.String)
|
||||
cfg.SSHAuthMode = sshAuthMode.String
|
||||
cfg.SSHPassword = sshPassword.String
|
||||
cfg.SSHPublicKey = sshPublicKey.String
|
||||
result = append(result, t)
|
||||
configs = append(configs, cfg)
|
||||
}
|
||||
|
||||
@@ -406,6 +406,15 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
||||
seedPath := filepath.Join(m.instanceDir(vmName), "seed.iso")
|
||||
mac := randomMAC()
|
||||
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)
|
||||
if err != nil {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
if c == nil {
|
||||
return fmt.Errorf("container not found: %d", id)
|
||||
@@ -709,6 +718,19 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
||||
SnapshotLimit: c.SnapshotLimit,
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1788,8 +1810,8 @@ func shellQuoteWindows(value string) string {
|
||||
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
|
||||
}
|
||||
|
||||
func createSeedISO(seedPath, instanceID, hostname, password, mac string, ipv6s []string, image Image) error {
|
||||
guestSetup := kvmSSHSetupScript(password)
|
||||
func createSeedISO(seedPath, instanceID, hostname, password, publicKey, mac string, ipv6s []string, image Image) error {
|
||||
guestSetup := kvmSSHSetupScript(password, publicKey)
|
||||
if desktopSetup := kvmDesktopSetupScript(image); desktopSetup != "" {
|
||||
guestSetup += "\n" + desktopSetup
|
||||
}
|
||||
@@ -1797,6 +1819,12 @@ func createSeedISO(seedPath, instanceID, hostname, password, mac string, ipv6s [
|
||||
if len(ipv6s) > 0 {
|
||||
guestSetup += "\n" + kvmIPv6SetupScript(ipv6s)
|
||||
}
|
||||
authorizedKeys := ""
|
||||
if publicKey != "" {
|
||||
authorizedKeys = fmt.Sprintf(`
|
||||
ssh_authorized_keys:
|
||||
- %s`, yamlSingleQuote(publicKey))
|
||||
}
|
||||
setupScript := indentScript(guestSetup, 4)
|
||||
userData := fmt.Sprintf(`#cloud-config
|
||||
preserve_hostname: false
|
||||
@@ -1812,11 +1840,11 @@ chpasswd:
|
||||
type: text
|
||||
users:
|
||||
- name: root
|
||||
lock_passwd: false
|
||||
lock_passwd: false%s
|
||||
runcmd:
|
||||
- |
|
||||
%s
|
||||
`, hostname, password, setupScript)
|
||||
`, hostname, password, authorizedKeys, setupScript)
|
||||
metaData := fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", instanceID, hostname)
|
||||
ipv6Block := ""
|
||||
if len(ipv6s) > 0 {
|
||||
@@ -1907,6 +1935,10 @@ func indentScript(script string, spaces int) string {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func yamlSingleQuote(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
|
||||
}
|
||||
|
||||
func isKVMDesktopTemplate(templateID string) bool {
|
||||
image := FindImage(templateID)
|
||||
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
|
||||
ROOT_PASSWORD=` + shellQuote(password) + `
|
||||
SSH_PUBLIC_KEY=` + shellQuote(publicKey) + `
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
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
|
||||
@@ -2397,6 +2434,7 @@ fi
|
||||
mkdir -p /etc/ssh/sshd_config.d
|
||||
cat > /etc/ssh/sshd_config.d/99-clicd-root.conf <<'EOF'
|
||||
PermitRootLogin yes
|
||||
PubkeyAuthentication yes
|
||||
PasswordAuthentication yes
|
||||
KbdInteractiveAuthentication yes
|
||||
ChallengeResponseAuthentication yes
|
||||
@@ -2404,11 +2442,21 @@ EOF
|
||||
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 || 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 || 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 || true
|
||||
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
|
||||
printf 'root:%s\n' "$ROOT_PASSWORD" | chpasswd || true
|
||||
fi
|
||||
|
||||
@@ -241,6 +241,9 @@ type ContainerConfig struct {
|
||||
AssignIPv6 bool `json:"assign_ipv6"`
|
||||
IPv6Count int `json:"ipv6_count,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"`
|
||||
}
|
||||
|
||||
@@ -270,6 +273,10 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
if config.FindContainerByName(cfg.Name) != nil {
|
||||
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
|
||||
id := config.AllocateContainerID()
|
||||
@@ -329,7 +336,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
}
|
||||
}
|
||||
|
||||
sshPassword := generateRandomString(16)
|
||||
sshPassword := sshAccess.Password
|
||||
|
||||
sshPort := 0
|
||||
portMappings := []config.PortMapping{}
|
||||
@@ -420,6 +427,13 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
if err := m.preconfigureSSH(rootfsPath, cfg.TemplateID); err != nil {
|
||||
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 {
|
||||
_ = 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'
|
||||
PermitRootLogin yes
|
||||
PubkeyAuthentication yes
|
||||
PasswordAuthentication yes
|
||||
KbdInteractiveAuthentication no
|
||||
ChallengeResponseAuthentication no
|
||||
@@ -1938,6 +1953,7 @@ UsePAM no
|
||||
EOF
|
||||
|
||||
set_sshd_option PermitRootLogin yes
|
||||
set_sshd_option PubkeyAuthentication yes
|
||||
set_sshd_option PasswordAuthentication yes
|
||||
set_sshd_option KbdInteractiveAuthentication no
|
||||
set_sshd_option ChallengeResponseAuthentication no
|
||||
@@ -2102,6 +2118,45 @@ func (m *Manager) setRootfsPassword(rootfsPath, password string) error {
|
||||
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) {
|
||||
if len(args) == 0 {
|
||||
return nil, fmt.Errorf("empty rootfs command")
|
||||
@@ -2499,7 +2554,7 @@ func copyRootfsContents(src, dst string) error {
|
||||
}
|
||||
|
||||
// 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)
|
||||
if c == nil {
|
||||
return fmt.Errorf("container not found: %d", id)
|
||||
@@ -2509,6 +2564,14 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
||||
if tmpl == nil {
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
if c.SSHPassword == "" {
|
||||
c.SSHPassword = generateRandomString(16)
|
||||
}
|
||||
c.SSHPassword = sshAccess.Password
|
||||
if err := m.preconfigureSSH(rootfsPath, templateID); err != nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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,8 +1,9 @@
|
||||
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 { useDialog } from './Dialog'
|
||||
import { useLanguage, type Language } from '../contexts/LanguageContext'
|
||||
import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type SSHAuthMode } from '../utils/sshAuth'
|
||||
|
||||
interface CreateContainerModalProps {
|
||||
isOpen: boolean
|
||||
@@ -35,6 +36,9 @@ const defaultForm: CreateContainerRequest = {
|
||||
assign_ipv6: false,
|
||||
ipv6_count: 1,
|
||||
ipv6_addresses: [],
|
||||
ssh_auth_mode: 'auto_password',
|
||||
ssh_password: '',
|
||||
ssh_public_key: '',
|
||||
expires_at: '',
|
||||
}
|
||||
|
||||
@@ -94,6 +98,8 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
|
||||
const natEnabled = form.assign_nat !== false
|
||||
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(() => {
|
||||
if (!natEnabled) return []
|
||||
@@ -148,6 +154,12 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
return
|
||||
}
|
||||
|
||||
const authError = validateSSHAuthInputs(form)
|
||||
if (authError) {
|
||||
dialog.alert('登录方式有误', authError)
|
||||
return
|
||||
}
|
||||
|
||||
const boundedForm = normalizeCreateForm(form)
|
||||
const wantsNAT = boundedForm.assign_nat !== false
|
||||
|
||||
@@ -254,6 +266,55 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
|
||||
</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'}`}>
|
||||
<label className="flex items-start gap-3">
|
||||
<input
|
||||
@@ -620,6 +681,8 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
|
||||
const wantsNAT = normalized.assign_nat !== false
|
||||
const wantsIPv4 = !!normalized.assign_ipv4
|
||||
const wantsIPv6 = !!normalized.assign_ipv6
|
||||
const linuxTemplate = !isWindowsTemplate(normalized.template_id)
|
||||
const sshAuthMode = linuxTemplate ? (normalized.ssh_auth_mode || 'auto_password') : 'auto_password'
|
||||
return {
|
||||
...normalized,
|
||||
vcpu: normalized.virtualization === 'kvm' ? Math.round(normalized.vcpu) : normalizeLXCvCPU(normalized.vcpu),
|
||||
@@ -633,10 +696,22 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
|
||||
assign_ipv6: wantsIPv6,
|
||||
ipv6_count: wantsIPv6 ? clampInt(normalized.ipv6_count || 1, 1, 64, 1) : 0,
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!isWindowsTemplate(form.template_id)) return form
|
||||
return {
|
||||
|
||||
@@ -734,9 +734,17 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
port_mapping_count: 2,
|
||||
snapshot_limit: 1,
|
||||
assign_ipv6: true,
|
||||
ssh_auth_mode: 'auto_password',
|
||||
ssh_password: '',
|
||||
ssh_public_key: '',
|
||||
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': {
|
||||
traffic_mode: 'total',
|
||||
monthly_traffic_gb: 100,
|
||||
@@ -788,6 +796,8 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
port_mapping_count: 2,
|
||||
snapshot_limit: 1,
|
||||
assign_ipv6: true,
|
||||
ssh_auth_mode: 'key',
|
||||
ssh_public_key: 'ssh-ed25519 AAAA... user@example',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -78,6 +78,7 @@ import ResourceStatsPanel, {
|
||||
StatsRangeKey,
|
||||
statsRanges,
|
||||
} from '../components/ResourceStatsPanel'
|
||||
import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type ReinstallSSHAuthMode } from '../utils/sshAuth'
|
||||
|
||||
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'
|
||||
@@ -135,6 +136,9 @@ export default function ContainerDetail() {
|
||||
const [showReinstall, setShowReinstall] = useState(false)
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [selectedTemplate, setSelectedTemplate] = useState('')
|
||||
const [reinstallAuthMode, setReinstallAuthMode] = useState<ReinstallSSHAuthMode>('keep')
|
||||
const [reinstallPasswordDraft, setReinstallPasswordDraft] = useState('')
|
||||
const [reinstallPublicKeyDraft, setReinstallPublicKeyDraft] = useState('')
|
||||
const [reinstalling, setReinstalling] = useState(false)
|
||||
const [traffic, setTraffic] = useState<TrafficInfo | null>(null)
|
||||
const [subUser, setSubUser] = useState<SubUser | null>(null)
|
||||
@@ -413,6 +417,9 @@ export default function ContainerDetail() {
|
||||
setTemplates(res.data.data)
|
||||
setSelectedTemplate(res.data.data[0]?.id || '')
|
||||
}
|
||||
setReinstallAuthMode('keep')
|
||||
setReinstallPasswordDraft('')
|
||||
setReinstallPublicKeyDraft('')
|
||||
setShowReinstall(true)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
@@ -434,9 +441,28 @@ export default function ContainerDetail() {
|
||||
|
||||
const handleReinstall = async () => {
|
||||
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)
|
||||
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)
|
||||
setShowSSH(false)
|
||||
setShowVNC(false)
|
||||
@@ -450,23 +476,12 @@ export default function ContainerDetail() {
|
||||
}
|
||||
|
||||
const generateResetPassword = () => {
|
||||
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)
|
||||
setResetPasswordDraft(secureShuffle(password.split('')).join(''))
|
||||
setResetPasswordDraft(generateSSHPassword())
|
||||
setResetPasswordResult('')
|
||||
}
|
||||
|
||||
const resetPasswordError = (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 ''
|
||||
return sshPasswordError(password)
|
||||
}
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
@@ -740,6 +755,7 @@ export default function ContainerDetail() {
|
||||
const isRunning = container.status === 'running'
|
||||
const isKVM = (container.virtualization || 'lxc') === 'kvm'
|
||||
const isWindows = container.template?.includes('windows')
|
||||
const reinstallLinuxTemplate = !isWindowsTemplate(selectedTemplate)
|
||||
const canOpenVNC = isKVM && isRunning
|
||||
const isExpired = container.expires_at ? new Date(container.expires_at) < new Date() : false
|
||||
const isPolicyBlocked = !!container.policy_blocked
|
||||
@@ -1484,6 +1500,55 @@ export default function ContainerDetail() {
|
||||
))}
|
||||
</select>
|
||||
</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">
|
||||
<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">
|
||||
@@ -2135,30 +2200,8 @@ function TrafficBar({ container }: { container: Container }) {
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
function isWindowsTemplate(templateID: string) {
|
||||
return templateID.toLowerCase().includes('windows')
|
||||
}
|
||||
|
||||
function getTemplateIcon(id: string): ReactNode {
|
||||
|
||||
@@ -138,9 +138,18 @@ export interface CreateContainerRequest {
|
||||
assign_ipv6: boolean
|
||||
ipv6_count?: number
|
||||
ipv6_addresses?: string[]
|
||||
ssh_auth_mode?: string
|
||||
ssh_password?: string
|
||||
ssh_public_key?: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export interface ReinstallContainerOptions {
|
||||
ssh_auth_mode?: string
|
||||
ssh_password?: string
|
||||
ssh_public_key?: string
|
||||
}
|
||||
|
||||
export interface IPv6PrefixInfo {
|
||||
interface: string
|
||||
address: string
|
||||
@@ -426,8 +435,8 @@ export const stopContainer = (id: ContainerIdentifier) =>
|
||||
export const restartContainer = (id: ContainerIdentifier) =>
|
||||
api.post<APIResponse>(`/containers/${id}/restart`)
|
||||
|
||||
export const reinstallContainer = (id: ContainerIdentifier, templateId: string) =>
|
||||
api.post<APIResponse>(`/containers/${id}/reinstall`, { template_id: templateId })
|
||||
export const reinstallContainer = (id: ContainerIdentifier, templateId: string, options?: ReinstallContainerOptions) =>
|
||||
api.post<APIResponse>(`/containers/${id}/reinstall`, { template_id: templateId, ...(options || {}) })
|
||||
|
||||
export const resetSSHPassword = (id: ContainerIdentifier, password?: string) =>
|
||||
api.post<APIResponse<{ password: string }>>(`/containers/${id}/reset-password`, password ? { password } : {})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user