diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index 58b87eb..0453f3d 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -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) { diff --git a/backend/internal/api/runtime.go b/backend/internal/api/runtime.go index 13a0727..55c186c 100644 --- a/backend/internal/api/runtime.go +++ b/backend/internal/api/runtime.go @@ -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) { diff --git a/backend/internal/api/taskqueue.go b/backend/internal/api/taskqueue.go index 9420c80..512f6f3 100644 --- a/backend/internal/api/taskqueue.go +++ b/backend/internal/api/taskqueue.go @@ -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,7 +350,11 @@ func (q *TaskQueue) opWorker() { } } 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 templateID string + var taskConfig *lxc.ContainerConfig switch action { case "start": taskType = TaskStart @@ -483,7 +495,10 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti taskType = TaskDelete case "reinstall": 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) 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()) @@ -631,9 +663,12 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) { return } var req struct { - Action string `json:"action"` - Containers []int `json:"containers"` - TemplateID string `json:"template_id,omitempty"` + 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}) } diff --git a/backend/internal/config/store_sqlite.go b/backend/internal/config/store_sqlite.go index e074b77..9cb9dcc 100644 --- a/backend/internal/config/store_sqlite.go +++ b/backend/internal/config/store_sqlite.go @@ -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) } diff --git a/backend/internal/kvm/kvm.go b/backend/internal/kvm/kvm.go index 785bf6e..653eafa 100644 --- a/backend/internal/kvm/kvm.go +++ b/backend/internal/kvm/kvm.go @@ -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 diff --git a/backend/internal/lxc/lxc.go b/backend/internal/lxc/lxc.go index 1229559..a9eeb98 100644 --- a/backend/internal/lxc/lxc.go +++ b/backend/internal/lxc/lxc.go @@ -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 } diff --git a/backend/internal/lxc/ssh_auth.go b/backend/internal/lxc/ssh_auth.go new file mode 100644 index 0000000..269b3a0 --- /dev/null +++ b/backend/internal/lxc/ssh_auth.go @@ -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 + } +} diff --git a/frontend/src/components/CreateContainerModal.tsx b/frontend/src/components/CreateContainerModal.tsx index 62a01a8..358b33e 100644 --- a/frontend/src/components/CreateContainerModal.tsx +++ b/frontend/src/components/CreateContainerModal.tsx @@ -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 + {linuxTemplate && ( +