修复了一些已知问题

This commit is contained in:
MengMengCode
2026-06-08 21:18:17 +08:00
parent 49b13af91c
commit f3a1687a18
7 changed files with 294 additions and 78 deletions
+13
View File
@@ -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/")
+45 -7
View File
@@ -766,15 +766,16 @@ 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)
if used, ok := smartPercentToInt(data.NVMe.PercentageUsed); ok {
smart.LifeUsedPercent = &used
}
}
smart.ReadDataBytes = data.NVMe.DataUnitsRead * 512000
smart.WrittenDataBytes = data.NVMe.DataUnitsWritten * 512000
smart.ReadCommands = data.NVMe.HostReadCommands
@@ -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,9 +887,10 @@ func parseATAAttributes(smart *HostDiskSMARTProbe, attrs []smartctlAttribute) {
}
case "percentageused":
if smart.LifeUsedPercent == nil && raw <= 255 {
used := int(raw)
if used, ok := smartPercentToInt(raw); ok {
smart.LifeUsedPercent = &used
}
}
case "erasefailcounttotal", "erasecount", "nandwrites", "programfailcnttotal":
if smart.EraseCount == "" && rawText != "" {
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 {
+180 -52
View File
@@ -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)
}
+20 -13
View File
@@ -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)
}
})
+2
View File
@@ -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)))
+4 -2
View File
@@ -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 && (
<div className="space-y-6 p-5">
<div className="rounded-lg bg-gray-900 p-4 font-mono text-xs text-gray-100">
<div>curl -H "X-API-Key: clicd_sk_xxxx" {BASE_URL}/api/v1/containers</div>
<div className="mt-2 text-gray-400">curl -H "Authorization: Bearer clicd_sk_xxxx" {BASE_URL}/api/v1/dashboard</div>
<div>curl -X GET {BASE_URL}/api/v1/containers -H "X-API-Key: clicd_sk_xxxx"</div>
<div className="mt-2 text-gray-400">curl -X GET {BASE_URL}/api/v1/dashboard -H "Authorization: Bearer clicd_sk_xxxx"</div>
<div className="mt-2 text-amber-300"> /api/containers/list 使 GET /api/v1/containers</div>
</div>
{endpointGroups.map(group => (
+28 -2
View File
@@ -452,10 +452,10 @@ export default function ContainerDetail() {
const digits = '23456789'
const symbols = '!@#$%*-_+='
const all = letters + digits + symbols
const pick = (chars: string) => chars[Math.floor(Math.random() * chars.length)]
const pick = (chars: string) => chars[secureRandomInt(chars.length)]
let password = pick(letters) + pick(digits)
while (password.length < 16) password += pick(all)
setResetPasswordDraft(password.split('').sort(() => Math.random() - 0.5).join(''))
setResetPasswordDraft(secureShuffle(password.split('')).join(''))
setResetPasswordResult('')
}
@@ -2054,6 +2054,32 @@ function TrafficBar({ container }: { container: Container }) {
)
}
function secureRandomInt(maxExclusive: number) {
if (!Number.isSafeInteger(maxExclusive) || maxExclusive <= 0) {
throw new Error('invalid random range')
}
const values = new Uint32Array(1)
const maxUint32 = 0x100000000
const limit = Math.floor(maxUint32 / maxExclusive) * maxExclusive
let value = 0
do {
crypto.getRandomValues(values)
value = values[0]
} while (value >= limit)
return value % maxExclusive
}
function secureShuffle<T>(items: T[]) {
const next = [...items]
for (let i = next.length - 1; i > 0; i--) {
const j = secureRandomInt(i + 1)
const value = next[i]
next[i] = next[j]
next[j] = value
}
return next
}
function getTemplateIcon(id: string): ReactNode {
const size = 'w-6 h-6'
id = id.startsWith('kvm-') ? id.slice(4) : id