mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
修复了一些已知问题
This commit is contained in:
@@ -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/")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user