mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-06 05:52:19 +08:00
支持限制用户可选择的系统
This commit is contained in:
@@ -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"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
||||||
return
|
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 {
|
if cfg.VCPU <= 0 {
|
||||||
cfg.VCPU = 1
|
cfg.VCPU = 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type HostInfo struct {
|
type HostInfo struct {
|
||||||
CPU CpuInfo `json:"cpu"`
|
CPU CpuInfo `json:"cpu"`
|
||||||
RAM MemoryInfo `json:"ram"`
|
RAM MemoryInfo `json:"ram"`
|
||||||
Disk DiskInfo `json:"disk"`
|
Disk DiskInfo `json:"disk"`
|
||||||
Network NetworkInfo `json:"network"`
|
Network NetworkInfo `json:"network"`
|
||||||
DiskIO DiskIOInfo `json:"disk_io"`
|
DiskIO DiskIOInfo `json:"disk_io"`
|
||||||
Load LoadInfo `json:"load"`
|
Load LoadInfo `json:"load"`
|
||||||
|
Runtime HostRuntimeProbe `json:"runtime"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type HostProbeReport struct {
|
type HostProbeReport struct {
|
||||||
@@ -247,9 +248,32 @@ func getHostInfo() HostInfo {
|
|||||||
info.CPU.Usage = getCPUUsage()
|
info.CPU.Usage = getCPUUsage()
|
||||||
info.Network, info.DiskIO = getHostRates()
|
info.Network, info.DiskIO = getHostRates()
|
||||||
info.Load = getLoadInfo()
|
info.Load = getLoadInfo()
|
||||||
|
info.Runtime = detectRuntimeProbeQuick()
|
||||||
return info
|
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 {
|
func getMemoryInfo() MemoryInfo {
|
||||||
f, err := os.Open("/proc/meminfo")
|
f, err := os.Open("/proc/meminfo")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -541,6 +541,24 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
runtime := runtimeFromRequest(r.URL.Query().Get("type"))
|
runtime := runtimeFromRequest(r.URL.Query().Get("type"))
|
||||||
enabledSet := getEnabledImageSet()
|
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)
|
result := make([]map[string]string, 0)
|
||||||
if runtime == config.VirtualizationKVM {
|
if runtime == config.VirtualizationKVM {
|
||||||
@@ -549,7 +567,10 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, t := range kvm.GetImages() {
|
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{
|
result = append(result, map[string]string{
|
||||||
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
|
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
|
||||||
"description": t.Description, "type": config.VirtualizationKVM, "desktop": t.Desktop,
|
"description": t.Description, "type": config.VirtualizationKVM, "desktop": t.Desktop,
|
||||||
@@ -558,7 +579,10 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for _, t := range lxc.GetTemplates() {
|
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{
|
result = append(result, map[string]string{
|
||||||
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
|
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
|
||||||
"variant": t.Variant, "description": t.Description, "type": config.VirtualizationLXC,
|
"variant": t.Variant, "description": t.Description, "type": config.VirtualizationLXC,
|
||||||
@@ -574,6 +598,42 @@ func isTemplateEnabledAndDownloaded(templateID string) bool {
|
|||||||
return isImageEnabledAndDownloaded(templateID, runtimeFromTemplateID(templateID))
|
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 {
|
func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
|
||||||
runtime = runtimeFromRequest(runtime)
|
runtime = runtimeFromRequest(runtime)
|
||||||
if runtime == config.VirtualizationKVM {
|
if runtime == config.VirtualizationKVM {
|
||||||
|
|||||||
+230
-41
@@ -22,24 +22,30 @@ func generateRandomStr(length int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type subUserResponse struct {
|
type subUserResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password,omitempty"`
|
Password string `json:"password,omitempty"`
|
||||||
ContainerNames []string `json:"container_names"`
|
ContainerNames []string `json:"container_names"`
|
||||||
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
||||||
AccessCode string `json:"access_code"`
|
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
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 {
|
func newSubUserResponse(su config.SubUser, password string) subUserResponse {
|
||||||
return subUserResponse{
|
return subUserResponse{
|
||||||
ID: su.ID,
|
ID: su.ID,
|
||||||
Username: su.Username,
|
Username: su.Username,
|
||||||
Password: password,
|
Password: password,
|
||||||
ContainerNames: su.ContainerNames,
|
ContainerNames: su.ContainerNames,
|
||||||
ContainerUUIDs: su.ContainerUUIDs,
|
ContainerUUIDs: su.ContainerUUIDs,
|
||||||
AccessCode: su.AccessCode,
|
AllowedImageIDs: effectiveSubUserAllowedImageIDs(&su),
|
||||||
CreatedAt: su.CreatedAt,
|
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.ContainerNames = appendUniqueString(su.ContainerNames, containerName)
|
||||||
su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID)
|
su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID)
|
||||||
|
if !su.ImageLimitConfigured && len(su.AllowedImageIDs) == 0 {
|
||||||
|
su.AllowedImageIDs = effectiveContainerAllowedImageIDs(c)
|
||||||
|
su.ImageLimitConfigured = true
|
||||||
|
}
|
||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{
|
jsonResponse(w, http.StatusOK, APIResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
@@ -114,14 +124,16 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
accessCode := generateRandomStr(8)
|
accessCode := generateRandomStr(8)
|
||||||
|
|
||||||
subUser := config.SubUser{
|
subUser := config.SubUser{
|
||||||
ID: "sub-" + generateRandomStr(8),
|
ID: "sub-" + generateRandomStr(8),
|
||||||
Username: username,
|
Username: username,
|
||||||
Password: password,
|
Password: password,
|
||||||
PassHash: string(hash),
|
PassHash: string(hash),
|
||||||
ContainerNames: []string{containerName},
|
ContainerNames: []string{containerName},
|
||||||
ContainerUUIDs: []string{c.UUID},
|
ContainerUUIDs: []string{c.UUID},
|
||||||
AccessCode: accessCode,
|
AllowedImageIDs: effectiveContainerAllowedImageIDs(c),
|
||||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
ImageLimitConfigured: true,
|
||||||
|
AccessCode: accessCode,
|
||||||
|
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
}
|
}
|
||||||
|
|
||||||
config.AppConfig.SubUsers = append(config.AppConfig.SubUsers, subUser)
|
config.AppConfig.SubUsers = append(config.AppConfig.SubUsers, subUser)
|
||||||
@@ -306,6 +318,155 @@ func requestAllowedContainers(r *http.Request) (subUserAccess, bool) {
|
|||||||
return subUserAllowedContainers(r)
|
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 {
|
func isAccessRestrictedRequest(r *http.Request) bool {
|
||||||
_, restricted := requestAllowedContainers(r)
|
_, restricted := requestAllowedContainers(r)
|
||||||
return restricted
|
return restricted
|
||||||
@@ -580,18 +741,21 @@ func splitBy(s, sep string) []string {
|
|||||||
|
|
||||||
// SubUserListItem is the enriched sub-user info returned by the list API
|
// SubUserListItem is the enriched sub-user info returned by the list API
|
||||||
type SubUserListItem struct {
|
type SubUserListItem struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
ContainerNames []string `json:"container_names"`
|
ContainerNames []string `json:"container_names"`
|
||||||
ContainerUUIDs []string `json:"container_uuids"`
|
ContainerUUIDs []string `json:"container_uuids"`
|
||||||
ContainerName string `json:"container_name"`
|
AllowedImageIDs []string `json:"allowed_image_ids"`
|
||||||
ContainerUUID string `json:"container_uuid"`
|
ImageLimitConfigured bool `json:"image_limit_configured"`
|
||||||
AccessCode string `json:"access_code"`
|
CurrentImageIDs []string `json:"current_image_ids"`
|
||||||
Password string `json:"password,omitempty"`
|
ContainerName string `json:"container_name"`
|
||||||
CreatedAt string `json:"created_at"`
|
ContainerUUID string `json:"container_uuid"`
|
||||||
LastLogin string `json:"last_login"`
|
AccessCode string `json:"access_code"`
|
||||||
LastLoginIP string `json:"last_login_ip"`
|
Password string `json:"password,omitempty"`
|
||||||
LastLoginUA string `json:"last_login_ua"`
|
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
|
// 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))
|
result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers))
|
||||||
for _, su := range config.AppConfig.SubUsers {
|
for _, su := range config.AppConfig.SubUsers {
|
||||||
item := SubUserListItem{
|
item := SubUserListItem{
|
||||||
ID: su.ID,
|
ID: su.ID,
|
||||||
Username: su.Username,
|
Username: su.Username,
|
||||||
ContainerNames: su.ContainerNames,
|
ContainerNames: su.ContainerNames,
|
||||||
ContainerUUIDs: su.ContainerUUIDs,
|
ContainerUUIDs: su.ContainerUUIDs,
|
||||||
AccessCode: su.AccessCode,
|
AllowedImageIDs: effectiveSubUserAllowedImageIDs(&su),
|
||||||
Password: su.Password,
|
ImageLimitConfigured: su.ImageLimitConfigured,
|
||||||
CreatedAt: su.CreatedAt,
|
CurrentImageIDs: subUserCurrentImageIDs(&su),
|
||||||
|
AccessCode: su.AccessCode,
|
||||||
|
Password: su.Password,
|
||||||
|
CreatedAt: su.CreatedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve container name from first active UUID
|
// Resolve container name from first active UUID
|
||||||
@@ -711,6 +878,28 @@ func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
logs := filterSubUserLoginLogs(target.Username)
|
logs := filterSubUserLoginLogs(target.Username)
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
|
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:
|
default:
|
||||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -560,7 +560,11 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
|
|||||||
if c := config.FindContainer(id); c != nil {
|
if c := config.FindContainer(id); c != nil {
|
||||||
runtime = c.Runtime()
|
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"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
||||||
return
|
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"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
|
||||||
return
|
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 {
|
if req.Containers[i].PortMappingCount < 0 {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot be negative"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot be negative"})
|
||||||
return
|
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"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to one or more containers"})
|
||||||
return
|
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 taskConfig != nil {
|
||||||
if err := validateReinstallSSHAuth(c, req.TemplateID, *taskConfig); err != nil {
|
if err := validateReinstallSSHAuth(c, req.TemplateID, *taskConfig); err != nil {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: c.Name + ": " + err.Error()})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: c.Name + ": " + err.Error()})
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ type Container struct {
|
|||||||
FirewallEnabled bool `json:"firewall_enabled"`
|
FirewallEnabled bool `json:"firewall_enabled"`
|
||||||
FirewallDefaultAction string `json:"firewall_default_action"`
|
FirewallDefaultAction string `json:"firewall_default_action"`
|
||||||
FirewallRules []FirewallRule `json:"firewall_rules"`
|
FirewallRules []FirewallRule `json:"firewall_rules"`
|
||||||
|
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||||
|
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||||
SnapshotLimit int `json:"snapshot_limit"`
|
SnapshotLimit int `json:"snapshot_limit"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
ExpiresAt string `json:"expires_at"`
|
ExpiresAt string `json:"expires_at"`
|
||||||
@@ -319,16 +321,18 @@ 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,omitempty"`
|
Password string `json:"password,omitempty"`
|
||||||
PassHash string `json:"pass_hash"`
|
PassHash string `json:"pass_hash"`
|
||||||
ContainerNames []string `json:"container_names"`
|
ContainerNames []string `json:"container_names"`
|
||||||
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
||||||
Token string `json:"-"`
|
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||||
AccessCode string `json:"access_code"`
|
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
Token string `json:"-"`
|
||||||
TokenVersion int `json:"token_version"`
|
AccessCode string `json:"access_code"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
TokenVersion int `json:"token_version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Snapshot struct {
|
type Snapshot struct {
|
||||||
|
|||||||
@@ -20,37 +20,39 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type savedTaskConfig struct {
|
type savedTaskConfig struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Virtualization string `json:"virtualization,omitempty"`
|
Virtualization string `json:"virtualization,omitempty"`
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
VCPU float64 `json:"vcpu"`
|
VCPU float64 `json:"vcpu"`
|
||||||
CPUPercent int `json:"cpu_percent"`
|
CPUPercent int `json:"cpu_percent"`
|
||||||
RAMMB int `json:"ram_mb"`
|
RAMMB int `json:"ram_mb"`
|
||||||
DiskGB int `json:"disk_gb"`
|
DiskGB int `json:"disk_gb"`
|
||||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||||
NetworkDownMbps int `json:"network_down_mbps"`
|
NetworkDownMbps int `json:"network_down_mbps"`
|
||||||
NetworkUpMbps int `json:"network_up_mbps"`
|
NetworkUpMbps int `json:"network_up_mbps"`
|
||||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||||
TrafficMode string `json:"traffic_mode"`
|
TrafficMode string `json:"traffic_mode"`
|
||||||
TrafficInGB int `json:"traffic_in_gb"`
|
TrafficInGB int `json:"traffic_in_gb"`
|
||||||
TrafficOutGB int `json:"traffic_out_gb"`
|
TrafficOutGB int `json:"traffic_out_gb"`
|
||||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||||
IOReadMBps int `json:"io_read_mbps"`
|
IOReadMBps int `json:"io_read_mbps"`
|
||||||
IOWriteMBps int `json:"io_write_mbps"`
|
IOWriteMBps int `json:"io_write_mbps"`
|
||||||
ExtraPorts []int `json:"extra_ports"`
|
ExtraPorts []int `json:"extra_ports"`
|
||||||
PortMappingCount int `json:"port_mapping_count"`
|
PortMappingCount int `json:"port_mapping_count"`
|
||||||
AssignNAT *bool `json:"assign_nat,omitempty"`
|
AssignNAT *bool `json:"assign_nat,omitempty"`
|
||||||
SnapshotLimit int `json:"snapshot_limit"`
|
SnapshotLimit int `json:"snapshot_limit"`
|
||||||
AssignIPv4 bool `json:"assign_ipv4"`
|
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||||
IPv4Count int `json:"ipv4_count,omitempty"`
|
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||||
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
AssignIPv4 bool `json:"assign_ipv4"`
|
||||||
AssignIPv6 bool `json:"assign_ipv6"`
|
IPv4Count int `json:"ipv4_count,omitempty"`
|
||||||
IPv6Count int `json:"ipv6_count,omitempty"`
|
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
||||||
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
AssignIPv6 bool `json:"assign_ipv6"`
|
||||||
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
IPv6Count int `json:"ipv6_count,omitempty"`
|
||||||
SSHPassword string `json:"ssh_password,omitempty"`
|
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
||||||
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||||
ExpiresAt string `json:"expires_at"`
|
SSHPassword string `json:"ssh_password,omitempty"`
|
||||||
|
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseSavedTaskConfig(raw string) savedTaskConfig {
|
func parseSavedTaskConfig(raw string) savedTaskConfig {
|
||||||
@@ -222,7 +224,9 @@ func ensureSchema() error {
|
|||||||
snapshot_schedule_created_by TEXT,
|
snapshot_schedule_created_by TEXT,
|
||||||
policy_blocked INTEGER,
|
policy_blocked INTEGER,
|
||||||
policy_blocked_reason TEXT,
|
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 (
|
`CREATE TABLE IF NOT EXISTS port_mappings (
|
||||||
container_id INTEGER NOT NULL,
|
container_id INTEGER NOT NULL,
|
||||||
@@ -258,7 +262,9 @@ func ensureSchema() error {
|
|||||||
pass_hash TEXT,
|
pass_hash TEXT,
|
||||||
access_code TEXT,
|
access_code TEXT,
|
||||||
created_at 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 (
|
`CREATE TABLE IF NOT EXISTS sub_user_container_names (
|
||||||
sub_user_id TEXT NOT NULL,
|
sub_user_id TEXT NOT NULL,
|
||||||
@@ -348,6 +354,8 @@ func ensureSchema() error {
|
|||||||
cfg_ssh_auth_mode TEXT,
|
cfg_ssh_auth_mode TEXT,
|
||||||
cfg_ssh_password TEXT,
|
cfg_ssh_password TEXT,
|
||||||
cfg_ssh_public_key TEXT,
|
cfg_ssh_public_key TEXT,
|
||||||
|
cfg_allowed_image_ids TEXT,
|
||||||
|
cfg_image_limit_configured INTEGER NOT NULL DEFAULT 0,
|
||||||
cfg_expires_at TEXT
|
cfg_expires_at TEXT
|
||||||
)`,
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS task_extra_ports (
|
`CREATE TABLE IF NOT EXISTS task_extra_ports (
|
||||||
@@ -415,9 +423,13 @@ func ensureSchemaMigrations() error {
|
|||||||
{"tasks", "cfg_ssh_auth_mode", "TEXT"},
|
{"tasks", "cfg_ssh_auth_mode", "TEXT"},
|
||||||
{"tasks", "cfg_ssh_password", "TEXT"},
|
{"tasks", "cfg_ssh_password", "TEXT"},
|
||||||
{"tasks", "cfg_ssh_public_key", "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"},
|
{"port_mappings", "host_ip", "TEXT"},
|
||||||
{"container_public_ipv4s", "prefix_len", "INTEGER"},
|
{"container_public_ipv4s", "prefix_len", "INTEGER"},
|
||||||
{"container_public_ipv4s", "gateway", "TEXT"},
|
{"container_public_ipv4s", "gateway", "TEXT"},
|
||||||
|
{"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_down_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||||
{"containers", "network_up_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
{"containers", "network_up_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||||
{"containers", "io_read_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_enabled", "INTEGER NOT NULL DEFAULT 0"},
|
||||||
{"containers", "firewall_default_action", "TEXT NOT NULL DEFAULT 'DROP'"},
|
{"containers", "firewall_default_action", "TEXT NOT NULL DEFAULT 'DROP'"},
|
||||||
{"containers", "firewall_rules", "TEXT"},
|
{"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)
|
wasAdded, err := ensureColumn(column.table, column.name, column.def)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -677,6 +691,7 @@ func saveMeta(tx *sql.Tx) error {
|
|||||||
func saveContainers(tx *sql.Tx) error {
|
func saveContainers(tx *sql.Tx) error {
|
||||||
for _, c := range AppConfig.Containers {
|
for _, c := range AppConfig.Containers {
|
||||||
NormalizeContainerResourceAliases(&c)
|
NormalizeContainerResourceAliases(&c)
|
||||||
|
allowedImageIDs := encodeStringSlice(c.AllowedImageIDs)
|
||||||
if _, err := tx.Exec(`INSERT INTO containers (
|
if _, err := tx.Exec(`INSERT INTO containers (
|
||||||
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
|
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,
|
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_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
|
||||||
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
|
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
|
||||||
policy_blocked, policy_blocked_reason, policy_blocked_at,
|
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
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.MACAddress, c.Template,
|
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.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.NetworkDownMbps, c.NetworkUpMbps,
|
||||||
c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB,
|
c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB,
|
||||||
@@ -700,7 +715,7 @@ func saveContainers(tx *sql.Tx) error {
|
|||||||
boolInt(c.SnapshotScheduleEnabled), c.SnapshotScheduleIntervalHours, c.SnapshotScheduleTime,
|
boolInt(c.SnapshotScheduleEnabled), c.SnapshotScheduleIntervalHours, c.SnapshotScheduleTime,
|
||||||
c.SnapshotScheduleLastRun, c.SnapshotScheduleNextRun, c.SnapshotScheduleCreatedBy,
|
c.SnapshotScheduleLastRun, c.SnapshotScheduleNextRun, c.SnapshotScheduleCreatedBy,
|
||||||
boolInt(c.PolicyBlocked), c.PolicyBlockedReason, c.PolicyBlockedAt,
|
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 {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -728,8 +743,9 @@ func saveContainers(tx *sql.Tx) error {
|
|||||||
|
|
||||||
func saveSubUsers(tx *sql.Tx) error {
|
func saveSubUsers(tx *sql.Tx) error {
|
||||||
for _, su := range AppConfig.SubUsers {
|
for _, su := range AppConfig.SubUsers {
|
||||||
if _, err := tx.Exec(`INSERT INTO sub_users(id, username, password, pass_hash, access_code, created_at, token_version)
|
allowedImageIDs := encodeStringSlice(su.AllowedImageIDs)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`, su.ID, su.Username, su.Password, su.PassHash, su.AccessCode, su.CreatedAt, su.TokenVersion); err != nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
for i, name := range su.ContainerNames {
|
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_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_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
|
||||||
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
|
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
|
||||||
cfg_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
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent,
|
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent,
|
||||||
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
|
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
|
||||||
cfg.NetworkBWMbps, cfg.NetworkDownMbps, cfg.NetworkUpMbps,
|
cfg.NetworkBWMbps, cfg.NetworkDownMbps, cfg.NetworkUpMbps,
|
||||||
@@ -850,7 +866,7 @@ func saveTasksDB(tx *sql.Tx) error {
|
|||||||
cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.SnapshotLimit,
|
cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.SnapshotLimit,
|
||||||
boolInt(cfg.AssignIPv4), cfg.IPv4Count, encodeStringSlice(cfg.PublicIPv4s),
|
boolInt(cfg.AssignIPv4), cfg.IPv4Count, encodeStringSlice(cfg.PublicIPv4s),
|
||||||
boolInt(cfg.AssignIPv6), cfg.IPv6Count, encodeStringSlice(cfg.IPv6Addresses),
|
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 {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -904,7 +920,7 @@ func loadContainers() ([]Container, error) {
|
|||||||
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
|
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
|
||||||
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
|
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
|
||||||
policy_blocked, policy_blocked_reason, policy_blocked_at,
|
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`)
|
FROM containers ORDER BY id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -914,9 +930,9 @@ func loadContainers() ([]Container, error) {
|
|||||||
result := []Container{}
|
result := []Container{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var c Container
|
var c Container
|
||||||
var scheduleEnabled, policyBlocked, firewallEnabled int
|
var scheduleEnabled, policyBlocked, firewallEnabled, imageLimitConfigured int
|
||||||
var firewallDefaultAction string
|
var firewallDefaultAction string
|
||||||
var firewallRulesJSON sql.NullString
|
var firewallRulesJSON, allowedImageIDs sql.NullString
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &c.MACAddress, &c.Template,
|
&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.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.NetworkDownMbps, &c.NetworkUpMbps,
|
||||||
@@ -928,7 +944,7 @@ func loadContainers() ([]Container, error) {
|
|||||||
&scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime,
|
&scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime,
|
||||||
&c.SnapshotScheduleLastRun, &c.SnapshotScheduleNextRun, &c.SnapshotScheduleCreatedBy,
|
&c.SnapshotScheduleLastRun, &c.SnapshotScheduleNextRun, &c.SnapshotScheduleCreatedBy,
|
||||||
&policyBlocked, &c.PolicyBlockedReason, &c.PolicyBlockedAt,
|
&policyBlocked, &c.PolicyBlockedReason, &c.PolicyBlockedAt,
|
||||||
&firewallEnabled, &firewallDefaultAction, &firewallRulesJSON,
|
&firewallEnabled, &firewallDefaultAction, &firewallRulesJSON, &allowedImageIDs, &imageLimitConfigured,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -936,9 +952,11 @@ func loadContainers() ([]Container, error) {
|
|||||||
c.PolicyBlocked = policyBlocked != 0
|
c.PolicyBlocked = policyBlocked != 0
|
||||||
c.FirewallEnabled = firewallEnabled != 0
|
c.FirewallEnabled = firewallEnabled != 0
|
||||||
c.FirewallDefaultAction = normalizeFirewallDefaultAction(firewallDefaultAction)
|
c.FirewallDefaultAction = normalizeFirewallDefaultAction(firewallDefaultAction)
|
||||||
|
c.ImageLimitConfigured = imageLimitConfigured != 0
|
||||||
if firewallRulesJSON.Valid && strings.TrimSpace(firewallRulesJSON.String) != "" {
|
if firewallRulesJSON.Valid && strings.TrimSpace(firewallRulesJSON.String) != "" {
|
||||||
_ = json.Unmarshal([]byte(firewallRulesJSON.String), &c.FirewallRules)
|
_ = json.Unmarshal([]byte(firewallRulesJSON.String), &c.FirewallRules)
|
||||||
}
|
}
|
||||||
|
c.AllowedImageIDs = decodeStringSlice(allowedImageIDs.String)
|
||||||
NormalizeContainerResourceAliases(&c)
|
NormalizeContainerResourceAliases(&c)
|
||||||
result = append(result, c)
|
result = append(result, c)
|
||||||
}
|
}
|
||||||
@@ -1034,7 +1052,7 @@ func loadContainerIPv6Addresses(containerID int) ([]IPv6Assignment, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadSubUsers() ([]SubUser, 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1042,9 +1060,13 @@ func loadSubUsers() ([]SubUser, error) {
|
|||||||
result := []SubUser{}
|
result := []SubUser{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var su SubUser
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
su.AllowedImageIDs = decodeStringSlice(allowedImageIDs.String)
|
||||||
|
su.ImageLimitConfigured = imageLimitConfigured != 0
|
||||||
result = append(result, su)
|
result = append(result, su)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
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_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_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
|
||||||
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
|
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
|
||||||
cfg_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`)
|
FROM tasks ORDER BY created_at, id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1149,9 +1171,9 @@ func loadTasks() ([]SavedTask, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t SavedTask
|
var t SavedTask
|
||||||
var cfg savedTaskConfig
|
var cfg savedTaskConfig
|
||||||
var assignIPv4, assignIPv6 int
|
var assignIPv4, assignIPv6, imageLimitConfigured int
|
||||||
var ip, userAgent, publicIPv4s, ipv6Addresses sql.NullString
|
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
|
var assignNAT, ipv4Count, ipv6Count sql.NullInt64
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent,
|
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent,
|
||||||
@@ -1161,7 +1183,7 @@ func loadTasks() ([]SavedTask, error) {
|
|||||||
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.IOReadMBps, &cfg.IOWriteMBps,
|
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.IOReadMBps, &cfg.IOWriteMBps,
|
||||||
&cfg.PortMappingCount, &assignNAT, &cfg.SnapshotLimit,
|
&cfg.PortMappingCount, &assignNAT, &cfg.SnapshotLimit,
|
||||||
&assignIPv4, &ipv4Count, &publicIPv4s, &assignIPv6, &ipv6Count, &ipv6Addresses,
|
&assignIPv4, &ipv4Count, &publicIPv4s, &assignIPv6, &ipv6Count, &ipv6Addresses,
|
||||||
&sshAuthMode, &sshPassword, &sshPublicKey, &cfg.ExpiresAt,
|
&sshAuthMode, &sshPassword, &sshPublicKey, &allowedImageIDs, &imageLimitConfigured, &cfg.ExpiresAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1184,6 +1206,8 @@ func loadTasks() ([]SavedTask, error) {
|
|||||||
cfg.SSHAuthMode = sshAuthMode.String
|
cfg.SSHAuthMode = sshAuthMode.String
|
||||||
cfg.SSHPassword = sshPassword.String
|
cfg.SSHPassword = sshPassword.String
|
||||||
cfg.SSHPublicKey = sshPublicKey.String
|
cfg.SSHPublicKey = sshPublicKey.String
|
||||||
|
cfg.AllowedImageIDs = decodeStringSlice(allowedImageIDs.String)
|
||||||
|
cfg.ImageLimitConfigured = imageLimitConfigured != 0
|
||||||
normalizeSavedTaskConfigLimits(&cfg)
|
normalizeSavedTaskConfigLimits(&cfg)
|
||||||
result = append(result, t)
|
result = append(result, t)
|
||||||
configs = append(configs, cfg)
|
configs = append(configs, cfg)
|
||||||
|
|||||||
@@ -376,6 +376,10 @@ func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
|
|||||||
if cfg.SnapshotLimit <= 0 {
|
if cfg.SnapshotLimit <= 0 {
|
||||||
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
||||||
}
|
}
|
||||||
|
if !cfg.ImageLimitConfigured && len(cfg.AllowedImageIDs) == 0 && cfg.TemplateID != "" {
|
||||||
|
cfg.AllowedImageIDs = []string{cfg.TemplateID}
|
||||||
|
cfg.ImageLimitConfigured = true
|
||||||
|
}
|
||||||
|
|
||||||
id := config.AllocateContainerID()
|
id := config.AllocateContainerID()
|
||||||
vmName := fmt.Sprintf("vm-%d", id)
|
vmName := fmt.Sprintf("vm-%d", id)
|
||||||
@@ -567,11 +571,13 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
}
|
}
|
||||||
return sshPassword
|
return sshPassword
|
||||||
}(),
|
}(),
|
||||||
PortMappings: portMappings,
|
PortMappings: portMappings,
|
||||||
PortMappingLimit: cfg.PortMappingCount,
|
PortMappingLimit: cfg.PortMappingCount,
|
||||||
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
|
AllowedImageIDs: append([]string(nil), cfg.AllowedImageIDs...),
|
||||||
CreatedAt: now,
|
ImageLimitConfigured: cfg.ImageLimitConfigured,
|
||||||
ExpiresAt: cfg.ExpiresAt,
|
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: cfg.ExpiresAt,
|
||||||
}
|
}
|
||||||
container.NormalizeNetworkAssignments()
|
container.NormalizeNetworkAssignments()
|
||||||
return container, nil
|
return container, nil
|
||||||
|
|||||||
+70
-62
@@ -218,37 +218,39 @@ func NewManager() *Manager {
|
|||||||
|
|
||||||
// ContainerConfig defines container creation parameters
|
// ContainerConfig defines container creation parameters
|
||||||
type ContainerConfig struct {
|
type ContainerConfig struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Virtualization string `json:"virtualization,omitempty"`
|
Virtualization string `json:"virtualization,omitempty"`
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
VCPU float64 `json:"vcpu"`
|
VCPU float64 `json:"vcpu"`
|
||||||
CPUPercent int `json:"cpu_percent"`
|
CPUPercent int `json:"cpu_percent"`
|
||||||
RAMMB int `json:"ram_mb"`
|
RAMMB int `json:"ram_mb"`
|
||||||
DiskGB int `json:"disk_gb"`
|
DiskGB int `json:"disk_gb"`
|
||||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||||
NetworkDownMbps int `json:"network_down_mbps"`
|
NetworkDownMbps int `json:"network_down_mbps"`
|
||||||
NetworkUpMbps int `json:"network_up_mbps"`
|
NetworkUpMbps int `json:"network_up_mbps"`
|
||||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||||
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
|
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
|
||||||
TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited
|
TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited
|
||||||
TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited
|
TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited
|
||||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||||
IOReadMBps int `json:"io_read_mbps"`
|
IOReadMBps int `json:"io_read_mbps"`
|
||||||
IOWriteMBps int `json:"io_write_mbps"`
|
IOWriteMBps int `json:"io_write_mbps"`
|
||||||
ExtraPorts []int `json:"extra_ports"`
|
ExtraPorts []int `json:"extra_ports"`
|
||||||
PortMappingCount int `json:"port_mapping_count"`
|
PortMappingCount int `json:"port_mapping_count"`
|
||||||
AssignNAT *bool `json:"assign_nat,omitempty"`
|
AssignNAT *bool `json:"assign_nat,omitempty"`
|
||||||
SnapshotLimit int `json:"snapshot_limit"`
|
SnapshotLimit int `json:"snapshot_limit"`
|
||||||
AssignIPv4 bool `json:"assign_ipv4"`
|
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||||
IPv4Count int `json:"ipv4_count,omitempty"`
|
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||||
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
AssignIPv4 bool `json:"assign_ipv4"`
|
||||||
AssignIPv6 bool `json:"assign_ipv6"`
|
IPv4Count int `json:"ipv4_count,omitempty"`
|
||||||
IPv6Count int `json:"ipv6_count,omitempty"`
|
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
||||||
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
AssignIPv6 bool `json:"assign_ipv6"`
|
||||||
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
IPv6Count int `json:"ipv6_count,omitempty"`
|
||||||
SSHPassword string `json:"ssh_password,omitempty"`
|
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
||||||
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||||
ExpiresAt string `json:"expires_at"`
|
SSHPassword string `json:"ssh_password,omitempty"`
|
||||||
|
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cfg *ContainerConfig) NormalizeResourceAliases() {
|
func (cfg *ContainerConfig) NormalizeResourceAliases() {
|
||||||
@@ -306,6 +308,10 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
if cfg.SnapshotLimit <= 0 {
|
if cfg.SnapshotLimit <= 0 {
|
||||||
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
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) {
|
if !config.IsValidContainerName(cfg.Name) {
|
||||||
return fmt.Errorf("invalid container name: %s", 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
|
trafficResetDate := now[:7] // YYYY-MM for monthly tracking
|
||||||
|
|
||||||
container := config.Container{
|
container := config.Container{
|
||||||
ID: id,
|
ID: id,
|
||||||
UUID: config.NewContainerUUID(),
|
UUID: config.NewContainerUUID(),
|
||||||
Name: cfg.Name,
|
Name: cfg.Name,
|
||||||
Virtualization: config.VirtualizationLXC,
|
Virtualization: config.VirtualizationLXC,
|
||||||
Template: cfg.TemplateID,
|
Template: cfg.TemplateID,
|
||||||
VCPU: cfg.VCPU,
|
VCPU: cfg.VCPU,
|
||||||
RAMMB: cfg.RAMMB,
|
RAMMB: cfg.RAMMB,
|
||||||
DiskGB: cfg.DiskGB,
|
DiskGB: cfg.DiskGB,
|
||||||
NetworkBWMbps: cfg.NetworkBWMbps,
|
NetworkBWMbps: cfg.NetworkBWMbps,
|
||||||
NetworkDownMbps: cfg.NetworkDownMbps,
|
NetworkDownMbps: cfg.NetworkDownMbps,
|
||||||
NetworkUpMbps: cfg.NetworkUpMbps,
|
NetworkUpMbps: cfg.NetworkUpMbps,
|
||||||
MonthlyTrafficGB: cfg.MonthlyTrafficGB,
|
MonthlyTrafficGB: cfg.MonthlyTrafficGB,
|
||||||
TrafficMode: trafficMode,
|
TrafficMode: trafficMode,
|
||||||
TrafficInGB: cfg.TrafficInGB,
|
TrafficInGB: cfg.TrafficInGB,
|
||||||
TrafficOutGB: cfg.TrafficOutGB,
|
TrafficOutGB: cfg.TrafficOutGB,
|
||||||
TrafficResetDate: trafficResetDate,
|
TrafficResetDate: trafficResetDate,
|
||||||
IOSpeedMBps: cfg.IOSpeedMBps,
|
IOSpeedMBps: cfg.IOSpeedMBps,
|
||||||
IOReadMBps: cfg.IOReadMBps,
|
IOReadMBps: cfg.IOReadMBps,
|
||||||
IOWriteMBps: cfg.IOWriteMBps,
|
IOWriteMBps: cfg.IOWriteMBps,
|
||||||
Status: "stopped",
|
Status: "stopped",
|
||||||
IP: "",
|
IP: "",
|
||||||
PublicIPv4s: publicIPv4s,
|
PublicIPv4s: publicIPv4s,
|
||||||
IPv6Addresses: ipv6Assignments,
|
IPv6Addresses: ipv6Assignments,
|
||||||
VNCPort: 0,
|
VNCPort: 0,
|
||||||
SSHPort: sshPort,
|
SSHPort: sshPort,
|
||||||
SSHPassword: sshPassword,
|
SSHPassword: sshPassword,
|
||||||
PortMappings: portMappings,
|
PortMappings: portMappings,
|
||||||
PortMappingLimit: cfg.PortMappingCount,
|
PortMappingLimit: cfg.PortMappingCount,
|
||||||
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
|
AllowedImageIDs: append([]string(nil), cfg.AllowedImageIDs...),
|
||||||
CreatedAt: now,
|
ImageLimitConfigured: cfg.ImageLimitConfigured,
|
||||||
ExpiresAt: cfg.ExpiresAt,
|
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: cfg.ExpiresAt,
|
||||||
}
|
}
|
||||||
container.NormalizeNetworkAssignments()
|
container.NormalizeNetworkAssignments()
|
||||||
config.AddContainer(container)
|
config.AddContainer(container)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ const defaultForm: CreateContainerRequest = {
|
|||||||
ssh_auth_mode: 'auto_password',
|
ssh_auth_mode: 'auto_password',
|
||||||
ssh_password: '',
|
ssh_password: '',
|
||||||
ssh_public_key: '',
|
ssh_public_key: '',
|
||||||
|
allowed_image_ids: [],
|
||||||
|
image_limit_configured: false,
|
||||||
expires_at: '',
|
expires_at: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +69,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
setTemplates(data)
|
setTemplates(data)
|
||||||
setForm((prev) => {
|
setForm((prev) => {
|
||||||
const templateID = data.some((item) => item.id === prev.template_id) ? prev.template_id : (data[0]?.id || '')
|
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)
|
.catch(console.error)
|
||||||
@@ -197,7 +206,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
await onSuccess(containers)
|
await onSuccess(containers)
|
||||||
onClose()
|
onClose()
|
||||||
setBatchCount(1)
|
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) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { message?: string } } }
|
const error = err as { response?: { data?: { message?: string } } }
|
||||||
dialog.alert('创建失败', error.response?.data?.message || '请稍后重试')
|
dialog.alert('创建失败', error.response?.data?.message || '请稍后重试')
|
||||||
@@ -241,7 +250,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '' }))}
|
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '', allowed_image_ids: [], image_limit_configured: false }))}
|
||||||
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'lxc' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'lxc' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||||
>
|
>
|
||||||
LXC 容器
|
LXC 容器
|
||||||
@@ -252,7 +261,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
title={kvmAvailable ? '' : '当前宿主机不支持 KVM'}
|
title={kvmAvailable ? '' : '当前宿主机不支持 KVM'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (kvmAvailable) {
|
if (kvmAvailable) {
|
||||||
setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '' }))
|
setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '', allowed_image_ids: [], image_limit_configured: false }))
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:border-gray-200 disabled:bg-gray-50 disabled:text-gray-400 ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:border-gray-200 disabled:bg-gray-50 disabled:text-gray-400 ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||||
@@ -270,7 +279,12 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
value={form.template_id}
|
value={form.template_id}
|
||||||
onChange={(event) => setForm(applyTemplateDefaults({ ...form, template_id: event.target.value }))}
|
onChange={(event) => {
|
||||||
|
const templateID = event.target.value
|
||||||
|
const allowed = new Set(form.allowed_image_ids || [])
|
||||||
|
if (templateID) allowed.add(templateID)
|
||||||
|
setForm(applyTemplateDefaults({ ...form, template_id: templateID, allowed_image_ids: Array.from(allowed), image_limit_configured: true }))
|
||||||
|
}}
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
>
|
>
|
||||||
{templates.map((template) => (
|
{templates.map((template) => (
|
||||||
@@ -283,6 +297,38 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
|
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
{templates.length > 0 && (
|
||||||
|
<Field label="子用户可用镜像">
|
||||||
|
<div className="rounded-md border border-gray-200 bg-gray-50 p-3">
|
||||||
|
<div className="mb-2 text-xs text-gray-500">默认勾选当前系统;取消后,子用户也不能重装该系统。</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{templates.map((template) => {
|
||||||
|
const checked = (form.allowed_image_ids || []).includes(template.id)
|
||||||
|
const current = template.id === form.template_id
|
||||||
|
return (
|
||||||
|
<label key={template.id} className={`flex cursor-pointer items-start gap-2 rounded border px-2.5 py-2 text-xs ${checked ? 'border-black bg-white' : 'border-gray-200 bg-white hover:bg-gray-50'}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => {
|
||||||
|
const currentIDs = form.allowed_image_ids || []
|
||||||
|
const next = checked ? currentIDs.filter((id) => id !== template.id) : [...currentIDs, template.id]
|
||||||
|
setForm({ ...form, allowed_image_ids: next, image_limit_configured: true })
|
||||||
|
}}
|
||||||
|
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-black focus:ring-black"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate font-medium text-gray-800">{template.name}{current ? '(当前系统)' : ''}</span>
|
||||||
|
<span className="block text-gray-500">{template.arch} · {template.distro} {template.release}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
{linuxTemplate && (
|
{linuxTemplate && (
|
||||||
<div className="rounded-md border border-gray-200 bg-white px-3 py-3 text-sm">
|
<div className="rounded-md border border-gray-200 bg-white px-3 py-3 text-sm">
|
||||||
<div className="mb-2 font-medium text-gray-800">登录方式</div>
|
<div className="mb-2 font-medium text-gray-800">登录方式</div>
|
||||||
|
|||||||
@@ -523,10 +523,12 @@ export default function ContainerDetail() {
|
|||||||
|
|
||||||
const openReinstall = async () => {
|
const openReinstall = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getEnabledImages(container?.virtualization || 'lxc')
|
const res = await getEnabledImages(container?.virtualization || 'lxc', containerIdentifier)
|
||||||
if (res.data.data) {
|
if (res.data.data) {
|
||||||
setTemplates(res.data.data)
|
const data = res.data.data
|
||||||
setSelectedTemplate(res.data.data[0]?.id || '')
|
setTemplates(data)
|
||||||
|
const currentTemplate = container?.template || ''
|
||||||
|
setSelectedTemplate(data.some((template) => template.id === currentTemplate) ? currentTemplate : (data[0]?.id || ''))
|
||||||
}
|
}
|
||||||
setReinstallAuthMode('keep')
|
setReinstallAuthMode('keep')
|
||||||
setReinstallPasswordDraft('')
|
setReinstallPasswordDraft('')
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
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 { 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'
|
import { copyToClipboard } from '../utils/clipboard'
|
||||||
|
|
||||||
interface SubUserItem {
|
interface SubUserItem {
|
||||||
@@ -9,6 +9,9 @@ interface SubUserItem {
|
|||||||
username: string
|
username: string
|
||||||
container_names: string[]
|
container_names: string[]
|
||||||
container_uuids: string[]
|
container_uuids: string[]
|
||||||
|
allowed_image_ids?: string[]
|
||||||
|
image_limit_configured?: boolean
|
||||||
|
current_image_ids?: string[]
|
||||||
container_name: string
|
container_name: string
|
||||||
container_uuid: string
|
container_uuid: string
|
||||||
access_code: string
|
access_code: string
|
||||||
@@ -34,6 +37,11 @@ export default function SubUserManagement() {
|
|||||||
const [loginLogs, setLoginLogs] = useState<LoginLog[] | null>(null)
|
const [loginLogs, setLoginLogs] = useState<LoginLog[] | null>(null)
|
||||||
const [modalTitle, setModalTitle] = useState('')
|
const [modalTitle, setModalTitle] = useState('')
|
||||||
const [passwordUser, setPasswordUser] = useState<SubUserItem | null>(null)
|
const [passwordUser, setPasswordUser] = useState<SubUserItem | null>(null)
|
||||||
|
const [imageUser, setImageUser] = useState<SubUserItem | null>(null)
|
||||||
|
const [images, setImages] = useState<ImageInfo[]>([])
|
||||||
|
const [selectedImageIDs, setSelectedImageIDs] = useState<string[]>([])
|
||||||
|
const [imagesLoading, setImagesLoading] = useState(false)
|
||||||
|
const [savingImages, setSavingImages] = useState(false)
|
||||||
const [rotatingPassword, setRotatingPassword] = useState(false)
|
const [rotatingPassword, setRotatingPassword] = useState(false)
|
||||||
const [logPage, setLogPage] = useState(1)
|
const [logPage, setLogPage] = useState(1)
|
||||||
const [logPageSize, setLogPageSize] = useState(10)
|
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) => {
|
const showAuditLogs = async (user: SubUserItem) => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/sub-users/${user.id}/audit-logs`)
|
const res = await api.get(`/sub-users/${user.id}/audit-logs`)
|
||||||
@@ -190,6 +238,14 @@ export default function SubUserManagement() {
|
|||||||
<LogIn className="w-3.5 h-3.5" />
|
<LogIn className="w-3.5 h-3.5" />
|
||||||
登录日志
|
登录日志
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openImageLimit(user)}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1.5 rounded text-xs text-purple-600 hover:bg-purple-50 dark:hover:bg-purple-900/30 transition-colors"
|
||||||
|
title="可用镜像"
|
||||||
|
>
|
||||||
|
<HardDrive className="w-3.5 h-3.5" />
|
||||||
|
可用镜像
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -253,6 +309,75 @@ export default function SubUserManagement() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{imageUser && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl w-full max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||||
|
<div className="flex items-center justify-between gap-3 px-5 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-black dark:text-white">可用镜像</h3>
|
||||||
|
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{imageUser.username} · 默认勾选当前系统,取消后将禁止重装该系统</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setImageUser(null)} className="p-1 text-gray-400 hover:text-black dark:hover:text-white rounded">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-5">
|
||||||
|
{imagesLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="h-7 w-7 animate-spin rounded-full border-b-2 border-black" />
|
||||||
|
</div>
|
||||||
|
) : images.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-gray-300 px-4 py-10 text-center text-sm text-gray-500">
|
||||||
|
暂无已下载并启用的镜像
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{images.map((image) => {
|
||||||
|
const checked = selectedImageIDs.includes(image.id)
|
||||||
|
const current = (imageUser.current_image_ids || []).includes(image.id)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={image.id}
|
||||||
|
className={`flex cursor-pointer items-start gap-3 rounded-lg border px-3 py-3 text-sm transition-colors ${checked ? 'border-black bg-gray-50 dark:border-white dark:bg-gray-800' : 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800'}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggleImageID(image.id)}
|
||||||
|
className="mt-1 h-4 w-4 rounded border-gray-300 text-black focus:ring-black"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate font-medium text-black dark:text-white">{image.name}{current ? '(当前系统)' : ''}</span>
|
||||||
|
<span className="mt-1 block text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{image.type.toUpperCase()} · {image.arch} · {image.distro} {image.release}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3 border-t border-gray-200 dark:border-gray-700 px-5 py-3">
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">已选择 {selectedImageIDs.length} 个镜像</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => setImageUser(null)} className="px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 rounded-md">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={saveImageLimit}
|
||||||
|
disabled={savingImages || imagesLoading}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{savingImages ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Log Modal */}
|
{/* Log Modal */}
|
||||||
{(auditLogs || loginLogs) && (
|
{(auditLogs || loginLogs) && (
|
||||||
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
|
||||||
|
|||||||
@@ -164,6 +164,8 @@ export interface CreateContainerRequest {
|
|||||||
ssh_auth_mode?: string
|
ssh_auth_mode?: string
|
||||||
ssh_password?: string
|
ssh_password?: string
|
||||||
ssh_public_key?: string
|
ssh_public_key?: string
|
||||||
|
allowed_image_ids?: string[]
|
||||||
|
image_limit_configured?: boolean
|
||||||
expires_at: string
|
expires_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,8 +659,8 @@ export const deleteImage = (templateId: string) =>
|
|||||||
export const toggleImage = (templateId: string, enabled: boolean) =>
|
export const toggleImage = (templateId: string, enabled: boolean) =>
|
||||||
api.put<APIResponse>('/images/toggle', { template_id: templateId, enabled })
|
api.put<APIResponse>('/images/toggle', { template_id: templateId, enabled })
|
||||||
|
|
||||||
export const getEnabledImages = (virtualization = 'lxc') =>
|
export const getEnabledImages = (virtualization = 'lxc', container?: ContainerIdentifier) =>
|
||||||
api.get<APIResponse<Template[]>>('/images/enabled', { params: { type: virtualization } })
|
api.get<APIResponse<Template[]>>('/images/enabled', { params: { type: virtualization, ...(container ? { container: String(container) } : {}) } })
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
export const getDashboard = () =>
|
export const getDashboard = () =>
|
||||||
@@ -771,6 +773,9 @@ export interface SubUser {
|
|||||||
password?: string
|
password?: string
|
||||||
container_names: string[]
|
container_names: string[]
|
||||||
container_uuids?: string[]
|
container_uuids?: string[]
|
||||||
|
allowed_image_ids?: string[]
|
||||||
|
image_limit_configured?: boolean
|
||||||
|
current_image_ids?: string[]
|
||||||
access_code: string
|
access_code: string
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
@@ -778,6 +783,9 @@ export interface SubUser {
|
|||||||
export const createSubUser = (containerId: ContainerIdentifier) =>
|
export const createSubUser = (containerId: ContainerIdentifier) =>
|
||||||
api.post<APIResponse<SubUser>>('/sub-user/create', { container_name: String(containerId) })
|
api.post<APIResponse<SubUser>>('/sub-user/create', { container_name: String(containerId) })
|
||||||
|
|
||||||
|
export const updateSubUserImages = (id: string, allowedImageIds: string[]) =>
|
||||||
|
api.put<APIResponse<SubUser>>(`/sub-users/${id}/images`, { allowed_image_ids: allowedImageIds })
|
||||||
|
|
||||||
// Audit Logs
|
// Audit Logs
|
||||||
export interface AuditLog {
|
export interface AuditLog {
|
||||||
time: string
|
time: string
|
||||||
|
|||||||
Reference in New Issue
Block a user