修复一些已知问题,优化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-1.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 (
"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)
}
+2 -5
View File
@@ -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
}
+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"})
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})
}
+9
View File
@@ -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.
+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 (
"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{}{} }()
+74 -39
View File
@@ -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, "/") {
+8
View File
@@ -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
}
+61 -1
View File
@@ -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])
}
}
}
}
+64 -6
View File
@@ -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
+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 {
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()
}
+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.
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()
+40 -3
View File
@@ -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
+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 [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
+1 -4
View File
@@ -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[]
}
+1 -2
View File
@@ -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
}
+69 -8
View File
@@ -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