mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-06 05:52:19 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb0c4f999d | |||
| 0c9f420474 | |||
| 9a826add87 | |||
| 63611dc932 |
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
type webSSHOriginSettingsRequest struct {
|
||||
Origins []string `json:"origins"`
|
||||
WebSSHAllowedOrigins []string `json:"webssh_allowed_origins"`
|
||||
}
|
||||
|
||||
type webSSHOriginSettingsResponse struct {
|
||||
Origins []string `json:"origins"`
|
||||
CurrentOrigin string `json:"current_origin,omitempty"`
|
||||
}
|
||||
|
||||
func HandleWebSSHOriginSettings(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: webSSHOriginSettingsStatus(r)})
|
||||
case http.MethodPut:
|
||||
updateWebSSHOriginSettings(w, r)
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
func updateWebSSHOriginSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var req webSSHOriginSettingsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
origins := req.Origins
|
||||
if len(origins) == 0 && len(req.WebSSHAllowedOrigins) > 0 {
|
||||
origins = req.WebSSHAllowedOrigins
|
||||
}
|
||||
normalized, err := config.NormalizeAllowedOrigins(origins)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
config.AppConfig.WebSSHAllowedOrigins = normalized
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Save Origin allowlist failed"})
|
||||
return
|
||||
}
|
||||
auditRequest(r, "settings.webssh_origins", "WebSSH Origin", "origins="+strings.Join(normalized, ","), true, "")
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Origin allowlist saved", Data: webSSHOriginSettingsStatus(r)})
|
||||
}
|
||||
|
||||
func webSSHOriginSettingsStatus(r *http.Request) webSSHOriginSettingsResponse {
|
||||
origins := config.AppConfig.WebSSHAllowedOrigins
|
||||
if origins == nil {
|
||||
origins = []string{}
|
||||
}
|
||||
return webSSHOriginSettingsResponse{
|
||||
Origins: origins,
|
||||
CurrentOrigin: requestOrigin(r),
|
||||
}
|
||||
}
|
||||
|
||||
func requestOrigin(r *http.Request) string {
|
||||
host := strings.TrimSpace(r.Host)
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" {
|
||||
scheme = strings.ToLower(strings.Split(forwarded, ",")[0])
|
||||
}
|
||||
return scheme + "://" + host
|
||||
}
|
||||
@@ -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,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})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/config"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
@@ -17,19 +16,6 @@ var upgrader = websocket.Upgrader{
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
originURL, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
originHost := strings.ToLower(stripPort(originURL.Host))
|
||||
requestHost := strings.ToLower(stripPort(r.Host))
|
||||
return originHost != "" && originHost == requestHost
|
||||
return config.IsOriginAllowed(origin, r.Host)
|
||||
},
|
||||
}
|
||||
|
||||
func stripPort(host string) string {
|
||||
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
|
||||
return parsedHost
|
||||
}
|
||||
return strings.Trim(host, "[]")
|
||||
}
|
||||
|
||||
@@ -363,6 +363,7 @@ type ClicdConfig struct {
|
||||
Snapshots []Snapshot `json:"snapshots"`
|
||||
PublicIPv4Pool []PublicIPv4Assignment `json:"public_ipv4_pool"`
|
||||
PublicIPv6Prefixes []PublicIPv6Prefix `json:"public_ipv6_prefixes"`
|
||||
WebSSHAllowedOrigins []string `json:"webssh_allowed_origins"`
|
||||
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
|
||||
Language string `json:"language"`
|
||||
SSL SSLConfig `json:"ssl"`
|
||||
@@ -480,23 +481,24 @@ func InitConfig() (*ClicdConfig, error) {
|
||||
}
|
||||
|
||||
AppConfig = &ClicdConfig{
|
||||
AdminUser: adminUser,
|
||||
AdminPassHash: string(hash),
|
||||
JWTSecret: jwtSecret,
|
||||
Port: 8999,
|
||||
DataDir: dataDir,
|
||||
Containers: []Container{},
|
||||
NextContainerID: 1,
|
||||
NextVNCPort: 5900,
|
||||
NextSSHPort: 22000,
|
||||
SetupComplete: false,
|
||||
SubUsers: []SubUser{},
|
||||
AuditLogs: []AuditLog{},
|
||||
Tasks: []SavedTask{},
|
||||
LoginLogs: []SavedLoginLog{},
|
||||
Snapshots: []Snapshot{},
|
||||
PublicIPv4Pool: []PublicIPv4Assignment{},
|
||||
PublicIPv6Prefixes: []PublicIPv6Prefix{},
|
||||
AdminUser: adminUser,
|
||||
AdminPassHash: string(hash),
|
||||
JWTSecret: jwtSecret,
|
||||
Port: 8999,
|
||||
DataDir: dataDir,
|
||||
Containers: []Container{},
|
||||
NextContainerID: 1,
|
||||
NextVNCPort: 5900,
|
||||
NextSSHPort: 22000,
|
||||
SetupComplete: false,
|
||||
SubUsers: []SubUser{},
|
||||
AuditLogs: []AuditLog{},
|
||||
Tasks: []SavedTask{},
|
||||
LoginLogs: []SavedLoginLog{},
|
||||
Snapshots: []Snapshot{},
|
||||
PublicIPv4Pool: []PublicIPv4Assignment{},
|
||||
PublicIPv6Prefixes: []PublicIPv6Prefix{},
|
||||
WebSSHAllowedOrigins: []string{},
|
||||
}
|
||||
|
||||
if err := SaveConfig(); err != nil {
|
||||
@@ -555,6 +557,13 @@ func normalizeConfigDefaults(dataDir string) bool {
|
||||
AppConfig.PublicIPv6Prefixes = make([]PublicIPv6Prefix, 0)
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.WebSSHAllowedOrigins == nil {
|
||||
AppConfig.WebSSHAllowedOrigins = make([]string, 0)
|
||||
changed = true
|
||||
} else if normalized, err := NormalizeAllowedOrigins(AppConfig.WebSSHAllowedOrigins); err == nil && strings.Join(normalized, "\n") != strings.Join(AppConfig.WebSSHAllowedOrigins, "\n") {
|
||||
AppConfig.WebSSHAllowedOrigins = normalized
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.SubUsers == nil {
|
||||
AppConfig.SubUsers = make([]SubUser, 0)
|
||||
changed = true
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NormalizeAllowedOrigin accepts a browser Origin value such as
|
||||
// https://www.example.com and returns a canonical form for exact matching.
|
||||
func NormalizeAllowedOrigin(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
u, err := url.Parse(value)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return "", fmt.Errorf("Origin must include scheme and host: %s", value)
|
||||
}
|
||||
scheme := strings.ToLower(u.Scheme)
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return "", fmt.Errorf("Origin scheme must be http or https: %s", value)
|
||||
}
|
||||
if (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" {
|
||||
return "", fmt.Errorf("Origin must not include path, query, or fragment: %s", value)
|
||||
}
|
||||
host := normalizeOriginHostPort(u.Host, scheme)
|
||||
if host == "" {
|
||||
return "", fmt.Errorf("Origin host is required: %s", value)
|
||||
}
|
||||
return scheme + "://" + host, nil
|
||||
}
|
||||
|
||||
func NormalizeAllowedOrigins(values []string) ([]string, error) {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
origin, err := NormalizeAllowedOrigin(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if origin == "" || seen[origin] {
|
||||
continue
|
||||
}
|
||||
seen[origin] = true
|
||||
result = append(result, origin)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func IsOriginAllowed(origin string, requestHost string) bool {
|
||||
origin = strings.TrimSpace(origin)
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
if isSameRequestOrigin(origin, requestHost) {
|
||||
return true
|
||||
}
|
||||
normalized, err := NormalizeAllowedOrigin(origin)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if AppConfig == nil {
|
||||
return false
|
||||
}
|
||||
for _, allowed := range AppConfig.WebSSHAllowedOrigins {
|
||||
allowed, err := NormalizeAllowedOrigin(allowed)
|
||||
if err == nil && normalized == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isSameRequestOrigin(origin string, requestHost string) bool {
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
originHost := normalizeHostOnly(u.Hostname())
|
||||
host := normalizeHostOnly(requestHost)
|
||||
if originHost == "" || host == "" {
|
||||
return false
|
||||
}
|
||||
if originHost == host {
|
||||
return true
|
||||
}
|
||||
return isLoopbackHost(originHost) && isLoopbackHost(host)
|
||||
}
|
||||
|
||||
func normalizeOriginHostPort(raw string, scheme string) string {
|
||||
host := raw
|
||||
port := ""
|
||||
if h, p, err := net.SplitHostPort(raw); err == nil {
|
||||
host = h
|
||||
port = p
|
||||
}
|
||||
host = normalizeHostOnly(host)
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
if (scheme == "https" && port == "443") || (scheme == "http" && port == "80") {
|
||||
port = ""
|
||||
}
|
||||
if port != "" {
|
||||
return net.JoinHostPort(host, port)
|
||||
}
|
||||
if strings.Contains(host, ":") && net.ParseIP(host) != nil {
|
||||
return "[" + host + "]"
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func normalizeHostOnly(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if h, _, err := net.SplitHostPort(raw); err == nil {
|
||||
raw = h
|
||||
}
|
||||
raw = strings.Trim(raw, "[]")
|
||||
if ip := net.ParseIP(raw); ip != nil {
|
||||
return strings.ToLower(ip.String())
|
||||
}
|
||||
return strings.TrimSuffix(strings.ToLower(raw), ".")
|
||||
}
|
||||
|
||||
func isLoopbackHost(host string) bool {
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
@@ -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"},
|
||||
@@ -426,6 +435,9 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
||||
if raw := strings.TrimSpace(meta["public_ipv6_prefixes"]); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &cfg.PublicIPv6Prefixes)
|
||||
}
|
||||
if raw := strings.TrimSpace(meta["webssh_allowed_origins"]); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &cfg.WebSSHAllowedOrigins)
|
||||
}
|
||||
|
||||
if cfg.Containers, err = loadContainers(); err != nil {
|
||||
return nil, false, err
|
||||
@@ -524,6 +536,7 @@ func saveMeta(tx *sql.Tx) error {
|
||||
sslCertificatesJSON, _ := json.Marshal(AppConfig.SSLCertificates)
|
||||
publicIPv4PoolJSON, _ := json.Marshal(AppConfig.PublicIPv4Pool)
|
||||
publicIPv6PrefixesJSON, _ := json.Marshal(AppConfig.PublicIPv6Prefixes)
|
||||
webSSHAllowedOriginsJSON, _ := json.Marshal(AppConfig.WebSSHAllowedOrigins)
|
||||
values := map[string]string{
|
||||
"admin_user": AppConfig.AdminUser,
|
||||
"admin_pass_hash": AppConfig.AdminPassHash,
|
||||
@@ -540,6 +553,7 @@ func saveMeta(tx *sql.Tx) error {
|
||||
"ssl_certificates": string(sslCertificatesJSON),
|
||||
"public_ipv4_pool": string(publicIPv4PoolJSON),
|
||||
"public_ipv6_prefixes": string(publicIPv6PrefixesJSON),
|
||||
"webssh_allowed_origins": string(webSSHAllowedOriginsJSON),
|
||||
"schema_version": "1",
|
||||
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
@@ -655,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
|
||||
}
|
||||
@@ -934,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
|
||||
@@ -947,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
|
||||
}
|
||||
@@ -973,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
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,7 @@ import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/api"
|
||||
@@ -19,7 +17,7 @@ var webFS http.FileSystem
|
||||
// corsMiddleware adds CORS headers
|
||||
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if origin := r.Header.Get("Origin"); origin != "" && isAllowedOrigin(origin, r.Host) {
|
||||
if origin := r.Header.Get("Origin"); origin != "" && config.IsOriginAllowed(origin, r.Host) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
@@ -28,7 +26,7 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
if origin := r.Header.Get("Origin"); origin != "" && !isAllowedOrigin(origin, r.Host) {
|
||||
if origin := r.Header.Get("Origin"); origin != "" && !config.IsOriginAllowed(origin, r.Host) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
@@ -40,34 +38,6 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func isAllowedOrigin(origin string, requestHost string) bool {
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
originHost := normalizeHost(u.Host)
|
||||
host := normalizeHost(requestHost)
|
||||
if originHost == host {
|
||||
return true
|
||||
}
|
||||
return isLoopbackHost(originHost) && isLoopbackHost(host)
|
||||
}
|
||||
|
||||
func normalizeHost(host string) string {
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
return strings.ToLower(h)
|
||||
}
|
||||
return strings.ToLower(host)
|
||||
}
|
||||
|
||||
func isLoopbackHost(host string) bool {
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
// setupRoutes configures API and static routes
|
||||
func setupRoutes(mux *http.ServeMux) {
|
||||
// API routes
|
||||
@@ -78,6 +48,7 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange)))
|
||||
mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs)))
|
||||
mux.HandleFunc("/api/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
|
||||
mux.HandleFunc("/api/webssh-origins", corsMiddleware(api.AdminMiddleware(api.HandleWebSSHOriginSettings)))
|
||||
mux.HandleFunc("/api/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
|
||||
mux.HandleFunc("/api/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
|
||||
mux.HandleFunc("/api/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
|
||||
@@ -148,6 +119,7 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/audit-logs", corsMiddleware(api.AuthMiddleware(api.HandleAuditLogs)))
|
||||
mux.HandleFunc("/api/v1/login-logs", corsMiddleware(api.AuthMiddleware(api.HandleLoginLogs)))
|
||||
mux.HandleFunc("/api/v1/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
|
||||
mux.HandleFunc("/api/v1/webssh-origins", corsMiddleware(api.AdminMiddleware(api.HandleWebSSHOriginSettings)))
|
||||
mux.HandleFunc("/api/v1/security/alerts", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityAlerts))))
|
||||
mux.HandleFunc("/api/v1/security/check", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:check", api.HandleSecurityCheck))))
|
||||
mux.HandleFunc("/api/v1/security/logs", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityLogs))))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package version
|
||||
|
||||
var (
|
||||
Version = "1.1.10"
|
||||
Version = "1.1.12"
|
||||
Repo = "MengMengCode/CLICD"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "clicd-frontend",
|
||||
"private": true,
|
||||
"version": "1.1.10",
|
||||
"version": "1.1.12",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -128,7 +128,7 @@ export default function Login() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.10</p>
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.12</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+114
-22
@@ -1,13 +1,16 @@
|
||||
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'
|
||||
import { Clock, Globe, Lock, LogIn, Monitor, RefreshCw, ShieldCheck, Upload, UserCog } from 'lucide-react'
|
||||
import { Clock, Globe, Lock, LogIn, Monitor, RefreshCw, ShieldCheck, Terminal, Upload, UserCog } from 'lucide-react'
|
||||
import {
|
||||
changePassword,
|
||||
changeUsername,
|
||||
getLoginLogs,
|
||||
getSSLSettings,
|
||||
getWebSSHOriginSettings,
|
||||
LoginLog,
|
||||
SSLSettings,
|
||||
updateSSLSettings,
|
||||
updateWebSSHOriginSettings,
|
||||
WebSSHOriginSettings,
|
||||
} from '../services/api'
|
||||
import { useDialog } from '../components/Dialog'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
@@ -33,6 +36,9 @@ export default function Settings() {
|
||||
const [keyPEM, setKeyPEM] = useState('')
|
||||
const [applyNow, setApplyNow] = useState(true)
|
||||
const [savingSSL, setSavingSSL] = useState(false)
|
||||
const [webSSHOrigins, setWebSSHOrigins] = useState<WebSSHOriginSettings | null>(null)
|
||||
const [webSSHOriginsText, setWebSSHOriginsText] = useState('')
|
||||
const [savingWebSSHOrigins, setSavingWebSSHOrigins] = useState(false)
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
try {
|
||||
@@ -60,12 +66,25 @@ export default function Settings() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchWebSSHOrigins = useCallback(async () => {
|
||||
try {
|
||||
const res = await getWebSSHOriginSettings()
|
||||
const data = res.data.data
|
||||
if (!data) return
|
||||
setWebSSHOrigins(data)
|
||||
setWebSSHOriginsText((data.origins || []).join('\n'))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs()
|
||||
fetchSSL()
|
||||
fetchWebSSHOrigins()
|
||||
const timer = setInterval(fetchLogs, 15000)
|
||||
return () => clearInterval(timer)
|
||||
}, [fetchLogs, fetchSSL])
|
||||
}, [fetchLogs, fetchSSL, fetchWebSSHOrigins])
|
||||
|
||||
const handleSSLModeChange = (mode: SSLSettings['mode']) => {
|
||||
setSSLMode(mode)
|
||||
@@ -101,6 +120,25 @@ export default function Settings() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveWebSSHOrigins = async () => {
|
||||
setSavingWebSSHOrigins(true)
|
||||
try {
|
||||
const origins = webSSHOriginsText.split(/\r?\n/).map(item => item.trim()).filter(Boolean)
|
||||
const res = await updateWebSSHOriginSettings(origins)
|
||||
const data = res.data.data
|
||||
if (data) {
|
||||
setWebSSHOrigins(data)
|
||||
setWebSSHOriginsText((data.origins || []).join('\n'))
|
||||
}
|
||||
dialog.alert('完成', 'Origin 白名单已保存')
|
||||
} catch (err: unknown) {
|
||||
const e = err as { response?: { data?: { message?: string } } }
|
||||
dialog.alert('失败', e.response?.data?.message || 'Origin 白名单保存失败')
|
||||
} finally {
|
||||
setSavingWebSSHOrigins(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveAccount = async () => {
|
||||
if (!oldPwd) {
|
||||
dialog.alert('提示', '请输入当前密码以确认修改')
|
||||
@@ -159,26 +197,37 @@ export default function Settings() {
|
||||
</div>
|
||||
|
||||
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
|
||||
<SSLCard
|
||||
ssl={ssl}
|
||||
sslEnabled={sslEnabled}
|
||||
sslMode={sslMode}
|
||||
sslTarget={sslTarget}
|
||||
sslEmail={sslEmail}
|
||||
certPEM={certPEM}
|
||||
keyPEM={keyPEM}
|
||||
applyNow={applyNow}
|
||||
savingSSL={savingSSL}
|
||||
onRefresh={fetchSSL}
|
||||
onEnabledChange={setSSLEnabled}
|
||||
onModeChange={handleSSLModeChange}
|
||||
onTargetChange={setSSLTarget}
|
||||
onEmailChange={setSSLEmail}
|
||||
onCertChange={setCertPEM}
|
||||
onKeyChange={setKeyPEM}
|
||||
onApplyNowChange={setApplyNow}
|
||||
onSave={handleSaveSSL}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<SSLCard
|
||||
ssl={ssl}
|
||||
sslEnabled={sslEnabled}
|
||||
sslMode={sslMode}
|
||||
sslTarget={sslTarget}
|
||||
sslEmail={sslEmail}
|
||||
certPEM={certPEM}
|
||||
keyPEM={keyPEM}
|
||||
applyNow={applyNow}
|
||||
savingSSL={savingSSL}
|
||||
onRefresh={fetchSSL}
|
||||
onEnabledChange={setSSLEnabled}
|
||||
onModeChange={handleSSLModeChange}
|
||||
onTargetChange={setSSLTarget}
|
||||
onEmailChange={setSSLEmail}
|
||||
onCertChange={setCertPEM}
|
||||
onKeyChange={setKeyPEM}
|
||||
onApplyNowChange={setApplyNow}
|
||||
onSave={handleSaveSSL}
|
||||
/>
|
||||
|
||||
<WebSSHOriginCard
|
||||
settings={webSSHOrigins}
|
||||
originsText={webSSHOriginsText}
|
||||
saving={savingWebSSHOrigins}
|
||||
onOriginsTextChange={setWebSSHOriginsText}
|
||||
onRefresh={fetchWebSSHOrigins}
|
||||
onSave={handleSaveWebSSHOrigins}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
|
||||
@@ -232,6 +281,49 @@ interface SSLCardProps {
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
interface WebSSHOriginCardProps {
|
||||
settings: WebSSHOriginSettings | null
|
||||
originsText: string
|
||||
saving: boolean
|
||||
onOriginsTextChange: (value: string) => void
|
||||
onRefresh: () => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
function WebSSHOriginCard(props: WebSSHOriginCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
|
||||
<Terminal className="h-4 w-4" />WebSSH Origin 白名单
|
||||
</h2>
|
||||
<button onClick={props.onRefresh} className="rounded-md border border-gray-200 p-1.5 text-gray-500 hover:bg-gray-50" title="刷新">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">允许的 Origin</label>
|
||||
<textarea
|
||||
value={props.originsText}
|
||||
onChange={(e) => props.onOriginsTextChange(e.target.value)}
|
||||
rows={5}
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 text-xs text-gray-600">
|
||||
<div className="truncate font-mono" title={props.settings?.current_origin || ''}>当前面板来源:{props.settings?.current_origin || '-'}</div>
|
||||
<div className="mt-1">默认允许当前面板来源和本机回环来源;额外域名每行填写一个完整 Origin。</div>
|
||||
</div>
|
||||
<button onClick={props.onSave} disabled={props.saving} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Upload className="h-4 w-4" />
|
||||
{props.saving ? '保存中...' : '保存 Origin 白名单'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SSLCard(props: SSLCardProps) {
|
||||
const selectedSSL = props.ssl?.mode_certificates?.[props.sslMode]
|
||||
const modeOptions: Array<{ value: SSLSettings['mode']; label: string }> = [
|
||||
|
||||
@@ -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
|
||||
@@ -393,6 +402,17 @@ export const getSSLSettings = () =>
|
||||
export const updateSSLSettings = (data: UpdateSSLSettingsRequest) =>
|
||||
api.put<APIResponse<SSLSettings>>('/ssl', data)
|
||||
|
||||
export interface WebSSHOriginSettings {
|
||||
origins: string[]
|
||||
current_origin?: string
|
||||
}
|
||||
|
||||
export const getWebSSHOriginSettings = () =>
|
||||
api.get<APIResponse<WebSSHOriginSettings>>('/webssh-origins')
|
||||
|
||||
export const updateWebSSHOriginSettings = (origins: string[]) =>
|
||||
api.put<APIResponse<WebSSHOriginSettings>>('/webssh-origins', { origins })
|
||||
|
||||
// Containers
|
||||
export const getContainers = () =>
|
||||
api.get<APIResponse<Container[]>>('/containers')
|
||||
@@ -415,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 } : {})
|
||||
|
||||
@@ -397,6 +397,13 @@ const exact: Record<string, string> = {
|
||||
'保存后自动重启服务并立即生效': 'Restart service automatically after saving',
|
||||
'保存中...': 'Saving...',
|
||||
'保存 SSL 设置': 'Save SSL Settings',
|
||||
'WebSSH Origin 白名单': 'WebSSH Origin Allowlist',
|
||||
'允许的 Origin': 'Allowed Origins',
|
||||
'当前面板来源:': 'Current panel origin:',
|
||||
'默认允许当前面板来源和本机回环来源;额外域名每行填写一个完整 Origin。': 'The current panel origin and local loopback origins are allowed by default. Add one full Origin per line.',
|
||||
'保存 Origin 白名单': 'Save Origin Allowlist',
|
||||
'Origin 白名单已保存': 'Origin allowlist saved',
|
||||
'Origin 白名单保存失败': 'Failed to save Origin allowlist',
|
||||
'登录日志': 'Login Logs',
|
||||
'首页': 'First',
|
||||
'上一页': 'Previous',
|
||||
|
||||
@@ -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