修复一些已知问题,优化V6路由分配

This commit is contained in:
MengMengCode
2026-06-06 10:08:31 +08:00
parent a9784539ea
commit 8dd01fe714
18 changed files with 691 additions and 118 deletions
+10
View File
@@ -52,3 +52,13 @@ curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh
![alt text](/img/image.png) ![alt text](/img/image.png)
![alt text](/img/image-1.png) ![alt text](/img/image-1.png)
![alt text](/img/image-2.png) ![alt text](/img/image-2.png)
## Star History
<a href="https://www.star-history.com/?repos=MengMengCode%2FCLICD&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
</picture>
</a>
+36 -43
View File
@@ -2,6 +2,8 @@ package api
import ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"net" "net"
@@ -11,8 +13,6 @@ import (
"time" "time"
"clicd/internal/config" "clicd/internal/config"
"github.com/golang-jwt/jwt/v5"
) )
type ApiKey struct { type ApiKey struct {
@@ -116,6 +116,11 @@ func generateShortID() string {
// hashKey creates a simple hash for storage (not reversible) // hashKey creates a simple hash for storage (not reversible)
func hashKey(key string) string { func hashKey(key string) string {
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])
}
func legacyHashKey(key string) string {
b := make([]byte, 32) b := make([]byte, 32)
for i := range key { for i := range key {
b[i%32] ^= key[i] 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 // validateApiKey checks if the given key is valid and IP is allowed
func validateApiKey(rawKey, clientIP string) bool { func validateApiKey(rawKey, clientIP string) bool {
hashed := hashKey(rawKey) hashed := hashKey(rawKey)
legacyHashed := legacyHashKey(rawKey)
for _, k := range config.AppConfig.ApiKeys { 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 == "" { if k.IPWhitelist == "" {
return true return true
} }
@@ -137,6 +144,29 @@ func validateApiKey(rawKey, clientIP string) bool {
return false 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 // isIPAllowed checks if clientIP matches any entry in the whitelist
func isIPAllowed(clientIP, whitelist string) bool { func isIPAllowed(clientIP, whitelist string) bool {
clientIP = strings.TrimSpace(clientIP) 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 { func ApiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
// Check header apiKey := apiKeyFromRequest(r)
apiKey := r.Header.Get("X-API-Key") if apiKey == "" || !validateApiKey(apiKey, clientIP(r)) {
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) {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"}) jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"})
return 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) updateApiKeyLastUsed(apiKey)
next(w, r) next(w, r)
} }
+2 -5
View File
@@ -100,10 +100,7 @@ func HandleLogin(w http.ResponseWriter, r *http.Request) {
return return
} }
ip := r.RemoteAddr ip := clientIP(r)
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
ip = forwarded
}
ua := r.Header.Get("User-Agent") ua := r.Header.Get("User-Agent")
if req.Username != config.AppConfig.AdminUser { 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 { func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
tokenString := tokenFromRequest(r) tokenString := tokenFromRequest(r)
if !isValidToken(tokenString) { if !isValidToken(tokenString) && !isValidApiKeyRequest(r) {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"}) jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"})
return return
} }
+8
View File
@@ -107,6 +107,10 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Template is required"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Template is required"})
return return
} }
if !isTemplateEnabledAndDownloaded(cfg.TemplateID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return
}
if cfg.VCPU <= 0 { if cfg.VCPU <= 0 {
cfg.VCPU = 1 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"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if isSubUserRequest(r) {
HandleEnabledImages(w, r)
return
}
templates := lxc.GetTemplates() templates := lxc.GetTemplates()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: templates}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: templates})
} }
+9
View File
@@ -272,6 +272,15 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: result}) 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) { func ensureImageEnabled(id string) {
// If the enabled list is empty, all templates are currently enabled by default. // 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. // We must populate the list with all template IDs first so that explicit toggles stick.
+15
View File
@@ -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)
}
+43 -3
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -73,7 +74,7 @@ func HandleWebSSHTicket(w http.ResponseWriter, r *http.Request) {
// HandleWebSSH proxies an SSH session to the browser over WebSocket. // HandleWebSSH proxies an SSH session to the browser over WebSocket.
func HandleWebSSH(w http.ResponseWriter, r *http.Request) { func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
ticket := r.URL.Query().Get("ticket") ticket := webSSHTicketFromRequest(r)
if ticket == "" { if ticket == "" {
http.Error(w, "ticket required", http.StatusUnauthorized) http.Error(w, "ticket required", http.StatusUnauthorized)
return return
@@ -114,7 +115,11 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
http.Error(w, "container ip is not available", http.StatusBadRequest) http.Error(w, "container ip is not available", http.StatusBadRequest)
return 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 { if err != nil {
log.Printf("WebSSH upgrade failed: %v", err) log.Printf("WebSSH upgrade failed: %v", err)
return return
@@ -141,7 +146,7 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
Auth: []ssh.AuthMethod{ Auth: []ssh.AuthMethod{
ssh.Password(c.SSHPassword), ssh.Password(c.SSHPassword),
}, },
HostKeyCallback: ssh.InsecureIgnoreHostKey(), HostKeyCallback: containerHostKeyCallback(c),
Timeout: 4 * time.Second, Timeout: 4 * time.Second,
} }
@@ -250,6 +255,41 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
log.Printf("WebSSH disconnected for container %s", containerName) 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{}) { func streamSSHOutput(ws *websocket.Conn, writeMu *sync.Mutex, src io.Reader, done chan<- struct{}) {
defer func() { done <- struct{}{} }() defer func() { done <- struct{}{} }()
+74 -39
View File
@@ -21,6 +21,28 @@ func generateRandomStr(length int) string {
return hex.EncodeToString(b)[:length] 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 // HandleSubUserCreate creates a sub-user for a specific container
func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) { func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { 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 // Check if sub-user already exists for this container
for i := range config.AppConfig.SubUsers { for i := range config.AppConfig.SubUsers {
su := &config.AppConfig.SubUsers[i] su := &config.AppConfig.SubUsers[i]
for _, cn := range su.ContainerNames { for _, uuid := range su.ContainerUUIDs {
if cn == containerName { if uuid == c.UUID {
if su.AccessCode == "" { if su.AccessCode == "" {
su.AccessCode = generateRandomStr(8) su.AccessCode = generateRandomStr(8)
} }
if su.PassHash == "" && su.Password != "" { password := generateRandomStr(16)
if hash, err := bcrypt.GenerateFromPassword([]byte(su.Password), bcrypt.DefaultCost); err == nil { if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil {
su.PassHash = string(hash) su.PassHash = string(hash)
}
} }
if su.Password == "" { su.Password = ""
su.Password = generateRandomStr(16) su.Token = ""
if hash, err := bcrypt.GenerateFromPassword([]byte(su.Password), bcrypt.DefaultCost); err == nil { su.ContainerNames = appendUniqueString(su.ContainerNames, containerName)
su.PassHash = string(hash) su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID)
}
}
su.Token = newSubUserToken(su.Username, []string{c.UUID}, time.Now().AddDate(1, 0, 0))
config.SaveConfig() config.SaveConfig()
// Return existing
jsonResponse(w, http.StatusOK, APIResponse{ jsonResponse(w, http.StatusOK, APIResponse{
Success: true, Success: true,
Message: "Sub-user already exists", Message: "Sub-user password rotated",
Data: *su, Data: newSubUserResponse(*su, password),
}) })
return return
} }
@@ -84,16 +101,12 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
// Generate short access code (8 chars, for URL sharing) // Generate short access code (8 chars, for URL sharing)
accessCode := generateRandomStr(8) accessCode := generateRandomStr(8)
// Generate JWT for sub-user
tokenStr := newSubUserToken(username, []string{c.UUID}, time.Now().AddDate(1, 0, 0))
subUser := config.SubUser{ subUser := config.SubUser{
ID: "sub-" + generateRandomStr(8), ID: "sub-" + generateRandomStr(8),
Username: username, Username: username,
Password: password,
PassHash: string(hash), PassHash: string(hash),
ContainerNames: []string{containerName}, ContainerNames: []string{containerName},
Token: tokenStr, ContainerUUIDs: []string{c.UUID},
AccessCode: accessCode, AccessCode: accessCode,
CreatedAt: time.Now().Format("2006-01-02 15:04:05"), 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.SaveConfig()
config.AddAuditLog("创建子用户", containerName, fmt.Sprintf("用户: %s", username), "admin") 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 // HandleSubUserLogin handles sub-user login
@@ -126,7 +139,7 @@ func HandleSubUserLogin(w http.ResponseWriter, r *http.Request) {
if su.Username == req.Username { if su.Username == req.Username {
if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err == nil { if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err == nil {
// Generate fresh token // Generate fresh token
containerUUIDs := subUserContainerUUIDs(su.ContainerNames) containerUUIDs := activeSubUserContainerUUIDs(&su)
if len(containerUUIDs) == 0 { if len(containerUUIDs) == 0 {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this user"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this user"})
return return
@@ -173,7 +186,7 @@ func HandleSubUserAccessCode(w http.ResponseWriter, r *http.Request) {
return return
} }
containerUUIDs := subUserContainerUUIDs(su.ContainerNames) containerUUIDs := activeSubUserContainerUUIDs(&su)
if len(containerUUIDs) == 0 { if len(containerUUIDs) == 0 {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this link"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this link"})
return return
@@ -224,18 +237,6 @@ func subUserAllowedContainers(r *http.Request) (subUserAccess, bool) {
names: make(map[string]bool), names: make(map[string]bool),
uuids: 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 { if containerUUIDs, ok := claims["container_uuids"].([]interface{}); ok {
for _, item := range containerUUIDs { for _, item := range containerUUIDs {
if uuid, ok := item.(string); ok { if uuid, ok := item.(string); ok {
@@ -359,15 +360,27 @@ func filterTasksForRequest(r *http.Request, tasks []*Task) []*Task {
} }
filtered := make([]*Task, 0, len(tasks)) filtered := make([]*Task, 0, len(tasks))
for _, task := range 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) 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 return filtered
} }
func isContainerAllowed(allowed subUserAccess, c *config.Container) bool { 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 { 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 { func subUserContainerUUIDs(containerNames []string) []string {
uuids := make([]string, 0, len(containerNames)) uuids := make([]string, 0, len(containerNames))
for _, name := range containerNames { for _, name := range containerNames {
if c := config.FindContainerByName(name); c != nil && c.UUID != "" { if c := config.FindContainerByName(name); c != nil && c.UUID != "" {
uuids = append(uuids, c.UUID) uuids = appendUniqueString(uuids, c.UUID)
} }
} }
return uuids 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 { func splitPath(path string) []string {
parts := make([]string, 0) parts := make([]string, 0)
for _, p := range splitBy(path, "/") { for _, p := range splitBy(path, "/") {
+8
View File
@@ -442,6 +442,10 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
templateID = c.Template templateID = c.Template
} }
} }
if !isTemplateEnabledAndDownloaded(templateID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return
}
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"})
@@ -504,6 +508,10 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
if req.Containers[i].DiskGB < 1 { if req.Containers[i].DiskGB < 1 {
req.Containers[i].DiskGB = 5 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 { if req.Containers[i].PortMappingCount < 2 {
req.Containers[i].PortMappingCount = 2 req.Containers[i].PortMappingCount = 2
} }
+61 -1
View File
@@ -399,7 +399,67 @@ func destroyAllLXCContainers() {
fmt.Printf("Destroying LXC container %s...\n", name) fmt.Printf("Destroying LXC container %s...\n", name)
runQuiet("lxc-stop", "-n", name, "-k") runQuiet("lxc-stop", "-n", name, "-k")
runQuiet("lxc-destroy", "-n", name, "-f") 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])
}
}
} }
} }
+64 -6
View File
@@ -91,6 +91,7 @@ type Container struct {
VNCPort int `json:"vnc_port"` VNCPort int `json:"vnc_port"`
SSHPort int `json:"ssh_port"` SSHPort int `json:"ssh_port"`
SSHPassword string `json:"ssh_password"` SSHPassword string `json:"ssh_password"`
SSHHostKey string `json:"ssh_host_key,omitempty"`
PortMappings []PortMapping `json:"port_mappings"` PortMappings []PortMapping `json:"port_mappings"`
PortMappingLimit int `json:"port_mapping_limit"` PortMappingLimit int `json:"port_mapping_limit"`
SnapshotLimit int `json:"snapshot_limit"` SnapshotLimit int `json:"snapshot_limit"`
@@ -138,10 +139,11 @@ func DeleteApiKey(id string) {
type SubUser struct { type SubUser struct {
ID string `json:"id"` ID string `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` // plaintext for display Password string `json:"-"`
PassHash string `json:"pass_hash"` PassHash string `json:"pass_hash"`
ContainerNames []string `json:"container_names"` ContainerNames []string `json:"container_names"`
Token string `json:"token"` ContainerUUIDs []string `json:"container_uuids,omitempty"`
Token string `json:"-"`
AccessCode string `json:"access_code"` AccessCode string `json:"access_code"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
} }
@@ -345,6 +347,9 @@ func InitConfig() (*ClicdConfig, error) {
if ensureContainerSnapshotScheduleDefaults() { if ensureContainerSnapshotScheduleDefaults() {
changed = true changed = true
} }
if migrateSubUsers() {
changed = true
}
if removeLegacyVNCMappings() { if removeLegacyVNCMappings() {
changed = true changed = true
} }
@@ -422,6 +427,47 @@ func ensureContainerSnapshotLimits() bool {
return changed 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 { func NormalizeSnapshotLimit(limit int) int {
if limit <= 0 { if limit <= 0 {
return DefaultSnapshotLimit return DefaultSnapshotLimit
@@ -488,7 +534,7 @@ func AllocateContainerID() int {
func RemoveContainer(id int) bool { func RemoveContainer(id int) bool {
for i, c := range AppConfig.Containers { for i, c := range AppConfig.Containers {
if c.ID == id { if c.ID == id {
removeSubUserContainerAccess(c.Name) removeSubUserContainerAccess(c.Name, c.UUID)
removeContainerSnapshotMetadata(id) removeContainerSnapshotMetadata(id)
AppConfig.Containers = append(AppConfig.Containers[:i], AppConfig.Containers[i+1:]...) AppConfig.Containers = append(AppConfig.Containers[:i], AppConfig.Containers[i+1:]...)
SaveConfig() SaveConfig()
@@ -543,8 +589,13 @@ func removeContainerSnapshotMetadata(containerID int) {
AppConfig.Snapshots = filtered AppConfig.Snapshots = filtered
} }
func removeSubUserContainerAccess(containerName string) { func RemoveSubUserContainerAccess(containerName string, containerUUID string) {
if containerName == "" || len(AppConfig.SubUsers) == 0 { removeSubUserContainerAccess(containerName, containerUUID)
SaveConfig()
}
func removeSubUserContainerAccess(containerName string, containerUUID string) {
if containerName == "" && containerUUID == "" || len(AppConfig.SubUsers) == 0 {
return return
} }
filteredUsers := make([]SubUser, 0, len(AppConfig.SubUsers)) filteredUsers := make([]SubUser, 0, len(AppConfig.SubUsers))
@@ -555,10 +606,17 @@ func removeSubUserContainerAccess(containerName string) {
filteredNames = append(filteredNames, name) 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 continue
} }
su.ContainerNames = filteredNames su.ContainerNames = filteredNames
su.ContainerUUIDs = filteredUUIDs
filteredUsers = append(filteredUsers, su) filteredUsers = append(filteredUsers, su)
} }
AppConfig.SubUsers = filteredUsers AppConfig.SubUsers = filteredUsers
+234 -1
View File
@@ -428,6 +428,12 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) {
if err := m.applyIPv6Config(c.LxcName(), c.IPv6); err != nil { if err := m.applyIPv6Config(c.LxcName(), c.IPv6); err != nil {
return nil, err 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 { if err := m.ApplyIPv6(id); err != nil {
return nil, err return nil, err
} }
@@ -477,6 +483,12 @@ func (m *Manager) ApplyIPv6(id int) error {
config.SaveConfig() 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 { if err := ensureHostIPv6Routing(c.IPv6, c.IPv6Interface); err != nil {
return err return err
} }
@@ -485,12 +497,16 @@ func (m *Manager) ApplyIPv6(id int) error {
return nil return nil
} }
cmd := exec.Command("lxc-attach", "-n", c.LxcName(), "--", "sh", "-c", 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))) shellQuote(c.IPv6), shellQuote(ipv6GatewayLinkLocal)))
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return fmt.Errorf("failed to apply IPv6 inside container: %v, output: %s", err, string(output)) 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 return nil
} }
@@ -513,6 +529,191 @@ func ensureHostIPv6Routing(ipv6, uplink string) error {
return nil 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) { func ensureIPv6ForwardRules(ipv6 string) {
rules := [][]string{ rules := [][]string{
{"FORWARD", "-i", "lxcbr0", "-s", ipv6 + "/128", "-j", "ACCEPT"}, {"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) { func runQuiet(name string, args ...string) {
_ = exec.Command(name, args...).Run() _ = exec.Command(name, args...).Run()
} }
+14
View File
@@ -367,6 +367,11 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
// Pre-configure network and SSH in the rootfs before first boot. // Pre-configure network and SSH in the rootfs before first boot.
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
m.preconfigureNetwork(rootfsPath, cfg.TemplateID) 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 { if err := m.preconfigureSSH(rootfsPath, sshPassword, 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)
} }
@@ -1419,6 +1424,9 @@ func (m *Manager) DestroyContainer(id int) error {
return fmt.Errorf("container not found: %d", id) return fmt.Errorf("container not found: %d", id)
} }
lxcName := c.LxcName() lxcName := c.LxcName()
if c.IPv6 != "" && c.IPv6Interface != "" {
removeIPv6NAT66(c.IPv6, c.IPv6Interface)
}
if err := m.StopContainer(id); err != nil { if err := m.StopContainer(id); err != nil {
return fmt.Errorf("failed to stop container before destroy: %v", err) 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. // Set root password and pre-configure network/SSH via chroot.
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
m.preconfigureNetwork(rootfsPath, templateID) 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 == "" { if c.SSHPassword == "" {
c.SSHPassword = generateRandomString(16) 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 // Update template and keep everything else the same
c.Template = templateID c.Template = templateID
c.SSHHostKey = ""
c.Status = "running" c.Status = "running"
config.SaveConfig() config.SaveConfig()
+40 -3
View File
@@ -3,7 +3,9 @@ package server
import ( import (
"fmt" "fmt"
"log" "log"
"net"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
@@ -18,12 +20,19 @@ 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) {
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-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
w.Header().Set("Access-Control-Allow-Credentials", "true")
if r.Method == http.MethodOptions { 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) w.WriteHeader(http.StatusOK)
return 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 // setupRoutes configures API and static routes
func setupRoutes(mux *http.ServeMux) { func setupRoutes(mux *http.ServeMux) {
// API routes // API routes
+2 -3
View File
@@ -19,11 +19,10 @@ export default function WebSSHViewer({ containerName, onClose }: WebSSHViewerPro
const [status, setStatus] = useState<'connecting' | 'preparing' | 'connected' | 'disconnected' | 'error'>('connecting') const [status, setStatus] = useState<'connecting' | 'preparing' | 'connected' | 'disconnected' | 'error'>('connecting')
const [errorMsg, setErrorMsg] = useState('') const [errorMsg, setErrorMsg] = useState('')
const buildWebSSHUrl = (ticket: string) => { const buildWebSSHUrl = () => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const params = new URLSearchParams({ const params = new URLSearchParams({
container: containerName, container: containerName,
ticket,
}) })
return `${protocol}//${window.location.host}/api/ssh?${params.toString()}` return `${protocol}//${window.location.host}/api/ssh?${params.toString()}`
} }
@@ -106,7 +105,7 @@ export default function WebSSHViewer({ containerName, onClose }: WebSSHViewerPro
return return
} }
const ws = new WebSocket(buildWebSSHUrl(ticket)) const ws = new WebSocket(buildWebSSHUrl(), [`clicd-ticket.${ticket}`])
ws.binaryType = 'arraybuffer' ws.binaryType = 'arraybuffer'
wsRef.current = ws wsRef.current = ws
+1 -4
View File
@@ -41,9 +41,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (savedToken) { if (savedToken) {
const payload = decodeTokenPayload(savedToken) const payload = decodeTokenPayload(savedToken)
const nextUsername = payload?.username || payload?.sub_user || savedUsername || null const nextUsername = payload?.username || payload?.sub_user || savedUsername || null
const nextContainerIdentifiers = Array.isArray(payload?.container_uuids) && payload.container_uuids.length > 0 const nextContainerIdentifiers = Array.isArray(payload?.container_uuids) ? payload.container_uuids : []
? payload.container_uuids
: Array.isArray(payload?.container_names) ? payload.container_names : []
setToken(savedToken) setToken(savedToken)
setUsername(nextUsername) setUsername(nextUsername)
@@ -123,7 +121,6 @@ export function useAuth() {
type TokenPayload = { type TokenPayload = {
username?: string username?: string
sub_user?: string sub_user?: string
container_names?: string[]
container_uuids?: string[] container_uuids?: string[]
} }
+1 -2
View File
@@ -454,10 +454,9 @@ export const batchAction = (action: string, containers: number[], templateId?: s
export interface SubUser { export interface SubUser {
id: string id: string
username: string username: string
password: string password?: string
container_names: string[] container_names: string[]
container_uuids?: string[] container_uuids?: string[]
token: string
access_code: string access_code: string
created_at: string created_at: string
} }
+69 -8
View File
@@ -71,6 +71,73 @@ remove_path() {
log "Removed $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() { uninstall_clicd() {
log "Uninstalling CLICD..." log "Uninstalling CLICD..."
@@ -89,14 +156,7 @@ uninstall_clicd() {
log "Destroying LXC containers under /var/lib/lxc..." log "Destroying LXC containers under /var/lib/lxc..."
for container_dir in /var/lib/lxc/*; do for container_dir in /var/lib/lxc/*; do
[ -d "$container_dir" ] || continue [ -d "$container_dir" ] || continue
container_name="$(basename "$container_dir")" remove_lxc_container_dir "$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"
done done
remove_path /etc/systemd/system/clicd.service remove_path /etc/systemd/system/clicd.service
@@ -106,6 +166,7 @@ uninstall_clicd() {
remove_path /var/log/clicd.log remove_path /var/log/clicd.log
remove_path /var/log/clicd.err remove_path /var/log/clicd.err
remove_path /root/.clicd remove_path /root/.clicd
unmount_path_tree /var/lib/lxc
remove_path /var/lib/lxc remove_path /var/lib/lxc
remove_path /var/cache/lxc remove_path /var/cache/lxc