Compare commits

..

3 Commits

Author SHA1 Message Date
MengMengCode 33603f5776 release: v1.1.6 2026-06-09 00:12:19 +08:00
MengMengCode 9ad7bcc97a 完善API文档 2026-06-09 00:10:45 +08:00
MengMengCode f3a1687a18 修复了一些已知问题 2026-06-08 21:18:17 +08:00
11 changed files with 763 additions and 84 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}/... // HandleSingleContainer handles individual container operations by ID or name: /api/containers/{id-or-name}/...
func HandleSingleContainer(w http.ResponseWriter, r *http.Request) { func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/v1/containers/") path := strings.TrimPrefix(r.URL.Path, "/api/v1/containers/")
+47 -9
View File
@@ -766,14 +766,15 @@ func detectDiskSMART(path string) HostDiskSMARTProbe {
smart.PowerOnHours = data.PowerOnTime.Hours smart.PowerOnHours = data.PowerOnTime.Hours
smart.PowerCycleCount = data.PowerCycleCount smart.PowerCycleCount = data.PowerCycleCount
if data.NVMe.PowerOnHours > 0 { if data.NVMe.PowerOnHours > 0 {
smart.PowerOnHours = int64(data.NVMe.PowerOnHours) smart.PowerOnHours = uint64ToInt64(data.NVMe.PowerOnHours)
} }
if data.NVMe.PowerCycles > 0 { if data.NVMe.PowerCycles > 0 {
smart.PowerCycleCount = int64(data.NVMe.PowerCycles) smart.PowerCycleCount = uint64ToInt64(data.NVMe.PowerCycles)
} }
if data.NVMe.PercentageUsed > 0 { if data.NVMe.PercentageUsed > 0 {
used := int(data.NVMe.PercentageUsed) if used, ok := smartPercentToInt(data.NVMe.PercentageUsed); ok {
smart.LifeUsedPercent = &used smart.LifeUsedPercent = &used
}
} }
smart.ReadDataBytes = data.NVMe.DataUnitsRead * 512000 smart.ReadDataBytes = data.NVMe.DataUnitsRead * 512000
smart.WrittenDataBytes = data.NVMe.DataUnitsWritten * 512000 smart.WrittenDataBytes = data.NVMe.DataUnitsWritten * 512000
@@ -829,11 +830,15 @@ func parseATAAttributes(smart *HostDiskSMARTProbe, attrs []smartctlAttribute) {
switch name { switch name {
case "poweronhours": case "poweronhours":
if smart.PowerOnHours == 0 { if smart.PowerOnHours == 0 {
smart.PowerOnHours = int64(raw) if parsed, ok := smartAttrRawInt64(attr); ok {
smart.PowerOnHours = parsed
}
} }
case "powercyclecount": case "powercyclecount":
if smart.PowerCycleCount == 0 { if smart.PowerCycleCount == 0 {
smart.PowerCycleCount = int64(raw) if parsed, ok := smartAttrRawInt64(attr); ok {
smart.PowerCycleCount = parsed
}
} }
case "totallbaswritten": case "totallbaswritten":
if smart.WrittenDataBytes == 0 { if smart.WrittenDataBytes == 0 {
@@ -868,7 +873,9 @@ func parseATAAttributes(smart *HostDiskSMARTProbe, attrs []smartctlAttribute) {
if smart.LifeUsedPercent == nil { if smart.LifeUsedPercent == nil {
remaining := attr.Value remaining := attr.Value
if raw > 0 && raw <= 100 { if raw > 0 && raw <= 100 {
remaining = int(raw) if parsed, ok := smartPercentToInt(raw); ok {
remaining = parsed
}
} }
used := 100 - remaining used := 100 - remaining
if used < 0 { if used < 0 {
@@ -880,8 +887,9 @@ func parseATAAttributes(smart *HostDiskSMARTProbe, attrs []smartctlAttribute) {
} }
case "percentageused": case "percentageused":
if smart.LifeUsedPercent == nil && raw <= 255 { if smart.LifeUsedPercent == nil && raw <= 255 {
used := int(raw) if used, ok := smartPercentToInt(raw); ok {
smart.LifeUsedPercent = &used smart.LifeUsedPercent = &used
}
} }
case "erasefailcounttotal", "erasecount", "nandwrites", "programfailcnttotal": case "erasefailcounttotal", "erasecount", "nandwrites", "programfailcnttotal":
if smart.EraseCount == "" && rawText != "" { 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 { func normalizeSMARTAttrName(name string) string {
name = strings.ToLower(name) name = strings.ToLower(name)
var b strings.Builder var b strings.Builder
@@ -914,6 +936,22 @@ func smartAttrRawText(attr smartctlAttribute) string {
return "" 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 { func smartAttrRawUint(attr smartctlAttribute) uint64 {
text := smartAttrRawText(attr) text := smartAttrRawText(attr)
if value, err := strconv.ParseUint(text, 10, 64); err == nil { 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) 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) 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, // Set root password AFTER shiftRootfsForUnprivileged,
// otherwise /etc/shadow ownership breaks and SSHD cannot authenticate. // otherwise /etc/shadow ownership breaks and SSHD cannot authenticate.
if err := m.runRootfsCommand(rootfsPath, if err := m.setRootfsPassword(rootfsPath, sshPassword); err != nil {
"sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(sshPassword))); err != nil {
fmt.Printf("Warning: failed to set root password in %s: %v\n", lxcName, err) 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. // 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 _ = templateID
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel() defer cancel()
cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(password, false)) cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(false))
if err != nil { if err != nil {
return err return err
} }
@@ -1650,7 +1649,7 @@ func (m *Manager) EnsureSSH(id int) error {
config.SaveConfig() config.SaveConfig()
} }
script := sshSetupScript(c.SSHPassword, true) script := sshSetupScript(true)
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel() defer cancel()
@@ -1662,6 +1661,9 @@ func (m *Manager) EnsureSSH(id int) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to configure SSH in container %d: %v, output: %s", id, err, string(output)) 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 c.IP == "" {
if ip, ipErr := m.GetContainerIP(lxcName); ipErr == nil && 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 { func (m *Manager) quickEnsureSSHPassword(lxcName, password string) error {
if password == "" { if err := validateRootPassword(password); err != nil {
return fmt.Errorf("empty SSH password") return err
} }
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel() defer cancel()
cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "chpasswd")
fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(password))) cmd.Stdin = strings.NewReader(rootPasswordInput(password))
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return fmt.Errorf("failed to update SSH password quickly: %v, output: %s", err, string(output)) 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 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 { func (m *Manager) containerPortListening(lxcName string, port int) bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() 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 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 script := `set -u
ROOT_PASSWORD=` + shellQuote(password) + `
# DNS setup: handle both traditional /etc/resolv.conf and systemd-resolved (Ubuntu 24.04). # 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. # 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 ChallengeResponseAuthentication no
set_sshd_option UsePAM 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 if command -v rc-update >/dev/null 2>&1; then
rc-update add sshd default >/dev/null 2>&1 || true rc-update add sshd default >/dev/null 2>&1 || true
fi fi
@@ -1912,16 +1922,11 @@ func (m *Manager) ResetSSHPassword(id int, password string) (string, error) {
return "", err return "", err
} }
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") 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) 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 := m.setRootfsPassword(rootfsPath, newPassword); err != nil {
if err != nil { return "", fmt.Errorf("failed to set password: %v", err)
return "", err
}
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("failed to set password: %v, output: %s", err, string(output))
} }
c.SSHPassword = newPassword c.SSHPassword = newPassword
config.SaveConfig() config.SaveConfig()
@@ -1935,6 +1940,10 @@ func (m *Manager) rootfsCommand(rootfsPath string, args ...string) (*exec.Cmd, e
if err != nil { if err != nil {
return nil, err return nil, err
} }
safeArgs, err := safeRootfsCommandArgs(args)
if err != nil {
return nil, err
}
marker := filepath.Join(cleanRootfsPath, ".clicd-unprivileged-shifted") marker := filepath.Join(cleanRootfsPath, ".clicd-unprivileged-shifted")
if _, err := os.Stat(marker); err == nil { 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), "-m", fmt.Sprintf("g:0:%d:65536", gidBase),
"--", "chroot", "--", cleanRootfsPath, "--", "chroot", "--", cleanRootfsPath,
} }
cmdArgs = append(cmdArgs, args...) cmdArgs = append(cmdArgs, safeArgs...)
return exec.Command("lxc-usernsexec", cmdArgs...), nil return exec.Command("lxc-usernsexec", cmdArgs...), nil
} }
} }
cmdArgs := append([]string{"--", cleanRootfsPath}, args...) cmdArgs := append([]string{"--", cleanRootfsPath}, safeArgs...)
return exec.Command("chroot", cmdArgs...), nil return exec.Command("chroot", cmdArgs...), nil
} }
@@ -1961,6 +1970,58 @@ func (m *Manager) runRootfsCommand(rootfsPath string, args ...string) error {
return cmd.Run() 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) { func (m *Manager) safeRootfsPath(rootfsPath string) (string, error) {
if rootfsPath == "" { if rootfsPath == "" {
return "", fmt.Errorf("empty rootfs path") 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) { if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) {
return "", fmt.Errorf("refusing unsafe rootfs path: %s", cleanRootfsPath) 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 return cleanRootfsPath, nil
} }
@@ -2235,6 +2303,85 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
return imported, nil 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 // ReinstallContainer reinstalls the container OS
func (m *Manager) ReinstallContainer(id int, templateID string) error { func (m *Manager) ReinstallContainer(id int, templateID string) error {
c := config.FindContainer(id) c := config.FindContainer(id)
@@ -2258,28 +2405,10 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
// Clean port mappings temporarily // Clean port mappings temporarily
m.CleanPortMappings(id) m.CleanPortMappings(id)
// Destroy old LXC but keep config. lxc-destroy can leave the config // Download the new OS into a temporary container, then replace only the
// directory behind when rootfs mounts are still present, which makes the // existing rootfs. The target container directory and config are preserved.
// following lxc-create fail with "Container already exists". if err := m.replaceRootfsFromTemplate(lxcName, tmpl); err != nil {
exec.Command("lxc-stop", "-n", lxcName, "-k").Run() return err
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))
} }
if err := m.applyDiskLimit(lxcName, c.DiskGB); err != nil { 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 == "" { if c.SSHPassword == "" {
c.SSHPassword = generateRandomString(16) 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) fmt.Printf("Warning: failed to pre-configure SSH in %s after reinstall: %v\n", lxcName, err)
} }
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil { if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
return err return err
} }
if err := m.runRootfsCommand(rootfsPath, if err := m.setRootfsPassword(rootfsPath, c.SSHPassword); err != nil {
"sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(c.SSHPassword))); err != nil {
fmt.Printf("Warning: failed to set root password in %s after reinstall: %v\n", lxcName, err) 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" "testing"
) )
func TestRootfsCommandAddsSeparatorAndPreservesArgs(t *testing.T) { func TestRootfsCommandAddsSeparatorForAllowedCommand(t *testing.T) {
base := t.TempDir() base := t.TempDir()
rootfs := filepath.Join(base, "ct-1", "rootfs") rootfs := filepath.Join(base, "ct-1", "rootfs")
if err := os.MkdirAll(rootfs, 0755); err != nil { if err := os.MkdirAll(rootfs, 0755); err != nil {
@@ -16,18 +16,31 @@ func TestRootfsCommandAddsSeparatorAndPreservesArgs(t *testing.T) {
} }
m := &Manager{LxcPath: base} m := &Manager{LxcPath: base}
cmd, err := m.rootfsCommand(rootfs, "sh", "-c", "true", "--flag") cmd, err := m.rootfsCommand(rootfs, "chpasswd")
if err != nil { if err != nil {
t.Fatalf("rootfsCommand returned error: %v", err) 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) { if !reflect.DeepEqual(cmd.Args, want) {
t.Fatalf("cmd.Args = %#v, want %#v", 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() base := t.TempDir()
rootfs := filepath.Join(base, "-ct", "rootfs") rootfs := filepath.Join(base, "-ct", "rootfs")
if err := os.MkdirAll(rootfs, 0755); err != nil { if err := os.MkdirAll(rootfs, 0755); err != nil {
@@ -35,14 +48,8 @@ func TestRootfsCommandAllowsLeadingDashContainerName(t *testing.T) {
} }
m := &Manager{LxcPath: base} m := &Manager{LxcPath: base}
cmd, err := m.rootfsCommand(rootfs, "true") if _, err := m.rootfsCommand(rootfs, "chpasswd"); err == nil {
if err != nil { t.Fatal("rootfsCommand allowed leading-dash container name")
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)
} }
} }
@@ -64,7 +71,7 @@ func TestRootfsCommandRejectsUnsafeRootfsPaths(t *testing.T) {
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { 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) 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/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange)))
mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs))) 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", 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/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates))) mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages))) mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages)))
@@ -117,6 +118,7 @@ func setupRoutes(mux *http.ServeMux) {
// Versioned external API routes // Versioned external API routes
mux.HandleFunc("/api/v1/dashboard", corsMiddleware(api.AuthMiddleware(api.HandleDashboard))) 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", 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/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
mux.HandleFunc("/api/v1/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates))) mux.HandleFunc("/api/v1/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
mux.HandleFunc("/api/v1/images", corsMiddleware(api.AuthMiddleware(api.HandleImages))) mux.HandleFunc("/api/v1/images", corsMiddleware(api.AuthMiddleware(api.HandleImages)))
+1 -1
View File
@@ -1 +1 @@
?
+1 -1
View File
@@ -1,7 +1,7 @@
package version package version
var ( var (
Version = "1.1.5" Version = "1.1.6"
Repo = "MengMengCode/CLICD" Repo = "MengMengCode/CLICD"
) )
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "clicd-frontend", "name": "clicd-frontend",
"private": true, "private": true,
"version": "1.1.5", "version": "1.1.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+469 -4
View File
@@ -5,6 +5,7 @@ import {
ChevronUp, ChevronUp,
Copy, Copy,
Edit3, Edit3,
Eye,
Key, Key,
Plus, Plus,
RefreshCw, RefreshCw,
@@ -40,6 +41,20 @@ interface ApiKeyForm {
} }
const BASE_URL = window.location.origin const BASE_URL = window.location.origin
const SAMPLE_BASE_URL = 'https://panel.example.com'
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
type EndpointTuple = [HttpMethod, string, string]
interface EndpointDoc {
method: HttpMethod
path: string
desc: string
examplePath: string
body?: Record<string, unknown>
response: unknown
note?: string
}
const scopeGroups = [ const scopeGroups = [
{ {
@@ -116,7 +131,7 @@ const defaultReadScopes = [
'host:read', 'host:read',
] ]
const endpointGroups = [ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
{ {
title: '总览', title: '总览',
endpoints: [ endpoints: [
@@ -132,6 +147,7 @@ const endpointGroups = [
title: '容器', title: '容器',
endpoints: [ endpoints: [
['GET', '/api/v1/containers', '容器列表'], ['GET', '/api/v1/containers', '容器列表'],
['POST', '/api/v1/containers/list', '容器列表(兼容旧接口)'],
['POST', '/api/v1/containers', '创建容器'], ['POST', '/api/v1/containers', '创建容器'],
['GET', '/api/v1/containers/{id|uuid|name}', '容器详情'], ['GET', '/api/v1/containers/{id|uuid|name}', '容器详情'],
['POST', '/api/v1/containers/{id}/start', '开机'], ['POST', '/api/v1/containers/{id}/start', '开机'],
@@ -226,6 +242,8 @@ export default function ApiIntegration() {
const [newKey, setNewKey] = useState('') const [newKey, setNewKey] = useState('')
const [copiedKey, setCopiedKey] = useState(false) const [copiedKey, setCopiedKey] = useState(false)
const [showDocs, setShowDocs] = useState(true) const [showDocs, setShowDocs] = useState(true)
const [selectedEndpoint, setSelectedEndpoint] = useState<EndpointDoc | null>(null)
const [copiedDoc, setCopiedDoc] = useState(false)
const containerNameByUUID = useMemo(() => { const containerNameByUUID = useMemo(() => {
const map = new Map<string, string>() const map = new Map<string, string>()
@@ -322,6 +340,15 @@ export default function ApiIntegration() {
} }
} }
const copyDocCode = async () => {
if (!selectedEndpoint) return
const copied = await copyToClipboard(buildPythonExample(selectedEndpoint))
if (copied) {
setCopiedDoc(true)
setTimeout(() => setCopiedDoc(false), 1600)
}
}
const toggleScope = (scope: string) => { const toggleScope = (scope: string) => {
setForm(prev => { setForm(prev => {
if (scope === '*') { if (scope === '*') {
@@ -472,8 +499,9 @@ export default function ApiIntegration() {
{showDocs && ( {showDocs && (
<div className="space-y-6 p-5"> <div className="space-y-6 p-5">
<div className="rounded-lg bg-gray-900 p-4 font-mono text-xs text-gray-100"> <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>curl -X GET {BASE_URL}/api/v1/containers -H "X-API-Key: clicd_sk_xxxx"</div>
<div className="mt-2 text-gray-400">curl -H "Authorization: Bearer clicd_sk_xxxx" {BASE_URL}/api/v1/dashboard</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> </div>
{endpointGroups.map(group => ( {endpointGroups.map(group => (
@@ -481,10 +509,18 @@ export default function ApiIntegration() {
<h3 className="mb-2 text-sm font-semibold text-black">{group.title}</h3> <h3 className="mb-2 text-sm font-semibold text-black">{group.title}</h3>
<div className="overflow-hidden rounded-lg border border-gray-200"> <div className="overflow-hidden rounded-lg border border-gray-200">
{group.endpoints.map(([method, path, desc]) => ( {group.endpoints.map(([method, path, desc]) => (
<div key={`${method}-${path}`} className="grid gap-2 border-b border-gray-100 px-3 py-2 text-xs last:border-b-0 md:grid-cols-[72px_minmax(280px,1fr)_180px]"> <div key={`${method}-${path}`} className="grid gap-2 border-b border-gray-100 px-3 py-2 text-xs last:border-b-0 md:grid-cols-[72px_minmax(220px,1fr)_180px_72px]">
<span className="w-fit rounded border border-blue-200 bg-blue-50 px-1.5 py-0.5 font-mono font-bold text-blue-700">{method}</span> <span className="w-fit rounded border border-blue-200 bg-blue-50 px-1.5 py-0.5 font-mono font-bold text-blue-700">{method}</span>
<code className="min-w-0 break-all font-mono text-gray-800">{path}</code> <code className="min-w-0 break-all font-mono text-gray-800">{path}</code>
<span className="text-gray-500">{desc}</span> <span className="text-gray-500">{desc}</span>
<button
onClick={() => setSelectedEndpoint(buildEndpointDoc(method, path, desc))}
className="inline-flex w-fit items-center justify-center gap-1 rounded border border-gray-200 px-2 py-1 text-gray-600 hover:border-gray-300 hover:bg-gray-50 hover:text-black"
title="查看使用范例"
>
<Eye className="h-3.5 w-3.5" />
</button>
</div> </div>
))} ))}
</div> </div>
@@ -494,6 +530,58 @@ export default function ApiIntegration() {
)} )}
</div> </div>
{selectedEndpoint && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50" onClick={() => setSelectedEndpoint(null)} />
<div className="relative flex max-h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
<div className="flex items-center justify-between gap-3 border-b border-gray-200 px-5 py-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded border border-blue-200 bg-blue-50 px-1.5 py-0.5 font-mono text-xs font-bold text-blue-700">
{selectedEndpoint.method}
</span>
<code className="min-w-0 break-all font-mono text-sm text-gray-900">{selectedEndpoint.path}</code>
</div>
<p className="mt-1 text-xs text-gray-500">{selectedEndpoint.desc}</p>
</div>
<button onClick={() => setSelectedEndpoint(null)} className="shrink-0 rounded p-1 text-gray-400 hover:text-black" title="关闭">
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-5">
<div className="grid gap-4 lg:grid-cols-2">
<section className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-black">Python 使</h3>
<button
onClick={copyDocCode}
className="inline-flex items-center gap-1 rounded border border-gray-200 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
>
{copiedDoc ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copiedDoc ? '已复制' : '复制'}
</button>
</div>
<pre className="max-h-[52vh] overflow-auto rounded-lg bg-gray-900 p-4 text-xs text-gray-100">
<code>{buildPythonExample(selectedEndpoint)}</code>
</pre>
</section>
<section className="min-w-0">
<h3 className="mb-2 text-sm font-semibold text-black"></h3>
{selectedEndpoint.note && (
<div className="mb-2 rounded border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-500">{selectedEndpoint.note}</div>
)}
<pre className="max-h-[52vh] overflow-auto rounded-lg bg-gray-900 p-4 text-xs text-gray-100">
<code>{formatJSON(selectedEndpoint.response)}</code>
</pre>
</section>
</div>
</div>
</div>
</div>
)}
{showForm && ( {showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50" onClick={() => setShowForm(false)} /> <div className="absolute inset-0 bg-black/50" onClick={() => setShowForm(false)} />
@@ -627,6 +715,383 @@ export default function ApiIntegration() {
) )
} }
const requestBodySamples: Record<string, Record<string, unknown>> = {
'POST /api/v1/containers/list': {},
'POST /api/v1/containers': {
name: 'demo-lxc-01',
virtualization: 'lxc',
template_id: 'debian-bookworm',
vcpu: 1,
ram_mb: 512,
disk_gb: 10,
network_bw_mbps: 0,
monthly_traffic_gb: 0,
traffic_mode: 'total',
traffic_in_gb: 0,
traffic_out_gb: 0,
io_speed_mbps: 0,
extra_ports: [8080],
port_mapping_count: 2,
snapshot_limit: 1,
assign_ipv6: true,
expires_at: '',
},
'POST /api/v1/containers/{id}/reinstall': { template_id: 'debian-bookworm' },
'PUT /api/v1/containers/{id}/traffic-limit': {
traffic_mode: 'total',
monthly_traffic_gb: 100,
traffic_in_gb: 0,
traffic_out_gb: 0,
},
'PUT /api/v1/containers/{id}/resource-limit': {
vcpu: 1,
ram_mb: 512,
io_speed_mbps: 0,
network_bw_mbps: 0,
},
'PUT /api/v1/containers/{id}/expiry': { expires_at: '2026-12-31 23:59:59' },
'POST /api/v1/containers/{id}/reset-password': { password: 'NewPass123456' },
'POST /api/v1/containers/{id}/port-mappings': {
container_port: 8080,
host_port: 61320,
protocol: 'tcp',
description: 'HTTP',
},
'PUT /api/v1/containers/{id}/port-mappings/{index}': {
container_port: 8081,
host_port: 61320,
protocol: 'tcp',
description: 'HTTP',
},
'POST /api/v1/containers/{id}/snapshots/schedule': {
enabled: true,
interval_hours: 24,
time: '03:00',
},
'PUT /api/v1/containers/{id}/snapshots/quota': { snapshot_limit: 2 },
'POST /api/v1/images/download': { template_id: 'debian-bookworm' },
'POST /api/v1/images/cancel': { template_id: 'debian-bookworm' },
'DELETE /api/v1/images/delete': { template_id: 'debian-bookworm' },
'PUT /api/v1/images/toggle': { template_id: 'debian-bookworm', enabled: true },
'POST /api/v1/security/check': { container_name: 'example-vm' },
'PUT /api/v1/security/settings': { auto_shutdown: false },
'POST /api/v1/swap': { action: 'resize', size_mb: 16384 },
'POST /api/v1/batch-create': {
containers: [
{
name: 'batch-lxc-01',
virtualization: 'lxc',
template_id: 'debian-bookworm',
vcpu: 1,
ram_mb: 512,
disk_gb: 10,
port_mapping_count: 2,
snapshot_limit: 1,
assign_ipv6: true,
},
],
},
'POST /api/v1/batch-action': { action: 'restart', containers: [5], template_id: '' },
'POST /api/v1/ssh-ticket': { container_name: 'example-vm' },
'POST /api/v1/vnc-ticket': { container_name: 'kvm-demo' },
'POST /api/v1/sub-user/create': { container_name: 'example-vm' },
'POST /api/v1/sub-users/{id}/rotate-password': {},
'POST /api/v1/api-keys': {
name: 'Automation',
ip_whitelist: '',
scopes: ['dashboard:read', 'container:read'],
expires_at: '',
disabled: false,
container_uuids: [],
},
'PATCH /api/v1/api-keys/{id}': {
name: 'Automation',
ip_whitelist: '',
scopes: ['dashboard:read', 'container:read'],
expires_at: '',
disabled: false,
container_uuids: [],
},
}
const responseSamples: Record<string, unknown> = {
'GET /api/v1/dashboard': { success: true, data: { running: 31, stopped: 0, total_containers: 31 } },
'GET /api/v1/host-info': {
success: true,
data: {
cpu: { cores: 8, usage_pct: 1.16 },
ram: { total_mb: 31825, used_mb: 1275, free_mb: 30550 },
disk: { total_gb: 1750.49, used_gb: 123.98, free_gb: 1626.51 },
network: {
public_ipv4: '203.0.113.10',
public_ipv4_interface: 'eth0',
public_ipv6: '2001:db8:100::2',
public_ipv6_interface: 'eth0',
},
load: { load1: 0.01, load5: 0.03, load15: 0.01 },
},
},
'GET /api/v1/routing': {
success: true,
data: {
nat4: { used: 62, remaining: '45474', total: '45536' },
ipv6: { used: 31, remaining: 'large', total: 'large' },
nat4_mappings: [
{ container_id: 5, container_name: 'example-vm', status: 'running', ip: '10.0.0.10', host_port: 22004, container_port: 22, protocol: 'tcp' },
],
ipv6_assignments: [{ container_id: 5, container_name: 'example-vm', address: '2001:db8:100::1005', prefix_len: 64, interface: 'eth0' }],
},
},
'GET /api/v1/ipv6/status': {
success: true,
data: {
available: true,
reachable: true,
reason: 'usable public IPv6 prefix detected',
prefixes: [{ interface: 'eth0', address: '2001:db8:100::2', prefix: '2001:db8:100::/64', prefix_len: 64, gateway: '2001:db8:100::1' }],
},
},
'GET /api/v1/tasks': { success: true, data: [] },
'DELETE /api/v1/tasks/{task_id}': { success: true, message: 'Task deleted' },
'GET /api/v1/containers': {
success: true,
data: [
{
id: 5,
uuid: '00000000-0000-4000-8000-000000000005',
name: 'example-vm',
virtualization: 'lxc',
template: 'debian-bullseye',
vcpu: 1,
ram_mb: 512,
disk_gb: 10,
status: 'running',
ip: '10.0.0.10',
ipv6: '2001:db8:100::1005',
ssh_port: 22004,
ssh_password: '***',
port_mappings: [
{ container_port: 22, host_port: 22004, protocol: 'tcp', description: 'SSH' },
{ container_port: 20000, host_port: 20000, protocol: 'tcp', description: 'Port-20000' },
],
},
],
},
'POST /api/v1/containers/list': {
success: true,
data: [
{ id: 5, uuid: '00000000-0000-4000-8000-000000000005', name: 'example-vm', status: 'running', ip: '10.0.0.10' },
],
},
'POST /api/v1/containers': { success: true, message: 'Container created successfully' },
'GET /api/v1/containers/{id|uuid|name}': {
success: true,
data: {
id: 5,
uuid: '00000000-0000-4000-8000-000000000005',
name: 'example-vm',
status: 'running',
ip: '10.0.0.10',
ipv6: '2001:db8:100::1005',
ssh_port: 22004,
ssh_password: '***',
policy_blocked: false,
},
},
'POST /api/v1/containers/{id}/start': queuedTaskSample('start'),
'POST /api/v1/containers/{id}/stop': queuedTaskSample('stop'),
'POST /api/v1/containers/{id}/restart': queuedTaskSample('restart'),
'POST /api/v1/containers/{id}/reinstall': queuedTaskSample('reinstall'),
'DELETE /api/v1/containers/{id}/delete': queuedTaskSample('delete'),
'GET /api/v1/containers/{id}/usage': {
success: true,
data: {
cpu_usage_pct: 0,
cpu_usage_usec: 3908852,
memory_usage_bytes: 29331456,
disk_usage_bytes: 515100672,
network_rx_bytes: 131232,
network_tx_bytes: 16828,
load1: 0.1,
load5: 0.06,
load15: 0.01,
},
},
'GET /api/v1/containers/{id}/traffic': {
success: true,
data: {
mode: 'total',
limit_gb: 0,
in_limit_gb: 0,
out_limit_gb: 0,
total_used_bytes: 142082,
rx_used_bytes: 127212,
tx_used_bytes: 14870,
used_pct: 0,
reset_date: '2026-06',
},
},
'POST /api/v1/containers/{id}/traffic-reset': { success: true, message: 'Traffic reset' },
'PUT /api/v1/containers/{id}/traffic-limit': { success: true, message: 'Traffic limit updated' },
'PUT /api/v1/containers/{id}/resource-limit': { success: true, message: 'Resource limits updated' },
'PUT /api/v1/containers/{id}/expiry': { success: true, message: 'Expiry updated' },
'POST /api/v1/containers/{id}/reset-password': { success: true, message: 'SSH password reset successfully', data: { password: '***' } },
'POST /api/v1/containers/{id}/ipv6': { success: true, message: 'IPv6 assigned', data: { id: 5, name: 'example-vm', ipv6: '2001:db8:100::1005' } },
'GET /api/v1/containers/{id}/random-port': { success: true, data: { port: 61320 } },
'POST /api/v1/containers/{id}/port-mappings': {
success: true,
data: [
{ container_port: 22, host_port: 22004, protocol: 'tcp', description: 'SSH' },
{ container_port: 8080, host_port: 61320, protocol: 'tcp', description: 'HTTP' },
],
},
'PUT /api/v1/containers/{id}/port-mappings/{index}': {
success: true,
data: [{ container_port: 8081, host_port: 61320, protocol: 'tcp', description: 'HTTP' }],
},
'DELETE /api/v1/containers/{id}/port-mappings/{index}': { success: true, data: [] },
'GET /api/v1/snapshots': { success: true, data: null },
'GET /api/v1/containers/{id}/snapshots': {
success: true,
data: { quota: 1, schedule: { enabled: false, interval_hours: 0, last_run: '', next_run: '', time: '', created_by: '' }, snapshots: [] },
},
'POST /api/v1/containers/{id}/snapshots': {
success: true,
data: { id: 'snap-20260608-001', container_id: 5, container_name: 'example-vm', created_at: '2026-06-08 16:00:00', created_by: 'api:Automation', scheduled: false, size_bytes: 10485760 },
},
'DELETE /api/v1/containers/{id}/snapshots/{snapshot_id}': { success: true, message: 'Snapshot deleted' },
'POST /api/v1/containers/{id}/snapshots/{snapshot_id}/restore': { success: true, message: 'Snapshot restored' },
'POST /api/v1/containers/{id}/snapshots/schedule': { success: true, data: { container: { id: 5, name: 'example-vm', snapshot_schedule_enabled: true, snapshot_schedule_interval_hours: 24, snapshot_schedule_time: '03:00' } } },
'PUT /api/v1/containers/{id}/snapshots/quota': { success: true, data: { quota: 2, container: { id: 5, name: 'example-vm', snapshot_limit: 2 } } },
'GET /api/v1/templates': {
success: true,
data: [
{ id: 'ubuntu-noble', name: 'Ubuntu 24.04', distro: 'ubuntu', release: 'noble', arch: 'amd64', description: 'Ubuntu 24.04 LTS' },
{ id: 'debian-bookworm', name: 'Debian 12', distro: 'debian', release: 'bookworm', arch: 'amd64', description: 'Debian 12 (Bookworm)' },
],
},
'GET /api/v1/images': {
success: true,
data: [
{ id: 'ubuntu-noble', name: 'Ubuntu 24.04', type: 'lxc', downloaded: true, enabled: true, downloading: false, progress: 0, size_bytes: 135005452 },
],
},
'POST /api/v1/images/download': { success: true, message: 'Already downloaded' },
'POST /api/v1/images/cancel': { success: true, message: 'Cancel requested' },
'DELETE /api/v1/images/delete': { success: true, message: 'Deleted' },
'PUT /api/v1/images/toggle': { success: true, message: 'OK' },
'GET /api/v1/security/alerts': { success: true, data: [] },
'POST /api/v1/security/check': { success: true, message: 'Security check completed' },
'GET /api/v1/security/logs?container={name}': { success: true, data: [] },
'GET /api/v1/security/summary': { success: true, data: { critical: 0, high: 0, low: 0, medium: 0, total_alerts: 0 } },
'GET /api/v1/security/settings': { success: true, data: { auto_shutdown: false } },
'PUT /api/v1/security/settings': { success: true, data: { auto_shutdown: false } },
'GET /api/v1/swap': { success: true, data: { total_mb: 16383, used_mb: 0, free_mb: 16383, enabled: true, swap_file: '/swapfile' } },
'POST /api/v1/swap': { success: true, message: 'SWAP 已调整为 16384 MB', data: { total_mb: 16383, used_mb: 0, free_mb: 16383, enabled: true, swap_file: '/swapfile' } },
'POST /api/v1/batch-create': { success: true, data: ['task-12'] },
'POST /api/v1/batch-action': { success: true, data: ['task-13'] },
'POST /api/v1/ssh-ticket': { success: true, data: { ticket: '***60秒有效票据***' } },
'POST /api/v1/vnc-ticket': { success: true, data: { ticket: '***60秒有效票据***' } },
'POST /api/v1/sub-user/create': {
success: true,
message: 'Sub-user created',
data: { id: 'sub-xxxxxxxx', username: 'user-xxxxxxxx', password: '***', container_names: ['example-vm'], access_code: '********', created_at: '2026-06-08 16:00:00' },
},
'GET /api/v1/sub-users': { success: true, data: [] },
'POST /api/v1/sub-users/{id}/rotate-password': { success: true, data: { username: 'user-xxxxxxxx', password: '***', access_code: '********' } },
'GET /api/v1/sub-users/{id}/audit-logs': { success: true, data: [] },
'GET /api/v1/sub-users/{id}/login-logs': { success: true, data: [] },
'GET /api/v1/audit-logs': {
success: true,
data: [{ time: '2026-06-08 15:44:40', action: 'apikey.create', target: 'Test', detail: 'scopes=*', user: 'admin', success: true }],
},
'GET /api/v1/login-logs': {
success: true,
data: [{ time: '2026-06-08 08:24:00 UTC', username: 'admin', ip: '198.51.100.23', user_agent: 'Mozilla/5.0 ...', success: true }],
},
'GET /api/v1/api-keys': {
success: true,
data: [{ id: 'c271023f', name: 'Test', prefix: 'clicd_sk_dd9d...', ip_whitelist: '', created_at: '2026-06-08 15:44:40', last_used: '2026-06-08 15:46:10', scopes: ['*'], last_used_ip: '198.51.100.23' }],
},
'POST /api/v1/api-keys': {
success: true,
message: "API key created. Save this key now - it won't be shown again.",
data: { id: 'a1b2c3d4', name: 'Automation', key: 'clicd_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', prefix: 'clicd_sk_xxxx...', scopes: ['dashboard:read', 'container:read'] },
},
'PATCH /api/v1/api-keys/{id}': { success: true, data: { id: 'a1b2c3d4', name: 'Automation', prefix: 'clicd_sk_xxxx...', scopes: ['dashboard:read', 'container:read'], disabled: false } },
'DELETE /api/v1/api-keys/{id}': { success: true, message: 'API key deleted' },
}
function queuedTaskSample(action: string) {
return {
success: true,
message: 'Task queued',
data: { task_id: 'task-10', container_name: 'example-vm', status: 'pending', action },
}
}
function buildEndpointDoc(method: HttpMethod, path: string, desc: string): EndpointDoc {
const key = `${method} ${path}`
return {
method,
path,
desc,
examplePath: examplePathFor(path),
body: requestBodySamples[key],
response: responseSamples[key] || defaultResponseFor(method),
note: endpointNoteFor(key),
}
}
function examplePathFor(path: string) {
return path
.replace('{id|uuid|name}', '5')
.replace('{id}', '5')
.replace('{task_id}', 'task-10')
.replace('{index}', '1')
.replace('{snapshot_id}', 'snap-20260608-001')
.replace('{name}', 'example-vm')
}
function endpointNoteFor(key: string) {
if (key.includes('/vnc-ticket')) return 'WebVNC 仅适用于 KVM 虚拟机;LXC 容器会返回 VNC console is only available for KVM VMs。'
if (key.includes('/containers/{id}/delete') || key.includes('/batch-action')) return '该接口会进入任务队列,请随后调用 GET /api/v1/tasks 查看执行状态。'
if (key.includes('/reset-password') || key.includes('/api-keys') || key.includes('/sub-user')) return '样例中的密钥、密码和票据已脱敏;创建类接口的完整密钥只在创建响应中出现一次。'
return ''
}
function defaultResponseFor(method: HttpMethod) {
if (method === 'GET') return { success: true, data: [] }
if (method === 'DELETE') return { success: true, message: 'Deleted' }
return { success: true, message: 'OK' }
}
function buildPythonExample(doc: EndpointDoc) {
const hasBody = doc.body !== undefined && doc.method !== 'GET'
const bodyJSON = hasBody ? formatJSON(doc.body) : ''
return [
'import json',
'import requests',
'',
`BASE_URL = "${SAMPLE_BASE_URL}"`,
'API_KEY = "clicd_sk_xxxx"',
'',
...(hasBody ? [`payload = json.loads(r'''${bodyJSON}''')`, ''] : []),
`response = requests.${doc.method.toLowerCase()}(`,
` f"{BASE_URL}${doc.examplePath}",`,
' headers={"X-API-Key": API_KEY},',
...(hasBody ? [' json=payload,'] : []),
' timeout=30,',
')',
'response.raise_for_status()',
'print(response.json())',
].join('\n')
}
function formatJSON(value: unknown) {
return JSON.stringify(value, null, 2)
}
function ScopeSummary({ scopes }: { scopes: string[] }) { function ScopeSummary({ scopes }: { scopes: string[] }) {
if (scopes.includes('*')) { if (scopes.includes('*')) {
return <span className="rounded bg-red-50 px-2 py-1 text-xs font-medium text-red-600"></span> return <span className="rounded bg-red-50 px-2 py-1 text-xs font-medium text-red-600"></span>
+28 -2
View File
@@ -452,10 +452,10 @@ export default function ContainerDetail() {
const digits = '23456789' const digits = '23456789'
const symbols = '!@#$%*-_+=' const symbols = '!@#$%*-_+='
const all = letters + digits + 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) let password = pick(letters) + pick(digits)
while (password.length < 16) password += pick(all) while (password.length < 16) password += pick(all)
setResetPasswordDraft(password.split('').sort(() => Math.random() - 0.5).join('')) setResetPasswordDraft(secureShuffle(password.split('')).join(''))
setResetPasswordResult('') 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 { function getTemplateIcon(id: string): ReactNode {
const size = 'w-6 h-6' const size = 'w-6 h-6'
id = id.startsWith('kvm-') ? id.slice(4) : id id = id.startsWith('kvm-') ? id.slice(4) : id
+1 -1
View File
@@ -106,7 +106,7 @@ export default function Login() {
</form> </form>
</div> </div>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.5</p> <p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.6</p>
</div> </div>
</div> </div>
) )