mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-08 14:34:49 +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}/...
|
// 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/")
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
@@ -132,6 +132,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', '开机'],
|
||||||
@@ -472,8 +473,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 => (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user