mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-07 22:24:42 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb0c4f999d | |||
| 0c9f420474 | |||
| 9a826add87 | |||
| 63611dc932 |
@@ -2,12 +2,10 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"clicd/internal/config"
|
"clicd/internal/config"
|
||||||
"clicd/internal/lxc"
|
"clicd/internal/lxc"
|
||||||
@@ -267,6 +265,10 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := validateCreateSSHAuth(cfg); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
if cfg.ExpiresAt != "" {
|
if cfg.ExpiresAt != "" {
|
||||||
expiresAt, ok := lxc.ParseExpiration(cfg.ExpiresAt)
|
expiresAt, ok := lxc.ParseExpiration(cfg.ExpiresAt)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -536,26 +538,7 @@ func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateSSHPassword(password string) error {
|
func validateSSHPassword(password string) error {
|
||||||
if len(password) < 8 || len(password) > 64 {
|
return lxc.ValidateCustomSSHPassword(password)
|
||||||
return fmt.Errorf("密码长度必须为 8-64 位")
|
|
||||||
}
|
|
||||||
hasLetter := false
|
|
||||||
hasDigit := false
|
|
||||||
for _, r := range password {
|
|
||||||
if unicode.IsSpace(r) {
|
|
||||||
return fmt.Errorf("密码不能包含空白字符")
|
|
||||||
}
|
|
||||||
if unicode.IsLetter(r) {
|
|
||||||
hasLetter = true
|
|
||||||
}
|
|
||||||
if unicode.IsDigit(r) {
|
|
||||||
hasDigit = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasLetter || !hasDigit {
|
|
||||||
return fmt.Errorf("密码至少需要包含字母和数字")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func addPortMapping(w http.ResponseWriter, r *http.Request, id int) {
|
func addPortMapping(w http.ResponseWriter, r *http.Request, id int) {
|
||||||
|
|||||||
@@ -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)
|
return lxcManager.CreateContainer(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateCreateSSHAuth(cfg lxc.ContainerConfig) error {
|
||||||
|
if cfg.Virtualization == config.VirtualizationKVM && kvm.IsWindowsImage(cfg.TemplateID) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := lxc.ResolveCreateSSHAccess(cfg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateReinstallSSHAuth(c *config.Container, templateID string, cfg lxc.ContainerConfig) error {
|
||||||
|
if c != nil && c.IsKVM() && kvm.IsWindowsImage(templateID) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
currentPassword := ""
|
||||||
|
if c != nil {
|
||||||
|
currentPassword = c.SSHPassword
|
||||||
|
}
|
||||||
|
_, err := lxc.ResolveReinstallSSHAccess(currentPassword, cfg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func startByRuntime(id int) error {
|
func startByRuntime(id int) error {
|
||||||
c := config.FindContainer(id)
|
c := config.FindContainer(id)
|
||||||
if c != nil && c.IsKVM() {
|
if c != nil && c.IsKVM() {
|
||||||
@@ -70,12 +90,12 @@ func destroyByRuntime(id int) error {
|
|||||||
return lxcManager.DestroyContainer(id)
|
return lxcManager.DestroyContainer(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func reinstallByRuntime(id int, templateID string) error {
|
func reinstallByRuntime(id int, templateID string, authConfig ...lxc.ContainerConfig) error {
|
||||||
c := config.FindContainer(id)
|
c := config.FindContainer(id)
|
||||||
if c != nil && c.IsKVM() {
|
if c != nil && c.IsKVM() {
|
||||||
return kvmManager.ReinstallContainer(id, templateID)
|
return kvmManager.ReinstallContainer(id, templateID, authConfig...)
|
||||||
}
|
}
|
||||||
return lxcManager.ReinstallContainer(id, templateID)
|
return lxcManager.ReinstallContainer(id, templateID, authConfig...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetPasswordByRuntime(id int, password string) (string, error) {
|
func resetPasswordByRuntime(id int, password string) (string, error) {
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ func (q *TaskQueue) enqueueTask(task *Task) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (q *TaskQueue) Enqueue(containerID int, containerName string, taskType TaskType, templateID string, cfg *lxc.ContainerConfig) []string {
|
func (q *TaskQueue) Enqueue(containerID int, containerName string, taskType TaskType, templateID string, cfg *lxc.ContainerConfig) []string {
|
||||||
|
return q.EnqueueWithAudit(containerID, containerName, taskType, templateID, cfg, "admin", "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *TaskQueue) EnqueueWithAudit(containerID int, containerName string, taskType TaskType, templateID string, cfg *lxc.ContainerConfig, user string, ip string, userAgent string) []string {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
@@ -88,6 +92,9 @@ func (q *TaskQueue) Enqueue(containerID int, containerName string, taskType Task
|
|||||||
Status: "pending",
|
Status: "pending",
|
||||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
TemplateID: templateID,
|
TemplateID: templateID,
|
||||||
|
User: user,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: userAgent,
|
||||||
}
|
}
|
||||||
if cfg != nil {
|
if cfg != nil {
|
||||||
task.Config = *cfg
|
task.Config = *cfg
|
||||||
@@ -343,9 +350,13 @@ func (q *TaskQueue) opWorker() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case TaskReinstall:
|
case TaskReinstall:
|
||||||
|
if lxc.HasSSHAuthOptions(task.Config) {
|
||||||
|
err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config)
|
||||||
|
} else {
|
||||||
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
|
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
auditUser := task.User
|
auditUser := task.User
|
||||||
@@ -472,6 +483,7 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
|
|||||||
|
|
||||||
var taskType TaskType
|
var taskType TaskType
|
||||||
var templateID string
|
var templateID string
|
||||||
|
var taskConfig *lxc.ContainerConfig
|
||||||
switch action {
|
switch action {
|
||||||
case "start":
|
case "start":
|
||||||
taskType = TaskStart
|
taskType = TaskStart
|
||||||
@@ -484,6 +496,9 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
|
|||||||
case "reinstall":
|
case "reinstall":
|
||||||
var req struct {
|
var req struct {
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
|
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||||
|
SSHPassword string `json:"ssh_password,omitempty"`
|
||||||
|
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
||||||
}
|
}
|
||||||
json.NewDecoder(r.Body).Decode(&req)
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
templateID = req.TemplateID
|
templateID = req.TemplateID
|
||||||
@@ -501,13 +516,26 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
|
|||||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
authCfg := lxc.ContainerConfig{
|
||||||
|
TemplateID: templateID,
|
||||||
|
SSHAuthMode: req.SSHAuthMode,
|
||||||
|
SSHPassword: req.SSHPassword,
|
||||||
|
SSHPublicKey: req.SSHPublicKey,
|
||||||
|
}
|
||||||
|
if lxc.HasSSHAuthOptions(authCfg) {
|
||||||
|
if err := validateReinstallSSHAuth(c, templateID, authCfg); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskConfig = &authCfg
|
||||||
|
}
|
||||||
taskType = TaskReinstall
|
taskType = TaskReinstall
|
||||||
default:
|
default:
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ids := globalQueue.EnqueueBatchWithAudit(taskType, []int{id}, templateID, user, ip, userAgent)
|
ids := globalQueue.EnqueueWithAudit(id, name, taskType, templateID, taskConfig, user, ip, userAgent)
|
||||||
jsonResponse(w, http.StatusAccepted, APIResponse{
|
jsonResponse(w, http.StatusAccepted, APIResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Task queued",
|
Message: "Task queued",
|
||||||
@@ -614,6 +642,10 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := validateCreateSSHAuth(req.Containers[i]); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
requestNames[name] = true
|
requestNames[name] = true
|
||||||
}
|
}
|
||||||
ids := globalQueue.EnqueueBatchCreateWithAudit(req.Containers, requestActor(r), clientIP(r), r.UserAgent())
|
ids := globalQueue.EnqueueBatchCreateWithAudit(req.Containers, requestActor(r), clientIP(r), r.UserAgent())
|
||||||
@@ -634,6 +666,9 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
Containers []int `json:"containers"`
|
Containers []int `json:"containers"`
|
||||||
TemplateID string `json:"template_id,omitempty"`
|
TemplateID string `json:"template_id,omitempty"`
|
||||||
|
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||||
|
SSHPassword string `json:"ssh_password,omitempty"`
|
||||||
|
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||||
@@ -642,6 +677,7 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var taskType TaskType
|
var taskType TaskType
|
||||||
var requiredScope string
|
var requiredScope string
|
||||||
|
var taskConfig *lxc.ContainerConfig
|
||||||
switch req.Action {
|
switch req.Action {
|
||||||
case "start":
|
case "start":
|
||||||
taskType = TaskStart
|
taskType = TaskStart
|
||||||
@@ -664,6 +700,15 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
authCfg := lxc.ContainerConfig{
|
||||||
|
TemplateID: req.TemplateID,
|
||||||
|
SSHAuthMode: req.SSHAuthMode,
|
||||||
|
SSHPassword: req.SSHPassword,
|
||||||
|
SSHPublicKey: req.SSHPublicKey,
|
||||||
|
}
|
||||||
|
if lxc.HasSSHAuthOptions(authCfg) {
|
||||||
|
taskConfig = &authCfg
|
||||||
|
}
|
||||||
taskType = TaskReinstall
|
taskType = TaskReinstall
|
||||||
requiredScope = "container:reinstall"
|
requiredScope = "container:reinstall"
|
||||||
default:
|
default:
|
||||||
@@ -679,9 +724,28 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to one or more containers"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to one or more containers"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if taskConfig != nil {
|
||||||
|
if err := validateReinstallSSHAuth(c, req.TemplateID, *taskConfig); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: c.Name + ": " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ids := globalQueue.EnqueueBatchWithAudit(taskType, req.Containers, req.TemplateID, requestActor(r), clientIP(r), r.UserAgent())
|
var ids []string
|
||||||
|
if taskConfig != nil {
|
||||||
|
for _, id := range req.Containers {
|
||||||
|
c := config.FindContainer(id)
|
||||||
|
name := ""
|
||||||
|
if c != nil {
|
||||||
|
name = c.Name
|
||||||
|
}
|
||||||
|
queued := globalQueue.EnqueueWithAudit(id, name, taskType, req.TemplateID, taskConfig, requestActor(r), clientIP(r), r.UserAgent())
|
||||||
|
ids = append(ids, queued...)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ids = globalQueue.EnqueueBatchWithAudit(taskType, req.Containers, req.TemplateID, requestActor(r), clientIP(r), r.UserAgent())
|
||||||
|
}
|
||||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
|
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"clicd/internal/config"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
@@ -17,19 +16,6 @@ var upgrader = websocket.Upgrader{
|
|||||||
if origin == "" {
|
if origin == "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
originURL, err := url.Parse(origin)
|
return config.IsOriginAllowed(origin, r.Host)
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
originHost := strings.ToLower(stripPort(originURL.Host))
|
|
||||||
requestHost := strings.ToLower(stripPort(r.Host))
|
|
||||||
return originHost != "" && originHost == requestHost
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
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"`
|
Snapshots []Snapshot `json:"snapshots"`
|
||||||
PublicIPv4Pool []PublicIPv4Assignment `json:"public_ipv4_pool"`
|
PublicIPv4Pool []PublicIPv4Assignment `json:"public_ipv4_pool"`
|
||||||
PublicIPv6Prefixes []PublicIPv6Prefix `json:"public_ipv6_prefixes"`
|
PublicIPv6Prefixes []PublicIPv6Prefix `json:"public_ipv6_prefixes"`
|
||||||
|
WebSSHAllowedOrigins []string `json:"webssh_allowed_origins"`
|
||||||
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
|
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
|
||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
SSL SSLConfig `json:"ssl"`
|
SSL SSLConfig `json:"ssl"`
|
||||||
@@ -497,6 +498,7 @@ func InitConfig() (*ClicdConfig, error) {
|
|||||||
Snapshots: []Snapshot{},
|
Snapshots: []Snapshot{},
|
||||||
PublicIPv4Pool: []PublicIPv4Assignment{},
|
PublicIPv4Pool: []PublicIPv4Assignment{},
|
||||||
PublicIPv6Prefixes: []PublicIPv6Prefix{},
|
PublicIPv6Prefixes: []PublicIPv6Prefix{},
|
||||||
|
WebSSHAllowedOrigins: []string{},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := SaveConfig(); err != nil {
|
if err := SaveConfig(); err != nil {
|
||||||
@@ -555,6 +557,13 @@ func normalizeConfigDefaults(dataDir string) bool {
|
|||||||
AppConfig.PublicIPv6Prefixes = make([]PublicIPv6Prefix, 0)
|
AppConfig.PublicIPv6Prefixes = make([]PublicIPv6Prefix, 0)
|
||||||
changed = true
|
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 {
|
if AppConfig.SubUsers == nil {
|
||||||
AppConfig.SubUsers = make([]SubUser, 0)
|
AppConfig.SubUsers = make([]SubUser, 0)
|
||||||
changed = true
|
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"`
|
AssignIPv6 bool `json:"assign_ipv6"`
|
||||||
IPv6Count int `json:"ipv6_count,omitempty"`
|
IPv6Count int `json:"ipv6_count,omitempty"`
|
||||||
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
||||||
|
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||||
|
SSHPassword string `json:"ssh_password,omitempty"`
|
||||||
|
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
||||||
ExpiresAt string `json:"expires_at"`
|
ExpiresAt string `json:"expires_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,6 +288,9 @@ func ensureSchema() error {
|
|||||||
cfg_assign_ipv6 INTEGER,
|
cfg_assign_ipv6 INTEGER,
|
||||||
cfg_ipv6_count INTEGER,
|
cfg_ipv6_count INTEGER,
|
||||||
cfg_ipv6_addresses TEXT,
|
cfg_ipv6_addresses TEXT,
|
||||||
|
cfg_ssh_auth_mode TEXT,
|
||||||
|
cfg_ssh_password TEXT,
|
||||||
|
cfg_ssh_public_key TEXT,
|
||||||
cfg_expires_at TEXT
|
cfg_expires_at TEXT
|
||||||
)`,
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS task_extra_ports (
|
`CREATE TABLE IF NOT EXISTS task_extra_ports (
|
||||||
@@ -344,6 +350,9 @@ func ensureSchemaMigrations() error {
|
|||||||
{"tasks", "cfg_assign_nat", "INTEGER"},
|
{"tasks", "cfg_assign_nat", "INTEGER"},
|
||||||
{"tasks", "cfg_ipv6_count", "INTEGER"},
|
{"tasks", "cfg_ipv6_count", "INTEGER"},
|
||||||
{"tasks", "cfg_ipv6_addresses", "TEXT"},
|
{"tasks", "cfg_ipv6_addresses", "TEXT"},
|
||||||
|
{"tasks", "cfg_ssh_auth_mode", "TEXT"},
|
||||||
|
{"tasks", "cfg_ssh_password", "TEXT"},
|
||||||
|
{"tasks", "cfg_ssh_public_key", "TEXT"},
|
||||||
{"port_mappings", "host_ip", "TEXT"},
|
{"port_mappings", "host_ip", "TEXT"},
|
||||||
{"container_public_ipv4s", "prefix_len", "INTEGER"},
|
{"container_public_ipv4s", "prefix_len", "INTEGER"},
|
||||||
{"container_public_ipv4s", "gateway", "TEXT"},
|
{"container_public_ipv4s", "gateway", "TEXT"},
|
||||||
@@ -426,6 +435,9 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
|||||||
if raw := strings.TrimSpace(meta["public_ipv6_prefixes"]); raw != "" {
|
if raw := strings.TrimSpace(meta["public_ipv6_prefixes"]); raw != "" {
|
||||||
_ = json.Unmarshal([]byte(raw), &cfg.PublicIPv6Prefixes)
|
_ = 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 {
|
if cfg.Containers, err = loadContainers(); err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
@@ -524,6 +536,7 @@ func saveMeta(tx *sql.Tx) error {
|
|||||||
sslCertificatesJSON, _ := json.Marshal(AppConfig.SSLCertificates)
|
sslCertificatesJSON, _ := json.Marshal(AppConfig.SSLCertificates)
|
||||||
publicIPv4PoolJSON, _ := json.Marshal(AppConfig.PublicIPv4Pool)
|
publicIPv4PoolJSON, _ := json.Marshal(AppConfig.PublicIPv4Pool)
|
||||||
publicIPv6PrefixesJSON, _ := json.Marshal(AppConfig.PublicIPv6Prefixes)
|
publicIPv6PrefixesJSON, _ := json.Marshal(AppConfig.PublicIPv6Prefixes)
|
||||||
|
webSSHAllowedOriginsJSON, _ := json.Marshal(AppConfig.WebSSHAllowedOrigins)
|
||||||
values := map[string]string{
|
values := map[string]string{
|
||||||
"admin_user": AppConfig.AdminUser,
|
"admin_user": AppConfig.AdminUser,
|
||||||
"admin_pass_hash": AppConfig.AdminPassHash,
|
"admin_pass_hash": AppConfig.AdminPassHash,
|
||||||
@@ -540,6 +553,7 @@ func saveMeta(tx *sql.Tx) error {
|
|||||||
"ssl_certificates": string(sslCertificatesJSON),
|
"ssl_certificates": string(sslCertificatesJSON),
|
||||||
"public_ipv4_pool": string(publicIPv4PoolJSON),
|
"public_ipv4_pool": string(publicIPv4PoolJSON),
|
||||||
"public_ipv6_prefixes": string(publicIPv6PrefixesJSON),
|
"public_ipv6_prefixes": string(publicIPv6PrefixesJSON),
|
||||||
|
"webssh_allowed_origins": string(webSSHAllowedOriginsJSON),
|
||||||
"schema_version": "1",
|
"schema_version": "1",
|
||||||
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
|
"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_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
|
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
|
||||||
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
|
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
|
||||||
cfg_expires_at
|
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_expires_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent,
|
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent,
|
||||||
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
|
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
|
||||||
cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
|
cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
|
||||||
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.SnapshotLimit,
|
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.SnapshotLimit,
|
||||||
boolInt(cfg.AssignIPv4), cfg.IPv4Count, encodeStringSlice(cfg.PublicIPv4s),
|
boolInt(cfg.AssignIPv4), cfg.IPv4Count, encodeStringSlice(cfg.PublicIPv4s),
|
||||||
boolInt(cfg.AssignIPv6), cfg.IPv6Count, encodeStringSlice(cfg.IPv6Addresses), cfg.ExpiresAt,
|
boolInt(cfg.AssignIPv6), cfg.IPv6Count, encodeStringSlice(cfg.IPv6Addresses),
|
||||||
|
cfg.SSHAuthMode, cfg.SSHPassword, cfg.SSHPublicKey, cfg.ExpiresAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -934,7 +949,7 @@ func loadTasks() ([]SavedTask, error) {
|
|||||||
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
|
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
|
||||||
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
|
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
|
||||||
cfg_expires_at
|
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_expires_at
|
||||||
FROM tasks ORDER BY created_at, id`)
|
FROM tasks ORDER BY created_at, id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -947,13 +962,15 @@ func loadTasks() ([]SavedTask, error) {
|
|||||||
var cfg savedTaskConfig
|
var cfg savedTaskConfig
|
||||||
var assignIPv4, assignIPv6 int
|
var assignIPv4, assignIPv6 int
|
||||||
var ip, userAgent, publicIPv4s, ipv6Addresses sql.NullString
|
var ip, userAgent, publicIPv4s, ipv6Addresses sql.NullString
|
||||||
|
var sshAuthMode, sshPassword, sshPublicKey sql.NullString
|
||||||
var assignNAT, ipv4Count, ipv6Count sql.NullInt64
|
var assignNAT, ipv4Count, ipv6Count sql.NullInt64
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent,
|
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent,
|
||||||
&cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
|
&cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
|
||||||
&cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
|
&cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
|
||||||
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &assignNAT, &cfg.SnapshotLimit,
|
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &assignNAT, &cfg.SnapshotLimit,
|
||||||
&assignIPv4, &ipv4Count, &publicIPv4s, &assignIPv6, &ipv6Count, &ipv6Addresses, &cfg.ExpiresAt,
|
&assignIPv4, &ipv4Count, &publicIPv4s, &assignIPv6, &ipv6Count, &ipv6Addresses,
|
||||||
|
&sshAuthMode, &sshPassword, &sshPublicKey, &cfg.ExpiresAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -973,6 +990,9 @@ func loadTasks() ([]SavedTask, error) {
|
|||||||
cfg.IPv6Count = int(ipv6Count.Int64)
|
cfg.IPv6Count = int(ipv6Count.Int64)
|
||||||
}
|
}
|
||||||
cfg.IPv6Addresses = decodeStringSlice(ipv6Addresses.String)
|
cfg.IPv6Addresses = decodeStringSlice(ipv6Addresses.String)
|
||||||
|
cfg.SSHAuthMode = sshAuthMode.String
|
||||||
|
cfg.SSHPassword = sshPassword.String
|
||||||
|
cfg.SSHPublicKey = sshPublicKey.String
|
||||||
result = append(result, t)
|
result = append(result, t)
|
||||||
configs = append(configs, cfg)
|
configs = append(configs, cfg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -406,6 +406,15 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
seedPath := filepath.Join(m.instanceDir(vmName), "seed.iso")
|
seedPath := filepath.Join(m.instanceDir(vmName), "seed.iso")
|
||||||
mac := randomMAC()
|
mac := randomMAC()
|
||||||
sshPassword := generateRandomString(16)
|
sshPassword := generateRandomString(16)
|
||||||
|
sshPublicKey := ""
|
||||||
|
if !IsWindowsImage(image.ID) {
|
||||||
|
sshAccess, err := lxc.ResolveCreateSSHAccess(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sshPassword = sshAccess.Password
|
||||||
|
sshPublicKey = sshAccess.PublicKey
|
||||||
|
}
|
||||||
publicIPv4s, err := lxc.AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4)
|
publicIPv4s, err := lxc.AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -455,7 +464,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil {
|
if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, mac, ipv6List, *image); err != nil {
|
if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, sshPublicKey, mac, ipv6List, *image); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps, image.Desktop != "")
|
xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps, image.Desktop != "")
|
||||||
@@ -677,7 +686,7 @@ func (m *Manager) DestroyContainer(id int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...lxc.ContainerConfig) error {
|
||||||
c := config.FindContainer(id)
|
c := config.FindContainer(id)
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return fmt.Errorf("container not found: %d", id)
|
return fmt.Errorf("container not found: %d", id)
|
||||||
@@ -709,6 +718,19 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
|||||||
SnapshotLimit: c.SnapshotLimit,
|
SnapshotLimit: c.SnapshotLimit,
|
||||||
ExpiresAt: c.ExpiresAt,
|
ExpiresAt: c.ExpiresAt,
|
||||||
}
|
}
|
||||||
|
if len(authConfig) > 0 && lxc.HasSSHAuthOptions(authConfig[0]) && !IsWindowsImage(templateID) {
|
||||||
|
sshAccess, err := lxc.ResolveReinstallSSHAccess(c.SSHPassword, authConfig[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if sshAccess.PublicKey != "" {
|
||||||
|
cfg.SSHAuthMode = lxc.SSHAuthKey
|
||||||
|
cfg.SSHPublicKey = sshAccess.PublicKey
|
||||||
|
} else {
|
||||||
|
cfg.SSHAuthMode = lxc.SSHAuthPassword
|
||||||
|
}
|
||||||
|
cfg.SSHPassword = sshAccess.Password
|
||||||
|
}
|
||||||
next, err := m.defineContainer(id, name, cfg, false)
|
next, err := m.defineContainer(id, name, cfg, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1788,8 +1810,8 @@ func shellQuoteWindows(value string) string {
|
|||||||
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
|
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func createSeedISO(seedPath, instanceID, hostname, password, mac string, ipv6s []string, image Image) error {
|
func createSeedISO(seedPath, instanceID, hostname, password, publicKey, mac string, ipv6s []string, image Image) error {
|
||||||
guestSetup := kvmSSHSetupScript(password)
|
guestSetup := kvmSSHSetupScript(password, publicKey)
|
||||||
if desktopSetup := kvmDesktopSetupScript(image); desktopSetup != "" {
|
if desktopSetup := kvmDesktopSetupScript(image); desktopSetup != "" {
|
||||||
guestSetup += "\n" + desktopSetup
|
guestSetup += "\n" + desktopSetup
|
||||||
}
|
}
|
||||||
@@ -1797,6 +1819,12 @@ func createSeedISO(seedPath, instanceID, hostname, password, mac string, ipv6s [
|
|||||||
if len(ipv6s) > 0 {
|
if len(ipv6s) > 0 {
|
||||||
guestSetup += "\n" + kvmIPv6SetupScript(ipv6s)
|
guestSetup += "\n" + kvmIPv6SetupScript(ipv6s)
|
||||||
}
|
}
|
||||||
|
authorizedKeys := ""
|
||||||
|
if publicKey != "" {
|
||||||
|
authorizedKeys = fmt.Sprintf(`
|
||||||
|
ssh_authorized_keys:
|
||||||
|
- %s`, yamlSingleQuote(publicKey))
|
||||||
|
}
|
||||||
setupScript := indentScript(guestSetup, 4)
|
setupScript := indentScript(guestSetup, 4)
|
||||||
userData := fmt.Sprintf(`#cloud-config
|
userData := fmt.Sprintf(`#cloud-config
|
||||||
preserve_hostname: false
|
preserve_hostname: false
|
||||||
@@ -1812,11 +1840,11 @@ chpasswd:
|
|||||||
type: text
|
type: text
|
||||||
users:
|
users:
|
||||||
- name: root
|
- name: root
|
||||||
lock_passwd: false
|
lock_passwd: false%s
|
||||||
runcmd:
|
runcmd:
|
||||||
- |
|
- |
|
||||||
%s
|
%s
|
||||||
`, hostname, password, setupScript)
|
`, hostname, password, authorizedKeys, setupScript)
|
||||||
metaData := fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", instanceID, hostname)
|
metaData := fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", instanceID, hostname)
|
||||||
ipv6Block := ""
|
ipv6Block := ""
|
||||||
if len(ipv6s) > 0 {
|
if len(ipv6s) > 0 {
|
||||||
@@ -1907,6 +1935,10 @@ func indentScript(script string, spaces int) string {
|
|||||||
return strings.Join(lines, "\n")
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func yamlSingleQuote(value string) string {
|
||||||
|
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
func isKVMDesktopTemplate(templateID string) bool {
|
func isKVMDesktopTemplate(templateID string) bool {
|
||||||
image := FindImage(templateID)
|
image := FindImage(templateID)
|
||||||
return image != nil && image.Desktop != ""
|
return image != nil && image.Desktop != ""
|
||||||
@@ -2368,9 +2400,14 @@ func runKVMSSHScript(client *ssh.Client, script string, description string, time
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func kvmSSHSetupScript(password string) string {
|
func kvmSSHSetupScript(password string, publicKeys ...string) string {
|
||||||
|
publicKey := ""
|
||||||
|
if len(publicKeys) > 0 {
|
||||||
|
publicKey = strings.TrimSpace(publicKeys[0])
|
||||||
|
}
|
||||||
return `set -u
|
return `set -u
|
||||||
ROOT_PASSWORD=` + shellQuote(password) + `
|
ROOT_PASSWORD=` + shellQuote(password) + `
|
||||||
|
SSH_PUBLIC_KEY=` + shellQuote(publicKey) + `
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
if command -v apt-get >/dev/null 2>&1; then
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
if ! command -v sshd >/dev/null 2>&1 || ! command -v qemu-ga >/dev/null 2>&1; then
|
if ! command -v sshd >/dev/null 2>&1 || ! command -v qemu-ga >/dev/null 2>&1; then
|
||||||
@@ -2397,6 +2434,7 @@ fi
|
|||||||
mkdir -p /etc/ssh/sshd_config.d
|
mkdir -p /etc/ssh/sshd_config.d
|
||||||
cat > /etc/ssh/sshd_config.d/99-clicd-root.conf <<'EOF'
|
cat > /etc/ssh/sshd_config.d/99-clicd-root.conf <<'EOF'
|
||||||
PermitRootLogin yes
|
PermitRootLogin yes
|
||||||
|
PubkeyAuthentication yes
|
||||||
PasswordAuthentication yes
|
PasswordAuthentication yes
|
||||||
KbdInteractiveAuthentication yes
|
KbdInteractiveAuthentication yes
|
||||||
ChallengeResponseAuthentication yes
|
ChallengeResponseAuthentication yes
|
||||||
@@ -2404,11 +2442,21 @@ EOF
|
|||||||
if [ -f /etc/ssh/sshd_config ]; then
|
if [ -f /etc/ssh/sshd_config ]; then
|
||||||
grep -q '^PermitRootLogin ' /etc/ssh/sshd_config && sed -i 's/^PermitRootLogin .*/PermitRootLogin yes/' /etc/ssh/sshd_config || printf '\nPermitRootLogin yes\n' >> /etc/ssh/sshd_config
|
grep -q '^PermitRootLogin ' /etc/ssh/sshd_config && sed -i 's/^PermitRootLogin .*/PermitRootLogin yes/' /etc/ssh/sshd_config || printf '\nPermitRootLogin yes\n' >> /etc/ssh/sshd_config
|
||||||
grep -q '^#PermitRootLogin ' /etc/ssh/sshd_config && sed -i 's/^#PermitRootLogin .*/PermitRootLogin yes/' /etc/ssh/sshd_config || true
|
grep -q '^#PermitRootLogin ' /etc/ssh/sshd_config && sed -i 's/^#PermitRootLogin .*/PermitRootLogin yes/' /etc/ssh/sshd_config || true
|
||||||
|
grep -q '^PubkeyAuthentication ' /etc/ssh/sshd_config && sed -i 's/^PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config || printf '\nPubkeyAuthentication yes\n' >> /etc/ssh/sshd_config
|
||||||
|
grep -q '^#PubkeyAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config || true
|
||||||
grep -q '^PasswordAuthentication ' /etc/ssh/sshd_config && sed -i 's/^PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config || printf '\nPasswordAuthentication yes\n' >> /etc/ssh/sshd_config
|
grep -q '^PasswordAuthentication ' /etc/ssh/sshd_config && sed -i 's/^PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config || printf '\nPasswordAuthentication yes\n' >> /etc/ssh/sshd_config
|
||||||
grep -q '^#PasswordAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config || true
|
grep -q '^#PasswordAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config || true
|
||||||
grep -q '^KbdInteractiveAuthentication ' /etc/ssh/sshd_config && sed -i 's/^KbdInteractiveAuthentication .*/KbdInteractiveAuthentication yes/' /etc/ssh/sshd_config || printf '\nKbdInteractiveAuthentication yes\n' >> /etc/ssh/sshd_config
|
grep -q '^KbdInteractiveAuthentication ' /etc/ssh/sshd_config && sed -i 's/^KbdInteractiveAuthentication .*/KbdInteractiveAuthentication yes/' /etc/ssh/sshd_config || printf '\nKbdInteractiveAuthentication yes\n' >> /etc/ssh/sshd_config
|
||||||
grep -q '^#KbdInteractiveAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#KbdInteractiveAuthentication .*/KbdInteractiveAuthentication yes/' /etc/ssh/sshd_config || true
|
grep -q '^#KbdInteractiveAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#KbdInteractiveAuthentication .*/KbdInteractiveAuthentication yes/' /etc/ssh/sshd_config || true
|
||||||
fi
|
fi
|
||||||
|
if [ -n "$SSH_PUBLIC_KEY" ]; then
|
||||||
|
mkdir -p /root/.ssh
|
||||||
|
touch /root/.ssh/authorized_keys
|
||||||
|
grep -qxF "$SSH_PUBLIC_KEY" /root/.ssh/authorized_keys 2>/dev/null || printf '%s\n' "$SSH_PUBLIC_KEY" >> /root/.ssh/authorized_keys
|
||||||
|
chmod 700 /root/.ssh
|
||||||
|
chmod 600 /root/.ssh/authorized_keys
|
||||||
|
chown -R root:root /root/.ssh 2>/dev/null || true
|
||||||
|
fi
|
||||||
if command -v chpasswd >/dev/null 2>&1; then
|
if command -v chpasswd >/dev/null 2>&1; then
|
||||||
printf 'root:%s\n' "$ROOT_PASSWORD" | chpasswd || true
|
printf 'root:%s\n' "$ROOT_PASSWORD" | chpasswd || true
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -241,6 +241,9 @@ type ContainerConfig struct {
|
|||||||
AssignIPv6 bool `json:"assign_ipv6"`
|
AssignIPv6 bool `json:"assign_ipv6"`
|
||||||
IPv6Count int `json:"ipv6_count,omitempty"`
|
IPv6Count int `json:"ipv6_count,omitempty"`
|
||||||
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
||||||
|
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||||
|
SSHPassword string `json:"ssh_password,omitempty"`
|
||||||
|
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
||||||
ExpiresAt string `json:"expires_at"`
|
ExpiresAt string `json:"expires_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +273,10 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
if config.FindContainerByName(cfg.Name) != nil {
|
if config.FindContainerByName(cfg.Name) != nil {
|
||||||
return fmt.Errorf("container name already exists: %s", cfg.Name)
|
return fmt.Errorf("container name already exists: %s", cfg.Name)
|
||||||
}
|
}
|
||||||
|
sshAccess, err := ResolveCreateSSHAccess(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Allocate ID and build LXC name
|
// Allocate ID and build LXC name
|
||||||
id := config.AllocateContainerID()
|
id := config.AllocateContainerID()
|
||||||
@@ -329,7 +336,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sshPassword := generateRandomString(16)
|
sshPassword := sshAccess.Password
|
||||||
|
|
||||||
sshPort := 0
|
sshPort := 0
|
||||||
portMappings := []config.PortMapping{}
|
portMappings := []config.PortMapping{}
|
||||||
@@ -420,6 +427,13 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
if err := m.preconfigureSSH(rootfsPath, cfg.TemplateID); err != nil {
|
if err := m.preconfigureSSH(rootfsPath, cfg.TemplateID); err != nil {
|
||||||
fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err)
|
fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err)
|
||||||
}
|
}
|
||||||
|
if sshAccess.PublicKey != "" {
|
||||||
|
if err := m.installRootAuthorizedKey(rootfsPath, sshAccess.PublicKey); err != nil {
|
||||||
|
_ = m.cleanupContainerStorage(lxcName)
|
||||||
|
config.RemoveContainer(id)
|
||||||
|
return fmt.Errorf("failed to install SSH public key: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
|
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
|
||||||
_ = m.cleanupContainerStorage(lxcName)
|
_ = m.cleanupContainerStorage(lxcName)
|
||||||
@@ -1931,6 +1945,7 @@ ssh-keygen -A >/dev/null 2>&1 || true
|
|||||||
|
|
||||||
cat >/etc/ssh/sshd_config.d/99-clicd.conf <<'EOF'
|
cat >/etc/ssh/sshd_config.d/99-clicd.conf <<'EOF'
|
||||||
PermitRootLogin yes
|
PermitRootLogin yes
|
||||||
|
PubkeyAuthentication yes
|
||||||
PasswordAuthentication yes
|
PasswordAuthentication yes
|
||||||
KbdInteractiveAuthentication no
|
KbdInteractiveAuthentication no
|
||||||
ChallengeResponseAuthentication no
|
ChallengeResponseAuthentication no
|
||||||
@@ -1938,6 +1953,7 @@ UsePAM no
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
set_sshd_option PermitRootLogin yes
|
set_sshd_option PermitRootLogin yes
|
||||||
|
set_sshd_option PubkeyAuthentication yes
|
||||||
set_sshd_option PasswordAuthentication yes
|
set_sshd_option PasswordAuthentication yes
|
||||||
set_sshd_option KbdInteractiveAuthentication no
|
set_sshd_option KbdInteractiveAuthentication no
|
||||||
set_sshd_option ChallengeResponseAuthentication no
|
set_sshd_option ChallengeResponseAuthentication no
|
||||||
@@ -2102,6 +2118,45 @@ func (m *Manager) setRootfsPassword(rootfsPath, password string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) installRootAuthorizedKey(rootfsPath, publicKey string) error {
|
||||||
|
key, err := NormalizeSSHPublicKey(publicKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if key == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sshDir := filepath.Join(rootfsPath, "root", ".ssh")
|
||||||
|
if err := os.MkdirAll(sshDir, 0700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
authPath := filepath.Join(sshDir, "authorized_keys")
|
||||||
|
existing, _ := os.ReadFile(authPath)
|
||||||
|
lines := strings.Split(string(existing), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.TrimSpace(line) == key {
|
||||||
|
_ = os.Chmod(sshDir, 0700)
|
||||||
|
_ = os.Chmod(authPath, 0600)
|
||||||
|
_ = os.Chown(sshDir, 0, 0)
|
||||||
|
_ = os.Chown(authPath, 0, 0)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content := strings.TrimRight(string(existing), "\r\n")
|
||||||
|
if content != "" {
|
||||||
|
content += "\n"
|
||||||
|
}
|
||||||
|
content += key + "\n"
|
||||||
|
if err := os.WriteFile(authPath, []byte(content), 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = os.Chmod(sshDir, 0700)
|
||||||
|
_ = os.Chmod(authPath, 0600)
|
||||||
|
_ = os.Chown(sshDir, 0, 0)
|
||||||
|
_ = os.Chown(authPath, 0, 0)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func safeRootfsCommandArgs(args []string) ([]string, error) {
|
func safeRootfsCommandArgs(args []string) ([]string, error) {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return nil, fmt.Errorf("empty rootfs command")
|
return nil, fmt.Errorf("empty rootfs command")
|
||||||
@@ -2499,7 +2554,7 @@ func copyRootfsContents(src, dst string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ReinstallContainer reinstalls the container OS
|
// ReinstallContainer reinstalls the container OS
|
||||||
func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...ContainerConfig) error {
|
||||||
c := config.FindContainer(id)
|
c := config.FindContainer(id)
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return fmt.Errorf("container not found: %d", id)
|
return fmt.Errorf("container not found: %d", id)
|
||||||
@@ -2509,6 +2564,14 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
|||||||
if tmpl == nil {
|
if tmpl == nil {
|
||||||
return fmt.Errorf("template not found: %s", templateID)
|
return fmt.Errorf("template not found: %s", templateID)
|
||||||
}
|
}
|
||||||
|
authCfg := ContainerConfig{SSHAuthMode: SSHAuthKeep}
|
||||||
|
if len(authConfig) > 0 {
|
||||||
|
authCfg = authConfig[0]
|
||||||
|
}
|
||||||
|
sshAccess, err := ResolveReinstallSSHAccess(c.SSHPassword, authCfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
lxcName := c.LxcName()
|
lxcName := c.LxcName()
|
||||||
|
|
||||||
@@ -2562,12 +2625,15 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
|||||||
fmt.Printf("Warning: failed to install IPv6 init in %s after reinstall: %v\n", lxcName, err)
|
fmt.Printf("Warning: failed to install IPv6 init in %s after reinstall: %v\n", lxcName, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if c.SSHPassword == "" {
|
c.SSHPassword = sshAccess.Password
|
||||||
c.SSHPassword = generateRandomString(16)
|
|
||||||
}
|
|
||||||
if err := m.preconfigureSSH(rootfsPath, templateID); err != nil {
|
if err := m.preconfigureSSH(rootfsPath, templateID); err != nil {
|
||||||
fmt.Printf("Warning: failed to pre-configure SSH in %s after reinstall: %v\n", lxcName, err)
|
fmt.Printf("Warning: failed to pre-configure SSH in %s after reinstall: %v\n", lxcName, err)
|
||||||
}
|
}
|
||||||
|
if sshAccess.PublicKey != "" {
|
||||||
|
if err := m.installRootAuthorizedKey(rootfsPath, sshAccess.PublicKey); err != nil {
|
||||||
|
return fmt.Errorf("failed to install SSH public key: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
|
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"clicd/internal/api"
|
"clicd/internal/api"
|
||||||
@@ -19,7 +17,7 @@ var webFS http.FileSystem
|
|||||||
// corsMiddleware adds CORS headers
|
// corsMiddleware adds CORS headers
|
||||||
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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("Access-Control-Allow-Origin", origin)
|
||||||
w.Header().Set("Vary", "Origin")
|
w.Header().Set("Vary", "Origin")
|
||||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
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")
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
|
||||||
|
|
||||||
if r.Method == http.MethodOptions {
|
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)
|
w.WriteHeader(http.StatusForbidden)
|
||||||
return
|
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
|
// setupRoutes configures API and static routes
|
||||||
func setupRoutes(mux *http.ServeMux) {
|
func setupRoutes(mux *http.ServeMux) {
|
||||||
// API routes
|
// API routes
|
||||||
@@ -78,6 +48,7 @@ func setupRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange)))
|
mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange)))
|
||||||
mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs)))
|
mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs)))
|
||||||
mux.HandleFunc("/api/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
|
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", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
|
||||||
mux.HandleFunc("/api/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
|
mux.HandleFunc("/api/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
|
||||||
mux.HandleFunc("/api/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
|
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/audit-logs", corsMiddleware(api.AuthMiddleware(api.HandleAuditLogs)))
|
||||||
mux.HandleFunc("/api/v1/login-logs", corsMiddleware(api.AuthMiddleware(api.HandleLoginLogs)))
|
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/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/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/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))))
|
mux.HandleFunc("/api/v1/security/logs", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityLogs))))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package version
|
package version
|
||||||
|
|
||||||
var (
|
var (
|
||||||
Version = "1.1.10"
|
Version = "1.1.12"
|
||||||
Repo = "MengMengCode/CLICD"
|
Repo = "MengMengCode/CLICD"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "clicd-frontend",
|
"name": "clicd-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.10",
|
"version": "1.1.12",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
import { CalendarClock, X } from 'lucide-react'
|
import { CalendarClock, RefreshCw, X } from 'lucide-react'
|
||||||
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, CreateContainerRequest, HostInfo, IPv6Status, Template } from '../services/api'
|
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, CreateContainerRequest, HostInfo, IPv6Status, Template } from '../services/api'
|
||||||
import { useDialog } from './Dialog'
|
import { useDialog } from './Dialog'
|
||||||
import { useLanguage, type Language } from '../contexts/LanguageContext'
|
import { useLanguage, type Language } from '../contexts/LanguageContext'
|
||||||
|
import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type SSHAuthMode } from '../utils/sshAuth'
|
||||||
|
|
||||||
interface CreateContainerModalProps {
|
interface CreateContainerModalProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
@@ -35,6 +36,9 @@ const defaultForm: CreateContainerRequest = {
|
|||||||
assign_ipv6: false,
|
assign_ipv6: false,
|
||||||
ipv6_count: 1,
|
ipv6_count: 1,
|
||||||
ipv6_addresses: [],
|
ipv6_addresses: [],
|
||||||
|
ssh_auth_mode: 'auto_password',
|
||||||
|
ssh_password: '',
|
||||||
|
ssh_public_key: '',
|
||||||
expires_at: '',
|
expires_at: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +98,8 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
|
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
|
||||||
const natEnabled = form.assign_nat !== false
|
const natEnabled = form.assign_nat !== false
|
||||||
const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2) : 0
|
const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2) : 0
|
||||||
|
const linuxTemplate = !isWindowsTemplate(form.template_id)
|
||||||
|
const sshAuthMode = (form.ssh_auth_mode || 'auto_password') as SSHAuthMode
|
||||||
|
|
||||||
const autoPorts = useMemo(() => {
|
const autoPorts = useMemo(() => {
|
||||||
if (!natEnabled) return []
|
if (!natEnabled) return []
|
||||||
@@ -148,6 +154,12 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authError = validateSSHAuthInputs(form)
|
||||||
|
if (authError) {
|
||||||
|
dialog.alert('登录方式有误', authError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const boundedForm = normalizeCreateForm(form)
|
const boundedForm = normalizeCreateForm(form)
|
||||||
const wantsNAT = boundedForm.assign_nat !== false
|
const wantsNAT = boundedForm.assign_nat !== false
|
||||||
|
|
||||||
@@ -254,6 +266,55 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
|
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
{linuxTemplate && (
|
||||||
|
<div className="rounded-md border border-gray-200 bg-white px-3 py-3 text-sm">
|
||||||
|
<div className="mb-2 font-medium text-gray-800">登录方式</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{([
|
||||||
|
['auto_password', '自动生成密码'],
|
||||||
|
['password', '自定义密码'],
|
||||||
|
['key', 'SSH Key'],
|
||||||
|
] as Array<[SSHAuthMode, string]>).map(([mode, label]) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm({ ...form, ssh_auth_mode: mode })}
|
||||||
|
className={`rounded-md border px-3 py-2 text-xs font-medium transition-colors ${sshAuthMode === mode ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{sshAuthMode === 'password' && (
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.ssh_password || ''}
|
||||||
|
onChange={(event) => setForm({ ...form, ssh_password: event.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="RootPass123"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm({ ...form, ssh_password: generateSSHPassword() })}
|
||||||
|
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-gray-300 text-gray-600 hover:bg-gray-50"
|
||||||
|
title="生成密码"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sshAuthMode === 'key' && (
|
||||||
|
<textarea
|
||||||
|
value={form.ssh_public_key || ''}
|
||||||
|
onChange={(event) => setForm({ ...form, ssh_public_key: event.target.value })}
|
||||||
|
className={`${inputClass} mt-3 min-h-20 resize-y font-mono text-xs`}
|
||||||
|
placeholder="ssh-ed25519 AAAA..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={`rounded-md border px-3 py-2 text-sm ${ipv4Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
|
<div className={`rounded-md border px-3 py-2 text-sm ${ipv4Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
|
||||||
<label className="flex items-start gap-3">
|
<label className="flex items-start gap-3">
|
||||||
<input
|
<input
|
||||||
@@ -620,6 +681,8 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
|
|||||||
const wantsNAT = normalized.assign_nat !== false
|
const wantsNAT = normalized.assign_nat !== false
|
||||||
const wantsIPv4 = !!normalized.assign_ipv4
|
const wantsIPv4 = !!normalized.assign_ipv4
|
||||||
const wantsIPv6 = !!normalized.assign_ipv6
|
const wantsIPv6 = !!normalized.assign_ipv6
|
||||||
|
const linuxTemplate = !isWindowsTemplate(normalized.template_id)
|
||||||
|
const sshAuthMode = linuxTemplate ? (normalized.ssh_auth_mode || 'auto_password') : 'auto_password'
|
||||||
return {
|
return {
|
||||||
...normalized,
|
...normalized,
|
||||||
vcpu: normalized.virtualization === 'kvm' ? Math.round(normalized.vcpu) : normalizeLXCvCPU(normalized.vcpu),
|
vcpu: normalized.virtualization === 'kvm' ? Math.round(normalized.vcpu) : normalizeLXCvCPU(normalized.vcpu),
|
||||||
@@ -633,10 +696,22 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
|
|||||||
assign_ipv6: wantsIPv6,
|
assign_ipv6: wantsIPv6,
|
||||||
ipv6_count: wantsIPv6 ? clampInt(normalized.ipv6_count || 1, 1, 64, 1) : 0,
|
ipv6_count: wantsIPv6 ? clampInt(normalized.ipv6_count || 1, 1, 64, 1) : 0,
|
||||||
ipv6_addresses: wantsIPv6 ? (normalized.ipv6_addresses || []) : [],
|
ipv6_addresses: wantsIPv6 ? (normalized.ipv6_addresses || []) : [],
|
||||||
|
ssh_auth_mode: sshAuthMode,
|
||||||
|
ssh_password: linuxTemplate && sshAuthMode === 'password' ? (normalized.ssh_password || '').trim() : '',
|
||||||
|
ssh_public_key: linuxTemplate && sshAuthMode === 'key' ? (normalized.ssh_public_key || '').trim() : '',
|
||||||
snapshot_limit: clampInt(normalized.snapshot_limit, 1, undefined, 3),
|
snapshot_limit: clampInt(normalized.snapshot_limit, 1, undefined, 3),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateSSHAuthInputs(form: CreateContainerRequest) {
|
||||||
|
if (isWindowsTemplate(form.template_id)) return ''
|
||||||
|
const mode = form.ssh_auth_mode || 'auto_password'
|
||||||
|
if (mode === 'password') return sshPasswordError((form.ssh_password || '').trim())
|
||||||
|
if (mode === 'key') return sshPublicKeyError(form.ssh_public_key || '')
|
||||||
|
if (mode !== 'auto_password') return '请选择登录方式'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
function applyTemplateDefaults(form: CreateContainerRequest): CreateContainerRequest {
|
function applyTemplateDefaults(form: CreateContainerRequest): CreateContainerRequest {
|
||||||
if (!isWindowsTemplate(form.template_id)) return form
|
if (!isWindowsTemplate(form.template_id)) return form
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -734,9 +734,17 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
|||||||
port_mapping_count: 2,
|
port_mapping_count: 2,
|
||||||
snapshot_limit: 1,
|
snapshot_limit: 1,
|
||||||
assign_ipv6: true,
|
assign_ipv6: true,
|
||||||
|
ssh_auth_mode: 'auto_password',
|
||||||
|
ssh_password: '',
|
||||||
|
ssh_public_key: '',
|
||||||
expires_at: '',
|
expires_at: '',
|
||||||
},
|
},
|
||||||
'POST /api/v1/containers/{id}/reinstall': { template_id: 'debian-bookworm' },
|
'POST /api/v1/containers/{id}/reinstall': {
|
||||||
|
template_id: 'debian-bookworm',
|
||||||
|
ssh_auth_mode: 'keep',
|
||||||
|
ssh_password: '',
|
||||||
|
ssh_public_key: '',
|
||||||
|
},
|
||||||
'PUT /api/v1/containers/{id}/traffic-limit': {
|
'PUT /api/v1/containers/{id}/traffic-limit': {
|
||||||
traffic_mode: 'total',
|
traffic_mode: 'total',
|
||||||
monthly_traffic_gb: 100,
|
monthly_traffic_gb: 100,
|
||||||
@@ -788,6 +796,8 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
|||||||
port_mapping_count: 2,
|
port_mapping_count: 2,
|
||||||
snapshot_limit: 1,
|
snapshot_limit: 1,
|
||||||
assign_ipv6: true,
|
assign_ipv6: true,
|
||||||
|
ssh_auth_mode: 'key',
|
||||||
|
ssh_public_key: 'ssh-ed25519 AAAA... user@example',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ import ResourceStatsPanel, {
|
|||||||
StatsRangeKey,
|
StatsRangeKey,
|
||||||
statsRanges,
|
statsRanges,
|
||||||
} from '../components/ResourceStatsPanel'
|
} from '../components/ResourceStatsPanel'
|
||||||
|
import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type ReinstallSSHAuthMode } from '../utils/sshAuth'
|
||||||
|
|
||||||
const PUBLIC_HOST = window.location.hostname
|
const PUBLIC_HOST = window.location.hostname
|
||||||
const inputClass = 'w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black'
|
const inputClass = 'w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black'
|
||||||
@@ -135,6 +136,9 @@ export default function ContainerDetail() {
|
|||||||
const [showReinstall, setShowReinstall] = useState(false)
|
const [showReinstall, setShowReinstall] = useState(false)
|
||||||
const [templates, setTemplates] = useState<Template[]>([])
|
const [templates, setTemplates] = useState<Template[]>([])
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState('')
|
const [selectedTemplate, setSelectedTemplate] = useState('')
|
||||||
|
const [reinstallAuthMode, setReinstallAuthMode] = useState<ReinstallSSHAuthMode>('keep')
|
||||||
|
const [reinstallPasswordDraft, setReinstallPasswordDraft] = useState('')
|
||||||
|
const [reinstallPublicKeyDraft, setReinstallPublicKeyDraft] = useState('')
|
||||||
const [reinstalling, setReinstalling] = useState(false)
|
const [reinstalling, setReinstalling] = useState(false)
|
||||||
const [traffic, setTraffic] = useState<TrafficInfo | null>(null)
|
const [traffic, setTraffic] = useState<TrafficInfo | null>(null)
|
||||||
const [subUser, setSubUser] = useState<SubUser | null>(null)
|
const [subUser, setSubUser] = useState<SubUser | null>(null)
|
||||||
@@ -413,6 +417,9 @@ export default function ContainerDetail() {
|
|||||||
setTemplates(res.data.data)
|
setTemplates(res.data.data)
|
||||||
setSelectedTemplate(res.data.data[0]?.id || '')
|
setSelectedTemplate(res.data.data[0]?.id || '')
|
||||||
}
|
}
|
||||||
|
setReinstallAuthMode('keep')
|
||||||
|
setReinstallPasswordDraft('')
|
||||||
|
setReinstallPublicKeyDraft('')
|
||||||
setShowReinstall(true)
|
setShowReinstall(true)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
@@ -434,9 +441,28 @@ export default function ContainerDetail() {
|
|||||||
|
|
||||||
const handleReinstall = async () => {
|
const handleReinstall = async () => {
|
||||||
if (!containerIdentifier || !selectedTemplate) return
|
if (!containerIdentifier || !selectedTemplate) return
|
||||||
|
const linuxTemplate = !isWindowsTemplate(selectedTemplate)
|
||||||
|
if (linuxTemplate && reinstallAuthMode === 'password') {
|
||||||
|
const validationError = sshPasswordError(reinstallPasswordDraft.trim())
|
||||||
|
if (validationError) {
|
||||||
|
await dialog.alert('密码格式不正确', validationError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (linuxTemplate && reinstallAuthMode === 'key') {
|
||||||
|
const validationError = sshPublicKeyError(reinstallPublicKeyDraft)
|
||||||
|
if (validationError) {
|
||||||
|
await dialog.alert('SSH Key 格式不正确', validationError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
setReinstalling(true)
|
setReinstalling(true)
|
||||||
try {
|
try {
|
||||||
await reinstallContainer(containerIdentifier, selectedTemplate)
|
await reinstallContainer(containerIdentifier, selectedTemplate, linuxTemplate ? {
|
||||||
|
ssh_auth_mode: reinstallAuthMode,
|
||||||
|
ssh_password: reinstallAuthMode === 'password' ? reinstallPasswordDraft.trim() : '',
|
||||||
|
ssh_public_key: reinstallAuthMode === 'key' ? reinstallPublicKeyDraft.trim() : '',
|
||||||
|
} : undefined)
|
||||||
setShowReinstall(false)
|
setShowReinstall(false)
|
||||||
setShowSSH(false)
|
setShowSSH(false)
|
||||||
setShowVNC(false)
|
setShowVNC(false)
|
||||||
@@ -450,23 +476,12 @@ export default function ContainerDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const generateResetPassword = () => {
|
const generateResetPassword = () => {
|
||||||
const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
|
setResetPasswordDraft(generateSSHPassword())
|
||||||
const digits = '23456789'
|
|
||||||
const symbols = '!@#$%*-_+='
|
|
||||||
const all = letters + digits + symbols
|
|
||||||
const pick = (chars: string) => chars[secureRandomInt(chars.length)]
|
|
||||||
let password = pick(letters) + pick(digits)
|
|
||||||
while (password.length < 16) password += pick(all)
|
|
||||||
setResetPasswordDraft(secureShuffle(password.split('')).join(''))
|
|
||||||
setResetPasswordResult('')
|
setResetPasswordResult('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetPasswordError = (password: string) => {
|
const resetPasswordError = (password: string) => {
|
||||||
if (password.length < 8 || password.length > 64) return '密码长度必须为 8-64 位'
|
return sshPasswordError(password)
|
||||||
if (/\s/.test(password)) return '密码不能包含空白字符'
|
|
||||||
if (!/[A-Za-z]/.test(password)) return '密码至少需要包含字母'
|
|
||||||
if (!/\d/.test(password)) return '密码至少需要包含数字'
|
|
||||||
return ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleResetPassword = async () => {
|
const handleResetPassword = async () => {
|
||||||
@@ -740,6 +755,7 @@ export default function ContainerDetail() {
|
|||||||
const isRunning = container.status === 'running'
|
const isRunning = container.status === 'running'
|
||||||
const isKVM = (container.virtualization || 'lxc') === 'kvm'
|
const isKVM = (container.virtualization || 'lxc') === 'kvm'
|
||||||
const isWindows = container.template?.includes('windows')
|
const isWindows = container.template?.includes('windows')
|
||||||
|
const reinstallLinuxTemplate = !isWindowsTemplate(selectedTemplate)
|
||||||
const canOpenVNC = isKVM && isRunning
|
const canOpenVNC = isKVM && isRunning
|
||||||
const isExpired = container.expires_at ? new Date(container.expires_at) < new Date() : false
|
const isExpired = container.expires_at ? new Date(container.expires_at) < new Date() : false
|
||||||
const isPolicyBlocked = !!container.policy_blocked
|
const isPolicyBlocked = !!container.policy_blocked
|
||||||
@@ -1484,6 +1500,55 @@ export default function ContainerDetail() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
|
{reinstallLinuxTemplate && (
|
||||||
|
<div className="rounded-md border border-gray-200 bg-white px-3 py-3 text-sm">
|
||||||
|
<div className="mb-2 font-medium text-gray-800">登录方式</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
|
{([
|
||||||
|
['keep', '保留当前密码'],
|
||||||
|
['auto_password', '生成新密码'],
|
||||||
|
['password', '自定义密码'],
|
||||||
|
['key', 'SSH Key'],
|
||||||
|
] as Array<[ReinstallSSHAuthMode, string]>).map(([mode, label]) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setReinstallAuthMode(mode)}
|
||||||
|
className={`rounded-md border px-3 py-2 text-xs font-medium transition-colors ${reinstallAuthMode === mode ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{reinstallAuthMode === 'password' && (
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={reinstallPasswordDraft}
|
||||||
|
onChange={(event) => setReinstallPasswordDraft(event.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="RootPass123"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setReinstallPasswordDraft(generateSSHPassword())}
|
||||||
|
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-gray-300 text-gray-600 hover:bg-gray-50"
|
||||||
|
title="生成密码"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{reinstallAuthMode === 'key' && (
|
||||||
|
<textarea
|
||||||
|
value={reinstallPublicKeyDraft}
|
||||||
|
onChange={(event) => setReinstallPublicKeyDraft(event.target.value)}
|
||||||
|
className={`${inputClass} mt-3 min-h-20 resize-y font-mono text-xs`}
|
||||||
|
placeholder="ssh-ed25519 AAAA..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<button onClick={() => setShowReinstall(false)} className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md">取消</button>
|
<button onClick={() => setShowReinstall(false)} className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md">取消</button>
|
||||||
<button onClick={handleReinstall} disabled={reinstalling} className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50">
|
<button onClick={handleReinstall} disabled={reinstalling} className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50">
|
||||||
@@ -2135,30 +2200,8 @@ function TrafficBar({ container }: { container: Container }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function secureRandomInt(maxExclusive: number) {
|
function isWindowsTemplate(templateID: string) {
|
||||||
if (!Number.isSafeInteger(maxExclusive) || maxExclusive <= 0) {
|
return templateID.toLowerCase().includes('windows')
|
||||||
throw new Error('invalid random range')
|
|
||||||
}
|
|
||||||
const values = new Uint32Array(1)
|
|
||||||
const maxUint32 = 0x100000000
|
|
||||||
const limit = Math.floor(maxUint32 / maxExclusive) * maxExclusive
|
|
||||||
let value = 0
|
|
||||||
do {
|
|
||||||
crypto.getRandomValues(values)
|
|
||||||
value = values[0]
|
|
||||||
} while (value >= limit)
|
|
||||||
return value % maxExclusive
|
|
||||||
}
|
|
||||||
|
|
||||||
function secureShuffle<T>(items: T[]) {
|
|
||||||
const next = [...items]
|
|
||||||
for (let i = next.length - 1; i > 0; i--) {
|
|
||||||
const j = secureRandomInt(i + 1)
|
|
||||||
const value = next[i]
|
|
||||||
next[i] = next[j]
|
|
||||||
next[j] = value
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTemplateIcon(id: string): ReactNode {
|
function getTemplateIcon(id: string): ReactNode {
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export default function Login() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.10</p>
|
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.12</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'
|
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 {
|
import {
|
||||||
changePassword,
|
changePassword,
|
||||||
changeUsername,
|
changeUsername,
|
||||||
getLoginLogs,
|
getLoginLogs,
|
||||||
getSSLSettings,
|
getSSLSettings,
|
||||||
|
getWebSSHOriginSettings,
|
||||||
LoginLog,
|
LoginLog,
|
||||||
SSLSettings,
|
SSLSettings,
|
||||||
updateSSLSettings,
|
updateSSLSettings,
|
||||||
|
updateWebSSHOriginSettings,
|
||||||
|
WebSSHOriginSettings,
|
||||||
} from '../services/api'
|
} from '../services/api'
|
||||||
import { useDialog } from '../components/Dialog'
|
import { useDialog } from '../components/Dialog'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
@@ -33,6 +36,9 @@ export default function Settings() {
|
|||||||
const [keyPEM, setKeyPEM] = useState('')
|
const [keyPEM, setKeyPEM] = useState('')
|
||||||
const [applyNow, setApplyNow] = useState(true)
|
const [applyNow, setApplyNow] = useState(true)
|
||||||
const [savingSSL, setSavingSSL] = useState(false)
|
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 () => {
|
const fetchLogs = useCallback(async () => {
|
||||||
try {
|
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(() => {
|
useEffect(() => {
|
||||||
fetchLogs()
|
fetchLogs()
|
||||||
fetchSSL()
|
fetchSSL()
|
||||||
|
fetchWebSSHOrigins()
|
||||||
const timer = setInterval(fetchLogs, 15000)
|
const timer = setInterval(fetchLogs, 15000)
|
||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer)
|
||||||
}, [fetchLogs, fetchSSL])
|
}, [fetchLogs, fetchSSL, fetchWebSSHOrigins])
|
||||||
|
|
||||||
const handleSSLModeChange = (mode: SSLSettings['mode']) => {
|
const handleSSLModeChange = (mode: SSLSettings['mode']) => {
|
||||||
setSSLMode(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 () => {
|
const handleSaveAccount = async () => {
|
||||||
if (!oldPwd) {
|
if (!oldPwd) {
|
||||||
dialog.alert('提示', '请输入当前密码以确认修改')
|
dialog.alert('提示', '请输入当前密码以确认修改')
|
||||||
@@ -159,6 +197,7 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
|
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
|
||||||
|
<div className="space-y-6">
|
||||||
<SSLCard
|
<SSLCard
|
||||||
ssl={ssl}
|
ssl={ssl}
|
||||||
sslEnabled={sslEnabled}
|
sslEnabled={sslEnabled}
|
||||||
@@ -180,6 +219,16 @@ export default function Settings() {
|
|||||||
onSave={handleSaveSSL}
|
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">
|
<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">
|
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
|
||||||
<UserCog className="h-4 w-4" />账号设置
|
<UserCog className="h-4 w-4" />账号设置
|
||||||
@@ -232,6 +281,49 @@ interface SSLCardProps {
|
|||||||
onSave: () => void
|
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) {
|
function SSLCard(props: SSLCardProps) {
|
||||||
const selectedSSL = props.ssl?.mode_certificates?.[props.sslMode]
|
const selectedSSL = props.ssl?.mode_certificates?.[props.sslMode]
|
||||||
const modeOptions: Array<{ value: SSLSettings['mode']; label: string }> = [
|
const modeOptions: Array<{ value: SSLSettings['mode']; label: string }> = [
|
||||||
|
|||||||
@@ -138,9 +138,18 @@ export interface CreateContainerRequest {
|
|||||||
assign_ipv6: boolean
|
assign_ipv6: boolean
|
||||||
ipv6_count?: number
|
ipv6_count?: number
|
||||||
ipv6_addresses?: string[]
|
ipv6_addresses?: string[]
|
||||||
|
ssh_auth_mode?: string
|
||||||
|
ssh_password?: string
|
||||||
|
ssh_public_key?: string
|
||||||
expires_at: string
|
expires_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReinstallContainerOptions {
|
||||||
|
ssh_auth_mode?: string
|
||||||
|
ssh_password?: string
|
||||||
|
ssh_public_key?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface IPv6PrefixInfo {
|
export interface IPv6PrefixInfo {
|
||||||
interface: string
|
interface: string
|
||||||
address: string
|
address: string
|
||||||
@@ -393,6 +402,17 @@ export const getSSLSettings = () =>
|
|||||||
export const updateSSLSettings = (data: UpdateSSLSettingsRequest) =>
|
export const updateSSLSettings = (data: UpdateSSLSettingsRequest) =>
|
||||||
api.put<APIResponse<SSLSettings>>('/ssl', data)
|
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
|
// Containers
|
||||||
export const getContainers = () =>
|
export const getContainers = () =>
|
||||||
api.get<APIResponse<Container[]>>('/containers')
|
api.get<APIResponse<Container[]>>('/containers')
|
||||||
@@ -415,8 +435,8 @@ export const stopContainer = (id: ContainerIdentifier) =>
|
|||||||
export const restartContainer = (id: ContainerIdentifier) =>
|
export const restartContainer = (id: ContainerIdentifier) =>
|
||||||
api.post<APIResponse>(`/containers/${id}/restart`)
|
api.post<APIResponse>(`/containers/${id}/restart`)
|
||||||
|
|
||||||
export const reinstallContainer = (id: ContainerIdentifier, templateId: string) =>
|
export const reinstallContainer = (id: ContainerIdentifier, templateId: string, options?: ReinstallContainerOptions) =>
|
||||||
api.post<APIResponse>(`/containers/${id}/reinstall`, { template_id: templateId })
|
api.post<APIResponse>(`/containers/${id}/reinstall`, { template_id: templateId, ...(options || {}) })
|
||||||
|
|
||||||
export const resetSSHPassword = (id: ContainerIdentifier, password?: string) =>
|
export const resetSSHPassword = (id: ContainerIdentifier, password?: string) =>
|
||||||
api.post<APIResponse<{ password: string }>>(`/containers/${id}/reset-password`, password ? { password } : {})
|
api.post<APIResponse<{ password: string }>>(`/containers/${id}/reset-password`, password ? { password } : {})
|
||||||
|
|||||||
@@ -397,6 +397,13 @@ const exact: Record<string, string> = {
|
|||||||
'保存后自动重启服务并立即生效': 'Restart service automatically after saving',
|
'保存后自动重启服务并立即生效': 'Restart service automatically after saving',
|
||||||
'保存中...': 'Saving...',
|
'保存中...': 'Saving...',
|
||||||
'保存 SSL 设置': 'Save SSL Settings',
|
'保存 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',
|
'登录日志': 'Login Logs',
|
||||||
'首页': 'First',
|
'首页': 'First',
|
||||||
'上一页': 'Previous',
|
'上一页': '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