diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index 71573bf..88610d1 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -38,6 +38,19 @@ func HandleContainers(w http.ResponseWriter, r *http.Request) { } } +// HandleContainerListAlias supports legacy integrations that call +// /api/containers/list or /api/v1/containers/list. +func HandleContainerListAlias(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + if !requireScope(w, r, "container:read") { + return + } + listContainers(w, r) +} + // HandleSingleContainer handles individual container operations by ID or name: /api/containers/{id-or-name}/... func HandleSingleContainer(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/api/v1/containers/") diff --git a/backend/internal/api/host.go b/backend/internal/api/host.go index c189488..d9f9043 100644 --- a/backend/internal/api/host.go +++ b/backend/internal/api/host.go @@ -766,14 +766,15 @@ func detectDiskSMART(path string) HostDiskSMARTProbe { smart.PowerOnHours = data.PowerOnTime.Hours smart.PowerCycleCount = data.PowerCycleCount if data.NVMe.PowerOnHours > 0 { - smart.PowerOnHours = int64(data.NVMe.PowerOnHours) + smart.PowerOnHours = uint64ToInt64(data.NVMe.PowerOnHours) } if data.NVMe.PowerCycles > 0 { - smart.PowerCycleCount = int64(data.NVMe.PowerCycles) + smart.PowerCycleCount = uint64ToInt64(data.NVMe.PowerCycles) } if data.NVMe.PercentageUsed > 0 { - used := int(data.NVMe.PercentageUsed) - smart.LifeUsedPercent = &used + if used, ok := smartPercentToInt(data.NVMe.PercentageUsed); ok { + smart.LifeUsedPercent = &used + } } smart.ReadDataBytes = data.NVMe.DataUnitsRead * 512000 smart.WrittenDataBytes = data.NVMe.DataUnitsWritten * 512000 @@ -829,11 +830,15 @@ func parseATAAttributes(smart *HostDiskSMARTProbe, attrs []smartctlAttribute) { switch name { case "poweronhours": if smart.PowerOnHours == 0 { - smart.PowerOnHours = int64(raw) + if parsed, ok := smartAttrRawInt64(attr); ok { + smart.PowerOnHours = parsed + } } case "powercyclecount": if smart.PowerCycleCount == 0 { - smart.PowerCycleCount = int64(raw) + if parsed, ok := smartAttrRawInt64(attr); ok { + smart.PowerCycleCount = parsed + } } case "totallbaswritten": if smart.WrittenDataBytes == 0 { @@ -868,7 +873,9 @@ func parseATAAttributes(smart *HostDiskSMARTProbe, attrs []smartctlAttribute) { if smart.LifeUsedPercent == nil { remaining := attr.Value if raw > 0 && raw <= 100 { - remaining = int(raw) + if parsed, ok := smartPercentToInt(raw); ok { + remaining = parsed + } } used := 100 - remaining if used < 0 { @@ -880,8 +887,9 @@ func parseATAAttributes(smart *HostDiskSMARTProbe, attrs []smartctlAttribute) { } case "percentageused": if smart.LifeUsedPercent == nil && raw <= 255 { - used := int(raw) - smart.LifeUsedPercent = &used + if used, ok := smartPercentToInt(raw); ok { + smart.LifeUsedPercent = &used + } } case "erasefailcounttotal", "erasecount", "nandwrites", "programfailcnttotal": if smart.EraseCount == "" && rawText != "" { @@ -893,6 +901,20 @@ func parseATAAttributes(smart *HostDiskSMARTProbe, attrs []smartctlAttribute) { } } +func uint64ToInt64(value uint64) int64 { + if value > 9223372036854775807 { + return 0 + } + return int64(value) +} + +func smartPercentToInt(value uint64) (int, bool) { + if value > 2147483647 { + return 0, false + } + return int(value), true +} + func normalizeSMARTAttrName(name string) string { name = strings.ToLower(name) var b strings.Builder @@ -914,6 +936,22 @@ func smartAttrRawText(attr smartctlAttribute) string { return "" } +func smartAttrRawInt64(attr smartctlAttribute) (int64, bool) { + text := smartAttrRawText(attr) + if value, err := strconv.ParseInt(text, 10, 64); err == nil { + return value, true + } + digits := firstUintText(text) + if digits == "" { + return 0, false + } + value, err := strconv.ParseInt(digits, 10, 64) + if err != nil { + return 0, false + } + return value, true +} + func smartAttrRawUint(attr smartctlAttribute) uint64 { text := smartAttrRawText(attr) if value, err := strconv.ParseUint(text, 10, 64); err == nil { diff --git a/backend/internal/lxc/lxc.go b/backend/internal/lxc/lxc.go index d32c8e7..4af2949 100644 --- a/backend/internal/lxc/lxc.go +++ b/backend/internal/lxc/lxc.go @@ -390,7 +390,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err) } } - if err := m.preconfigureSSH(rootfsPath, sshPassword, cfg.TemplateID); err != nil { + if err := m.preconfigureSSH(rootfsPath, cfg.TemplateID); err != nil { fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err) } @@ -402,8 +402,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { // Set root password AFTER shiftRootfsForUnprivileged, // otherwise /etc/shadow ownership breaks and SSHD cannot authenticate. - if err := m.runRootfsCommand(rootfsPath, - "sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(sshPassword))); err != nil { + if err := m.setRootfsPassword(rootfsPath, sshPassword); err != nil { fmt.Printf("Warning: failed to set root password in %s: %v\n", lxcName, err) } @@ -472,11 +471,11 @@ IPv6AcceptRA=no } // preconfigureSSH installs and configures SSH directly in the rootfs before first boot. -func (m *Manager) preconfigureSSH(rootfsPath, password, templateID string) error { +func (m *Manager) preconfigureSSH(rootfsPath, templateID string) error { _ = templateID ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) defer cancel() - cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(password, false)) + cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(false)) if err != nil { return err } @@ -1650,7 +1649,7 @@ func (m *Manager) EnsureSSH(id int) error { config.SaveConfig() } - script := sshSetupScript(c.SSHPassword, true) + script := sshSetupScript(true) ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) defer cancel() @@ -1662,6 +1661,9 @@ func (m *Manager) EnsureSSH(id int) error { if err != nil { return fmt.Errorf("failed to configure SSH in container %d: %v, output: %s", id, err, string(output)) } + if err := m.quickEnsureSSHPassword(lxcName, c.SSHPassword); err != nil { + return fmt.Errorf("failed to set SSH password in container %d: %v", id, err) + } if c.IP == "" { if ip, ipErr := m.GetContainerIP(lxcName); ipErr == nil && ip != "" { @@ -1680,13 +1682,13 @@ func (m *Manager) EnsureSSH(id int) error { } func (m *Manager) quickEnsureSSHPassword(lxcName, password string) error { - if password == "" { - return fmt.Errorf("empty SSH password") + if err := validateRootPassword(password); err != nil { + return err } ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) defer cancel() - cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", - fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(password))) + cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "chpasswd") + cmd.Stdin = strings.NewReader(rootPasswordInput(password)) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to update SSH password quickly: %v, output: %s", err, string(output)) @@ -1694,6 +1696,20 @@ func (m *Manager) quickEnsureSSHPassword(lxcName, password string) error { return nil } +func validateRootPassword(password string) error { + if password == "" { + return fmt.Errorf("empty SSH password") + } + if strings.ContainsAny(password, "\r\n") || strings.ContainsRune(password, '\x00') { + return fmt.Errorf("SSH password contains unsupported control characters") + } + return nil +} + +func rootPasswordInput(password string) string { + return "root:" + password + "\n" +} + func (m *Manager) containerPortListening(lxcName string, port int) bool { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -1701,9 +1717,8 @@ func (m *Manager) containerPortListening(lxcName string, port int) bool { return exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", check).Run() == nil } -func sshSetupScript(password string, startService bool) string { +func sshSetupScript(startService bool) string { script := `set -u -ROOT_PASSWORD=` + shellQuote(password) + ` # DNS setup: handle both traditional /etc/resolv.conf and systemd-resolved (Ubuntu 24.04). # On modern distros, /etc/resolv.conf is a symlink managed by systemd-resolved. @@ -1827,11 +1842,6 @@ set_sshd_option KbdInteractiveAuthentication no set_sshd_option ChallengeResponseAuthentication no set_sshd_option UsePAM no -if [ -n "$ROOT_PASSWORD" ]; then - printf '%s:%s\n' root "$ROOT_PASSWORD" | chpasswd || exit 31 - passwd -u root >/dev/null 2>&1 || true -fi - if command -v rc-update >/dev/null 2>&1; then rc-update add sshd default >/dev/null 2>&1 || true fi @@ -1912,16 +1922,11 @@ func (m *Manager) ResetSSHPassword(id int, password string) (string, error) { return "", err } rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") - if err := m.preconfigureSSH(rootfsPath, newPassword, c.Template); err != nil { + if err := m.preconfigureSSH(rootfsPath, c.Template); err != nil { return "", fmt.Errorf("failed to configure SSH: %v", err) } - cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(newPassword))) - if err != nil { - return "", err - } - output, err := cmd.CombinedOutput() - if err != nil { - return "", fmt.Errorf("failed to set password: %v, output: %s", err, string(output)) + if err := m.setRootfsPassword(rootfsPath, newPassword); err != nil { + return "", fmt.Errorf("failed to set password: %v", err) } c.SSHPassword = newPassword config.SaveConfig() @@ -1935,6 +1940,10 @@ func (m *Manager) rootfsCommand(rootfsPath string, args ...string) (*exec.Cmd, e if err != nil { return nil, err } + safeArgs, err := safeRootfsCommandArgs(args) + if err != nil { + return nil, err + } marker := filepath.Join(cleanRootfsPath, ".clicd-unprivileged-shifted") if _, err := os.Stat(marker); err == nil { @@ -1945,11 +1954,11 @@ func (m *Manager) rootfsCommand(rootfsPath string, args ...string) (*exec.Cmd, e "-m", fmt.Sprintf("g:0:%d:65536", gidBase), "--", "chroot", "--", cleanRootfsPath, } - cmdArgs = append(cmdArgs, args...) + cmdArgs = append(cmdArgs, safeArgs...) return exec.Command("lxc-usernsexec", cmdArgs...), nil } } - cmdArgs := append([]string{"--", cleanRootfsPath}, args...) + cmdArgs := append([]string{"--", cleanRootfsPath}, safeArgs...) return exec.Command("chroot", cmdArgs...), nil } @@ -1961,6 +1970,58 @@ func (m *Manager) runRootfsCommand(rootfsPath string, args ...string) error { return cmd.Run() } +func (m *Manager) setRootfsPassword(rootfsPath, password string) error { + if err := validateRootPassword(password); err != nil { + return err + } + cmd, err := m.rootfsCommand(rootfsPath, "chpasswd") + if err != nil { + return err + } + cmd.Stdin = strings.NewReader(rootPasswordInput(password)) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%v, output: %s", err, string(output)) + } + return nil +} + +func safeRootfsCommandArgs(args []string) ([]string, error) { + if len(args) == 0 { + return nil, fmt.Errorf("empty rootfs command") + } + allowed := map[string]bool{ + "chpasswd": true, + "rc-update": true, + "sh": true, + "systemctl": true, + } + if !allowed[args[0]] || strings.HasPrefix(args[0], "-") || strings.Contains(args[0], "/") { + return nil, fmt.Errorf("rootfs command is not allowed: %s", args[0]) + } + for _, arg := range args { + if strings.ContainsRune(arg, '\x00') { + return nil, fmt.Errorf("rootfs command argument contains NUL byte") + } + } + if args[0] == "sh" { + if len(args) != 3 || args[1] != "-c" { + return nil, fmt.Errorf("unsupported rootfs shell invocation") + } + if !isCLICDManagedRootfsScript(args[2]) { + return nil, fmt.Errorf("refusing unmanaged rootfs shell script") + } + } + return append([]string(nil), args...), nil +} + +func isCLICDManagedRootfsScript(script string) bool { + return strings.Contains(script, "99-clicd.conf") && + strings.Contains(script, "install_sshd") && + !strings.Contains(script, "ROOT_PASSWORD") && + !strings.Contains(script, "chpasswd") +} + func (m *Manager) safeRootfsPath(rootfsPath string) (string, error) { if rootfsPath == "" { return "", fmt.Errorf("empty rootfs path") @@ -1993,6 +2054,13 @@ func (m *Manager) safeRootfsPath(rootfsPath string) (string, error) { if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) { return "", fmt.Errorf("refusing unsafe rootfs path: %s", cleanRootfsPath) } + parts := strings.Split(rel, string(os.PathSeparator)) + if len(parts) != 2 || parts[1] != "rootfs" { + return "", fmt.Errorf("refusing nested or malformed rootfs path: %s", cleanRootfsPath) + } + if strings.HasPrefix(parts[0], "-") || !regexp.MustCompile(`^[A-Za-z0-9_.-]+$`).MatchString(parts[0]) { + return "", fmt.Errorf("refusing unsafe container directory name: %s", parts[0]) + } return cleanRootfsPath, nil } @@ -2235,6 +2303,85 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) { return imported, nil } +func (m *Manager) replaceRootfsFromTemplate(lxcName string, tmpl *Template) error { + if tmpl == nil { + return fmt.Errorf("template is nil") + } + tmpName := fmt.Sprintf("clicd-reinstall-%s-%s", lxcName, generateRandomString(8)) + tmpDir := filepath.Join(m.LxcPath, tmpName) + if err := os.RemoveAll(tmpDir); err != nil { + return fmt.Errorf("failed to clean temporary reinstall directory: %v", err) + } + defer m.cleanupTemporaryContainer(tmpName) + + args := []string{ + "-n", tmpName, + "-t", "download", + "--", + "-d", tmpl.Distro, + "-r", tmpl.Release, + "-a", tmpl.Arch, + } + if tmpl.Variant != "" { + args = append(args, "--variant", tmpl.Variant) + } + output, err := exec.Command("lxc-create", args...).CombinedOutput() + if err != nil { + return fmt.Errorf("failed to download replacement rootfs: %v, output: %s", err, string(output)) + } + + tmpRootfs := filepath.Join(tmpDir, "rootfs") + if !rootfsHasInit(tmpRootfs) { + return fmt.Errorf("downloaded replacement rootfs is invalid: init not found") + } + + rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") + if err := m.ensureDiskImageMounted(lxcName); err != nil { + return err + } + m.unmountRootfsChildMounts(rootfsPath) + if err := os.MkdirAll(rootfsPath, 0755); err != nil { + return err + } + if err := removeDirectoryContents(rootfsPath); err != nil { + return fmt.Errorf("failed to clear old rootfs: %v", err) + } + if err := copyRootfsContents(tmpRootfs, rootfsPath); err != nil { + return err + } + if !rootfsHasInit(rootfsPath) { + return fmt.Errorf("replacement rootfs copy failed: init not found") + } + return nil +} + +func (m *Manager) cleanupTemporaryContainer(lxcName string) { + exec.Command("lxc-stop", "-n", lxcName, "-k").Run() + exec.Command("lxc-destroy", "-n", lxcName, "-f").Run() + os.RemoveAll(filepath.Join(m.LxcPath, lxcName)) +} + +func removeDirectoryContents(dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + for _, entry := range entries { + if err := os.RemoveAll(filepath.Join(dir, entry.Name())); err != nil { + return err + } + } + return nil +} + +func copyRootfsContents(src, dst string) error { + output, err := exec.Command("cp", "-a", src+string(os.PathSeparator)+".", dst+string(os.PathSeparator)).CombinedOutput() + if err != nil { + return fmt.Errorf("failed to copy replacement rootfs: %v, output: %s", err, string(output)) + } + return nil +} + // ReinstallContainer reinstalls the container OS func (m *Manager) ReinstallContainer(id int, templateID string) error { c := config.FindContainer(id) @@ -2258,28 +2405,10 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error { // Clean port mappings temporarily m.CleanPortMappings(id) - // Destroy old LXC but keep config. lxc-destroy can leave the config - // directory behind when rootfs mounts are still present, which makes the - // following lxc-create fail with "Container already exists". - exec.Command("lxc-stop", "-n", lxcName, "-k").Run() - rootfs := filepath.Join(m.LxcPath, lxcName, "rootfs") - exec.Command("umount", "-R", "-l", rootfs).Run() - exec.Command("lxc-destroy", "-n", lxcName, "-f").Run() - exec.Command("umount", "-R", "-l", rootfs).Run() - os.RemoveAll(filepath.Join(m.LxcPath, lxcName)) - - // Create new container with same LXC name (preserves ID) - cmd := exec.Command("lxc-create", - "-n", lxcName, - "-t", "download", - "--", - "-d", tmpl.Distro, - "-r", tmpl.Release, - "-a", tmpl.Arch, - ) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("lxc-create failed: %v, output: %s", err, string(output)) + // Download the new OS into a temporary container, then replace only the + // existing rootfs. The target container directory and config are preserved. + if err := m.replaceRootfsFromTemplate(lxcName, tmpl); err != nil { + return err } if err := m.applyDiskLimit(lxcName, c.DiskGB); err != nil { @@ -2319,14 +2448,13 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error { if c.SSHPassword == "" { c.SSHPassword = generateRandomString(16) } - if err := m.preconfigureSSH(rootfsPath, c.SSHPassword, templateID); err != nil { + if err := m.preconfigureSSH(rootfsPath, templateID); err != nil { fmt.Printf("Warning: failed to pre-configure SSH in %s after reinstall: %v\n", lxcName, err) } if err := m.shiftRootfsForUnprivileged(lxcName); err != nil { return err } - if err := m.runRootfsCommand(rootfsPath, - "sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(c.SSHPassword))); err != nil { + if err := m.setRootfsPassword(rootfsPath, c.SSHPassword); err != nil { fmt.Printf("Warning: failed to set root password in %s after reinstall: %v\n", lxcName, err) } diff --git a/backend/internal/lxc/lxc_test.go b/backend/internal/lxc/lxc_test.go index 8f93c27..04545d0 100644 --- a/backend/internal/lxc/lxc_test.go +++ b/backend/internal/lxc/lxc_test.go @@ -8,7 +8,7 @@ import ( "testing" ) -func TestRootfsCommandAddsSeparatorAndPreservesArgs(t *testing.T) { +func TestRootfsCommandAddsSeparatorForAllowedCommand(t *testing.T) { base := t.TempDir() rootfs := filepath.Join(base, "ct-1", "rootfs") if err := os.MkdirAll(rootfs, 0755); err != nil { @@ -16,18 +16,31 @@ func TestRootfsCommandAddsSeparatorAndPreservesArgs(t *testing.T) { } m := &Manager{LxcPath: base} - cmd, err := m.rootfsCommand(rootfs, "sh", "-c", "true", "--flag") + cmd, err := m.rootfsCommand(rootfs, "chpasswd") if err != nil { t.Fatalf("rootfsCommand returned error: %v", err) } - want := []string{"chroot", "--", rootfs, "sh", "-c", "true", "--flag"} + want := []string{"chroot", "--", rootfs, "chpasswd"} if !reflect.DeepEqual(cmd.Args, want) { t.Fatalf("cmd.Args = %#v, want %#v", cmd.Args, want) } } -func TestRootfsCommandAllowsLeadingDashContainerName(t *testing.T) { +func TestRootfsCommandRejectsUnmanagedCommand(t *testing.T) { + base := t.TempDir() + rootfs := filepath.Join(base, "ct-1", "rootfs") + if err := os.MkdirAll(rootfs, 0755); err != nil { + t.Fatal(err) + } + + m := &Manager{LxcPath: base} + if _, err := m.rootfsCommand(rootfs, "true"); err == nil { + t.Fatal("rootfsCommand allowed unmanaged command") + } +} + +func TestRootfsCommandRejectsLeadingDashContainerName(t *testing.T) { base := t.TempDir() rootfs := filepath.Join(base, "-ct", "rootfs") if err := os.MkdirAll(rootfs, 0755); err != nil { @@ -35,14 +48,8 @@ func TestRootfsCommandAllowsLeadingDashContainerName(t *testing.T) { } m := &Manager{LxcPath: base} - cmd, err := m.rootfsCommand(rootfs, "true") - if err != nil { - t.Fatalf("rootfsCommand returned error: %v", err) - } - - want := []string{"chroot", "--", rootfs, "true"} - if !reflect.DeepEqual(cmd.Args, want) { - t.Fatalf("cmd.Args = %#v, want %#v", cmd.Args, want) + if _, err := m.rootfsCommand(rootfs, "chpasswd"); err == nil { + t.Fatal("rootfsCommand allowed leading-dash container name") } } @@ -64,7 +71,7 @@ func TestRootfsCommandRejectsUnsafeRootfsPaths(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - if _, err := m.rootfsCommand(tc.path, "true"); err == nil { + if _, err := m.rootfsCommand(tc.path, "chpasswd"); err == nil { t.Fatalf("rootfsCommand(%q) returned nil error", tc.path) } }) diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index ae80f67..1f6afa0 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -76,6 +76,7 @@ func setupRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange))) mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs))) mux.HandleFunc("/api/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers)))) + mux.HandleFunc("/api/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias)))) mux.HandleFunc("/api/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer)))) mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates))) mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages))) @@ -117,6 +118,7 @@ func setupRoutes(mux *http.ServeMux) { // Versioned external API routes mux.HandleFunc("/api/v1/dashboard", corsMiddleware(api.AuthMiddleware(api.HandleDashboard))) mux.HandleFunc("/api/v1/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers)))) + mux.HandleFunc("/api/v1/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias)))) mux.HandleFunc("/api/v1/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer)))) mux.HandleFunc("/api/v1/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates))) mux.HandleFunc("/api/v1/images", corsMiddleware(api.AuthMiddleware(api.HandleImages))) diff --git a/frontend/src/pages/ApiIntegration.tsx b/frontend/src/pages/ApiIntegration.tsx index fb77f30..7d41fd1 100644 --- a/frontend/src/pages/ApiIntegration.tsx +++ b/frontend/src/pages/ApiIntegration.tsx @@ -132,6 +132,7 @@ const endpointGroups = [ title: '容器', endpoints: [ ['GET', '/api/v1/containers', '容器列表'], + ['POST', '/api/v1/containers/list', '容器列表(兼容旧接口)'], ['POST', '/api/v1/containers', '创建容器'], ['GET', '/api/v1/containers/{id|uuid|name}', '容器详情'], ['POST', '/api/v1/containers/{id}/start', '开机'], @@ -472,8 +473,9 @@ export default function ApiIntegration() { {showDocs && (