From fdd83977fcc42a151b116b6f9bc16963c819035a Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:47:26 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E9=99=90=E5=88=B6=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=8F=AF=E9=80=89=E6=8B=A9=E7=9A=84=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/api/handlers.go | 6 + backend/internal/api/host.go | 36 ++- backend/internal/api/images.go | 64 ++++- backend/internal/api/subuser.go | 271 +++++++++++++++--- backend/internal/api/taskqueue.go | 16 +- backend/internal/config/config.go | 24 +- backend/internal/config/store_sqlite.go | 126 ++++---- backend/internal/kvm/kvm.go | 16 +- backend/internal/lxc/lxc.go | 132 +++++---- backend/internal/server/web/.gitkeep | 1 + .../src/components/CreateContainerModal.tsx | 56 +++- frontend/src/pages/ContainerDetail.tsx | 8 +- frontend/src/pages/SubUserManagement.tsx | 129 ++++++++- frontend/src/services/api.ts | 12 +- 14 files changed, 707 insertions(+), 190 deletions(-) diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index aa9ed92..5f08f87 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -240,6 +240,12 @@ func createContainer(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"}) return } + if ids, err := normalizeAllowedImageIDs(cfg.AllowedImageIDs); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) + return + } else { + cfg.AllowedImageIDs = ids + } if cfg.VCPU <= 0 { cfg.VCPU = 1 } diff --git a/backend/internal/api/host.go b/backend/internal/api/host.go index 1c5dc05..c05a1b4 100644 --- a/backend/internal/api/host.go +++ b/backend/internal/api/host.go @@ -22,12 +22,13 @@ import ( ) type HostInfo struct { - CPU CpuInfo `json:"cpu"` - RAM MemoryInfo `json:"ram"` - Disk DiskInfo `json:"disk"` - Network NetworkInfo `json:"network"` - DiskIO DiskIOInfo `json:"disk_io"` - Load LoadInfo `json:"load"` + CPU CpuInfo `json:"cpu"` + RAM MemoryInfo `json:"ram"` + Disk DiskInfo `json:"disk"` + Network NetworkInfo `json:"network"` + DiskIO DiskIOInfo `json:"disk_io"` + Load LoadInfo `json:"load"` + Runtime HostRuntimeProbe `json:"runtime"` } type HostProbeReport struct { @@ -247,9 +248,32 @@ func getHostInfo() HostInfo { info.CPU.Usage = getCPUUsage() info.Network, info.DiskIO = getHostRates() info.Load = getLoadInfo() + info.Runtime = detectRuntimeProbeQuick() return info } +func detectRuntimeProbeQuick() HostRuntimeProbe { + devKVM := fileExists("/dev/kvm") + nested, detail := detectNestedVirtualization() + lxcOK := commandExists("lxc-create") + kvmSupportedArch := runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64" + kvmOK := kvmSupportedArch && devKVM && commandExists("virsh") && commandExists(kvmQEMUCheckKey()) + probe := HostRuntimeProbe{ + LXCAvailable: lxcOK, + KVMAvailable: kvmOK, + DevKVM: devKVM, + NestedVirtualization: nested, + NestedDetail: detail, + SupportMode: "unsupported", + } + if probe.KVMAvailable { + probe.SupportMode = "kvm_lxc" + } else if probe.LXCAvailable { + probe.SupportMode = "lxc_only" + } + return probe +} + func getMemoryInfo() MemoryInfo { f, err := os.Open("/proc/meminfo") if err != nil { diff --git a/backend/internal/api/images.go b/backend/internal/api/images.go index 8f8f0b5..3790c73 100644 --- a/backend/internal/api/images.go +++ b/backend/internal/api/images.go @@ -541,6 +541,24 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) { runtime := runtimeFromRequest(r.URL.Query().Get("type")) enabledSet := getEnabledImageSet() + var subUser *config.SubUser + var targetContainer *config.Container + currentImageIDs := map[string]bool{} + if isSubUserRequest(r) { + subUser = subUserFromRequest(r) + if identifier := r.URL.Query().Get("container"); identifier != "" { + targetContainer = containerByIdentifier(identifier) + if targetContainer == nil || !isContainerAllowedForRequest(r, identifier) { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"}) + return + } + currentImageIDs[targetContainer.Template] = true + } else { + for _, id := range subUserCurrentImageIDs(subUser) { + currentImageIDs[id] = true + } + } + } result := make([]map[string]string, 0) if runtime == config.VirtualizationKVM { @@ -549,7 +567,10 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) { return } for _, t := range kvm.GetImages() { - if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); enabledSet[t.ID] && downloaded { + if subUser != nil && !isImageAllowedForSubUser(subUser, targetContainer, t.ID) { + continue + } + if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); downloaded && (enabledSet[t.ID] || currentImageIDs[t.ID]) { result = append(result, map[string]string{ "id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch, "description": t.Description, "type": config.VirtualizationKVM, "desktop": t.Desktop, @@ -558,7 +579,10 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) { } } else { for _, t := range lxc.GetTemplates() { - if enabledSet[t.ID] && isImageDownloaded(t.Distro, t.Release, t.Arch) { + if subUser != nil && !isImageAllowedForSubUser(subUser, targetContainer, t.ID) { + continue + } + if downloaded := isImageDownloaded(t.Distro, t.Release, t.Arch); downloaded && (enabledSet[t.ID] || currentImageIDs[t.ID]) { result = append(result, map[string]string{ "id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch, "variant": t.Variant, "description": t.Description, "type": config.VirtualizationLXC, @@ -574,6 +598,42 @@ func isTemplateEnabledAndDownloaded(templateID string) bool { return isImageEnabledAndDownloaded(templateID, runtimeFromTemplateID(templateID)) } +func imageTemplateExists(templateID string) bool { + return lxc.FindTemplate(templateID) != nil || kvm.FindImage(templateID) != nil +} + +func isImageDownloadedForRuntime(templateID string, runtime string) bool { + runtime = runtimeFromRequest(runtime) + if runtime == config.VirtualizationKVM { + if !hostKVMAvailable() { + return false + } + image := kvm.FindImage(templateID) + if image == nil { + return false + } + downloaded, _ := kvm.ImageDownloadedInfo(image.ID) + return downloaded + } + tmpl := lxc.FindTemplate(templateID) + if tmpl == nil { + return false + } + return isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) +} + +func isTemplateAvailableForRequest(r *http.Request, c *config.Container, templateID string, runtime string) bool { + if isSubUserRequest(r) { + if !isTemplateAllowedForRequest(r, c, templateID) { + return false + } + if c != nil && c.Template == templateID { + return isImageDownloadedForRuntime(templateID, runtime) + } + } + return isImageEnabledAndDownloaded(templateID, runtime) +} + func isImageEnabledAndDownloaded(templateID string, runtime string) bool { runtime = runtimeFromRequest(runtime) if runtime == config.VirtualizationKVM { diff --git a/backend/internal/api/subuser.go b/backend/internal/api/subuser.go index 7713e16..1cd10b1 100644 --- a/backend/internal/api/subuser.go +++ b/backend/internal/api/subuser.go @@ -22,24 +22,30 @@ func generateRandomStr(length int) string { } 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"` + ID string `json:"id"` + Username string `json:"username"` + Password string `json:"password,omitempty"` + ContainerNames []string `json:"container_names"` + ContainerUUIDs []string `json:"container_uuids,omitempty"` + AllowedImageIDs []string `json:"allowed_image_ids,omitempty"` + ImageLimitConfigured bool `json:"image_limit_configured,omitempty"` + CurrentImageIDs []string `json:"current_image_ids,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, + ID: su.ID, + Username: su.Username, + Password: password, + ContainerNames: su.ContainerNames, + ContainerUUIDs: su.ContainerUUIDs, + AllowedImageIDs: effectiveSubUserAllowedImageIDs(&su), + ImageLimitConfigured: su.ImageLimitConfigured, + CurrentImageIDs: subUserCurrentImageIDs(&su), + AccessCode: su.AccessCode, + CreatedAt: su.CreatedAt, } } @@ -94,6 +100,10 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) { } su.ContainerNames = appendUniqueString(su.ContainerNames, containerName) su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID) + if !su.ImageLimitConfigured && len(su.AllowedImageIDs) == 0 { + su.AllowedImageIDs = effectiveContainerAllowedImageIDs(c) + su.ImageLimitConfigured = true + } config.SaveConfig() jsonResponse(w, http.StatusOK, APIResponse{ Success: true, @@ -114,14 +124,16 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) { accessCode := generateRandomStr(8) subUser := config.SubUser{ - ID: "sub-" + generateRandomStr(8), - Username: username, - Password: password, - PassHash: string(hash), - ContainerNames: []string{containerName}, - ContainerUUIDs: []string{c.UUID}, - AccessCode: accessCode, - CreatedAt: time.Now().Format("2006-01-02 15:04:05"), + ID: "sub-" + generateRandomStr(8), + Username: username, + Password: password, + PassHash: string(hash), + ContainerNames: []string{containerName}, + ContainerUUIDs: []string{c.UUID}, + AllowedImageIDs: effectiveContainerAllowedImageIDs(c), + ImageLimitConfigured: true, + AccessCode: accessCode, + CreatedAt: time.Now().Format("2006-01-02 15:04:05"), } config.AppConfig.SubUsers = append(config.AppConfig.SubUsers, subUser) @@ -306,6 +318,155 @@ func requestAllowedContainers(r *http.Request) (subUserAccess, bool) { return subUserAllowedContainers(r) } +func subUserFromRequest(r *http.Request) *config.SubUser { + username := "" + if ctx, ok := authContextFromRequest(r); ok && ctx.Type == authTypeSubUser { + username = ctx.Username + } + if username == "" { + if claims, ok := claimsFromRequest(r); ok { + username, _ = claims["sub_user"].(string) + } + } + if username == "" { + return nil + } + for i := range config.AppConfig.SubUsers { + if config.AppConfig.SubUsers[i].Username == username { + return &config.AppConfig.SubUsers[i] + } + } + return nil +} + +func normalizeAllowedImageIDs(ids []string) ([]string, error) { + seen := map[string]bool{} + result := make([]string, 0, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" || seen[id] { + continue + } + if !imageTemplateExists(id) { + return nil, fmt.Errorf("unknown image template: %s", id) + } + seen[id] = true + result = append(result, id) + } + return result, nil +} + +func isTemplateAllowedForRequest(r *http.Request, c *config.Container, templateID string) bool { + if !isSubUserRequest(r) { + return true + } + return isImageAllowedForSubUser(subUserFromRequest(r), c, templateID) +} + +func isImageAllowedForSubUser(su *config.SubUser, c *config.Container, templateID string) bool { + if su == nil || strings.TrimSpace(templateID) == "" { + return false + } + for _, id := range effectiveSubUserAllowedImageIDs(su) { + if id == templateID { + return true + } + } + return false +} + +func effectiveContainerAllowedImageIDs(c *config.Container) []string { + if c == nil { + return nil + } + if c.ImageLimitConfigured || len(c.AllowedImageIDs) > 0 { + return cleanImageIDList(c.AllowedImageIDs) + } + if c.Template != "" { + return []string{c.Template} + } + return nil +} + +func effectiveSubUserAllowedImageIDs(su *config.SubUser) []string { + if su == nil { + return nil + } + if su.ImageLimitConfigured || len(su.AllowedImageIDs) > 0 { + return cleanImageIDList(su.AllowedImageIDs) + } + result := []string{} + seen := map[string]bool{} + for _, c := range subUserAssignedContainers(su) { + for _, id := range effectiveContainerAllowedImageIDs(c) { + if id != "" && !seen[id] { + seen[id] = true + result = append(result, id) + } + } + } + return result +} + +func cleanImageIDList(ids []string) []string { + result := make([]string, 0, len(ids)) + seen := map[string]bool{} + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" || seen[id] { + continue + } + seen[id] = true + result = append(result, id) + } + return result +} + +func subUserCurrentImageIDs(su *config.SubUser) []string { + seen := map[string]bool{} + result := []string{} + for _, c := range subUserAssignedContainers(su) { + if c.Template != "" && !seen[c.Template] { + seen[c.Template] = true + result = append(result, c.Template) + } + } + return result +} + +func subUserAssignedContainers(su *config.SubUser) []*config.Container { + if su == nil { + return nil + } + result := make([]*config.Container, 0, len(su.ContainerUUIDs)+len(su.ContainerNames)) + seen := map[string]bool{} + for _, uuid := range su.ContainerUUIDs { + if c := config.FindContainerByUUID(uuid); c != nil { + key := c.UUID + if key == "" { + key = c.Name + } + if !seen[key] { + seen[key] = true + result = append(result, c) + } + } + } + for _, name := range su.ContainerNames { + if c := config.FindContainerByName(name); c != nil { + key := c.UUID + if key == "" { + key = c.Name + } + if !seen[key] { + seen[key] = true + result = append(result, c) + } + } + } + return result +} + func isAccessRestrictedRequest(r *http.Request) bool { _, restricted := requestAllowedContainers(r) return restricted @@ -580,18 +741,21 @@ func splitBy(s, sep string) []string { // SubUserListItem is the enriched sub-user info returned by the list API type SubUserListItem struct { - ID string `json:"id"` - Username string `json:"username"` - ContainerNames []string `json:"container_names"` - ContainerUUIDs []string `json:"container_uuids"` - ContainerName string `json:"container_name"` - ContainerUUID string `json:"container_uuid"` - AccessCode string `json:"access_code"` - Password string `json:"password,omitempty"` - CreatedAt string `json:"created_at"` - LastLogin string `json:"last_login"` - LastLoginIP string `json:"last_login_ip"` - LastLoginUA string `json:"last_login_ua"` + ID string `json:"id"` + Username string `json:"username"` + ContainerNames []string `json:"container_names"` + ContainerUUIDs []string `json:"container_uuids"` + AllowedImageIDs []string `json:"allowed_image_ids"` + ImageLimitConfigured bool `json:"image_limit_configured"` + CurrentImageIDs []string `json:"current_image_ids"` + ContainerName string `json:"container_name"` + ContainerUUID string `json:"container_uuid"` + AccessCode string `json:"access_code"` + Password string `json:"password,omitempty"` + CreatedAt string `json:"created_at"` + LastLogin string `json:"last_login"` + LastLoginIP string `json:"last_login_ip"` + LastLoginUA string `json:"last_login_ua"` } // HandleSubUserList returns the list of all sub-users with container info @@ -607,13 +771,16 @@ func HandleSubUserList(w http.ResponseWriter, r *http.Request) { result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers)) for _, su := range config.AppConfig.SubUsers { item := SubUserListItem{ - ID: su.ID, - Username: su.Username, - ContainerNames: su.ContainerNames, - ContainerUUIDs: su.ContainerUUIDs, - AccessCode: su.AccessCode, - Password: su.Password, - CreatedAt: su.CreatedAt, + ID: su.ID, + Username: su.Username, + ContainerNames: su.ContainerNames, + ContainerUUIDs: su.ContainerUUIDs, + AllowedImageIDs: effectiveSubUserAllowedImageIDs(&su), + ImageLimitConfigured: su.ImageLimitConfigured, + CurrentImageIDs: subUserCurrentImageIDs(&su), + AccessCode: su.AccessCode, + Password: su.Password, + CreatedAt: su.CreatedAt, } // Resolve container name from first active UUID @@ -711,6 +878,28 @@ func HandleSubUserAction(w http.ResponseWriter, r *http.Request) { logs := filterSubUserLoginLogs(target.Username) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs}) + case action == "images" && r.Method == http.MethodPut: + if !requireScope(w, r, "subuser:update") { + return + } + var req struct { + AllowedImageIDs []string `json:"allowed_image_ids"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + ids, err := normalizeAllowedImageIDs(req.AllowedImageIDs) + if err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) + return + } + target.AllowedImageIDs = ids + target.ImageLimitConfigured = true + target.TokenVersion++ + config.SaveConfig() + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: newSubUserResponse(*target, target.Password)}) + default: jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"}) } diff --git a/backend/internal/api/taskqueue.go b/backend/internal/api/taskqueue.go index 195b290..6d46427 100644 --- a/backend/internal/api/taskqueue.go +++ b/backend/internal/api/taskqueue.go @@ -560,7 +560,11 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti if c := config.FindContainer(id); c != nil { runtime = c.Runtime() } - if !isImageEnabledAndDownloaded(templateID, runtime) { + if !isTemplateAllowedForRequest(r, c, templateID) { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not allowed for this user"}) + return + } + if !isTemplateAvailableForRequest(r, c, templateID, runtime) { jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"}) return } @@ -656,6 +660,12 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"}) return } + if ids, err := normalizeAllowedImageIDs(req.Containers[i].AllowedImageIDs); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()}) + return + } else { + req.Containers[i].AllowedImageIDs = ids + } if req.Containers[i].PortMappingCount < 0 { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot be negative"}) return @@ -777,6 +787,10 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to one or more containers"}) return } + if taskType == TaskReinstall && !isTemplateAllowedForRequest(r, c, req.TemplateID) { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: c.Name + ": template is not allowed for this user"}) + return + } if taskConfig != nil { if err := validateReinstallSSHAuth(c, req.TemplateID, *taskConfig); err != nil { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: c.Name + ": " + err.Error()}) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index fd8a437..1be379f 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -143,6 +143,8 @@ type Container struct { FirewallEnabled bool `json:"firewall_enabled"` FirewallDefaultAction string `json:"firewall_default_action"` FirewallRules []FirewallRule `json:"firewall_rules"` + AllowedImageIDs []string `json:"allowed_image_ids,omitempty"` + ImageLimitConfigured bool `json:"image_limit_configured,omitempty"` SnapshotLimit int `json:"snapshot_limit"` CreatedAt string `json:"created_at"` ExpiresAt string `json:"expires_at"` @@ -319,16 +321,18 @@ func DeleteApiKey(id string) { } type SubUser struct { - ID string `json:"id"` - Username string `json:"username"` - Password string `json:"password,omitempty"` - PassHash string `json:"pass_hash"` - ContainerNames []string `json:"container_names"` - ContainerUUIDs []string `json:"container_uuids,omitempty"` - Token string `json:"-"` - AccessCode string `json:"access_code"` - CreatedAt string `json:"created_at"` - TokenVersion int `json:"token_version"` + ID string `json:"id"` + Username string `json:"username"` + Password string `json:"password,omitempty"` + PassHash string `json:"pass_hash"` + ContainerNames []string `json:"container_names"` + ContainerUUIDs []string `json:"container_uuids,omitempty"` + AllowedImageIDs []string `json:"allowed_image_ids,omitempty"` + ImageLimitConfigured bool `json:"image_limit_configured,omitempty"` + Token string `json:"-"` + AccessCode string `json:"access_code"` + CreatedAt string `json:"created_at"` + TokenVersion int `json:"token_version"` } type Snapshot struct { diff --git a/backend/internal/config/store_sqlite.go b/backend/internal/config/store_sqlite.go index 02d337a..70cbcd9 100644 --- a/backend/internal/config/store_sqlite.go +++ b/backend/internal/config/store_sqlite.go @@ -20,37 +20,39 @@ var ( ) type savedTaskConfig struct { - Name string `json:"name"` - Virtualization string `json:"virtualization,omitempty"` - TemplateID string `json:"template_id"` - VCPU float64 `json:"vcpu"` - CPUPercent int `json:"cpu_percent"` - RAMMB int `json:"ram_mb"` - DiskGB int `json:"disk_gb"` - NetworkBWMbps int `json:"network_bw_mbps"` - NetworkDownMbps int `json:"network_down_mbps"` - NetworkUpMbps int `json:"network_up_mbps"` - MonthlyTrafficGB int `json:"monthly_traffic_gb"` - TrafficMode string `json:"traffic_mode"` - TrafficInGB int `json:"traffic_in_gb"` - TrafficOutGB int `json:"traffic_out_gb"` - IOSpeedMBps int `json:"io_speed_mbps"` - IOReadMBps int `json:"io_read_mbps"` - IOWriteMBps int `json:"io_write_mbps"` - ExtraPorts []int `json:"extra_ports"` - PortMappingCount int `json:"port_mapping_count"` - AssignNAT *bool `json:"assign_nat,omitempty"` - SnapshotLimit int `json:"snapshot_limit"` - AssignIPv4 bool `json:"assign_ipv4"` - IPv4Count int `json:"ipv4_count,omitempty"` - PublicIPv4s []string `json:"public_ipv4s,omitempty"` - AssignIPv6 bool `json:"assign_ipv6"` - IPv6Count int `json:"ipv6_count,omitempty"` - IPv6Addresses []string `json:"ipv6_addresses,omitempty"` - SSHAuthMode string `json:"ssh_auth_mode,omitempty"` - SSHPassword string `json:"ssh_password,omitempty"` - SSHPublicKey string `json:"ssh_public_key,omitempty"` - ExpiresAt string `json:"expires_at"` + Name string `json:"name"` + Virtualization string `json:"virtualization,omitempty"` + TemplateID string `json:"template_id"` + VCPU float64 `json:"vcpu"` + CPUPercent int `json:"cpu_percent"` + RAMMB int `json:"ram_mb"` + DiskGB int `json:"disk_gb"` + NetworkBWMbps int `json:"network_bw_mbps"` + NetworkDownMbps int `json:"network_down_mbps"` + NetworkUpMbps int `json:"network_up_mbps"` + MonthlyTrafficGB int `json:"monthly_traffic_gb"` + TrafficMode string `json:"traffic_mode"` + TrafficInGB int `json:"traffic_in_gb"` + TrafficOutGB int `json:"traffic_out_gb"` + IOSpeedMBps int `json:"io_speed_mbps"` + IOReadMBps int `json:"io_read_mbps"` + IOWriteMBps int `json:"io_write_mbps"` + ExtraPorts []int `json:"extra_ports"` + PortMappingCount int `json:"port_mapping_count"` + AssignNAT *bool `json:"assign_nat,omitempty"` + SnapshotLimit int `json:"snapshot_limit"` + AllowedImageIDs []string `json:"allowed_image_ids,omitempty"` + ImageLimitConfigured bool `json:"image_limit_configured,omitempty"` + AssignIPv4 bool `json:"assign_ipv4"` + IPv4Count int `json:"ipv4_count,omitempty"` + PublicIPv4s []string `json:"public_ipv4s,omitempty"` + AssignIPv6 bool `json:"assign_ipv6"` + IPv6Count int `json:"ipv6_count,omitempty"` + IPv6Addresses []string `json:"ipv6_addresses,omitempty"` + SSHAuthMode string `json:"ssh_auth_mode,omitempty"` + SSHPassword string `json:"ssh_password,omitempty"` + SSHPublicKey string `json:"ssh_public_key,omitempty"` + ExpiresAt string `json:"expires_at"` } func parseSavedTaskConfig(raw string) savedTaskConfig { @@ -222,7 +224,9 @@ func ensureSchema() error { snapshot_schedule_created_by TEXT, policy_blocked INTEGER, policy_blocked_reason TEXT, - policy_blocked_at TEXT + policy_blocked_at TEXT, + allowed_image_ids TEXT, + image_limit_configured INTEGER NOT NULL DEFAULT 0 )`, `CREATE TABLE IF NOT EXISTS port_mappings ( container_id INTEGER NOT NULL, @@ -258,7 +262,9 @@ func ensureSchema() error { pass_hash TEXT, access_code TEXT, created_at TEXT, - token_version INTEGER + token_version INTEGER, + allowed_image_ids TEXT, + image_limit_configured INTEGER NOT NULL DEFAULT 0 )`, `CREATE TABLE IF NOT EXISTS sub_user_container_names ( sub_user_id TEXT NOT NULL, @@ -348,6 +354,8 @@ func ensureSchema() error { cfg_ssh_auth_mode TEXT, cfg_ssh_password TEXT, cfg_ssh_public_key TEXT, + cfg_allowed_image_ids TEXT, + cfg_image_limit_configured INTEGER NOT NULL DEFAULT 0, cfg_expires_at TEXT )`, `CREATE TABLE IF NOT EXISTS task_extra_ports ( @@ -415,9 +423,13 @@ func ensureSchemaMigrations() error { {"tasks", "cfg_ssh_auth_mode", "TEXT"}, {"tasks", "cfg_ssh_password", "TEXT"}, {"tasks", "cfg_ssh_public_key", "TEXT"}, + {"tasks", "cfg_allowed_image_ids", "TEXT"}, + {"tasks", "cfg_image_limit_configured", "INTEGER NOT NULL DEFAULT 0"}, {"port_mappings", "host_ip", "TEXT"}, {"container_public_ipv4s", "prefix_len", "INTEGER"}, {"container_public_ipv4s", "gateway", "TEXT"}, + {"sub_users", "allowed_image_ids", "TEXT"}, + {"sub_users", "image_limit_configured", "INTEGER NOT NULL DEFAULT 0"}, {"containers", "network_down_mbps", "INTEGER NOT NULL DEFAULT 0"}, {"containers", "network_up_mbps", "INTEGER NOT NULL DEFAULT 0"}, {"containers", "io_read_mbps", "INTEGER NOT NULL DEFAULT 0"}, @@ -425,6 +437,8 @@ func ensureSchemaMigrations() error { {"containers", "firewall_enabled", "INTEGER NOT NULL DEFAULT 0"}, {"containers", "firewall_default_action", "TEXT NOT NULL DEFAULT 'DROP'"}, {"containers", "firewall_rules", "TEXT"}, + {"containers", "allowed_image_ids", "TEXT"}, + {"containers", "image_limit_configured", "INTEGER NOT NULL DEFAULT 0"}, } { wasAdded, err := ensureColumn(column.table, column.name, column.def) if err != nil { @@ -677,6 +691,7 @@ func saveMeta(tx *sql.Tx) error { func saveContainers(tx *sql.Tx) error { for _, c := range AppConfig.Containers { NormalizeContainerResourceAliases(&c) + allowedImageIDs := encodeStringSlice(c.AllowedImageIDs) if _, err := tx.Exec(`INSERT INTO containers ( id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template, vcpu, ram_mb, disk_gb, network_bw_mbps, network_down_mbps, network_up_mbps, @@ -688,8 +703,8 @@ func saveContainers(tx *sql.Tx) error { snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time, snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by, policy_blocked, policy_blocked_reason, policy_blocked_at, - firewall_enabled, firewall_default_action, firewall_rules - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + firewall_enabled, firewall_default_action, firewall_rules, allowed_image_ids, image_limit_configured + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.MACAddress, c.Template, c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.NetworkDownMbps, c.NetworkUpMbps, c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB, @@ -700,7 +715,7 @@ func saveContainers(tx *sql.Tx) error { boolInt(c.SnapshotScheduleEnabled), c.SnapshotScheduleIntervalHours, c.SnapshotScheduleTime, c.SnapshotScheduleLastRun, c.SnapshotScheduleNextRun, c.SnapshotScheduleCreatedBy, boolInt(c.PolicyBlocked), c.PolicyBlockedReason, c.PolicyBlockedAt, - boolInt(c.FirewallEnabled), normalizeFirewallDefaultAction(c.FirewallDefaultAction), marshalFirewallRules(c.FirewallRules), + boolInt(c.FirewallEnabled), normalizeFirewallDefaultAction(c.FirewallDefaultAction), marshalFirewallRules(c.FirewallRules), allowedImageIDs, boolInt(c.ImageLimitConfigured), ); err != nil { return err } @@ -728,8 +743,9 @@ func saveContainers(tx *sql.Tx) error { func saveSubUsers(tx *sql.Tx) error { for _, su := range AppConfig.SubUsers { - if _, err := tx.Exec(`INSERT INTO sub_users(id, username, password, pass_hash, access_code, created_at, token_version) - VALUES (?, ?, ?, ?, ?, ?, ?)`, su.ID, su.Username, su.Password, su.PassHash, su.AccessCode, su.CreatedAt, su.TokenVersion); err != nil { + allowedImageIDs := encodeStringSlice(su.AllowedImageIDs) + if _, err := tx.Exec(`INSERT INTO sub_users(id, username, password, pass_hash, access_code, created_at, token_version, allowed_image_ids, image_limit_configured) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, su.ID, su.Username, su.Password, su.PassHash, su.AccessCode, su.CreatedAt, su.TokenVersion, allowedImageIDs, boolInt(su.ImageLimitConfigured)); err != nil { return err } for i, name := range su.ContainerNames { @@ -840,8 +856,8 @@ func saveTasksDB(tx *sql.Tx) error { cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_io_read_mbps, cfg_io_write_mbps, cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit, cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses, - cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_expires_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_allowed_image_ids, cfg_image_limit_configured, cfg_expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent, cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB, cfg.NetworkBWMbps, cfg.NetworkDownMbps, cfg.NetworkUpMbps, @@ -850,7 +866,7 @@ func saveTasksDB(tx *sql.Tx) error { cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.SnapshotLimit, boolInt(cfg.AssignIPv4), cfg.IPv4Count, encodeStringSlice(cfg.PublicIPv4s), boolInt(cfg.AssignIPv6), cfg.IPv6Count, encodeStringSlice(cfg.IPv6Addresses), - cfg.SSHAuthMode, cfg.SSHPassword, cfg.SSHPublicKey, cfg.ExpiresAt, + cfg.SSHAuthMode, cfg.SSHPassword, cfg.SSHPublicKey, encodeStringSlice(cfg.AllowedImageIDs), boolInt(cfg.ImageLimitConfigured), cfg.ExpiresAt, ); err != nil { return err } @@ -904,7 +920,7 @@ func loadContainers() ([]Container, error) { snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time, snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by, policy_blocked, policy_blocked_reason, policy_blocked_at, - firewall_enabled, firewall_default_action, firewall_rules + firewall_enabled, firewall_default_action, firewall_rules, allowed_image_ids, image_limit_configured FROM containers ORDER BY id`) if err != nil { return nil, err @@ -914,9 +930,9 @@ func loadContainers() ([]Container, error) { result := []Container{} for rows.Next() { var c Container - var scheduleEnabled, policyBlocked, firewallEnabled int + var scheduleEnabled, policyBlocked, firewallEnabled, imageLimitConfigured int var firewallDefaultAction string - var firewallRulesJSON sql.NullString + var firewallRulesJSON, allowedImageIDs sql.NullString if err := rows.Scan( &c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &c.MACAddress, &c.Template, &c.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.NetworkDownMbps, &c.NetworkUpMbps, @@ -928,7 +944,7 @@ func loadContainers() ([]Container, error) { &scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime, &c.SnapshotScheduleLastRun, &c.SnapshotScheduleNextRun, &c.SnapshotScheduleCreatedBy, &policyBlocked, &c.PolicyBlockedReason, &c.PolicyBlockedAt, - &firewallEnabled, &firewallDefaultAction, &firewallRulesJSON, + &firewallEnabled, &firewallDefaultAction, &firewallRulesJSON, &allowedImageIDs, &imageLimitConfigured, ); err != nil { return nil, err } @@ -936,9 +952,11 @@ func loadContainers() ([]Container, error) { c.PolicyBlocked = policyBlocked != 0 c.FirewallEnabled = firewallEnabled != 0 c.FirewallDefaultAction = normalizeFirewallDefaultAction(firewallDefaultAction) + c.ImageLimitConfigured = imageLimitConfigured != 0 if firewallRulesJSON.Valid && strings.TrimSpace(firewallRulesJSON.String) != "" { _ = json.Unmarshal([]byte(firewallRulesJSON.String), &c.FirewallRules) } + c.AllowedImageIDs = decodeStringSlice(allowedImageIDs.String) NormalizeContainerResourceAliases(&c) result = append(result, c) } @@ -1034,7 +1052,7 @@ func loadContainerIPv6Addresses(containerID int) ([]IPv6Assignment, error) { } func loadSubUsers() ([]SubUser, error) { - rows, err := db.Query(`SELECT id, username, password, pass_hash, access_code, created_at, token_version FROM sub_users ORDER BY created_at, id`) + rows, err := db.Query(`SELECT id, username, password, pass_hash, access_code, created_at, token_version, allowed_image_ids, image_limit_configured FROM sub_users ORDER BY created_at, id`) if err != nil { return nil, err } @@ -1042,9 +1060,13 @@ func loadSubUsers() ([]SubUser, error) { result := []SubUser{} for rows.Next() { var su SubUser - if err := rows.Scan(&su.ID, &su.Username, &su.Password, &su.PassHash, &su.AccessCode, &su.CreatedAt, &su.TokenVersion); err != nil { + var allowedImageIDs sql.NullString + var imageLimitConfigured int + if err := rows.Scan(&su.ID, &su.Username, &su.Password, &su.PassHash, &su.AccessCode, &su.CreatedAt, &su.TokenVersion, &allowedImageIDs, &imageLimitConfigured); err != nil { return nil, err } + su.AllowedImageIDs = decodeStringSlice(allowedImageIDs.String) + su.ImageLimitConfigured = imageLimitConfigured != 0 result = append(result, su) } if err := rows.Err(); err != nil { @@ -1138,7 +1160,7 @@ func loadTasks() ([]SavedTask, error) { cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_io_read_mbps, cfg_io_write_mbps, cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit, cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses, - cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_expires_at + cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_allowed_image_ids, cfg_image_limit_configured, cfg_expires_at FROM tasks ORDER BY created_at, id`) if err != nil { return nil, err @@ -1149,9 +1171,9 @@ func loadTasks() ([]SavedTask, error) { for rows.Next() { var t SavedTask var cfg savedTaskConfig - var assignIPv4, assignIPv6 int + var assignIPv4, assignIPv6, imageLimitConfigured int var ip, userAgent, publicIPv4s, ipv6Addresses sql.NullString - var sshAuthMode, sshPassword, sshPublicKey sql.NullString + var sshAuthMode, sshPassword, sshPublicKey, allowedImageIDs sql.NullString var assignNAT, ipv4Count, ipv6Count sql.NullInt64 if err := rows.Scan( &t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent, @@ -1161,7 +1183,7 @@ func loadTasks() ([]SavedTask, error) { &cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.IOReadMBps, &cfg.IOWriteMBps, &cfg.PortMappingCount, &assignNAT, &cfg.SnapshotLimit, &assignIPv4, &ipv4Count, &publicIPv4s, &assignIPv6, &ipv6Count, &ipv6Addresses, - &sshAuthMode, &sshPassword, &sshPublicKey, &cfg.ExpiresAt, + &sshAuthMode, &sshPassword, &sshPublicKey, &allowedImageIDs, &imageLimitConfigured, &cfg.ExpiresAt, ); err != nil { return nil, err } @@ -1184,6 +1206,8 @@ func loadTasks() ([]SavedTask, error) { cfg.SSHAuthMode = sshAuthMode.String cfg.SSHPassword = sshPassword.String cfg.SSHPublicKey = sshPublicKey.String + cfg.AllowedImageIDs = decodeStringSlice(allowedImageIDs.String) + cfg.ImageLimitConfigured = imageLimitConfigured != 0 normalizeSavedTaskConfigLimits(&cfg) result = append(result, t) configs = append(configs, cfg) diff --git a/backend/internal/kvm/kvm.go b/backend/internal/kvm/kvm.go index 21aa5a9..1cb93f8 100644 --- a/backend/internal/kvm/kvm.go +++ b/backend/internal/kvm/kvm.go @@ -376,6 +376,10 @@ func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error { if cfg.SnapshotLimit <= 0 { cfg.SnapshotLimit = config.DefaultSnapshotLimit } + if !cfg.ImageLimitConfigured && len(cfg.AllowedImageIDs) == 0 && cfg.TemplateID != "" { + cfg.AllowedImageIDs = []string{cfg.TemplateID} + cfg.ImageLimitConfigured = true + } id := config.AllocateContainerID() vmName := fmt.Sprintf("vm-%d", id) @@ -567,11 +571,13 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig } return sshPassword }(), - PortMappings: portMappings, - PortMappingLimit: cfg.PortMappingCount, - SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit), - CreatedAt: now, - ExpiresAt: cfg.ExpiresAt, + PortMappings: portMappings, + PortMappingLimit: cfg.PortMappingCount, + AllowedImageIDs: append([]string(nil), cfg.AllowedImageIDs...), + ImageLimitConfigured: cfg.ImageLimitConfigured, + SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit), + CreatedAt: now, + ExpiresAt: cfg.ExpiresAt, } container.NormalizeNetworkAssignments() return container, nil diff --git a/backend/internal/lxc/lxc.go b/backend/internal/lxc/lxc.go index c8f02c0..4de59dc 100644 --- a/backend/internal/lxc/lxc.go +++ b/backend/internal/lxc/lxc.go @@ -218,37 +218,39 @@ func NewManager() *Manager { // ContainerConfig defines container creation parameters type ContainerConfig struct { - Name string `json:"name"` - Virtualization string `json:"virtualization,omitempty"` - TemplateID string `json:"template_id"` - VCPU float64 `json:"vcpu"` - CPUPercent int `json:"cpu_percent"` - RAMMB int `json:"ram_mb"` - DiskGB int `json:"disk_gb"` - NetworkBWMbps int `json:"network_bw_mbps"` - NetworkDownMbps int `json:"network_down_mbps"` - NetworkUpMbps int `json:"network_up_mbps"` - MonthlyTrafficGB int `json:"monthly_traffic_gb"` - TrafficMode string `json:"traffic_mode"` // "total" or "in_out" - TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited - TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited - IOSpeedMBps int `json:"io_speed_mbps"` - IOReadMBps int `json:"io_read_mbps"` - IOWriteMBps int `json:"io_write_mbps"` - ExtraPorts []int `json:"extra_ports"` - PortMappingCount int `json:"port_mapping_count"` - AssignNAT *bool `json:"assign_nat,omitempty"` - SnapshotLimit int `json:"snapshot_limit"` - AssignIPv4 bool `json:"assign_ipv4"` - IPv4Count int `json:"ipv4_count,omitempty"` - PublicIPv4s []string `json:"public_ipv4s,omitempty"` - AssignIPv6 bool `json:"assign_ipv6"` - IPv6Count int `json:"ipv6_count,omitempty"` - IPv6Addresses []string `json:"ipv6_addresses,omitempty"` - SSHAuthMode string `json:"ssh_auth_mode,omitempty"` - SSHPassword string `json:"ssh_password,omitempty"` - SSHPublicKey string `json:"ssh_public_key,omitempty"` - ExpiresAt string `json:"expires_at"` + Name string `json:"name"` + Virtualization string `json:"virtualization,omitempty"` + TemplateID string `json:"template_id"` + VCPU float64 `json:"vcpu"` + CPUPercent int `json:"cpu_percent"` + RAMMB int `json:"ram_mb"` + DiskGB int `json:"disk_gb"` + NetworkBWMbps int `json:"network_bw_mbps"` + NetworkDownMbps int `json:"network_down_mbps"` + NetworkUpMbps int `json:"network_up_mbps"` + MonthlyTrafficGB int `json:"monthly_traffic_gb"` + TrafficMode string `json:"traffic_mode"` // "total" or "in_out" + TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited + TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited + IOSpeedMBps int `json:"io_speed_mbps"` + IOReadMBps int `json:"io_read_mbps"` + IOWriteMBps int `json:"io_write_mbps"` + ExtraPorts []int `json:"extra_ports"` + PortMappingCount int `json:"port_mapping_count"` + AssignNAT *bool `json:"assign_nat,omitempty"` + SnapshotLimit int `json:"snapshot_limit"` + AllowedImageIDs []string `json:"allowed_image_ids,omitempty"` + ImageLimitConfigured bool `json:"image_limit_configured,omitempty"` + AssignIPv4 bool `json:"assign_ipv4"` + IPv4Count int `json:"ipv4_count,omitempty"` + PublicIPv4s []string `json:"public_ipv4s,omitempty"` + AssignIPv6 bool `json:"assign_ipv6"` + IPv6Count int `json:"ipv6_count,omitempty"` + IPv6Addresses []string `json:"ipv6_addresses,omitempty"` + SSHAuthMode string `json:"ssh_auth_mode,omitempty"` + SSHPassword string `json:"ssh_password,omitempty"` + SSHPublicKey string `json:"ssh_public_key,omitempty"` + ExpiresAt string `json:"expires_at"` } func (cfg *ContainerConfig) NormalizeResourceAliases() { @@ -306,6 +308,10 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { if cfg.SnapshotLimit <= 0 { cfg.SnapshotLimit = config.DefaultSnapshotLimit } + if !cfg.ImageLimitConfigured && len(cfg.AllowedImageIDs) == 0 && cfg.TemplateID != "" { + cfg.AllowedImageIDs = []string{cfg.TemplateID} + cfg.ImageLimitConfigured = true + } if !config.IsValidContainerName(cfg.Name) { return fmt.Errorf("invalid container name: %s", cfg.Name) @@ -424,37 +430,39 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { trafficResetDate := now[:7] // YYYY-MM for monthly tracking container := config.Container{ - ID: id, - UUID: config.NewContainerUUID(), - Name: cfg.Name, - Virtualization: config.VirtualizationLXC, - Template: cfg.TemplateID, - VCPU: cfg.VCPU, - RAMMB: cfg.RAMMB, - DiskGB: cfg.DiskGB, - NetworkBWMbps: cfg.NetworkBWMbps, - NetworkDownMbps: cfg.NetworkDownMbps, - NetworkUpMbps: cfg.NetworkUpMbps, - MonthlyTrafficGB: cfg.MonthlyTrafficGB, - TrafficMode: trafficMode, - TrafficInGB: cfg.TrafficInGB, - TrafficOutGB: cfg.TrafficOutGB, - TrafficResetDate: trafficResetDate, - IOSpeedMBps: cfg.IOSpeedMBps, - IOReadMBps: cfg.IOReadMBps, - IOWriteMBps: cfg.IOWriteMBps, - Status: "stopped", - IP: "", - PublicIPv4s: publicIPv4s, - IPv6Addresses: ipv6Assignments, - VNCPort: 0, - SSHPort: sshPort, - SSHPassword: sshPassword, - PortMappings: portMappings, - PortMappingLimit: cfg.PortMappingCount, - SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit), - CreatedAt: now, - ExpiresAt: cfg.ExpiresAt, + ID: id, + UUID: config.NewContainerUUID(), + Name: cfg.Name, + Virtualization: config.VirtualizationLXC, + Template: cfg.TemplateID, + VCPU: cfg.VCPU, + RAMMB: cfg.RAMMB, + DiskGB: cfg.DiskGB, + NetworkBWMbps: cfg.NetworkBWMbps, + NetworkDownMbps: cfg.NetworkDownMbps, + NetworkUpMbps: cfg.NetworkUpMbps, + MonthlyTrafficGB: cfg.MonthlyTrafficGB, + TrafficMode: trafficMode, + TrafficInGB: cfg.TrafficInGB, + TrafficOutGB: cfg.TrafficOutGB, + TrafficResetDate: trafficResetDate, + IOSpeedMBps: cfg.IOSpeedMBps, + IOReadMBps: cfg.IOReadMBps, + IOWriteMBps: cfg.IOWriteMBps, + Status: "stopped", + IP: "", + PublicIPv4s: publicIPv4s, + IPv6Addresses: ipv6Assignments, + VNCPort: 0, + SSHPort: sshPort, + SSHPassword: sshPassword, + PortMappings: portMappings, + PortMappingLimit: cfg.PortMappingCount, + AllowedImageIDs: append([]string(nil), cfg.AllowedImageIDs...), + ImageLimitConfigured: cfg.ImageLimitConfigured, + SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit), + CreatedAt: now, + ExpiresAt: cfg.ExpiresAt, } container.NormalizeNetworkAssignments() config.AddContainer(container) diff --git a/backend/internal/server/web/.gitkeep b/backend/internal/server/web/.gitkeep index e69de29..30259b2 100644 --- a/backend/internal/server/web/.gitkeep +++ b/backend/internal/server/web/.gitkeep @@ -0,0 +1 @@ + diff --git a/frontend/src/components/CreateContainerModal.tsx b/frontend/src/components/CreateContainerModal.tsx index 12f5f31..deabe1e 100644 --- a/frontend/src/components/CreateContainerModal.tsx +++ b/frontend/src/components/CreateContainerModal.tsx @@ -43,6 +43,8 @@ const defaultForm: CreateContainerRequest = { ssh_auth_mode: 'auto_password', ssh_password: '', ssh_public_key: '', + allowed_image_ids: [], + image_limit_configured: false, expires_at: '', } @@ -67,7 +69,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist setTemplates(data) setForm((prev) => { const templateID = data.some((item) => item.id === prev.template_id) ? prev.template_id : (data[0]?.id || '') - return applyTemplateDefaults({ ...prev, template_id: templateID }) + const allowed = new Set(data.map((item) => item.id)) + const selectedAllowedIDs = (prev.allowed_image_ids || []).filter((id) => allowed.has(id)) + return applyTemplateDefaults({ + ...prev, + template_id: templateID, + allowed_image_ids: prev.image_limit_configured ? selectedAllowedIDs : (templateID ? [templateID] : []), + image_limit_configured: true, + }) }) }) .catch(console.error) @@ -197,7 +206,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist await onSuccess(containers) onClose() setBatchCount(1) - setForm({ ...defaultForm, template_id: templates[0]?.id || '' }) + setForm({ ...defaultForm, template_id: templates[0]?.id || '', allowed_image_ids: templates[0]?.id ? [templates[0].id] : [], image_limit_configured: true }) } catch (err: unknown) { const error = err as { response?: { data?: { message?: string } } } dialog.alert('创建失败', error.response?.data?.message || '请稍后重试') @@ -241,7 +250,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
+ + + )} + {linuxTemplate && (
登录方式
diff --git a/frontend/src/pages/ContainerDetail.tsx b/frontend/src/pages/ContainerDetail.tsx index c4fbfa8..397059a 100644 --- a/frontend/src/pages/ContainerDetail.tsx +++ b/frontend/src/pages/ContainerDetail.tsx @@ -523,10 +523,12 @@ export default function ContainerDetail() { const openReinstall = async () => { try { - const res = await getEnabledImages(container?.virtualization || 'lxc') + const res = await getEnabledImages(container?.virtualization || 'lxc', containerIdentifier) if (res.data.data) { - setTemplates(res.data.data) - setSelectedTemplate(res.data.data[0]?.id || '') + const data = res.data.data + setTemplates(data) + const currentTemplate = container?.template || '' + setSelectedTemplate(data.some((template) => template.id === currentTemplate) ? currentTemplate : (data[0]?.id || '')) } setReinstallAuthMode('keep') setReinstallPasswordDraft('') diff --git a/frontend/src/pages/SubUserManagement.tsx b/frontend/src/pages/SubUserManagement.tsx index 6399e52..aecb70c 100644 --- a/frontend/src/pages/SubUserManagement.tsx +++ b/frontend/src/pages/SubUserManagement.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from 'react' -import { Copy, KeyRound, LogIn, RefreshCw, ScrollText, UserCog, X } from 'lucide-react' +import { Copy, HardDrive, KeyRound, LogIn, RefreshCw, Save, ScrollText, UserCog, X } from 'lucide-react' import { useDialog } from '../components/Dialog' -import api, { AuditLog, LoginLog } from '../services/api' +import api, { AuditLog, ImageInfo, LoginLog, getImages, updateSubUserImages } from '../services/api' import { copyToClipboard } from '../utils/clipboard' interface SubUserItem { @@ -9,6 +9,9 @@ interface SubUserItem { username: string container_names: string[] container_uuids: string[] + allowed_image_ids?: string[] + image_limit_configured?: boolean + current_image_ids?: string[] container_name: string container_uuid: string access_code: string @@ -34,6 +37,11 @@ export default function SubUserManagement() { const [loginLogs, setLoginLogs] = useState(null) const [modalTitle, setModalTitle] = useState('') const [passwordUser, setPasswordUser] = useState(null) + const [imageUser, setImageUser] = useState(null) + const [images, setImages] = useState([]) + const [selectedImageIDs, setSelectedImageIDs] = useState([]) + const [imagesLoading, setImagesLoading] = useState(false) + const [savingImages, setSavingImages] = useState(false) const [rotatingPassword, setRotatingPassword] = useState(false) const [logPage, setLogPage] = useState(1) const [logPageSize, setLogPageSize] = useState(10) @@ -78,6 +86,46 @@ export default function SubUserManagement() { } } + const openImageLimit = async (user: SubUserItem) => { + setImageUser(user) + setSelectedImageIDs(user.allowed_image_ids || []) + setImagesLoading(true) + try { + const res = await getImages() + const currentIDs = new Set(user.current_image_ids || []) + setImages((res.data.data || []).filter((image) => image.downloaded && (image.enabled || currentIDs.has(image.id)))) + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } } + dialog.alert('加载失败', error.response?.data?.message || '获取镜像列表失败') + } finally { + setImagesLoading(false) + } + } + + const toggleImageID = (id: string) => { + setSelectedImageIDs((prev) => prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]) + } + + const saveImageLimit = async () => { + if (!imageUser) return + setSavingImages(true) + try { + const res = await updateSubUserImages(imageUser.id, selectedImageIDs) + const updated = { + ...imageUser, + allowed_image_ids: res.data.data?.allowed_image_ids || selectedImageIDs, + image_limit_configured: true, + } + setUsers((prev) => prev.map((item) => (item.id === imageUser.id ? { ...item, allowed_image_ids: updated.allowed_image_ids, image_limit_configured: true } : item))) + setImageUser(null) + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } } + dialog.alert('保存失败', error.response?.data?.message || '保存可用镜像失败') + } finally { + setSavingImages(false) + } + } + const showAuditLogs = async (user: SubUserItem) => { try { const res = await api.get(`/sub-users/${user.id}/audit-logs`) @@ -190,6 +238,14 @@ export default function SubUserManagement() { 登录日志 +
@@ -253,6 +309,75 @@ export default function SubUserManagement() { )} + {imageUser && ( +
+
+
+
+

可用镜像

+

{imageUser.username} · 默认勾选当前系统,取消后将禁止重装该系统

+
+ +
+
+ {imagesLoading ? ( +
+
+
+ ) : images.length === 0 ? ( +
+ 暂无已下载并启用的镜像 +
+ ) : ( +
+ {images.map((image) => { + const checked = selectedImageIDs.includes(image.id) + const current = (imageUser.current_image_ids || []).includes(image.id) + return ( + + ) + })} +
+ )} +
+
+ 已选择 {selectedImageIDs.length} 个镜像 +
+ + +
+
+
+
+ )} + {/* Log Modal */} {(auditLogs || loginLogs) && (
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6d8e4be..0b83c83 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -164,6 +164,8 @@ export interface CreateContainerRequest { ssh_auth_mode?: string ssh_password?: string ssh_public_key?: string + allowed_image_ids?: string[] + image_limit_configured?: boolean expires_at: string } @@ -657,8 +659,8 @@ export const deleteImage = (templateId: string) => export const toggleImage = (templateId: string, enabled: boolean) => api.put('/images/toggle', { template_id: templateId, enabled }) -export const getEnabledImages = (virtualization = 'lxc') => - api.get>('/images/enabled', { params: { type: virtualization } }) +export const getEnabledImages = (virtualization = 'lxc', container?: ContainerIdentifier) => + api.get>('/images/enabled', { params: { type: virtualization, ...(container ? { container: String(container) } : {}) } }) // Dashboard export const getDashboard = () => @@ -771,6 +773,9 @@ export interface SubUser { password?: string container_names: string[] container_uuids?: string[] + allowed_image_ids?: string[] + image_limit_configured?: boolean + current_image_ids?: string[] access_code: string created_at: string } @@ -778,6 +783,9 @@ export interface SubUser { export const createSubUser = (containerId: ContainerIdentifier) => api.post>('/sub-user/create', { container_name: String(containerId) }) +export const updateSubUserImages = (id: string, allowedImageIds: string[]) => + api.put>(`/sub-users/${id}/images`, { allowed_image_ids: allowedImageIds }) + // Audit Logs export interface AuditLog { time: string