From 8dd01fe7144a793a6d25f1d42a8e24be98bb87df Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:08:31 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=80=E4=BA=9B=E5=B7=B2?= =?UTF-8?q?=E7=9F=A5=E9=97=AE=E9=A2=98=EF=BC=8C=E4=BC=98=E5=8C=96V6?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E5=88=86=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 + backend/internal/api/apikey.go | 79 ++++---- backend/internal/api/auth.go | 7 +- backend/internal/api/handlers.go | 8 + backend/internal/api/images.go | 9 + backend/internal/api/request.go | 15 ++ backend/internal/api/ssh.go | 46 ++++- backend/internal/api/subuser.go | 113 +++++++---- backend/internal/api/taskqueue.go | 8 + backend/internal/cli/cli.go | 62 +++++- backend/internal/config/config.go | 70 ++++++- backend/internal/lxc/ipv6.go | 235 ++++++++++++++++++++++- backend/internal/lxc/lxc.go | 14 ++ backend/internal/server/server.go | 43 ++++- frontend/src/components/WebSSHViewer.tsx | 5 +- frontend/src/contexts/AuthContext.tsx | 5 +- frontend/src/services/api.ts | 3 +- install.sh | 77 +++++++- 18 files changed, 691 insertions(+), 118 deletions(-) create mode 100644 backend/internal/api/request.go diff --git a/README.md b/README.md index 16bdf60..0cb851d 100644 --- a/README.md +++ b/README.md @@ -52,3 +52,13 @@ curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh ![alt text](/img/image.png) ![alt text](/img/image-1.png) ![alt text](/img/image-2.png) + +## Star History + + + + + + Star History Chart + + \ No newline at end of file diff --git a/backend/internal/api/apikey.go b/backend/internal/api/apikey.go index 245a36f..74268ae 100644 --- a/backend/internal/api/apikey.go +++ b/backend/internal/api/apikey.go @@ -2,6 +2,8 @@ package api import ( "crypto/rand" + "crypto/sha256" + "crypto/subtle" "encoding/hex" "encoding/json" "net" @@ -11,8 +13,6 @@ import ( "time" "clicd/internal/config" - - "github.com/golang-jwt/jwt/v5" ) type ApiKey struct { @@ -116,6 +116,11 @@ func generateShortID() string { // hashKey creates a simple hash for storage (not reversible) func hashKey(key string) string { + sum := sha256.Sum256([]byte(key)) + return hex.EncodeToString(sum[:]) +} + +func legacyHashKey(key string) string { b := make([]byte, 32) for i := range key { b[i%32] ^= key[i] @@ -126,8 +131,10 @@ func hashKey(key string) string { // validateApiKey checks if the given key is valid and IP is allowed func validateApiKey(rawKey, clientIP string) bool { hashed := hashKey(rawKey) + legacyHashed := legacyHashKey(rawKey) for _, k := range config.AppConfig.ApiKeys { - if k.KeyHash == hashed { + if subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(hashed)) == 1 || + subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(legacyHashed)) == 1 { if k.IPWhitelist == "" { return true } @@ -137,6 +144,29 @@ func validateApiKey(rawKey, clientIP string) bool { return false } +func apiKeyFromRequest(r *http.Request) string { + if apiKey := strings.TrimSpace(r.Header.Get("X-API-Key")); apiKey != "" { + return apiKey + } + auth := r.Header.Get("Authorization") + if strings.HasPrefix(auth, "Bearer clicd_sk_") { + return strings.TrimPrefix(auth, "Bearer ") + } + return "" +} + +func isValidApiKeyRequest(r *http.Request) bool { + apiKey := apiKeyFromRequest(r) + if apiKey == "" { + return false + } + if !validateApiKey(apiKey, clientIP(r)) { + return false + } + updateApiKeyLastUsed(apiKey) + return true +} + // isIPAllowed checks if clientIP matches any entry in the whitelist func isIPAllowed(clientIP, whitelist string) bool { clientIP = strings.TrimSpace(clientIP) @@ -211,52 +241,15 @@ func updateApiKeyLastUsed(rawKey string) { } } -// ApiKeyMiddleware authenticates requests via X-API-Key header or ?api_key query param +// ApiKeyMiddleware authenticates requests via X-API-Key header or Authorization bearer. func ApiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - // Check header - apiKey := r.Header.Get("X-API-Key") - if apiKey == "" { - // Check query param - apiKey = r.URL.Query().Get("api_key") - } - if apiKey == "" { - // Check Bearer token (some clients use this) - auth := r.Header.Get("Authorization") - if strings.HasPrefix(auth, "Bearer clicd_sk_") { - apiKey = strings.TrimPrefix(auth, "Bearer ") - } - } - - // Get client IP - clientIP := r.RemoteAddr - if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { - clientIP = strings.Split(forwarded, ",")[0] - } - if apiKey == "" || !validateApiKey(apiKey, clientIP) { + apiKey := apiKeyFromRequest(r) + if apiKey == "" || !validateApiKey(apiKey, clientIP(r)) { jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"}) return } - // Generate a short-lived JWT so downstream admin middleware passes - token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ - "username": config.AppConfig.AdminUser, - "api_key": true, - "exp": time.Now().Add(5 * time.Minute).Unix(), - "iat": time.Now().Unix(), - }) - tokenString, _ := token.SignedString([]byte(config.AppConfig.JWTSecret)) - - // Set cookie for subsequent requests - http.SetCookie(w, &http.Cookie{ - Name: "clicd_token", - Value: tokenString, - Path: "/", - HttpOnly: false, - SameSite: http.SameSiteLaxMode, - MaxAge: 300, - }) - updateApiKeyLastUsed(apiKey) next(w, r) } diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go index 6ee8088..cbc269b 100644 --- a/backend/internal/api/auth.go +++ b/backend/internal/api/auth.go @@ -100,10 +100,7 @@ func HandleLogin(w http.ResponseWriter, r *http.Request) { return } - ip := r.RemoteAddr - if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { - ip = forwarded - } + ip := clientIP(r) ua := r.Header.Get("User-Agent") if req.Username != config.AppConfig.AdminUser { @@ -192,7 +189,7 @@ func HandleCheckAuth(w http.ResponseWriter, r *http.Request) { func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tokenString := tokenFromRequest(r) - if !isValidToken(tokenString) { + if !isValidToken(tokenString) && !isValidApiKeyRequest(r) { jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"}) return } diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index ac30a67..67ef5cc 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -107,6 +107,10 @@ func createContainer(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Template is required"}) return } + if !isTemplateEnabledAndDownloaded(cfg.TemplateID) { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"}) + return + } if cfg.VCPU <= 0 { cfg.VCPU = 1 } @@ -316,6 +320,10 @@ func HandleTemplates(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if isSubUserRequest(r) { + HandleEnabledImages(w, r) + return + } templates := lxc.GetTemplates() jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: templates}) } diff --git a/backend/internal/api/images.go b/backend/internal/api/images.go index 42c613c..6369ae7 100644 --- a/backend/internal/api/images.go +++ b/backend/internal/api/images.go @@ -272,6 +272,15 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: result}) } +func isTemplateEnabledAndDownloaded(templateID string) bool { + tmpl := lxc.FindTemplate(templateID) + if tmpl == nil { + return false + } + enabledSet := getEnabledImageSet() + return enabledSet[tmpl.ID] && isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) +} + func ensureImageEnabled(id string) { // If the enabled list is empty, all templates are currently enabled by default. // We must populate the list with all template IDs first so that explicit toggles stick. diff --git a/backend/internal/api/request.go b/backend/internal/api/request.go new file mode 100644 index 0000000..2eefe17 --- /dev/null +++ b/backend/internal/api/request.go @@ -0,0 +1,15 @@ +package api + +import ( + "net" + "net/http" + "strings" +) + +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)) + if err == nil { + return host + } + return strings.TrimSpace(r.RemoteAddr) +} diff --git a/backend/internal/api/ssh.go b/backend/internal/api/ssh.go index c7213f3..33f4591 100644 --- a/backend/internal/api/ssh.go +++ b/backend/internal/api/ssh.go @@ -2,6 +2,7 @@ package api import ( "crypto/rand" + "crypto/sha256" "encoding/hex" "encoding/json" "fmt" @@ -73,7 +74,7 @@ func HandleWebSSHTicket(w http.ResponseWriter, r *http.Request) { // HandleWebSSH proxies an SSH session to the browser over WebSocket. func HandleWebSSH(w http.ResponseWriter, r *http.Request) { - ticket := r.URL.Query().Get("ticket") + ticket := webSSHTicketFromRequest(r) if ticket == "" { http.Error(w, "ticket required", http.StatusUnauthorized) return @@ -114,7 +115,11 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) { http.Error(w, "container ip is not available", http.StatusBadRequest) return } - ws, err := upgrader.Upgrade(w, r, nil) + responseHeader := http.Header{} + if protocol := webSSHTicketProtocol(r); protocol != "" { + responseHeader.Set("Sec-WebSocket-Protocol", protocol) + } + ws, err := upgrader.Upgrade(w, r, responseHeader) if err != nil { log.Printf("WebSSH upgrade failed: %v", err) return @@ -141,7 +146,7 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) { Auth: []ssh.AuthMethod{ ssh.Password(c.SSHPassword), }, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), + HostKeyCallback: containerHostKeyCallback(c), Timeout: 4 * time.Second, } @@ -250,6 +255,41 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) { log.Printf("WebSSH disconnected for container %s", containerName) } +func containerHostKeyCallback(c *config.Container) ssh.HostKeyCallback { + return func(hostname string, remote net.Addr, key ssh.PublicKey) error { + sum := sha256.Sum256(key.Marshal()) + fingerprint := hex.EncodeToString(sum[:]) + if c.SSHHostKey != "" && c.SSHHostKey != fingerprint { + return fmt.Errorf("container SSH host key mismatch") + } + if c.SSHHostKey == "" { + c.SSHHostKey = fingerprint + config.SaveConfig() + } + return nil + } +} + +func webSSHTicketFromRequest(r *http.Request) string { + for _, protocol := range websocket.Subprotocols(r) { + const prefix = "clicd-ticket." + if len(protocol) > len(prefix) && protocol[:len(prefix)] == prefix { + return protocol[len(prefix):] + } + } + return "" +} + +func webSSHTicketProtocol(r *http.Request) string { + for _, protocol := range websocket.Subprotocols(r) { + const prefix = "clicd-ticket." + if len(protocol) > len(prefix) && protocol[:len(prefix)] == prefix { + return protocol + } + } + return "" +} + func streamSSHOutput(ws *websocket.Conn, writeMu *sync.Mutex, src io.Reader, done chan<- struct{}) { defer func() { done <- struct{}{} }() diff --git a/backend/internal/api/subuser.go b/backend/internal/api/subuser.go index f65667d..71c894f 100644 --- a/backend/internal/api/subuser.go +++ b/backend/internal/api/subuser.go @@ -21,6 +21,28 @@ func generateRandomStr(length int) string { return hex.EncodeToString(b)[:length] } +type subUserResponse struct { + ID string `json:"id"` + Username string `json:"username"` + Password string `json:"password,omitempty"` + ContainerNames []string `json:"container_names"` + ContainerUUIDs []string `json:"container_uuids,omitempty"` + AccessCode string `json:"access_code"` + CreatedAt string `json:"created_at"` +} + +func newSubUserResponse(su config.SubUser, password string) subUserResponse { + return subUserResponse{ + ID: su.ID, + Username: su.Username, + Password: password, + ContainerNames: su.ContainerNames, + ContainerUUIDs: su.ContainerUUIDs, + AccessCode: su.AccessCode, + CreatedAt: su.CreatedAt, + } +} + // HandleSubUserCreate creates a sub-user for a specific container func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -47,29 +69,24 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) { // Check if sub-user already exists for this container for i := range config.AppConfig.SubUsers { su := &config.AppConfig.SubUsers[i] - for _, cn := range su.ContainerNames { - if cn == containerName { + for _, uuid := range su.ContainerUUIDs { + if uuid == c.UUID { if su.AccessCode == "" { su.AccessCode = generateRandomStr(8) } - if su.PassHash == "" && su.Password != "" { - if hash, err := bcrypt.GenerateFromPassword([]byte(su.Password), bcrypt.DefaultCost); err == nil { - su.PassHash = string(hash) - } + password := generateRandomStr(16) + if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil { + su.PassHash = string(hash) } - if su.Password == "" { - su.Password = generateRandomStr(16) - if hash, err := bcrypt.GenerateFromPassword([]byte(su.Password), bcrypt.DefaultCost); err == nil { - su.PassHash = string(hash) - } - } - su.Token = newSubUserToken(su.Username, []string{c.UUID}, time.Now().AddDate(1, 0, 0)) + su.Password = "" + su.Token = "" + su.ContainerNames = appendUniqueString(su.ContainerNames, containerName) + su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID) config.SaveConfig() - // Return existing jsonResponse(w, http.StatusOK, APIResponse{ Success: true, - Message: "Sub-user already exists", - Data: *su, + Message: "Sub-user password rotated", + Data: newSubUserResponse(*su, password), }) return } @@ -84,16 +101,12 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) { // Generate short access code (8 chars, for URL sharing) accessCode := generateRandomStr(8) - // Generate JWT for sub-user - tokenStr := newSubUserToken(username, []string{c.UUID}, time.Now().AddDate(1, 0, 0)) - subUser := config.SubUser{ ID: "sub-" + generateRandomStr(8), Username: username, - Password: password, PassHash: string(hash), ContainerNames: []string{containerName}, - Token: tokenStr, + ContainerUUIDs: []string{c.UUID}, AccessCode: accessCode, CreatedAt: time.Now().Format("2006-01-02 15:04:05"), } @@ -102,7 +115,7 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) { config.SaveConfig() config.AddAuditLog("创建子用户", containerName, fmt.Sprintf("用户: %s", username), "admin") - jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Sub-user created", Data: subUser}) + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Sub-user created", Data: newSubUserResponse(subUser, password)}) } // HandleSubUserLogin handles sub-user login @@ -126,7 +139,7 @@ func HandleSubUserLogin(w http.ResponseWriter, r *http.Request) { if su.Username == req.Username { if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err == nil { // Generate fresh token - containerUUIDs := subUserContainerUUIDs(su.ContainerNames) + containerUUIDs := activeSubUserContainerUUIDs(&su) if len(containerUUIDs) == 0 { jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this user"}) return @@ -173,7 +186,7 @@ func HandleSubUserAccessCode(w http.ResponseWriter, r *http.Request) { return } - containerUUIDs := subUserContainerUUIDs(su.ContainerNames) + containerUUIDs := activeSubUserContainerUUIDs(&su) if len(containerUUIDs) == 0 { jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this link"}) return @@ -224,18 +237,6 @@ func subUserAllowedContainers(r *http.Request) (subUserAccess, bool) { names: make(map[string]bool), uuids: make(map[string]bool), } - if containerNames, ok := claims["container_names"].([]interface{}); ok { - for _, cn := range containerNames { - if name, ok := cn.(string); ok { - allowed.names[name] = true - } - } - } - if containerNames, ok := claims["container_names"].([]string); ok { - for _, name := range containerNames { - allowed.names[name] = true - } - } if containerUUIDs, ok := claims["container_uuids"].([]interface{}); ok { for _, item := range containerUUIDs { if uuid, ok := item.(string); ok { @@ -359,15 +360,27 @@ func filterTasksForRequest(r *http.Request, tasks []*Task) []*Task { } filtered := make([]*Task, 0, len(tasks)) for _, task := range tasks { - if allowed.names[task.ContainerName] || (task.Config.Name != "" && allowed.names[task.Config.Name]) { + if c := config.FindContainer(task.ContainerID); c != nil && isContainerAllowed(allowed, c) { filtered = append(filtered, task) + continue + } + if task.ContainerName != "" { + if c := config.FindContainerByName(task.ContainerName); c != nil && isContainerAllowed(allowed, c) { + filtered = append(filtered, task) + continue + } + } + if task.Config.Name != "" { + if c := config.FindContainerByName(task.Config.Name); c != nil && isContainerAllowed(allowed, c) { + filtered = append(filtered, task) + } } } return filtered } func isContainerAllowed(allowed subUserAccess, c *config.Container) bool { - return allowed.names[c.Name] || (c.UUID != "" && allowed.uuids[c.UUID]) + return c != nil && c.UUID != "" && allowed.uuids[c.UUID] } func isSubUserContainerActionAllowed(action string, method string) bool { @@ -392,16 +405,38 @@ func isSubUserContainerActionAllowed(action string, method string) bool { } } +func activeSubUserContainerUUIDs(su *config.SubUser) []string { + uuids := make([]string, 0, len(su.ContainerUUIDs)) + for _, uuid := range su.ContainerUUIDs { + if c := config.FindContainerByUUID(uuid); c != nil { + uuids = appendUniqueString(uuids, c.UUID) + } + } + if len(uuids) > 0 { + return uuids + } + return subUserContainerUUIDs(su.ContainerNames) +} + func subUserContainerUUIDs(containerNames []string) []string { uuids := make([]string, 0, len(containerNames)) for _, name := range containerNames { if c := config.FindContainerByName(name); c != nil && c.UUID != "" { - uuids = append(uuids, c.UUID) + uuids = appendUniqueString(uuids, c.UUID) } } return uuids } +func appendUniqueString(values []string, value string) []string { + for _, existing := range values { + if existing == value { + return values + } + } + return append(values, value) +} + func splitPath(path string) []string { parts := make([]string, 0) for _, p := range splitBy(path, "/") { diff --git a/backend/internal/api/taskqueue.go b/backend/internal/api/taskqueue.go index 794b79a..c6917f0 100644 --- a/backend/internal/api/taskqueue.go +++ b/backend/internal/api/taskqueue.go @@ -442,6 +442,10 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti templateID = c.Template } } + if !isTemplateEnabledAndDownloaded(templateID) { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"}) + return + } taskType = TaskReinstall default: jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"}) @@ -504,6 +508,10 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) { if req.Containers[i].DiskGB < 1 { req.Containers[i].DiskGB = 5 } + if !isTemplateEnabledAndDownloaded(req.Containers[i].TemplateID) { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"}) + return + } if req.Containers[i].PortMappingCount < 2 { req.Containers[i].PortMappingCount = 2 } diff --git a/backend/internal/cli/cli.go b/backend/internal/cli/cli.go index 5dcbdaf..ebb1c47 100644 --- a/backend/internal/cli/cli.go +++ b/backend/internal/cli/cli.go @@ -399,7 +399,67 @@ func destroyAllLXCContainers() { fmt.Printf("Destroying LXC container %s...\n", name) runQuiet("lxc-stop", "-n", name, "-k") runQuiet("lxc-destroy", "-n", name, "-f") - removePath("/var/lib/lxc/" + name) + removeLXCContainerPath("/var/lib/lxc/" + name) + } +} + +func removeLXCContainerPath(path string) { + unmountPathTree(path) + detachLoopDevices(path) + if err := os.RemoveAll(path); err == nil { + fmt.Printf("Removed %s\n", path) + return + } + + runQuiet("fuser", "-km", path+"/rootfs") + runQuiet("fuser", "-km", path) + unmountPathTree(path) + detachLoopDevices(path) + removePath(path) +} + +func unmountPathTree(path string) { + if commandExists("findmnt") { + out, err := exec.Command("findmnt", "-R", "-n", "-o", "TARGET", path).Output() + if err == nil { + mounts := strings.Split(strings.TrimSpace(string(out)), "\n") + for i := len(mounts) - 1; i >= 0; i-- { + mountpoint := strings.TrimSpace(mounts[i]) + if mountpoint != "" { + runQuiet("umount", "-R", "-l", mountpoint) + runQuiet("umount", "-l", mountpoint) + } + } + } + } + runQuiet("umount", "-R", "-l", path+"/rootfs") + runQuiet("umount", "-l", path+"/rootfs") + runQuiet("umount", "-R", "-l", path) + runQuiet("umount", "-l", path) +} + +func detachLoopDevices(path string) { + if !commandExists("losetup") { + return + } + images := []string{path + "/rootfs.img"} + if entries, err := os.ReadDir(path); err == nil { + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".img") { + images = append(images, path+"/"+entry.Name()) + } + } + } + for _, image := range images { + out, err := exec.Command("losetup", "-j", image).Output() + if err != nil { + continue + } + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if idx := strings.Index(line, ":"); idx > 0 { + runQuiet("losetup", "-d", line[:idx]) + } + } } } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 5206931..2d184f2 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -91,6 +91,7 @@ type Container struct { VNCPort int `json:"vnc_port"` SSHPort int `json:"ssh_port"` SSHPassword string `json:"ssh_password"` + SSHHostKey string `json:"ssh_host_key,omitempty"` PortMappings []PortMapping `json:"port_mappings"` PortMappingLimit int `json:"port_mapping_limit"` SnapshotLimit int `json:"snapshot_limit"` @@ -138,10 +139,11 @@ func DeleteApiKey(id string) { type SubUser struct { ID string `json:"id"` Username string `json:"username"` - Password string `json:"password"` // plaintext for display + Password string `json:"-"` PassHash string `json:"pass_hash"` ContainerNames []string `json:"container_names"` - Token string `json:"token"` + ContainerUUIDs []string `json:"container_uuids,omitempty"` + Token string `json:"-"` AccessCode string `json:"access_code"` CreatedAt string `json:"created_at"` } @@ -345,6 +347,9 @@ func InitConfig() (*ClicdConfig, error) { if ensureContainerSnapshotScheduleDefaults() { changed = true } + if migrateSubUsers() { + changed = true + } if removeLegacyVNCMappings() { changed = true } @@ -422,6 +427,47 @@ func ensureContainerSnapshotLimits() bool { return changed } +func migrateSubUsers() bool { + changed := false + for i := range AppConfig.SubUsers { + su := &AppConfig.SubUsers[i] + if su.PassHash == "" && su.Password != "" { + if hash, err := bcrypt.GenerateFromPassword([]byte(su.Password), bcrypt.DefaultCost); err == nil { + su.PassHash = string(hash) + changed = true + } + } + if su.Password != "" { + su.Password = "" + changed = true + } + if su.Token != "" { + su.Token = "" + changed = true + } + if len(su.ContainerUUIDs) == 0 && len(su.ContainerNames) > 0 { + for _, name := range su.ContainerNames { + if c := FindContainerByName(name); c != nil && c.UUID != "" { + su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID) + } + } + if len(su.ContainerUUIDs) > 0 { + changed = true + } + } + } + return changed +} + +func appendUniqueString(values []string, value string) []string { + for _, existing := range values { + if existing == value { + return values + } + } + return append(values, value) +} + func NormalizeSnapshotLimit(limit int) int { if limit <= 0 { return DefaultSnapshotLimit @@ -488,7 +534,7 @@ func AllocateContainerID() int { func RemoveContainer(id int) bool { for i, c := range AppConfig.Containers { if c.ID == id { - removeSubUserContainerAccess(c.Name) + removeSubUserContainerAccess(c.Name, c.UUID) removeContainerSnapshotMetadata(id) AppConfig.Containers = append(AppConfig.Containers[:i], AppConfig.Containers[i+1:]...) SaveConfig() @@ -543,8 +589,13 @@ func removeContainerSnapshotMetadata(containerID int) { AppConfig.Snapshots = filtered } -func removeSubUserContainerAccess(containerName string) { - if containerName == "" || len(AppConfig.SubUsers) == 0 { +func RemoveSubUserContainerAccess(containerName string, containerUUID string) { + removeSubUserContainerAccess(containerName, containerUUID) + SaveConfig() +} + +func removeSubUserContainerAccess(containerName string, containerUUID string) { + if containerName == "" && containerUUID == "" || len(AppConfig.SubUsers) == 0 { return } filteredUsers := make([]SubUser, 0, len(AppConfig.SubUsers)) @@ -555,10 +606,17 @@ func removeSubUserContainerAccess(containerName string) { filteredNames = append(filteredNames, name) } } - if len(filteredNames) == 0 { + filteredUUIDs := make([]string, 0, len(su.ContainerUUIDs)) + for _, uuid := range su.ContainerUUIDs { + if uuid != containerUUID { + filteredUUIDs = append(filteredUUIDs, uuid) + } + } + if len(filteredNames) == 0 && len(filteredUUIDs) == 0 { continue } su.ContainerNames = filteredNames + su.ContainerUUIDs = filteredUUIDs filteredUsers = append(filteredUsers, su) } AppConfig.SubUsers = filteredUsers diff --git a/backend/internal/lxc/ipv6.go b/backend/internal/lxc/ipv6.go index 3fd9832..4495ed3 100644 --- a/backend/internal/lxc/ipv6.go +++ b/backend/internal/lxc/ipv6.go @@ -428,6 +428,12 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) { if err := m.applyIPv6Config(c.LxcName(), c.IPv6); err != nil { return nil, err } + rootfsPath := filepath.Join(m.LxcPath, c.LxcName(), "rootfs") + if _, err := os.Stat(rootfsPath); err == nil { + if err := installContainerIPv6Init(rootfsPath, c.IPv6); err != nil { + fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", c.LxcName(), err) + } + } if err := m.ApplyIPv6(id); err != nil { return nil, err } @@ -477,6 +483,12 @@ func (m *Manager) ApplyIPv6(id int) error { config.SaveConfig() } + rootfsPath := filepath.Join(m.LxcPath, c.LxcName(), "rootfs") + if _, err := os.Stat(rootfsPath); err == nil { + if err := installContainerIPv6Init(rootfsPath, c.IPv6); err != nil { + fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", c.LxcName(), err) + } + } if err := ensureHostIPv6Routing(c.IPv6, c.IPv6Interface); err != nil { return err } @@ -485,12 +497,16 @@ func (m *Manager) ApplyIPv6(id int) error { return nil } cmd := exec.Command("lxc-attach", "-n", c.LxcName(), "--", "sh", "-c", - fmt.Sprintf("ip -6 addr replace %s/128 dev eth0 && ip -6 route replace default via %s dev eth0", + fmt.Sprintf("ip -6 addr replace %s/128 dev eth0 && ip -6 route replace default via %s dev eth0 metric 100", shellQuote(c.IPv6), shellQuote(ipv6GatewayLinkLocal))) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to apply IPv6 inside container: %v, output: %s", err, string(output)) } + removeIPv6NAT66(c.IPv6, c.IPv6Interface) + if !containerIPv6ConnectivityOK(c.LxcName()) { + ensureIPv6NAT66(c.IPv6, c.IPv6Interface) + } return nil } @@ -513,6 +529,191 @@ func ensureHostIPv6Routing(ipv6, uplink string) error { return nil } +func installContainerIPv6Init(rootfsPath, ipv6 string) error { + if strings.TrimSpace(ipv6) == "" { + return nil + } + if _, err := netip.ParseAddr(ipv6); err != nil { + return fmt.Errorf("invalid IPv6 address %q: %w", ipv6, err) + } + + scriptPath := filepath.Join(rootfsPath, "usr", "local", "sbin", "clicd-ipv6-init") + if err := os.MkdirAll(filepath.Dir(scriptPath), 0755); err != nil { + return err + } + script := `#!/bin/sh +IPV6_ADDR=` + shellQuote(ipv6) + ` +IPV6_GW=` + shellQuote(ipv6GatewayLinkLocal) + ` +IFACE="${CLICD_IPV6_IFACE:-eth0}" + +command -v ip >/dev/null 2>&1 || exit 0 + +i=0 +while [ "$i" -lt 30 ]; do + if ip link show dev "$IFACE" >/dev/null 2>&1; then + break + fi + i=$((i + 1)) + sleep 1 +done + +ip link set dev "$IFACE" up >/dev/null 2>&1 || true +ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE" >/dev/null 2>&1 || true +ip -6 route replace default via "$IPV6_GW" dev "$IFACE" metric 100 >/dev/null 2>&1 || true +exit 0 +` + if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { + return err + } + + osRelease := "" + if data, err := os.ReadFile(filepath.Join(rootfsPath, "etc", "os-release")); err == nil { + osRelease = strings.ToLower(string(data)) + } + hasSystemd := dirExists(filepath.Join(rootfsPath, "etc", "systemd", "system")) + hasOpenRC := fileExists(filepath.Join(rootfsPath, "sbin", "openrc-run")) || strings.Contains(osRelease, "alpine") + + if hasSystemd { + if err := installContainerIPv6Systemd(rootfsPath); err != nil { + return err + } + } + if hasOpenRC { + if err := installContainerIPv6OpenRC(rootfsPath); err != nil { + return err + } + } + if !hasSystemd && !hasOpenRC { + if err := installContainerIPv6SysV(rootfsPath); err != nil { + return err + } + } + return nil +} + +func installContainerIPv6Systemd(rootfsPath string) error { + servicePath := filepath.Join(rootfsPath, "etc", "systemd", "system", "clicd-ipv6.service") + if err := os.MkdirAll(filepath.Dir(servicePath), 0755); err != nil { + return err + } + service := `[Unit] +Description=CLICD IPv6 setup +After=network-online.target network.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/clicd-ipv6-init +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target +` + if err := os.WriteFile(servicePath, []byte(service), 0644); err != nil { + return err + } + wantsDir := filepath.Join(rootfsPath, "etc", "systemd", "system", "multi-user.target.wants") + if err := os.MkdirAll(wantsDir, 0755); err != nil { + return err + } + return replaceSymlink("../clicd-ipv6.service", filepath.Join(wantsDir, "clicd-ipv6.service")) +} + +func installContainerIPv6OpenRC(rootfsPath string) error { + initPath := filepath.Join(rootfsPath, "etc", "init.d", "clicd-ipv6") + if err := os.MkdirAll(filepath.Dir(initPath), 0755); err != nil { + return err + } + initScript := `#!/sbin/openrc-run +name="CLICD IPv6 setup" +description="Apply CLICD IPv6 settings" + +depend() { + after net networking + need net +} + +start() { + ebegin "Applying CLICD IPv6" + /usr/local/sbin/clicd-ipv6-init + eend $? +} +` + if err := os.WriteFile(initPath, []byte(initScript), 0755); err != nil { + return err + } + runlevelDir := filepath.Join(rootfsPath, "etc", "runlevels", "default") + if err := os.MkdirAll(runlevelDir, 0755); err != nil { + return err + } + return replaceSymlink(filepath.Join("..", "..", "init.d", "clicd-ipv6"), filepath.Join(runlevelDir, "clicd-ipv6")) +} + +func installContainerIPv6SysV(rootfsPath string) error { + initDir := filepath.Join(rootfsPath, "etc", "init.d") + if err := os.MkdirAll(initDir, 0755); err != nil { + return err + } + initPath := filepath.Join(initDir, "clicd-ipv6") + initScript := `#!/bin/sh +### BEGIN INIT INFO +# Provides: clicd-ipv6 +# Required-Start: $network +# Required-Stop: +# Default-Start: 2 3 4 5 +# Default-Stop: +# Short-Description: CLICD IPv6 setup +### END INIT INFO + +case "$1" in + start|restart|force-reload) + /usr/local/sbin/clicd-ipv6-init + ;; + stop|status) + exit 0 + ;; + *) + echo "Usage: $0 {start|stop|restart|force-reload|status}" + exit 1 + ;; +esac +exit 0 +` + if err := os.WriteFile(initPath, []byte(initScript), 0755); err != nil { + return err + } + for _, level := range []string{"2", "3", "4", "5"} { + rcDir := filepath.Join(rootfsPath, "etc", "rc"+level+".d") + if !dirExists(rcDir) { + continue + } + if err := replaceSymlink(filepath.Join("..", "init.d", "clicd-ipv6"), filepath.Join(rcDir, "S99clicd-ipv6")); err != nil { + return err + } + } + return nil +} + +func replaceSymlink(target, linkPath string) error { + if current, err := os.Readlink(linkPath); err == nil && current == target { + return nil + } + if err := os.Remove(linkPath); err != nil && !os.IsNotExist(err) { + return err + } + return os.Symlink(target, linkPath) +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func dirExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + func ensureIPv6ForwardRules(ipv6 string) { rules := [][]string{ {"FORWARD", "-i", "lxcbr0", "-s", ipv6 + "/128", "-j", "ACCEPT"}, @@ -527,6 +728,38 @@ func ensureIPv6ForwardRules(ipv6 string) { } } +func containerIPv6ConnectivityOK(lxcName string) bool { + targets := []string{"2606:4700:4700::1111", "2001:4860:4860::8888"} + for _, target := range targets { + if exec.Command("lxc-attach", "-n", lxcName, "--", "ping", "-6", "-c", "1", "-W", "2", target).Run() == nil { + return true + } + } + return false +} + +func ensureIPv6NAT66(ipv6, uplink string) { + if ipv6 == "" || uplink == "" { + return + } + rule := []string{"POSTROUTING", "-s", ipv6 + "/128", "-o", uplink, "-j", "MASQUERADE"} + check := append([]string{"-t", "nat", "-C"}, rule...) + add := append([]string{"-t", "nat", "-A"}, rule...) + if exec.Command("ip6tables", check...).Run() != nil { + exec.Command("ip6tables", add...).Run() + } +} + +func removeIPv6NAT66(ipv6, uplink string) { + if ipv6 == "" || uplink == "" { + return + } + rule := []string{"POSTROUTING", "-s", ipv6 + "/128", "-o", uplink, "-j", "MASQUERADE"} + del := append([]string{"-t", "nat", "-D"}, rule...) + for exec.Command("ip6tables", del...).Run() == nil { + } +} + func runQuiet(name string, args ...string) { _ = exec.Command(name, args...).Run() } diff --git a/backend/internal/lxc/lxc.go b/backend/internal/lxc/lxc.go index 3f47a86..712e3cb 100644 --- a/backend/internal/lxc/lxc.go +++ b/backend/internal/lxc/lxc.go @@ -367,6 +367,11 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { // Pre-configure network and SSH in the rootfs before first boot. rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") m.preconfigureNetwork(rootfsPath, cfg.TemplateID) + if ipv6 != "" { + if err := installContainerIPv6Init(rootfsPath, ipv6); err != nil { + fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err) + } + } if err := m.preconfigureSSH(rootfsPath, sshPassword, cfg.TemplateID); err != nil { fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err) } @@ -1419,6 +1424,9 @@ func (m *Manager) DestroyContainer(id int) error { return fmt.Errorf("container not found: %d", id) } lxcName := c.LxcName() + if c.IPv6 != "" && c.IPv6Interface != "" { + removeIPv6NAT66(c.IPv6, c.IPv6Interface) + } if err := m.StopContainer(id); err != nil { return fmt.Errorf("failed to stop container before destroy: %v", err) @@ -2101,6 +2109,11 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error { // Set root password and pre-configure network/SSH via chroot. rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") m.preconfigureNetwork(rootfsPath, templateID) + if c.IPv6 != "" { + if err := installContainerIPv6Init(rootfsPath, c.IPv6); err != nil { + fmt.Printf("Warning: failed to install IPv6 init in %s after reinstall: %v\n", lxcName, err) + } + } if c.SSHPassword == "" { c.SSHPassword = generateRandomString(16) } @@ -2116,6 +2129,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error { // Update template and keep everything else the same c.Template = templateID + c.SSHHostKey = "" c.Status = "running" config.SaveConfig() diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index bcad835..060b9a5 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -3,7 +3,9 @@ package server import ( "fmt" "log" + "net" "net/http" + "net/url" "strings" "time" @@ -18,12 +20,19 @@ var webFS http.FileSystem // corsMiddleware adds CORS headers func corsMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") + if origin := r.Header.Get("Origin"); origin != "" && isAllowedOrigin(origin, r.Host) { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + w.Header().Set("Access-Control-Allow-Credentials", "true") + } w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") - w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key") if r.Method == http.MethodOptions { + if origin := r.Header.Get("Origin"); origin != "" && !isAllowedOrigin(origin, r.Host) { + w.WriteHeader(http.StatusForbidden) + return + } w.WriteHeader(http.StatusOK) return } @@ -32,6 +41,34 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc { } } +func isAllowedOrigin(origin string, requestHost string) bool { + u, err := url.Parse(origin) + if err != nil || u.Host == "" { + return false + } + originHost := normalizeHost(u.Host) + host := normalizeHost(requestHost) + if originHost == host { + return true + } + return isLoopbackHost(originHost) && isLoopbackHost(host) +} + +func normalizeHost(host string) string { + if h, _, err := net.SplitHostPort(host); err == nil { + return strings.ToLower(h) + } + return strings.ToLower(host) +} + +func isLoopbackHost(host string) bool { + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + // setupRoutes configures API and static routes func setupRoutes(mux *http.ServeMux) { // API routes diff --git a/frontend/src/components/WebSSHViewer.tsx b/frontend/src/components/WebSSHViewer.tsx index be189cd..9590c81 100644 --- a/frontend/src/components/WebSSHViewer.tsx +++ b/frontend/src/components/WebSSHViewer.tsx @@ -19,11 +19,10 @@ export default function WebSSHViewer({ containerName, onClose }: WebSSHViewerPro const [status, setStatus] = useState<'connecting' | 'preparing' | 'connected' | 'disconnected' | 'error'>('connecting') const [errorMsg, setErrorMsg] = useState('') - const buildWebSSHUrl = (ticket: string) => { + const buildWebSSHUrl = () => { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const params = new URLSearchParams({ container: containerName, - ticket, }) return `${protocol}//${window.location.host}/api/ssh?${params.toString()}` } @@ -106,7 +105,7 @@ export default function WebSSHViewer({ containerName, onClose }: WebSSHViewerPro return } - const ws = new WebSocket(buildWebSSHUrl(ticket)) + const ws = new WebSocket(buildWebSSHUrl(), [`clicd-ticket.${ticket}`]) ws.binaryType = 'arraybuffer' wsRef.current = ws diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index c2dff61..ef736b9 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -41,9 +41,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (savedToken) { const payload = decodeTokenPayload(savedToken) const nextUsername = payload?.username || payload?.sub_user || savedUsername || null - const nextContainerIdentifiers = Array.isArray(payload?.container_uuids) && payload.container_uuids.length > 0 - ? payload.container_uuids - : Array.isArray(payload?.container_names) ? payload.container_names : [] + const nextContainerIdentifiers = Array.isArray(payload?.container_uuids) ? payload.container_uuids : [] setToken(savedToken) setUsername(nextUsername) @@ -123,7 +121,6 @@ export function useAuth() { type TokenPayload = { username?: string sub_user?: string - container_names?: string[] container_uuids?: string[] } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index dfe408a..26c4989 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -454,10 +454,9 @@ export const batchAction = (action: string, containers: number[], templateId?: s export interface SubUser { id: string username: string - password: string + password?: string container_names: string[] container_uuids?: string[] - token: string access_code: string created_at: string } diff --git a/install.sh b/install.sh index 38f2f87..e0ec7da 100644 --- a/install.sh +++ b/install.sh @@ -71,6 +71,73 @@ remove_path() { log "Removed $path" } +unmount_path_tree() { + path="$1" + if [ ! -e "$path" ]; then + return + fi + + if has_cmd findmnt; then + findmnt -R -n -o TARGET "$path" 2>/dev/null | sort -r | while IFS= read -r mountpoint; do + [ -n "$mountpoint" ] || continue + umount -R -l "$mountpoint" >/dev/null 2>&1 || umount -l "$mountpoint" >/dev/null 2>&1 || true + done + fi + + umount -R -l "$path/rootfs" >/dev/null 2>&1 || umount -l "$path/rootfs" >/dev/null 2>&1 || true + umount -R -l "$path" >/dev/null 2>&1 || umount -l "$path" >/dev/null 2>&1 || true +} + +detach_container_loop_devices() { + path="$1" + if ! has_cmd losetup; then + return + fi + + for image in "$path"/rootfs.img "$path"/*.img; do + [ -e "$image" ] || continue + losetup -j "$image" 2>/dev/null | sed 's/:.*//' | while IFS= read -r loopdev; do + [ -n "$loopdev" ] || continue + losetup -d "$loopdev" >/dev/null 2>&1 || true + done + done +} + +kill_path_users() { + path="$1" + if has_cmd fuser && [ -e "$path" ]; then + fuser -km "$path" >/dev/null 2>&1 || true + fi +} + +remove_lxc_container_dir() { + container_dir="$1" + container_name="$(basename "$container_dir")" + + if has_cmd lxc-stop; then + lxc-stop -n "$container_name" -k >/dev/null 2>&1 || true + fi + if has_cmd lxc-destroy; then + lxc-destroy -n "$container_name" -f >/dev/null 2>&1 || true + fi + + unmount_path_tree "$container_dir" + detach_container_loop_devices "$container_dir" + + if rm -rf "$container_dir" >/dev/null 2>&1; then + log "Removed $container_dir" + return + fi + + log "Retrying removal after terminating processes using $container_dir..." + kill_path_users "$container_dir/rootfs" + kill_path_users "$container_dir" + unmount_path_tree "$container_dir" + detach_container_loop_devices "$container_dir" + rm -rf "$container_dir" + log "Removed $container_dir" +} + uninstall_clicd() { log "Uninstalling CLICD..." @@ -89,14 +156,7 @@ uninstall_clicd() { log "Destroying LXC containers under /var/lib/lxc..." for container_dir in /var/lib/lxc/*; do [ -d "$container_dir" ] || continue - container_name="$(basename "$container_dir")" - if has_cmd lxc-stop; then - lxc-stop -n "$container_name" -k >/dev/null 2>&1 || true - fi - if has_cmd lxc-destroy; then - lxc-destroy -n "$container_name" -f >/dev/null 2>&1 || true - fi - remove_path "$container_dir" + remove_lxc_container_dir "$container_dir" done remove_path /etc/systemd/system/clicd.service @@ -106,6 +166,7 @@ uninstall_clicd() { remove_path /var/log/clicd.log remove_path /var/log/clicd.err remove_path /root/.clicd + unmount_path_tree /var/lib/lxc remove_path /var/lib/lxc remove_path /var/cache/lxc