package lxc import ( "bufio" "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "reflect" "regexp" "strconv" "strings" "sync" "time" "clicd/internal/config" ) // Manager handles LXC container operations type Manager struct { LxcPath string } type containerUsageSample struct { CPUUsec uint64 RXBytes uint64 TXBytes uint64 ReadBytes uint64 WriteBytes uint64 At time.Time } // containerRateSnapshot stores computed rates, updated by a single background goroutine type containerRateSnapshot struct { CPUPct float64 RXBps float64 TXBps float64 ReadBps float64 WriteBps float64 UpdatedAt time.Time } var ( usageMu sync.RWMutex lastUsage = map[string]containerUsageSample{} rateCache = map[string]containerRateSnapshot{} ) var ( sshEnsureLocks sync.Map sshWarmupScheduled sync.Map sshWarmupSem = make(chan struct{}, 4) ) // StartUsageMonitor starts a background goroutine that computes rates every 5 seconds func (m *Manager) StartUsageMonitor() { go func() { for { time.Sleep(5 * time.Second) m.updateAllRates() } }() } // WarmRunningContainersSSH prepares sshd for containers that were already running // when clicd started, such as after host boot or service restart. func (m *Manager) WarmRunningContainersSSH() { containers := append([]config.Container(nil), config.AppConfig.Containers...) for _, container := range containers { c := container if c.IsKVM() { continue } status, err := m.GetContainerStatus(c.LxcName()) if err != nil || status != "running" { continue } config.UpdateContainerStatus(c.ID, "running") if c.IP != "" && m.containerPortListening(c.LxcName(), 22) { continue } m.WarmSSHAsync(c.ID, "running container scan") } } // StartSSHWarmupScanner repeatedly scans during service startup so containers // that autostart slightly after clicd still get prepared before WebSSH opens. func (m *Manager) StartSSHWarmupScanner() { go func() { for i := 0; i < 12; i++ { m.WarmRunningContainersSSH() time.Sleep(10 * time.Second) } }() } func (m *Manager) updateAllRates() { usageMu.Lock() defer usageMu.Unlock() for i := range config.AppConfig.Containers { c := &config.AppConfig.Containers[i] if c.IsKVM() { delete(lastUsage, c.VirshName()) delete(rateCache, c.VirshName()) continue } if c.Status != "running" { delete(lastUsage, c.LxcName()) delete(rateCache, c.LxcName()) continue } lxcName := c.LxcName() // Cache init PID once per scan so getContainerNetworkBytes / getContainerDiskIOBytes // don't each fork lxc-info separately. initPID := m.getContainerInitPID(lxcName) // Read memory from cgroup directly (no shell fork) memUsage := readCgroupFile(lxcName, "/sys/fs/cgroup/lxc/%s/memory.current", "/sys/fs/cgroup/lxc.payload.%s/memory.current", "/sys/fs/cgroup/memory/lxc/%s/memory.usage_in_bytes") // Read cpu usage from cgroup directly (no shell | awk fork) cpuUsec := readCgroupCPUUsec(lxcName, "/sys/fs/cgroup/lxc/%s/cpu.stat", "/sys/fs/cgroup/lxc.payload.%s/cpu.stat") rxBytes, txBytes := getNetworkBytesForPID(initPID) readBytes, writeBytes := getDiskIOBytesForPID(initPID) now := time.Now() sample := containerUsageSample{ CPUUsec: cpuUsec, RXBytes: rxBytes, TXBytes: txBytes, ReadBytes: readBytes, WriteBytes: writeBytes, At: now, } prev, exists := lastUsage[lxcName] lastUsage[lxcName] = sample rate := containerRateSnapshot{UpdatedAt: now} if exists { elapsed := sample.At.Sub(prev.At).Seconds() if elapsed > 0 && sample.CPUUsec >= prev.CPUUsec { rate.CPUPct = float64(sample.CPUUsec-prev.CPUUsec) / (elapsed * 1e6) * 100 } if elapsed > 0 { if sample.RXBytes >= prev.RXBytes { rate.RXBps = float64(sample.RXBytes-prev.RXBytes) / elapsed } if sample.TXBytes >= prev.TXBytes { rate.TXBps = float64(sample.TXBytes-prev.TXBytes) / elapsed } if sample.ReadBytes >= prev.ReadBytes { rate.ReadBps = float64(sample.ReadBytes-prev.ReadBytes) / elapsed } if sample.WriteBytes >= prev.WriteBytes { rate.WriteBps = float64(sample.WriteBytes-prev.WriteBytes) / elapsed } } } else { // First sample: estimate from container uptime uptimeSec := m.getContainerUptimeSeconds(lxcName) if uptimeSec > 0 { rate.CPUPct = (float64(cpuUsec) / 1e6) / uptimeSec * 100 rate.RXBps = float64(rxBytes) / uptimeSec rate.TXBps = float64(txBytes) / uptimeSec rate.ReadBps = float64(readBytes) / uptimeSec rate.WriteBps = float64(writeBytes) / uptimeSec } } // Fallback: if delta rate is 0 but cumulative has data, use cumulative average if rate.RXBps == 0 && rateCache[lxcName].RXBps > 0 { rate.RXBps = rateCache[lxcName].RXBps } if rate.TXBps == 0 && rateCache[lxcName].TXBps > 0 { rate.TXBps = rateCache[lxcName].TXBps } // Store memory as a rate field for convenience _ = memUsage rateCache[lxcName] = rate } // Clean up stale entries for name := range rateCache { found := false for i := range config.AppConfig.Containers { if config.AppConfig.Containers[i].LxcName() == name && config.AppConfig.Containers[i].Status == "running" { found = true break } } if !found { delete(rateCache, name) delete(lastUsage, name) } } } // NewManager creates a new LXC manager func NewManager() *Manager { return &Manager{ LxcPath: "/var/lib/lxc", } } // ContainerConfig defines container creation parameters type ContainerConfig struct { Name string `json:"name"` Virtualization string `json:"virtualization,omitempty"` TemplateID string `json:"template_id"` VCPU float64 `json:"vcpu"` CPUPercent int `json:"cpu_percent"` RAMMB int `json:"ram_mb"` DiskGB int `json:"disk_gb"` NetworkBWMbps int `json:"network_bw_mbps"` NetworkDownMbps int `json:"network_down_mbps"` NetworkUpMbps int `json:"network_up_mbps"` MonthlyTrafficGB int `json:"monthly_traffic_gb"` TrafficMode string `json:"traffic_mode"` // "total" or "in_out" TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited IOSpeedMBps int `json:"io_speed_mbps"` IOReadMBps int `json:"io_read_mbps"` IOWriteMBps int `json:"io_write_mbps"` ExtraPorts []int `json:"extra_ports"` PortMappingCount int `json:"port_mapping_count"` AssignNAT *bool `json:"assign_nat,omitempty"` SnapshotLimit int `json:"snapshot_limit"` AssignIPv4 bool `json:"assign_ipv4"` IPv4Count int `json:"ipv4_count,omitempty"` PublicIPv4s []string `json:"public_ipv4s,omitempty"` AssignIPv6 bool `json:"assign_ipv6"` IPv6Count int `json:"ipv6_count,omitempty"` IPv6Addresses []string `json:"ipv6_addresses,omitempty"` SSHAuthMode string `json:"ssh_auth_mode,omitempty"` SSHPassword string `json:"ssh_password,omitempty"` SSHPublicKey string `json:"ssh_public_key,omitempty"` ExpiresAt string `json:"expires_at"` } func (cfg *ContainerConfig) NormalizeResourceAliases() { if cfg == nil { return } if cfg.NetworkBWMbps < 0 { cfg.NetworkBWMbps = 0 } if cfg.NetworkDownMbps < 0 { cfg.NetworkDownMbps = 0 } if cfg.NetworkUpMbps < 0 { cfg.NetworkUpMbps = 0 } if cfg.NetworkDownMbps == 0 && cfg.NetworkUpMbps == 0 && cfg.NetworkBWMbps > 0 { cfg.NetworkDownMbps = cfg.NetworkBWMbps cfg.NetworkUpMbps = cfg.NetworkBWMbps } cfg.NetworkBWMbps = config.LegacySymmetricLimit(cfg.NetworkDownMbps, cfg.NetworkUpMbps) if cfg.IOSpeedMBps < 0 { cfg.IOSpeedMBps = 0 } if cfg.IOReadMBps < 0 { cfg.IOReadMBps = 0 } if cfg.IOWriteMBps < 0 { cfg.IOWriteMBps = 0 } if cfg.IOReadMBps == 0 && cfg.IOWriteMBps == 0 && cfg.IOSpeedMBps > 0 { cfg.IOReadMBps = cfg.IOSpeedMBps cfg.IOWriteMBps = cfg.IOSpeedMBps } cfg.IOSpeedMBps = config.LegacySymmetricLimit(cfg.IOReadMBps, cfg.IOWriteMBps) } func (cfg ContainerConfig) WantsNAT() bool { return cfg.AssignNAT == nil || *cfg.AssignNAT } // CreateContainer creates a new LXC container. Uses ct-{id} as LXC name internally. func (m *Manager) CreateContainer(cfg ContainerConfig) error { cfg.NormalizeResourceAliases() tmpl := FindTemplate(cfg.TemplateID) if tmpl == nil { return fmt.Errorf("template not found: %s", cfg.TemplateID) } if cfg.WantsNAT() && cfg.PortMappingCount < 2 { cfg.PortMappingCount = 2 } else if !cfg.WantsNAT() { cfg.PortMappingCount = 0 cfg.ExtraPorts = nil } if cfg.SnapshotLimit <= 0 { cfg.SnapshotLimit = config.DefaultSnapshotLimit } if !config.IsValidContainerName(cfg.Name) { return fmt.Errorf("invalid container name: %s", cfg.Name) } if config.FindContainerByName(cfg.Name) != nil { return fmt.Errorf("container name already exists: %s", cfg.Name) } sshAccess, err := ResolveCreateSSHAccess(cfg) if err != nil { return err } // Allocate ID and build LXC name id := config.AllocateContainerID() lxcName := fmt.Sprintf("ct-%d", id) containerDir := filepath.Join(m.LxcPath, lxcName) if _, err := os.Stat(containerDir); err == nil { if err := m.cleanupContainerStorage(lxcName); err != nil { return fmt.Errorf("failed to clean stale container directory %s: %v", lxcName, err) } } else if !os.IsNotExist(err) { return fmt.Errorf("failed to check container directory %s: %v", lxcName, err) } fmt.Printf("Creating LXC container: %s (ID=%d, template: %s/%s/%s)\n", lxcName, id, tmpl.Distro, tmpl.Release, tmpl.Arch) args := []string{"-n", lxcName, "-t", "download", "--", "-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch} if tmpl.Variant != "" { args = append(args, "--variant", tmpl.Variant) } cmd := exec.Command("lxc-create", args...) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("lxc-create failed: %v, output: %s", err, string(output)) } if err := m.applyDiskLimit(lxcName, cfg.DiskGB); err != nil { _ = m.cleanupContainerStorage(lxcName) return err } // Apply resource limits and mandatory security hardening. if err := m.applyResourceLimits(lxcName, cfg); err != nil { _ = m.cleanupContainerStorage(lxcName) return err } publicIPv4s, err := AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4) if err != nil { _ = m.cleanupContainerStorage(lxcName) return err } ipv6Assignments := []config.IPv6Assignment{} if cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0 { assigned, err := m.allocateIPv6AssignmentsForContainer(id, cfg.IPv6Addresses, cfg.IPv6Count, true) if err != nil { _ = m.cleanupContainerStorage(lxcName) return err } ipv6Assignments = assigned if err := m.applyIPv6Config(lxcName, ipv6AssignmentAddresses(ipv6Assignments)...); err != nil { _ = m.cleanupContainerStorage(lxcName) return err } } sshPassword := sshAccess.Password sshPort := 0 portMappings := []config.PortMapping{} if cfg.WantsNAT() { sshPort, err = config.AllocateSSHPort() if err != nil { _ = m.cleanupContainerStorage(lxcName) return err } // Setup default port mappings (SSH only) portMappings = SetupDefaultPortMappings(sshPort) // NAT4 port mappings should bind to the host IP, not the container's independent public IPv4. tempC := &config.Container{ID: id, PublicIPv4s: publicIPv4s, PortMappings: portMappings} extraPorts := cfg.ExtraPorts if len(extraPorts) == 0 && cfg.PortMappingCount > 1 { extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1) } for _, containerPort := range extraPorts { if containerPort <= 0 { continue } pm, err := normalizePortMapping(tempC, -1, config.PortMapping{ ContainerPort: containerPort, HostPort: containerPort, HostIP: "", Protocol: "tcp", Description: fmt.Sprintf("Port-%d", containerPort), }) if err != nil { continue } tempC.PortMappings = append(tempC.PortMappings, pm) portMappings = tempC.PortMappings } } now := time.Now().Format("2006-01-02 15:04:05") // Determine traffic mode trafficMode := cfg.TrafficMode if trafficMode == "" { trafficMode = "total" } trafficResetDate := now[:7] // YYYY-MM for monthly tracking container := config.Container{ ID: id, UUID: config.NewContainerUUID(), Name: cfg.Name, Virtualization: config.VirtualizationLXC, Template: cfg.TemplateID, VCPU: cfg.VCPU, RAMMB: cfg.RAMMB, DiskGB: cfg.DiskGB, NetworkBWMbps: cfg.NetworkBWMbps, NetworkDownMbps: cfg.NetworkDownMbps, NetworkUpMbps: cfg.NetworkUpMbps, MonthlyTrafficGB: cfg.MonthlyTrafficGB, TrafficMode: trafficMode, TrafficInGB: cfg.TrafficInGB, TrafficOutGB: cfg.TrafficOutGB, TrafficResetDate: trafficResetDate, IOSpeedMBps: cfg.IOSpeedMBps, IOReadMBps: cfg.IOReadMBps, IOWriteMBps: cfg.IOWriteMBps, Status: "stopped", IP: "", PublicIPv4s: publicIPv4s, IPv6Addresses: ipv6Assignments, VNCPort: 0, SSHPort: sshPort, SSHPassword: sshPassword, PortMappings: portMappings, PortMappingLimit: cfg.PortMappingCount, SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit), CreatedAt: now, ExpiresAt: cfg.ExpiresAt, } container.NormalizeNetworkAssignments() config.AddContainer(container) // Pre-configure network and SSH in the rootfs before first boot. rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") m.preconfigureNetwork(rootfsPath, cfg.TemplateID) if len(ipv6Assignments) > 0 { if err := installContainerIPv6Init(rootfsPath, ipv6AssignmentAddresses(ipv6Assignments)...); err != nil { fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err) } } if err := m.preconfigureSSH(rootfsPath, cfg.TemplateID, sshAccess.Mode); err != nil { fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err) } if sshAccess.PublicKey != "" { if err := m.installRootAuthorizedKey(rootfsPath, sshAccess.PublicKey); err != nil { _ = m.cleanupContainerStorage(lxcName) config.RemoveContainer(id) return fmt.Errorf("failed to install SSH public key: %v", err) } } if err := m.shiftRootfsForUnprivileged(lxcName); err != nil { _ = m.cleanupContainerStorage(lxcName) config.RemoveContainer(id) return err } // Set root password AFTER shiftRootfsForUnprivileged, // otherwise /etc/shadow ownership breaks and SSHD cannot authenticate. if err := m.setRootfsPassword(rootfsPath, sshPassword); err != nil { fmt.Printf("Warning: failed to set root password in %s: %v\n", lxcName, err) } fmt.Printf("Container %d (%s) created successfully\n", id, cfg.Name) return nil } func (m *Manager) preconfigureNetwork(rootfsPath, templateID string) { osRelease := "" if data, err := os.ReadFile(filepath.Join(rootfsPath, "etc", "os-release")); err == nil { osRelease = strings.ToLower(string(data)) } isAlpine := strings.Contains(osRelease, "alpine") isRHELFamily := strings.Contains(osRelease, "centos") || strings.Contains(osRelease, "rhel") || strings.Contains(osRelease, "rocky") || strings.Contains(osRelease, "alma") || strings.Contains(osRelease, "fedora") || strings.Contains(templateID, "fedora") || strings.Contains(templateID, "rockylinux") if isAlpine { interfaces := filepath.Join(rootfsPath, "etc", "network", "interfaces") content := "auto lo\niface lo inet loopback\n\nauto eth0\niface eth0 inet dhcp\n" _ = os.MkdirAll(filepath.Dir(interfaces), 0755) _ = os.WriteFile(interfaces, []byte(content), 0644) _ = m.runRootfsCommand(rootfsPath, "rc-update", "add", "networking", "boot") return } if isRHELFamily || strings.Contains(templateID, "centos") { nmDir := filepath.Join(rootfsPath, "etc", "NetworkManager", "system-connections") if err := os.MkdirAll(nmDir, 0700); err == nil { keyfile := `[connection] id=eth0 type=ethernet interface-name=eth0 autoconnect=true [ipv4] method=auto [ipv6] method=ignore ` path := filepath.Join(nmDir, "eth0.nmconnection") _ = os.WriteFile(path, []byte(keyfile), 0600) } _ = m.runRootfsCommand(rootfsPath, "systemctl", "enable", "NetworkManager") } networkdDir := filepath.Join(rootfsPath, "etc", "systemd", "network") if err := os.MkdirAll(networkdDir, 0755); err == nil { network := `[Match] Name=eth0 [Network] DHCP=ipv4 IPv6AcceptRA=no ` _ = os.WriteFile(filepath.Join(networkdDir, "10-eth0.network"), []byte(network), 0644) } if !isRHELFamily { _ = m.runRootfsCommand(rootfsPath, "systemctl", "enable", "systemd-networkd") } } // preconfigureSSH installs and configures SSH directly in the rootfs before first boot. func (m *Manager) preconfigureSSH(rootfsPath, templateID string, sshAuthMode string) error { _ = templateID // Disable pubkey auth when user chose password-only mode (password or auto_password) disablePubkey := sshAuthMode == SSHAuthPassword || sshAuthMode == SSHAuthAutoPassword ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) defer cancel() cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(false, disablePubkey)) if err != nil { return err } cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...) output, err := cmd.CombinedOutput() if ctx.Err() == context.DeadlineExceeded { return fmt.Errorf("timed out after 180s, output: %s", string(output)) } if err != nil { return fmt.Errorf("%v, output: %s", err, string(output)) } fmt.Printf("SSH pre-configured in rootfs\n") return nil } // applyResourceLimits applies cgroup v2 limits and mandatory security hardening to container config. func (m *Manager) applyResourceLimits(lxcName string, cfg ContainerConfig) error { cfg.NormalizeResourceAliases() configFile := filepath.Join(m.LxcPath, lxcName, "config") data, err := os.ReadFile(configFile) if err != nil { return fmt.Errorf("failed to read container config: %v", err) } content := string(data) lines := strings.Split(content, "\n") var newLines []string for _, line := range lines { trimmed := strings.TrimSpace(line) if !strings.Contains(trimmed, "# clicd managed") && !strings.HasPrefix(trimmed, "lxc.cgroup2.memory.max") && !strings.HasPrefix(trimmed, "lxc.cgroup2.cpuset.cpus") && !strings.HasPrefix(trimmed, "lxc.cgroup2.cpu.max") && !strings.HasPrefix(trimmed, "lxc.cgroup2.io.max") && !strings.HasPrefix(trimmed, "lxc.mount.auto") && !strings.HasPrefix(trimmed, "lxc.prlimit") && !strings.HasPrefix(trimmed, "lxc.idmap") && !strings.HasPrefix(trimmed, "lxc.apparmor.profile") && !strings.HasPrefix(trimmed, "lxc.seccomp.profile") && !strings.HasPrefix(trimmed, "lxc.no_new_privs") && !strings.HasPrefix(trimmed, "lxc.cap.drop") { newLines = append(newLines, line) } } seccompProfile, err := findSeccompProfile() if err != nil { return err } apparmorProfile, err := appArmorProfileForTemplate(cfg.TemplateID) if err != nil { return err } uidBase, gidBase, err := unprivilegedIDMap() if err != nil { return err } newLines = append(newLines, "", "# clicd managed: lxcfs virtualized /proc") newLines = append(newLines, "lxc.mount.auto = proc:mixed sys:mixed cgroup:mixed") newLines = append(newLines, "", "# clicd managed: mandatory unprivileged container hardening") newLines = append(newLines, fmt.Sprintf("lxc.idmap = u 0 %d 65536", uidBase)) newLines = append(newLines, fmt.Sprintf("lxc.idmap = g 0 %d 65536", gidBase)) newLines = append(newLines, fmt.Sprintf("lxc.apparmor.profile = %s", apparmorProfile)) newLines = append(newLines, fmt.Sprintf("lxc.seccomp.profile = %s", seccompProfile)) newLines = append(newLines, "lxc.no_new_privs = 1") // Keep sys_admin: unprivileged containers need it to mount tmpfs (/dev/shm, /run, etc.) // All capabilities are already confined to the container's user namespace. newLines = append(newLines, "lxc.cap.drop = mac_admin mac_override sys_module sys_rawio sys_time sys_boot sys_nice sys_resource sys_ptrace sys_pacct mknod audit_control audit_read") newLines = append(newLines, "lxc.prlimit.nofile = 1024:4096") newLines = append(newLines, "lxc.prlimit.nproc = 128:256") newLines = append(newLines, "", "# clicd managed resource limits (cgroup v2)") if cfg.VCPU > 0 { cpuPct := cfg.CPUPercent if cpuPct <= 0 || cpuPct > 100 { cpuPct = 100 } cpuQuota := int(cfg.VCPU * float64(cpuPct) / 100.0 * 100000) newLines = append(newLines, fmt.Sprintf("lxc.cgroup2.cpu.max = %d 100000", cpuQuota)) } if cfg.RAMMB > 0 { ramBytes := int64(cfg.RAMMB) * 1024 * 1024 newLines = append(newLines, fmt.Sprintf("lxc.cgroup2.memory.max = %d", ramBytes)) } if cfg.IOReadMBps > 0 || cfg.IOWriteMBps > 0 { // Note: lxc.cgroup2.io.max is skipped for unprivileged containers because // LXC's cgfsng_setup_limits cannot resolve host device numbers (e.g. 8:1) // in the unprivileged namespace context. // IO limits are instead applied post-start via direct cgroup2 writes. fmt.Printf("Info: IO limit (read=%d MB/s write=%d MB/s) for %s will be applied post-start via cgroup2\n", cfg.IOReadMBps, cfg.IOWriteMBps, lxcName) } newContent := strings.Join(newLines, "\n") if err := os.WriteFile(configFile, []byte(newContent), 0644); err != nil { return fmt.Errorf("failed to write container config: %v", err) } return nil } func (m *Manager) ioLimitLines(lxcName string, readMBps int, writeMBps int) ([]string, error) { if readMBps < 0 { readMBps = 0 } if writeMBps < 0 { writeMBps = 0 } devices, err := m.rootfsBlockDevices(lxcName) if err != nil { return nil, err } readValue := "max" if readMBps > 0 { readValue = strconv.Itoa(readMBps * 1024 * 1024) } writeValue := "max" if writeMBps > 0 { writeValue = strconv.Itoa(writeMBps * 1024 * 1024) } lines := make([]string, 0, len(devices)) for _, device := range devices { lines = append(lines, fmt.Sprintf("%s rbps=%s wbps=%s", device, readValue, writeValue)) } return lines, nil } func (m *Manager) rootfsBlockDevices(lxcName string) ([]string, error) { rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") out, err := exec.Command("findmnt", "-T", rootfsPath, "-no", "MAJ:MIN").Output() if err != nil { return nil, fmt.Errorf("failed to detect rootfs block device for IO limit: %v", err) } re := regexp.MustCompile(`\b\d+:\d+\b`) matches := re.FindAllString(string(out), -1) seen := map[string]bool{} devices := make([]string, 0, len(matches)) for _, match := range matches { if match == "0:0" || seen[match] { continue } seen[match] = true devices = append(devices, match) } if len(devices) == 0 { return nil, fmt.Errorf("failed to detect rootfs block device for IO limit; refusing to use hardcoded 8:0") } return devices, nil } func (m *Manager) applyDiskLimit(lxcName string, diskGB int) error { if diskGB <= 0 { return nil } return m.applyLoopbackDiskLimit(lxcName, diskGB) } func (m *Manager) applyProjectDiskLimit(lxcName string, diskGB int) error { if diskGB <= 0 { return nil } rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") if diskImageMounted(lxcName, rootfsPath) { return nil } fsType, err := findmntValue(rootfsPath, "FSTYPE") if err != nil { return fmt.Errorf("failed to detect rootfs filesystem for disk quota: %v", err) } fsType = strings.TrimSpace(fsType) switch fsType { case "btrfs": if err := exec.Command("btrfs", "quota", "enable", rootfsPath).Run(); err != nil { // btrfs returns an error when quota is already enabled on some versions. fmt.Printf("Warning: btrfs quota enable returned: %v\n", err) } output, err := exec.Command("btrfs", "qgroup", "limit", fmt.Sprintf("%dG", diskGB), rootfsPath).CombinedOutput() if err != nil { fmt.Printf("Warning: failed to apply btrfs disk quota, falling back to loopback rootfs: %v, output: %s\n", err, string(output)) return m.applyLoopbackDiskLimit(lxcName, diskGB) } return nil case "xfs": if err := applyXFSProjectQuota(rootfsPath, lxcName, diskGB); err != nil { fmt.Printf("Warning: xfs project quota unavailable, falling back to loopback rootfs: %v\n", err) return m.applyLoopbackDiskLimit(lxcName, diskGB) } return nil case "ext4": if err := applyExt4ProjectQuota(rootfsPath, lxcName, diskGB); err != nil { fmt.Printf("Warning: ext4 project quota unavailable, falling back to loopback rootfs: %v\n", err) return m.applyLoopbackDiskLimit(lxcName, diskGB) } return nil default: fmt.Printf("Warning: unsupported rootfs filesystem %q for project quota, falling back to loopback rootfs\n", fsType) return m.applyLoopbackDiskLimit(lxcName, diskGB) } } func (m *Manager) applyLoopbackDiskLimit(lxcName string, diskGB int) error { containerDir := filepath.Join(m.LxcPath, lxcName) rootfsPath := filepath.Join(containerDir, "rootfs") imagePath := filepath.Join(containerDir, "rootfs.img") if diskImageMounted(lxcName, rootfsPath) { return nil } if _, err := os.Stat(imagePath); err == nil { return m.ensureDiskImageMounted(lxcName) } tmpMount := filepath.Join(containerDir, ".rootfs-image") backupRootfs := filepath.Join(containerDir, "rootfs.dir") if err := os.MkdirAll(tmpMount, 0755); err != nil { return err } defer os.RemoveAll(tmpMount) output, err := exec.Command("truncate", "-s", fmt.Sprintf("%dG", diskGB), imagePath).CombinedOutput() if err != nil { return fmt.Errorf("failed to create rootfs disk image: %v, output: %s", err, string(output)) } output, err = exec.Command("mkfs.ext4", "-F", imagePath).CombinedOutput() if err != nil { return fmt.Errorf("failed to format rootfs disk image: %v, output: %s", err, string(output)) } output, err = exec.Command("mount", "-o", "loop", imagePath, tmpMount).CombinedOutput() if err != nil { return fmt.Errorf("failed to mount rootfs disk image: %v, output: %s", err, string(output)) } mountedTmp := true defer func() { if mountedTmp { exec.Command("umount", "-l", tmpMount).Run() } }() output, err = exec.Command("cp", "-a", rootfsPath+string(os.PathSeparator)+".", tmpMount+string(os.PathSeparator)).CombinedOutput() if err != nil { return fmt.Errorf("failed to copy rootfs into disk image: %v, output: %s", err, string(output)) } if !rootfsHasInit(tmpMount) { return fmt.Errorf("failed to copy rootfs into disk image: init not found in prepared rootfs") } if output, err = exec.Command("umount", tmpMount).CombinedOutput(); err != nil { return fmt.Errorf("failed to unmount prepared rootfs disk image: %v, output: %s", err, string(output)) } mountedTmp = false if err := os.Rename(rootfsPath, backupRootfs); err != nil { return fmt.Errorf("failed to move original rootfs aside: %v", err) } if err := os.MkdirAll(rootfsPath, 0755); err != nil { os.Rename(backupRootfs, rootfsPath) return err } if err := m.ensureDiskImageMounted(lxcName); err != nil { os.RemoveAll(rootfsPath) os.Rename(backupRootfs, rootfsPath) return err } if err := os.RemoveAll(backupRootfs); err != nil { fmt.Printf("Warning: failed to remove old rootfs backup %s: %v\n", backupRootfs, err) } return nil } func (m *Manager) ensureDiskImageMounted(lxcName string) error { containerDir := filepath.Join(m.LxcPath, lxcName) rootfsPath := filepath.Join(containerDir, "rootfs") imagePath := filepath.Join(containerDir, "rootfs.img") if _, err := os.Stat(imagePath); os.IsNotExist(err) { return nil } if diskImageMounted(lxcName, rootfsPath) { return nil } if err := os.MkdirAll(rootfsPath, 0755); err != nil { return err } output, err := exec.Command("mount", "-o", "loop", imagePath, rootfsPath).CombinedOutput() if err != nil { return fmt.Errorf("failed to mount rootfs disk image: %v, output: %s", err, string(output)) } return nil } func rootfsHasInit(rootfsPath string) bool { for _, rel := range []string{ "sbin/init", "usr/lib/systemd/systemd", "lib/systemd/systemd", "bin/busybox", "bin/sh", } { if _, err := os.Stat(filepath.Join(rootfsPath, rel)); err == nil { return true } } // NixOS has init under a hash-named nix store path; check nix/store for any init nixStore := filepath.Join(rootfsPath, "nix", "store") if entries, err := os.ReadDir(nixStore); err == nil { for _, entry := range entries { if entry.IsDir() && strings.Contains(entry.Name(), "nixos-system-") { if _, err := os.Stat(filepath.Join(nixStore, entry.Name(), "init")); err == nil { return true } if _, err := os.Stat(filepath.Join(nixStore, entry.Name(), "systemd")); err == nil { return true } } } } return false } func diskImageMounted(lxcName, rootfsPath string) bool { _ = lxcName target, err := findmntValue(rootfsPath, "TARGET") if err != nil { return false } targetAbs, err := filepath.Abs(strings.TrimSpace(target)) if err != nil { return false } rootfsAbs, err := filepath.Abs(rootfsPath) if err != nil { return false } return targetAbs == rootfsAbs } func applyXFSProjectQuota(rootfsPath, lxcName string, diskGB int) error { options, err := findmntValue(rootfsPath, "OPTIONS") if err != nil { return err } if !hasProjectQuotaOption(options) { return errors.New("xfs project quota is not enabled; remount with prjquota before creating containers") } mountPoint, err := findmntValue(rootfsPath, "TARGET") if err != nil { return err } projectID := projectQuotaID(lxcName) projectName := "clicd-" + lxcName if err := ensureProjectQuotaFiles(projectID, projectName, rootfsPath); err != nil { return err } output, err := exec.Command("xfs_quota", "-x", "-c", "project -s "+projectName, "-c", fmt.Sprintf("limit -p bhard=%dg %s", diskGB, projectName), mountPoint, ).CombinedOutput() if err != nil { return fmt.Errorf("failed to apply xfs project quota: %v, output: %s", err, string(output)) } return nil } func applyExt4ProjectQuota(rootfsPath, lxcName string, diskGB int) error { options, err := findmntValue(rootfsPath, "OPTIONS") if err != nil { return err } if !hasProjectQuotaOption(options) { return errors.New("ext4 project quota is not enabled; remount with prjquota before creating containers") } mountPoint, err := findmntValue(rootfsPath, "TARGET") if err != nil { return err } projectID := projectQuotaID(lxcName) output, err := exec.Command("chattr", "-p", strconv.Itoa(projectID), rootfsPath).CombinedOutput() if err != nil { return fmt.Errorf("failed to assign ext4 project id: %v, output: %s", err, string(output)) } hardKB := diskGB * 1024 * 1024 output, err = exec.Command("setquota", "-P", strconv.Itoa(projectID), "0", strconv.Itoa(hardKB), "0", "0", mountPoint).CombinedOutput() if err != nil { return fmt.Errorf("failed to apply ext4 project quota: %v, output: %s", err, string(output)) } return nil } func findmntValue(path, field string) (string, error) { out, err := exec.Command("findmnt", "-T", path, "-no", field).Output() if err != nil { return "", err } value := strings.TrimSpace(string(out)) if value == "" { return "", fmt.Errorf("findmnt returned empty %s for %s", field, path) } return value, nil } func hasProjectQuotaOption(options string) bool { for _, option := range strings.Split(options, ",") { option = strings.TrimSpace(option) if option == "prjquota" || option == "pquota" || option == "project" { return true } } return false } func projectQuotaID(lxcName string) int { idPart := strings.TrimPrefix(lxcName, "ct-") id, err := strconv.Atoi(idPart) if err != nil { id = 1 } return 200000 + id } func ensureProjectQuotaFiles(projectID int, projectName, rootfsPath string) error { projectsLine := fmt.Sprintf("%d:%s", projectID, rootfsPath) if err := appendUniqueLine("/etc/projects", projectsLine); err != nil { return err } projidLine := fmt.Sprintf("%s:%d", projectName, projectID) return appendUniqueLine("/etc/projid", projidLine) } func appendUniqueLine(path, line string) error { data, _ := os.ReadFile(path) for _, existing := range strings.Split(string(data), "\n") { if strings.TrimSpace(existing) == line { return nil } } f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) if err != nil { return err } defer f.Close() if len(data) > 0 && !strings.HasSuffix(string(data), "\n") { if _, err := f.WriteString("\n"); err != nil { return err } } _, err = f.WriteString(line + "\n") return err } func findSeccompProfile() (string, error) { for _, path := range []string{ "/usr/share/lxc/config/common.seccomp", "/usr/share/lxc/config/common.seccomp.policy", "/etc/lxc/common.seccomp", } { if _, err := os.Stat(path); err == nil { return ensureCVE202643499SeccompProfile(path) } } return "", errors.New("required LXC seccomp profile not found") } const clicdSeccompProfileDir = "/var/lib/clicd/security/seccomp" const clicdCVE202643499SeccompProfile = clicdSeccompProfileDir + "/lxc-cve-2026-43499.profile" var cve202643499FutexSeccompRules = []string{ "# clicd managed: mitigate CVE-2026-43499 from LXC guests by blocking PI futex operations", "futex errno 1 [1,0x6,SCMP_CMP_MASKED_EQ,0x7f]", "futex errno 1 [1,0x7,SCMP_CMP_MASKED_EQ,0x7f]", "futex errno 1 [1,0x8,SCMP_CMP_MASKED_EQ,0x7f]", "futex errno 1 [1,0xb,SCMP_CMP_MASKED_EQ,0x7f]", "futex errno 1 [1,0xc,SCMP_CMP_MASKED_EQ,0x7f]", "futex errno 1 [1,0xd,SCMP_CMP_MASKED_EQ,0x7f]", } func ensureCVE202643499SeccompProfile(basePath string) (string, error) { data, err := os.ReadFile(basePath) if err != nil { return "", fmt.Errorf("failed to read LXC seccomp profile: %v", err) } content := string(data) if !isLXCVDenylistSeccompProfile(content) { return "", fmt.Errorf("LXC seccomp profile %s is not a v2 denylist profile; cannot apply CVE-2026-43499 futex mitigation safely", basePath) } if err := os.MkdirAll(clicdSeccompProfileDir, 0755); err != nil { return "", fmt.Errorf("failed to create CLICD seccomp directory: %v", err) } hardened := appendMissingSeccompRules(content, cve202643499FutexSeccompRules) if err := os.WriteFile(clicdCVE202643499SeccompProfile, []byte(hardened), 0644); err != nil { return "", fmt.Errorf("failed to write CLICD seccomp profile: %v", err) } return clicdCVE202643499SeccompProfile, nil } func isLXCVDenylistSeccompProfile(content string) bool { lines := nonCommentSeccompLines(content) return len(lines) >= 2 && lines[0] == "2" && isSeccompDenylistPolicy(lines[1]) } func isSeccompDenylistPolicy(line string) bool { fields := strings.Fields(line) if len(fields) == 0 { return false } return fields[0] == "denylist" || fields[0] == "blacklist" } func appendMissingSeccompRules(content string, rules []string) string { trimmed := strings.TrimRight(content, "\r\n") existing := map[string]bool{} for _, line := range strings.Split(trimmed, "\n") { line = strings.TrimSpace(stripSeccompLineComment(line)) if line != "" { existing[line] = true } } var builder strings.Builder builder.WriteString(trimmed) for _, rule := range rules { key := strings.TrimSpace(stripSeccompLineComment(rule)) if key != "" && existing[key] { continue } builder.WriteString("\n") builder.WriteString(rule) } builder.WriteString("\n") return builder.String() } func nonCommentSeccompLines(content string) []string { lines := make([]string, 0) for _, line := range strings.Split(content, "\n") { line = strings.TrimSpace(stripSeccompLineComment(line)) if line == "" { continue } lines = append(lines, line) } return lines } func stripSeccompLineComment(line string) string { if idx := strings.Index(line, "#"); idx >= 0 { return line[:idx] } return line } func findAppArmorProfile() (string, error) { data, err := os.ReadFile("/sys/kernel/security/apparmor/profiles") if err != nil { return "", fmt.Errorf("apparmor is required but not available: %v", err) } profiles := string(data) for _, profile := range []string{"lxc-container-default-cgns", "lxc-container-default"} { if strings.Contains(profiles, profile+" ") || strings.Contains(profiles, profile+" (") { return profile, nil } } return "", errors.New("required LXC AppArmor profile not loaded") } func appArmorProfileForTemplate(templateID string) (string, error) { if systemdTemplateNeedsUnconfinedAppArmor(templateID) { return "unconfined", nil } return findAppArmorProfile() } func systemdTemplateNeedsUnconfinedAppArmor(templateID string) bool { id := strings.ToLower(strings.TrimSpace(templateID)) if id == "" || strings.Contains(id, "alpine") { return false } for _, token := range []string{"ubuntu", "debian", "centos", "fedora", "rocky", "rockylinux", "archlinux"} { if strings.Contains(id, token) { return true } } return false } func unprivilegedIDMap() (int, int, error) { if err := ensureSubIDRange("/etc/subuid", "root", 100000, 65536); err != nil { return 0, 0, err } if err := ensureSubIDRange("/etc/subgid", "root", 100000, 65536); err != nil { return 0, 0, err } uidBase, err := parseSubIDRange("/etc/subuid", "root") if err != nil { return 0, 0, err } gidBase, err := parseSubIDRange("/etc/subgid", "root") if err != nil { return 0, 0, err } return uidBase, gidBase, nil } func ensureSubIDRange(path, user string, start, count int) error { if _, err := parseSubIDRange(path, user); err == nil { return nil } f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) if err != nil { return fmt.Errorf("failed to update %s: %v", path, err) } defer f.Close() if _, err := f.WriteString(fmt.Sprintf("%s:%d:%d\n", user, start, count)); err != nil { return fmt.Errorf("failed to update %s: %v", path, err) } return nil } func parseSubIDRange(path, user string) (int, error) { data, err := os.ReadFile(path) if err != nil { return 0, fmt.Errorf("failed to read %s: %v", path, err) } for _, line := range strings.Split(string(data), "\n") { fields := strings.Split(strings.TrimSpace(line), ":") if len(fields) != 3 || fields[0] != user { continue } start, startErr := strconv.Atoi(fields[1]) count, countErr := strconv.Atoi(fields[2]) if startErr == nil && countErr == nil && count >= 65536 { return start, nil } } return 0, fmt.Errorf("%s must contain a %s subordinate id range with at least 65536 ids", path, user) } func (m *Manager) ensureUnprivilegedLXCPathAccess(lxcName string) error { // Unprivileged container root maps to a subordinate host UID, so it needs // execute permission on the LXC parent and container directories to reach // rootfs. Some distributions create /var/lib/lxc as 750/700, which causes // lxc-start to abort with "Could not access /var/lib/lxc". for _, path := range []string{m.LxcPath, filepath.Join(m.LxcPath, lxcName)} { info, err := os.Stat(path) if err != nil { return err } mode := info.Mode().Perm() if mode&0001 != 0 { continue } if err := os.Chmod(path, mode|0001); err != nil { return fmt.Errorf("failed to fix LXC path permissions for %s: %v", path, err) } } return nil } func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error { uidBase, gidBase, err := unprivilegedIDMap() if err != nil { return err } rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted") if err := m.ensureUnprivilegedLXCPathAccess(lxcName); err != nil { return err } if _, err := os.Stat(marker); err == nil { return nil } m.unmountRootfsChildMounts(rootfsPath) rootInfo, err := os.Lstat(rootfsPath) if err != nil { return err } rootDev, _, _, ok := fileStatFields(rootInfo) if !ok { return fmt.Errorf("failed to read rootfs device for %s", rootfsPath) } if err := filepath.WalkDir(rootfsPath, func(path string, _ os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } info, err := os.Lstat(path) if err != nil { return err } dev, uid, gid, ok := fileStatFields(info) if !ok { return fmt.Errorf("failed to read uid/gid for %s", path) } if path != rootfsPath && dev != rootDev { if info.IsDir() { return filepath.SkipDir } return nil } if uid >= uidBase && uid < uidBase+65536 && gid >= gidBase && gid < gidBase+65536 { return nil } if uid >= 0 && uid < 65536 { uid += uidBase } if gid >= 0 && gid < 65536 { gid += gidBase } return os.Lchown(path, uid, gid) }); err != nil { return fmt.Errorf("failed to shift rootfs ownership for unprivileged LXC: %v", err) } if err := os.WriteFile(marker, []byte("1\n"), 0644); err != nil { return err } if err := os.Lchown(marker, uidBase, gidBase); err != nil { return err } // Fix container directory permissions: unprivileged init runs as ns UID 0 // (host UID 100000), which is "other" on the host. lxc-create sets the // container dir to 770, so we need o+x to let the container process // traverse into the directory and access rootfs. containerDir := filepath.Join(m.LxcPath, lxcName) if err := os.Chmod(containerDir, 0771); err != nil { return fmt.Errorf("failed to fix container directory permissions: %v", err) } return nil } func fileStatFields(info os.FileInfo) (dev uint64, uid int, gid int, ok bool) { if info == nil || info.Sys() == nil { return 0, 0, 0, false } stat := reflect.ValueOf(info.Sys()) if stat.Kind() == reflect.Pointer { if stat.IsNil() { return 0, 0, 0, false } stat = stat.Elem() } if stat.Kind() != reflect.Struct { return 0, 0, 0, false } devValue, devOK := numericField(stat, "Dev") uidValue, uidOK := numericField(stat, "Uid") gidValue, gidOK := numericField(stat, "Gid") if !devOK || !uidOK || !gidOK { return 0, 0, 0, false } return devValue, int(uidValue), int(gidValue), true } func numericField(v reflect.Value, name string) (uint64, bool) { field := v.FieldByName(name) if !field.IsValid() { return 0, false } switch field.Kind() { case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: return field.Uint(), true case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: value := field.Int() if value < 0 { return 0, false } return uint64(value), true default: return 0, false } } func (m *Manager) unmountRootfsChildMounts(rootfsPath string) { rootAbs, err := filepath.Abs(rootfsPath) if err != nil { return } out, err := exec.Command("findmnt", "-R", "-n", "-o", "TARGET", rootfsPath).Output() if err != nil { return } targets := strings.Split(strings.TrimSpace(string(out)), "\n") for i, j := 0, len(targets)-1; i < j; i, j = i+1, j-1 { targets[i], targets[j] = targets[j], targets[i] } for _, target := range targets { target = strings.TrimSpace(target) if target == "" { continue } targetAbs, err := filepath.Abs(target) if err != nil || targetAbs == rootAbs { continue } if strings.HasPrefix(targetAbs, rootAbs+string(os.PathSeparator)) { exec.Command("umount", "-R", "-l", targetAbs).Run() } } } func (m *Manager) rootfsShifted(lxcName string) bool { marker := filepath.Join(m.LxcPath, lxcName, "rootfs", ".clicd-unprivileged-shifted") _, err := os.Stat(marker) return err == nil } func (m *Manager) hasUnprivilegedIDMap(lxcName string) bool { data, err := os.ReadFile(filepath.Join(m.LxcPath, lxcName, "config")) if err != nil { return false } content := string(data) return strings.Contains(content, "lxc.idmap = u 0 ") && strings.Contains(content, "lxc.idmap = g 0 ") } // StartContainer starts an LXC container by its ID func (m *Manager) StartContainer(id int) error { c := config.FindContainer(id) if c == nil { return fmt.Errorf("container not found: %d", id) } lxcName := c.LxcName() if err := m.ensureDiskImageMounted(lxcName); err != nil { return err } if m.hasUnprivilegedIDMap(lxcName) && !m.rootfsShifted(lxcName) { if err := m.shiftRootfsForUnprivileged(lxcName); err != nil { return err } } if m.rootfsShifted(lxcName) { if err := m.applyResourceLimits(lxcName, ContainerConfig{ Name: c.Name, TemplateID: c.Template, VCPU: c.VCPU, RAMMB: c.RAMMB, DiskGB: c.DiskGB, NetworkBWMbps: c.NetworkBWMbps, NetworkDownMbps: c.NetworkDownMbps, NetworkUpMbps: c.NetworkUpMbps, MonthlyTrafficGB: c.MonthlyTrafficGB, IOSpeedMBps: c.IOSpeedMBps, IOReadMBps: c.IOReadMBps, IOWriteMBps: c.IOWriteMBps, AssignIPv6: c.IPv6 != "" || len(c.IPv6Addresses) > 0, ExpiresAt: c.ExpiresAt, }); err != nil { return err } } if c.IPv6 != "" || len(c.IPv6Addresses) > 0 { c.NormalizeNetworkAssignments() if err := m.applyIPv6Config(lxcName, c.IPv6AddressStrings()...); err != nil { return err } if err := m.ApplyIPv6(id); err != nil { return err } } EnsureAssignedPublicIPv4s(c.PublicIPv4s) logFile, consoleLog, output, err := m.startLXCContainerDaemon(lxcName) if err != nil { config.UpdateContainerStatus(id, "stopped") return fmt.Errorf("failed to start container: %v, output: %s, lxc log: %s, console: %s", err, string(output), tailFile(logFile, 80), tailFile(consoleLog, 80)) } if err := m.waitForLXCStartup(lxcName, logFile, consoleLog); err != nil { config.UpdateContainerStatus(id, "stopped") return err } config.UpdateContainerStatus(id, "running") var ip string for retry := 0; retry < 10; retry++ { time.Sleep(2 * time.Second) ip, err = m.GetContainerIP(lxcName) if err == nil && ip != "" { break } } if ip != "" { c = config.FindContainer(id) if c != nil { c.IP = ip config.SaveConfig() } } if ip == "" { if repairedIP, repairErr := m.EnsureContainerIPv4(id); repairErr == nil && repairedIP != "" { ip = repairedIP c = config.FindContainer(id) } else if repairErr != nil { fmt.Printf("Warning: failed to prepare IPv4 for %s: %v\n", lxcName, repairErr) } } if ip != "" { if err := m.EnsureSSH(id); err != nil { return err } } if current := config.FindContainer(id); current != nil { c = current } if err := m.ApplyContainerLimits(c); err != nil { fmt.Printf("Warning: failed to apply runtime resource limits for %s: %v\n", lxcName, err) } if err := m.ApplyPortMappings(id); err != nil { fmt.Printf("Warning: failed to apply port mappings: %v\n", err) } if err := ApplyFirewallRules(id); err != nil { fmt.Printf("Warning: failed to apply firewall rules: %v\n", err) } if c.IPv6 != "" || len(c.IPv6Addresses) > 0 { if err := m.ApplyIPv6(id); err != nil { fmt.Printf("Warning: failed to apply IPv6 routing for %s: %v\n", lxcName, err) } } fmt.Printf("Container %d (%s) started, IP: %s\n", id, c.Name, ip) return nil } func (m *Manager) startLXCContainerDaemon(lxcName string) (string, string, []byte, error) { logFile := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-start.log") consoleLog := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-console.log") os.Remove(logFile) os.Remove(consoleLog) cmd := exec.Command("lxc-start", "-n", lxcName, "-d", "--logfile", logFile, "--logpriority", "DEBUG", "--console-log", consoleLog) output, err := cmd.CombinedOutput() return logFile, consoleLog, output, err } func (m *Manager) waitForLXCStartup(lxcName, logFile, consoleLog string) error { runningChecks := 0 lastStatus := "unknown" for retry := 0; retry < 10; retry++ { time.Sleep(1 * time.Second) status, err := m.GetContainerStatus(lxcName) if err != nil { lastStatus = "unknown" continue } lastStatus = status if status == "running" { runningChecks++ if runningChecks >= 3 { return nil } continue } if runningChecks > 0 || retry >= 1 { break } } return fmt.Errorf("container exited immediately after start (status: %s), lxc log: %s, console: %s", lastStatus, tailFile(logFile, 80), tailFile(consoleLog, 80)) } // applyBandwidthLimit applies tc-based bandwidth limit on container's veth interface // ApplyContainerLimits re-applies resource limits (CPU, RAM, IO, BW) to a running container. func (m *Manager) ApplyContainerLimits(c *config.Container) error { if c == nil || c.Status != "running" { return nil } config.NormalizeContainerResourceAliases(c) lxcName := c.LxcName() // CPU: write cpu.max cpuQuota := int(c.VCPU * 100000) cpuLine := fmt.Sprintf("%d 100000", cpuQuota) for _, path := range []string{ fmt.Sprintf("/sys/fs/cgroup/lxc/%s/cpu.max", lxcName), fmt.Sprintf("/sys/fs/cgroup/lxc.payload.%s/cpu.max", lxcName), } { os.WriteFile(path, []byte(cpuLine), 0644) } // Memory: write memory.max ramBytes := int64(c.RAMMB) * 1024 * 1024 memLine := fmt.Sprintf("%d", ramBytes) for _, path := range []string{ fmt.Sprintf("/sys/fs/cgroup/lxc/%s/memory.max", lxcName), fmt.Sprintf("/sys/fs/cgroup/lxc.payload.%s/memory.max", lxcName), } { os.WriteFile(path, []byte(memLine), 0644) } // IO speed: write io.max, including max values to clear old per-direction limits. ioLines, err := m.ioLimitLines(lxcName, c.IOReadMBps, c.IOWriteMBps) if err != nil { return err } ioLine := strings.Join(ioLines, "\n") for _, path := range []string{ fmt.Sprintf("/sys/fs/cgroup/lxc/%s/io.max", lxcName), fmt.Sprintf("/sys/fs/cgroup/lxc.payload.%s/io.max", lxcName), } { os.WriteFile(path, []byte(ioLine), 0644) } // Network bandwidth m.applyBandwidthLimit(lxcName, c.NetworkDownMbps, c.NetworkUpMbps) return nil } func (m *Manager) applyBandwidthLimit(lxcName string, downMbps int, upMbps int) { veth := m.getContainerVethByNS(lxcName) if veth == "" { fmt.Printf("Warning: could not find veth for %s\n", lxcName) return } exec.Command("tc", "qdisc", "del", "dev", veth, "root").Run() exec.Command("tc", "qdisc", "del", "dev", veth, "ingress").Run() if downMbps > 0 { rate := fmt.Sprintf("%dmbit", downMbps) burst := fmt.Sprintf("%dkbit", downMbps*100) exec.Command("tc", "qdisc", "add", "dev", veth, "root", "handle", "1:", "htb", "default", "10").Run() exec.Command("tc", "class", "add", "dev", veth, "parent", "1:", "classid", "1:10", "htb", "rate", rate, "burst", burst).Run() } if upMbps > 0 { rate := fmt.Sprintf("%dmbit", upMbps) burst := fmt.Sprintf("%dkbit", upMbps*100) exec.Command("tc", "qdisc", "add", "dev", veth, "handle", "ffff:", "ingress").Run() exec.Command("tc", "filter", "add", "dev", veth, "parent", "ffff:", "protocol", "all", "u32", "match", "u32", "0", "0", "police", "rate", rate, "burst", burst, "drop", "flowid", ":1").Run() } fmt.Printf("Bandwidth limit: %s down=%d Mbps up=%d Mbps on %s\n", lxcName, downMbps, upMbps, veth) } func (m *Manager) cleanupBandwidthLimit(lxcName string) { veth := m.getContainerVethByNS(lxcName) if veth != "" { exec.Command("tc", "qdisc", "del", "dev", veth, "root").Run() exec.Command("tc", "qdisc", "del", "dev", veth, "ingress").Run() } } func (m *Manager) getContainerVethByNS(lxcName string) string { pid := m.getContainerInitPID(lxcName) if pid == "" { return "" } cmd := exec.Command("sh", "-c", fmt.Sprintf("nsenter -t %s -n ip -o link show 2>/dev/null | grep -oP 'eth0@if\\K[0-9]+'", pid)) out, _ := cmd.Output() ifIdx := strings.TrimSpace(string(out)) if ifIdx == "" { return "" } cmd2 := exec.Command("sh", "-c", fmt.Sprintf("ip -o link show | grep '^%s:' | grep -oP 'veth[^:@]+'", ifIdx)) out2, _ := cmd2.Output() return strings.TrimSpace(string(out2)) } // StopContainer stops an LXC container by its ID func (m *Manager) StopContainer(id int) error { c := config.FindContainer(id) if c == nil { return fmt.Errorf("container not found: %d", id) } lxcName := c.LxcName() status, _ := m.GetContainerStatus(lxcName) if status != "running" { config.UpdateContainerStatus(id, "stopped") m.CleanPortMappings(id) CleanFirewallRules(id) m.cleanupBandwidthLimit(lxcName) return nil } m.CleanPortMappings(id) CleanFirewallRules(id) m.cleanupBandwidthLimit(lxcName) cmd := exec.Command("lxc-stop", "-n", lxcName) output, err := cmd.CombinedOutput() if err != nil { if strings.Contains(string(output), "not running") { config.UpdateContainerStatus(id, "stopped") return nil } return fmt.Errorf("failed to stop container: %v, output: %s", err, string(output)) } config.UpdateContainerStatus(id, "stopped") fmt.Printf("Container %d (%s) stopped\n", id, c.Name) return nil } // RestartContainer restarts an LXC container by its ID func (m *Manager) RestartContainer(id int) error { c := config.FindContainer(id) if c == nil { return fmt.Errorf("container not found: %d", id) } status, _ := m.GetContainerStatus(c.LxcName()) if status == "running" { if err := m.StopContainer(id); err != nil { if !strings.Contains(err.Error(), "not running") { return err } } time.Sleep(1 * time.Second) } return m.StartContainer(id) } // EnsureContainerIPv4 brings eth0 up and asks the guest network stack for DHCP. func (m *Manager) EnsureContainerIPv4(id int) (string, error) { c := config.FindContainer(id) if c == nil { return "", fmt.Errorf("container not found: %d", id) } lxcName := c.LxcName() status, _ := m.GetContainerStatus(lxcName) if status != "running" { return "", fmt.Errorf("container %d is not running; cannot configure IPv4", id) } if ip, err := m.GetContainerIP(lxcName); err == nil && ip != "" { c.IP = ip config.SaveConfig() return ip, nil } script := ` set +e ip link set lo up 2>/dev/null ip link set eth0 up 2>/dev/null if command -v systemctl >/dev/null 2>&1; then systemctl start NetworkManager >/dev/null 2>&1 systemctl start systemd-networkd >/dev/null 2>&1 systemctl start networking >/dev/null 2>&1 fi if command -v rc-service >/dev/null 2>&1; then rc-service networking start >/dev/null 2>&1 fi if command -v nmcli >/dev/null 2>&1; then nmcli networking on >/dev/null 2>&1 nmcli device set eth0 managed yes >/dev/null 2>&1 nmcli connection up eth0 >/dev/null 2>&1 || nmcli device connect eth0 >/dev/null 2>&1 fi if command -v dhclient >/dev/null 2>&1; then timeout 12 dhclient -4 -v eth0 >/dev/null 2>&1 elif command -v dhcpcd >/dev/null 2>&1; then pkill dhcpcd >/dev/null 2>&1 || true rm -f /run/dhcpcd*.pid /var/lib/dhcpcd/* /run/dhcpcd/* 2>/dev/null timeout 20 dhcpcd -4 -q -w -K eth0 >/dev/null 2>&1 elif command -v udhcpc >/dev/null 2>&1; then timeout 12 udhcpc -i eth0 -q >/dev/null 2>&1 elif command -v busybox >/dev/null 2>&1; then timeout 12 busybox udhcpc -i eth0 -q >/dev/null 2>&1 fi ip -4 addr show eth0 2>/dev/null | awk '/inet / {sub(/\/.*/, "", $2); print $2; exit}' ` ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", script) output, err := cmd.CombinedOutput() if ctx.Err() == context.DeadlineExceeded { return "", fmt.Errorf("timed out waiting for IPv4 DHCP in %s", lxcName) } if err != nil { return "", fmt.Errorf("failed to run IPv4 repair in %s: %v, output: %s", lxcName, err, string(output)) } ip := strings.TrimSpace(string(output)) if ip == "" { return "", fmt.Errorf("no IPv4 address after DHCP repair in %s", lxcName) } c.IP = ip config.SaveConfig() return ip, nil } // WarmSSH waits briefly for container networking metadata and then ensures sshd // is installed, configured, running, and using the saved root password. func (m *Manager) WarmSSH(id int) error { c := config.FindContainer(id) if c == nil { return fmt.Errorf("container not found: %d", id) } lxcName := c.LxcName() status, _ := m.GetContainerStatus(lxcName) if status != "running" { return fmt.Errorf("container %d is not running; cannot warm SSH", id) } for retry := 0; retry < 15; retry++ { if ip, err := m.GetContainerIP(lxcName); err == nil && ip != "" { if current := config.FindContainer(id); current != nil { current.IP = ip config.SaveConfig() } break } time.Sleep(2 * time.Second) } if current := config.FindContainer(id); current != nil && current.IP == "" { if ip, err := m.EnsureContainerIPv4(id); err == nil && ip != "" { current.IP = ip config.SaveConfig() } } return m.EnsureSSH(id) } func (m *Manager) WarmSSHAsync(id int, reason string) { if _, loaded := sshWarmupScheduled.LoadOrStore(id, struct{}{}); loaded { return } go func() { sshWarmupSem <- struct{}{} defer func() { <-sshWarmupSem sshWarmupScheduled.Delete(id) }() if err := m.WarmSSH(id); err != nil { if c := config.FindContainer(id); c != nil { fmt.Printf("Warning: SSH warmup failed for %s (%s, %s): %v\n", c.LxcName(), c.Name, reason, err) } else { fmt.Printf("Warning: SSH warmup failed for container %d (%s): %v\n", id, reason, err) } } }() } func sshEnsureLock(id int) *sync.Mutex { lock, _ := sshEnsureLocks.LoadOrStore(id, &sync.Mutex{}) return lock.(*sync.Mutex) } // DestroyContainer destroys an LXC container by its ID func (m *Manager) DestroyContainer(id int) error { if id <= 0 { return fmt.Errorf("invalid container id: %d", id) } c := config.FindContainer(id) if c == nil { return fmt.Errorf("container not found: %d", id) } lxcName := c.LxcName() if c.IPv6 != "" || len(c.IPv6Addresses) > 0 { c.NormalizeNetworkAssignments() for _, assignment := range c.IPv6Addresses { uplink := assignment.Interface if uplink == "" { uplink = c.IPv6Interface } if uplink != "" { removeHostIPv6Routing(assignment.Address, uplink) } } } if err := m.StopContainer(id); err != nil { return fmt.Errorf("failed to stop container before destroy: %v", err) } time.Sleep(1 * time.Second) containerDir := filepath.Join(m.LxcPath, lxcName) m.detachContainerMounts(containerDir) m.detachContainerLoopDevices(containerDir) // Retry lxc-destroy up to 3 times, since LXC may need time to release resources var destroyErr error for attempt := 0; attempt < 3; attempt++ { cmd := exec.Command("lxc-destroy", "-n", lxcName, "-f") output, err := cmd.CombinedOutput() if err == nil { destroyErr = nil break } out := string(output) if strings.Contains(strings.ToLower(out), "does not exist") || strings.Contains(strings.ToLower(out), "not found") || strings.Contains(strings.ToLower(out), "is not defined") { destroyErr = nil break } destroyErr = fmt.Errorf("failed to destroy container (attempt %d/3): %v, output: %s", attempt+1, err, out) if attempt < 2 { // Wait for LXC to release resources before retry time.Sleep(2 * time.Second) m.detachContainerMounts(containerDir) m.detachContainerLoopDevices(containerDir) } } if err := m.cleanupContainerStorage(lxcName); err != nil { if destroyErr != nil { return fmt.Errorf("%v; cleanup also failed: %v", destroyErr, err) } return err } if status, err := m.GetContainerStatus(lxcName); err == nil { if destroyErr != nil { return fmt.Errorf("%v; container still exists after cleanup with status %s", destroyErr, status) } return fmt.Errorf("container still exists after cleanup with status %s", status) } // Remove snapshot physical files (by container ID, not lxcName) snapshotDir := filepath.Join(snapshotBaseDir(), strconv.Itoa(id)) if err := safePathUnder(snapshotDir, snapshotBaseDir()); err == nil { os.RemoveAll(snapshotDir) } // Also remove any legacy snapshot dir that used lxcName legacySnapshotDir := filepath.Join(snapshotBaseDir(), lxcName) if legacySnapshotDir != snapshotDir { if err := safePathUnder(legacySnapshotDir, snapshotBaseDir()); err == nil { os.RemoveAll(legacySnapshotDir) } } if !config.RemoveContainer(id) { return fmt.Errorf("container destroyed but config entry was not removed: %d", id) } if config.FindContainer(id) != nil { return fmt.Errorf("container destroyed but config entry still exists: %d", id) } fmt.Printf("Container %d destroyed\n", id) return nil } // EnsureSSH installs and starts sshd, enables root password login, and verifies port 22. func (m *Manager) EnsureSSH(id int) error { lock := sshEnsureLock(id) lock.Lock() defer lock.Unlock() c := config.FindContainer(id) if c == nil { return fmt.Errorf("container not found: %d", id) } lxcName := c.LxcName() status, _ := m.GetContainerStatus(lxcName) if status != "running" { return fmt.Errorf("container %d is not running; cannot configure SSH", id) } if c.SSHPassword == "" { c.SSHPassword = generateRandomString(16) config.SaveConfig() } script := sshSetupScript(true, false) // keep pubkey enabled for runtime ensure ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", script) output, err := cmd.CombinedOutput() if ctx.Err() == context.DeadlineExceeded { return fmt.Errorf("timed out configuring SSH in container %d after 180s; package manager or service startup may be stuck, output: %s", id, string(output)) } 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 != "" { c.IP = ip config.SaveConfig() } } if c.IP != "" { if mapErr := m.ApplyPortMappings(id); mapErr != nil { fmt.Printf("Warning: failed to refresh SSH port mapping for %s: %v\n", lxcName, mapErr) } } fmt.Printf("SSH ready in container %d (root password login enabled)\n", id) return nil } func (m *Manager) quickEnsureSSHPassword(lxcName, password string) error { 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, "--", "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)) } 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() check := fmt.Sprintf("(ss -ltn 2>/dev/null || netstat -tln 2>/dev/null) | grep -Eq '(^|[[:space:]])[^[:space:]]*:%d[[:space:]]'", port) return exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", check).Run() == nil } func sshSetupScript(startService bool, disablePubkeyAuth bool) string { pubkeyValue := "yes" if disablePubkeyAuth { pubkeyValue = "no" } script := `set -u # 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. # Remove any existing symlink and write a real file so DNS always works in LXC. if [ -L /etc/resolv.conf ] 2>/dev/null; then rm -f /etc/resolv.conf fi # Also try resolvectl for systemd-resolved setups if command -v resolvectl >/dev/null 2>&1; then resolvectl dns eth0 10.0.3.1 2>/dev/null || true resolvectl dns eth0 8.8.8.8 2>/dev/null || true resolvectl domain eth0 '~.' 2>/dev/null || true fi # Check if we have a usable (non-localhost) nameserver; if not, force-write one. # Avoid the trap where systemd stub resolver puts "nameserver 127.0.0.53" # but doesn't actually resolve anything. if ! grep -q '^nameserver [1-9]' /etc/resolv.conf 2>/dev/null; then echo "nameserver 10.0.3.1" > /etc/resolv.conf echo "nameserver 8.8.8.8" >> /etc/resolv.conf fi export DEBIAN_FRONTEND=noninteractive export APT_LISTCHANGES_FRONTEND=none run_timeout() { if command -v timeout >/dev/null 2>&1; then timeout "$@" else shift "$@" fi } has_sshd() { command -v sshd >/dev/null 2>&1 || [ -x /usr/sbin/sshd ] || [ -x /sbin/sshd ] } sshd_path() { if command -v sshd >/dev/null 2>&1; then command -v sshd elif [ -x /usr/sbin/sshd ]; then printf /usr/sbin/sshd elif [ -x /sbin/sshd ]; then printf /sbin/sshd else return 1 fi } install_sshd() { if has_sshd; then return 0 fi if command -v apt-get >/dev/null 2>&1; then for i in 1 2; do run_timeout 20 dpkg --configure -a >/dev/null 2>&1 || true run_timeout 45 apt-get update -o Acquire::Retries=2 -o Acquire::http::Timeout=15 && run_timeout 90 apt-get install -y --no-install-recommends -o Dpkg::Options::=--force-confold openssh-server passwd iproute2 procps net-tools && return 0 sleep 3 done elif command -v apk >/dev/null 2>&1; then for i in 1 2 3; do run_timeout 120 apk add --no-cache openssh-server openssh-client shadow iproute2 procps net-tools && return 0 sleep 3 done elif command -v pacman >/dev/null 2>&1; then run_timeout 45 pacman -Syu --noconfirm >/dev/null 2>&1 || true run_timeout 90 pacman -S --noconfirm openssh shadow iproute2 procps-ng net-tools && return 0 elif command -v dnf >/dev/null 2>&1; then run_timeout 90 dnf install -y openssh-server openssh-clients passwd iproute procps-ng net-tools && return 0 elif command -v yum >/dev/null 2>&1; then run_timeout 90 yum install -y openssh-server openssh-clients passwd iproute procps-ng net-tools && return 0 elif command -v nix-env >/dev/null 2>&1; then { run_timeout 120 nix-env -iA nixos.openssh >/dev/null 2>&1 && return 0; } || true # NixOS cloud images may already have sshd or use different package paths # Fall through to let the script try whatever sshd is available fi return 1 } ensure_sshd_runtime_dir() { mkdir -p /run/sshd /var/run/sshd chmod 0755 /run/sshd /var/run/sshd 2>/dev/null || true } set_sshd_option() { key="$1" value="$2" file=/etc/ssh/sshd_config tmp="${file}.clicd" touch "$file" awk -v key="$key" -v value="$value" ' BEGIN { done=0; inmatch=0 } /^[[:space:]]*Match[[:space:]]/ { if (!done) { print key " " value; done=1 } inmatch=1 print next } !inmatch && $0 ~ "^[#[:space:]]*" key "[[:space:]]+" { if (!done) { print key " " value; done=1 } next } { print } END { if (!done) print key " " value } ' "$file" >"$tmp" && cat "$tmp" >"$file" rm -f "$tmp" } install_sshd || exit 30 mkdir -p /etc/ssh /etc/ssh/sshd_config.d ensure_sshd_runtime_dir ssh-keygen -A >/dev/null 2>&1 || true cat >/etc/ssh/sshd_config.d/99-clicd.conf <<'EOF' PermitRootLogin yes PubkeyAuthentication __CLICD_PUBKEY_AUTH__ PasswordAuthentication yes KbdInteractiveAuthentication no ChallengeResponseAuthentication no UsePAM no EOF set_sshd_option PermitRootLogin yes set_sshd_option PubkeyAuthentication __CLICD_PUBKEY_AUTH__ set_sshd_option PasswordAuthentication yes set_sshd_option KbdInteractiveAuthentication no set_sshd_option ChallengeResponseAuthentication no set_sshd_option UsePAM no if command -v rc-update >/dev/null 2>&1; then rc-update add sshd default >/dev/null 2>&1 || true fi if command -v systemctl >/dev/null 2>&1; then systemctl stop ssh.socket 2>/dev/null || true systemctl disable ssh.socket 2>/dev/null || true systemctl enable ssh >/dev/null 2>&1 || systemctl enable sshd >/dev/null 2>&1 || true fi if command -v update-rc.d >/dev/null 2>&1; then update-rc.d ssh defaults >/dev/null 2>&1 || true fi if command -v chkconfig >/dev/null 2>&1; then chkconfig sshd on >/dev/null 2>&1 || true fi SSHD_BIN="$(sshd_path)" || exit 32 ensure_sshd_runtime_dir "$SSHD_BIN" -t -f /etc/ssh/sshd_config >/tmp/clicd-sshd-test.log 2>&1 || { cat /tmp/clicd-sshd-test.log exit 32 } ` script = strings.ReplaceAll(script, "__CLICD_PUBKEY_AUTH__", pubkeyValue) if !startService { return script } return script + ` # Disable socket-activated SSH (Ubuntu 24.04 default) to avoid conflicts. if command -v systemctl >/dev/null 2>&1; then systemctl stop ssh.socket 2>/dev/null || true systemctl disable ssh.socket 2>/dev/null || true fi ensure_sshd_runtime_dir if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then systemctl restart ssh >/dev/null 2>&1 || systemctl restart sshd >/dev/null 2>&1 || true fi service ssh restart >/dev/null 2>&1 || service sshd restart >/dev/null 2>&1 || rc-service sshd restart >/dev/null 2>&1 || /etc/init.d/ssh restart >/dev/null 2>&1 || /etc/init.d/sshd restart >/dev/null 2>&1 || true ensure_sshd_runtime_dir for i in 1 2 3 4 5; do if (ss -ltn 2>/dev/null || netstat -tln 2>/dev/null) | grep -Eq '(^|[[:space:]])[^[:space:]]*:22[[:space:]]'; then exit 0 fi if pgrep -x sshd >/dev/null 2>&1; then exit 0 fi sleep 1 done if ! (ss -ltn 2>/dev/null || netstat -tln 2>/dev/null) | grep -Eq '(^|[[:space:]])[^[:space:]]*:22[[:space:]]'; then pkill -x sshd >/dev/null 2>&1 || killall sshd >/dev/null 2>&1 || true rm -f /run/sshd.pid /var/run/sshd.pid ensure_sshd_runtime_dir "$SSHD_BIN" -f /etc/ssh/sshd_config >/dev/null 2>&1 || exit 32 fi for i in 1 2 3 4 5; do if (ss -ltn 2>/dev/null || netstat -tln 2>/dev/null) | grep -Eq '(^|[[:space:]])[^[:space:]]*:22[[:space:]]'; then exit 0 fi sleep 1 done pgrep -x sshd >/dev/null 2>&1 || exit 33 ` } // ResetSSHPassword resets the root password of a container func (m *Manager) ResetSSHPassword(id int, password string) (string, error) { c := config.FindContainer(id) if c == nil { return "", fmt.Errorf("container not found: %d", id) } lxcName := c.LxcName() newPassword := strings.TrimSpace(password) if newPassword == "" { newPassword = generateRandomString(16) } if c.Status == "running" { c.SSHPassword = newPassword config.SaveConfig() if err := m.EnsureSSH(id); err != nil { return "", err } } else { if err := m.ensureDiskImageMounted(lxcName); err != nil { return "", err } rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") if err := m.preconfigureSSH(rootfsPath, c.Template, ""); err != nil { return "", fmt.Errorf("failed to configure SSH: %v", err) } if err := m.setRootfsPassword(rootfsPath, newPassword); err != nil { return "", fmt.Errorf("failed to set password: %v", err) } c.SSHPassword = newPassword config.SaveConfig() } return newPassword, nil } func (m *Manager) rootfsCommand(rootfsPath string, args ...string) (*exec.Cmd, error) { cleanRootfsPath, err := m.safeRootfsPath(rootfsPath) 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 { uidBase, gidBase, mapErr := unprivilegedIDMap() if mapErr == nil { cmdArgs := []string{ "-m", fmt.Sprintf("u:0:%d:65536", uidBase), "-m", fmt.Sprintf("g:0:%d:65536", gidBase), "--", "chroot", "--", cleanRootfsPath, } cmdArgs = append(cmdArgs, safeArgs...) return exec.Command("lxc-usernsexec", cmdArgs...), nil } } cmdArgs := append([]string{"--", cleanRootfsPath}, safeArgs...) return exec.Command("chroot", cmdArgs...), nil } func (m *Manager) runRootfsCommand(rootfsPath string, args ...string) error { cmd, err := m.rootfsCommand(rootfsPath, args...) if err != nil { return err } 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 (m *Manager) installRootAuthorizedKey(rootfsPath, publicKey string) error { key, err := NormalizeSSHPublicKey(publicKey) if err != nil { return err } if key == "" { return nil } sshDir := filepath.Join(rootfsPath, "root", ".ssh") if err := os.MkdirAll(sshDir, 0700); err != nil { return err } authPath := filepath.Join(sshDir, "authorized_keys") existing, _ := os.ReadFile(authPath) lines := strings.Split(string(existing), "\n") for _, line := range lines { if strings.TrimSpace(line) == key { _ = os.Chmod(sshDir, 0700) _ = os.Chmod(authPath, 0600) _ = os.Chown(sshDir, 0, 0) _ = os.Chown(authPath, 0, 0) return nil } } content := strings.TrimRight(string(existing), "\r\n") if content != "" { content += "\n" } content += key + "\n" if err := os.WriteFile(authPath, []byte(content), 0600); err != nil { return err } _ = os.Chmod(sshDir, 0700) _ = os.Chmod(authPath, 0600) _ = os.Chown(sshDir, 0, 0) _ = os.Chown(authPath, 0, 0) 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") } if !filepath.IsAbs(rootfsPath) { return "", fmt.Errorf("rootfs path must be absolute: %s", rootfsPath) } cleanRootfsPath := filepath.Clean(rootfsPath) cleanLxcPath, err := filepath.Abs(m.LxcPath) if err != nil { return "", fmt.Errorf("failed to resolve LXC path: %v", err) } cleanLxcPath = filepath.Clean(cleanLxcPath) if cleanRootfsPath == cleanLxcPath { return "", fmt.Errorf("refusing LXC base path as rootfs: %s", cleanRootfsPath) } if filepath.Base(cleanRootfsPath) != "rootfs" { return "", fmt.Errorf("refusing non-rootfs path: %s", cleanRootfsPath) } if filepath.Dir(cleanRootfsPath) == cleanLxcPath { return "", fmt.Errorf("refusing rootfs directly under LXC path: %s", cleanRootfsPath) } rel, err := filepath.Rel(cleanLxcPath, cleanRootfsPath) if err != nil { return "", fmt.Errorf("failed to validate rootfs path: %v", err) } 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 } func (m *Manager) cleanupContainerStorage(lxcName string) error { containerDir := filepath.Join(m.LxcPath, lxcName) cleanPath, err := filepath.Abs(containerDir) if err != nil { return fmt.Errorf("failed to resolve container path: %v", err) } basePath, err := filepath.Abs(m.LxcPath) if err != nil { return fmt.Errorf("failed to resolve LXC path: %v", err) } if cleanPath == basePath || !strings.HasPrefix(cleanPath, basePath+string(os.PathSeparator)) { return fmt.Errorf("refusing to remove unsafe container path: %s", cleanPath) } if _, err := os.Stat(cleanPath); os.IsNotExist(err) { return nil } exec.Command("lxc-stop", "-n", lxcName, "-k").Run() exec.Command("lxc-destroy", "-n", lxcName, "-f").Run() m.detachContainerMounts(cleanPath) m.detachContainerLoopDevices(cleanPath) rootfs := filepath.Join(cleanPath, "rootfs") exec.Command("umount", "-R", "-l", rootfs).Run() m.detachContainerMounts(cleanPath) m.detachContainerLoopDevices(cleanPath) if err := os.RemoveAll(cleanPath); err != nil { return fmt.Errorf("failed to remove container directory %s: %v", cleanPath, err) } return nil } func (m *Manager) detachContainerMounts(containerDir string) { out, err := exec.Command("findmnt", "-R", "-n", "-o", "TARGET", containerDir).Output() if err != nil { return } lines := strings.Split(strings.TrimSpace(string(out)), "\n") for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 { lines[i], lines[j] = lines[j], lines[i] } for _, line := range lines { target := strings.TrimSpace(line) if target == "" { continue } exec.Command("umount", "-R", "-l", target).Run() } } func (m *Manager) detachContainerLoopDevices(containerDir string) { out, err := exec.Command("losetup", "-j", filepath.Join(containerDir, "rootfs.img")).Output() if err != nil { return } for _, line := range strings.Split(string(out), "\n") { line = strings.TrimSpace(line) if line == "" { continue } device := strings.TrimSuffix(strings.SplitN(line, ":", 2)[0], ":") if device != "" { exec.Command("losetup", "-d", device).Run() } } } // GetContainerStatus gets container running status func (m *Manager) GetContainerStatus(lxcName string) (string, error) { cmd := exec.Command("lxc-info", "-n", lxcName, "-sH") output, err := cmd.Output() if err != nil { cmd2 := exec.Command("lxc-info", "-n", lxcName, "-s") output2, err2 := cmd2.Output() if err2 != nil { return "unknown", err2 } output = output2 } status := strings.TrimSpace(string(output)) upper := strings.ToUpper(status) if strings.Contains(upper, "RUNNING") { return "running", nil } return "stopped", nil } // GetContainerIP gets container IP address func (m *Manager) GetContainerIP(lxcName string) (string, error) { cmd := exec.Command("lxc-info", "-n", lxcName, "-iH") output, err := cmd.Output() if err != nil { cmd2 := exec.Command("lxc-info", "-n", lxcName, "-i") output2, err2 := cmd2.Output() if err2 != nil { return "", err2 } output = output2 } ip := strings.TrimSpace(string(output)) // Always prefer IPv4; IPv6 addresses break WebSSH and port forwarding. re := regexp.MustCompile(`(\d+\.\d+\.\d+\.\d+)`) matches := re.FindStringSubmatch(ip) if len(matches) > 1 { return matches[1], nil } // If no IPv4 found, try lxc-attach as fallback (DHCP may be delayed) attachCmd := exec.Command("lxc-attach", "-n", lxcName, "--", "sh", "-c", "ip -4 addr show eth0 2>/dev/null | grep -oP 'inet \\K[\\d.]+' || true") if attachOut, attachErr := attachCmd.Output(); attachErr == nil { v4 := strings.TrimSpace(string(attachOut)) if v4 != "" { return v4, nil } } return "", fmt.Errorf("no IPv4 address found for %s (IPv6 is disabled for containers)", lxcName) } // ListContainers lists all LXC containers and updates statuses func (m *Manager) ListContainers() ([]config.Container, error) { containers := config.AppConfig.Containers for i := range containers { if containers[i].IsKVM() { continue } status, err := m.GetContainerStatus(containers[i].LxcName()) if err == nil { containers[i].Status = status } if status == "running" { ip, err := m.GetContainerIP(containers[i].LxcName()) if err == nil { containers[i].IP = ip } } } return containers, nil } // ImportExistingClicdContainers imports existing LXC containers into the CLICD // config. Native CLICD containers keep ct-{id}; arbitrary LXC names are stored // in Container.LXCName so Web and CLI can manage the same imported container. func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) { entries, err := os.ReadDir(m.LxcPath) if err != nil { return nil, err } existingIDs := make(map[int]bool) existingNames := make(map[string]bool) existingLXCNames := make(map[string]bool) maxID := config.AppConfig.NextContainerID - 1 for _, c := range config.AppConfig.Containers { existingIDs[c.ID] = true existingNames[c.Name] = true existingLXCNames[c.LxcName()] = true if c.ID > maxID { maxID = c.ID } } re := regexp.MustCompile(`^ct-([0-9]+)$`) imported := make([]config.Container, 0) for _, entry := range entries { if !entry.IsDir() { continue } lxcName := entry.Name() if existingLXCNames[lxcName] { continue } id := 0 if matches := re.FindStringSubmatch(lxcName); len(matches) == 2 { if parsed, err := strconv.Atoi(matches[1]); err == nil && parsed > 0 && !existingIDs[parsed] { id = parsed } } if id == 0 { id = maxID + 1 for existingIDs[id] { id++ } } name := lxcName if existingNames[name] { name = fmt.Sprintf("imported-%d", id) } status, err := m.GetContainerStatus(lxcName) if err != nil || status == "" { status = "unknown" } c := config.Container{ ID: id, UUID: config.NewContainerUUID(), Name: name, LXCName: lxcName, Virtualization: config.VirtualizationLXC, Template: "imported", VCPU: 1, RAMMB: 512, DiskGB: 10, NetworkBWMbps: 100, NetworkDownMbps: 100, NetworkUpMbps: 100, MonthlyTrafficGB: 1000, TrafficMode: "total", Status: status, CreatedAt: time.Now().Format(time.RFC3339), PortMappingLimit: 2, SnapshotLimit: config.DefaultSnapshotLimit, } if status == "running" { if ip, err := m.GetContainerIP(lxcName); err == nil { c.IP = ip } } config.AppConfig.Containers = append(config.AppConfig.Containers, c) imported = append(imported, c) existingIDs[id] = true existingNames[name] = true existingLXCNames[lxcName] = true if id > maxID { maxID = id } } if len(imported) > 0 { config.AppConfig.NextContainerID = maxID + 1 if err := config.SaveConfig(); err != nil { return nil, err } } 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, authConfig ...ContainerConfig) error { c := config.FindContainer(id) if c == nil { return fmt.Errorf("container not found: %d", id) } tmpl := FindTemplate(templateID) if tmpl == nil { return fmt.Errorf("template not found: %s", templateID) } authCfg := ContainerConfig{SSHAuthMode: SSHAuthKeep} if len(authConfig) > 0 { authCfg = authConfig[0] } sshAccess, err := ResolveReinstallSSHAccess(c.SSHPassword, authCfg) if err != nil { return err } lxcName := c.LxcName() // Stop the container first status, _ := m.GetContainerStatus(lxcName) if status == "running" { m.StopContainer(id) } // Clean port mappings temporarily m.CleanPortMappings(id) CleanFirewallRules(id) // 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 { return err } // Re-apply resource limits and mandatory security hardening. cfg := ContainerConfig{ Name: c.Name, TemplateID: templateID, VCPU: c.VCPU, RAMMB: c.RAMMB, DiskGB: c.DiskGB, NetworkBWMbps: c.NetworkBWMbps, NetworkDownMbps: c.NetworkDownMbps, NetworkUpMbps: c.NetworkUpMbps, MonthlyTrafficGB: c.MonthlyTrafficGB, IOSpeedMBps: c.IOSpeedMBps, IOReadMBps: c.IOReadMBps, IOWriteMBps: c.IOWriteMBps, AssignIPv6: c.IPv6 != "" || len(c.IPv6Addresses) > 0, ExpiresAt: c.ExpiresAt, } if err := m.applyResourceLimits(lxcName, cfg); err != nil { return err } if c.IPv6 != "" || len(c.IPv6Addresses) > 0 { c.NormalizeNetworkAssignments() if err := m.applyIPv6Config(lxcName, c.IPv6AddressStrings()...); err != nil { return err } } // Set root password and pre-configure network/SSH via chroot. rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") m.preconfigureNetwork(rootfsPath, templateID) if c.IPv6 != "" || len(c.IPv6Addresses) > 0 { if err := installContainerIPv6Init(rootfsPath, c.IPv6AddressStrings()...); err != nil { fmt.Printf("Warning: failed to install IPv6 init in %s after reinstall: %v\n", lxcName, err) } } c.SSHPassword = sshAccess.Password if err := m.preconfigureSSH(rootfsPath, templateID, sshAccess.Mode); err != nil { fmt.Printf("Warning: failed to pre-configure SSH in %s after reinstall: %v\n", lxcName, err) } if sshAccess.PublicKey != "" { if err := m.installRootAuthorizedKey(rootfsPath, sshAccess.PublicKey); err != nil { return fmt.Errorf("failed to install SSH public key: %v", err) } } if err := m.shiftRootfsForUnprivileged(lxcName); err != nil { return err } 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) } // Update template and keep everything else the same c.Template = templateID c.SSHHostKey = "" c.Status = "running" config.SaveConfig() // Start the container to trigger ensureSSH if err := m.ensureDiskImageMounted(lxcName); err != nil { c.Status = "stopped" config.SaveConfig() return err } logFile, consoleLog, output, err := m.startLXCContainerDaemon(lxcName) if err != nil { fmt.Printf("Warning: failed to start container after reinstall: %v\n", err) c.Status = "stopped" config.SaveConfig() return fmt.Errorf("reinstalled but failed to start: %v, output: %s, lxc log: %s, console: %s", err, string(output), tailFile(logFile, 80), tailFile(consoleLog, 80)) } if err := m.waitForLXCStartup(lxcName, logFile, consoleLog); err != nil { c.Status = "stopped" config.SaveConfig() return fmt.Errorf("reinstalled but container did not stay running: %v", err) } // Wait for network and install SSH time.Sleep(5 * time.Second) var ip string for retry := 0; retry < 5; retry++ { ip, _ = m.GetContainerIP(lxcName) if ip != "" { break } time.Sleep(2 * time.Second) } if ip != "" { c.IP = ip config.SaveConfig() } if ip != "" { if err := m.EnsureSSH(id); err != nil { return err } } // Apply bandwidth limit after reinstall config.NormalizeContainerResourceAliases(c) m.applyBandwidthLimit(c.LxcName(), c.NetworkDownMbps, c.NetworkUpMbps) if c.IPv6 != "" || len(c.IPv6Addresses) > 0 { if err := m.ApplyIPv6(id); err != nil { fmt.Printf("Warning: failed to apply IPv6 after reinstall: %v\n", err) } } fmt.Printf("Container %d (%s) reinstalled with %s\n", id, c.Name, templateID) return nil } // GetResourceUsage returns resource usage info for a container by ID. // Rates come from the background monitor goroutine (no shared-state races). func (m *Manager) GetResourceUsage(id int) (map[string]interface{}, error) { c := config.FindContainer(id) if c == nil { return nil, fmt.Errorf("container not found: %d", id) } lxcName := c.LxcName() usage := make(map[string]interface{}) // Read raw values load1, load5, load15 := m.getContainerLoadAvg(lxcName) usage["load1"] = load1 usage["load5"] = load5 usage["load15"] = load15 memUsage := readIntCommand(fmt.Sprintf( "cat /sys/fs/cgroup/lxc/%[1]s/memory.current 2>/dev/null || "+ "cat /sys/fs/cgroup/lxc.payload.%[1]s/memory.current 2>/dev/null || "+ "cat /sys/fs/cgroup/memory/lxc/%[1]s/memory.usage_in_bytes 2>/dev/null || echo 0", shellQuote(lxcName))) usage["memory_usage_bytes"] = memUsage cpuUsec := uint64(readIntCommand(fmt.Sprintf( "(cat /sys/fs/cgroup/lxc/%[1]s/cpu.stat 2>/dev/null || "+ "cat /sys/fs/cgroup/lxc.payload.%[1]s/cpu.stat 2>/dev/null) | "+ "awk '/usage_usec/ {print $2; found=1} END {if (!found) print 0}'", shellQuote(lxcName)))) usage["cpu_usage_usec"] = cpuUsec diskUsage := readIntCommand(fmt.Sprintf("du -s -B1 %s 2>/dev/null | awk '{print $1}' || echo 0", shellQuote(filepath.Join(m.LxcPath, lxcName, "rootfs")))) usage["disk_usage_bytes"] = diskUsage rxBytes, txBytes := m.getContainerNetworkBytes(lxcName) usage["network_rx_bytes"] = rxBytes usage["network_tx_bytes"] = txBytes readBytes, writeBytes := m.getContainerDiskIOBytes(lxcName) usage["disk_read_bytes"] = readBytes usage["disk_write_bytes"] = writeBytes // Read rates from background monitor cache (single writer, no race) usageMu.RLock() rate, hasRate := rateCache[lxcName] usageMu.RUnlock() if hasRate && time.Since(rate.UpdatedAt) < 15*time.Second { usage["cpu_usage_pct"] = rate.CPUPct usage["network_rx_bps"] = rate.RXBps usage["network_tx_bps"] = rate.TXBps usage["disk_read_bps"] = rate.ReadBps usage["disk_write_bps"] = rate.WriteBps } else { // Cache miss or stale — return zeros (monitor will populate soon) usage["cpu_usage_pct"] = 0.0 usage["network_rx_bps"] = 0.0 usage["network_tx_bps"] = 0.0 usage["disk_read_bps"] = 0.0 usage["disk_write_bps"] = 0.0 } return usage, nil } func (m *Manager) getContainerLoadAvg(lxcName string) (float64, float64, float64) { pid := m.getContainerInitPID(lxcName) if pid == "" { return 0, 0, 0 } out, err := exec.Command("nsenter", "-t", pid, "-m", "-p", "cat", "/proc/loadavg").Output() if err != nil { return 0, 0, 0 } parts := strings.Fields(string(out)) if len(parts) < 3 { return 0, 0, 0 } load1, _ := strconv.ParseFloat(parts[0], 64) load5, _ := strconv.ParseFloat(parts[1], 64) load15, _ := strconv.ParseFloat(parts[2], 64) return load1, load5, load15 } func (m *Manager) getContainerNetworkBytes(lxcName string) (uint64, uint64) { pid := m.getContainerInitPID(lxcName) if pid == "" { return 0, 0 } return readProcNetDev(fmt.Sprintf("/proc/%s/net/dev", pid)) } // readProcNetDev parses /proc/PID/net/dev directly (no shell/awk fork). func readProcNetDev(path string) (uint64, uint64) { data, err := os.ReadFile(path) if err != nil { return 0, 0 } var rx, tx uint64 scanner := bufio.NewScanner(strings.NewReader(string(data))) for scanner.Scan() { line := scanner.Text() // Skip header lines if strings.Contains(line, "|") || strings.Contains(line, "face") || strings.TrimSpace(line) == "" { continue } fields := strings.Fields(line) // Fields: face | rx_bytes rx_packets rx_errs rx_drop rx_fifo rx_frame rx_compressed rx_multicast | tx_bytes tx_packets tx_errs tx_drop tx_fifo tx_colls tx_carrier tx_compressed // Skip loopback (face starts with "lo") if len(fields) < 10 { continue } if strings.HasPrefix(fields[0], "lo") { continue } r, _ := strconv.ParseUint(fields[1], 10, 64) t, _ := strconv.ParseUint(fields[9], 10, 64) rx += r tx += t } return rx, tx } func (m *Manager) getContainerDiskIOBytes(lxcName string) (uint64, uint64) { pid := m.getContainerInitPID(lxcName) if pid == "" { return 0, 0 } data, err := os.ReadFile(fmt.Sprintf("/proc/%s/io", pid)) if err != nil { return 0, 0 } var readBytes, writeBytes uint64 scanner := bufio.NewScanner(strings.NewReader(string(data))) for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "read_bytes:") { val := strings.TrimSpace(strings.TrimPrefix(line, "read_bytes:")) readBytes, _ = strconv.ParseUint(val, 10, 64) } else if strings.HasPrefix(line, "write_bytes:") { val := strings.TrimSpace(strings.TrimPrefix(line, "write_bytes:")) writeBytes, _ = strconv.ParseUint(val, 10, 64) } } return readBytes, writeBytes } func (m *Manager) getContainerInitPID(lxcName string) string { cmd := exec.Command("lxc-info", "-n", lxcName, "-pH") out, err := cmd.Output() if err != nil { return "" } return strings.TrimSpace(string(out)) } // readCgroupFile tries each path template in order, reads the file directly (no shell), // and returns the first valid int64 value. func readCgroupFile(name string, paths ...string) int64 { for _, tmpl := range paths { data, err := os.ReadFile(fmt.Sprintf(tmpl, name)) if err != nil { continue } val, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64) if err == nil && val > 0 { return val } } return 0 } // readCgroupCPUUsec tries each path template, reads cpu.stat, and extracts usage_usec. func readCgroupCPUUsec(name string, paths ...string) uint64 { for _, tmpl := range paths { data, err := os.ReadFile(fmt.Sprintf(tmpl, name)) if err != nil { continue } scanner := bufio.NewScanner(strings.NewReader(string(data))) for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "usage_usec ") { val, err := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "usage_usec")), 10, 64) if err == nil { return val } } } } return 0 } // getNetworkBytesForPID reads /proc/PID/net/dev for a given PID (no lxc-info needed). func getNetworkBytesForPID(pid string) (uint64, uint64) { if pid == "" { return 0, 0 } return readProcNetDev(fmt.Sprintf("/proc/%s/net/dev", pid)) } // getDiskIOBytesForPID reads /proc/PID/io for a given PID (no lxc-info needed). func getDiskIOBytesForPID(pid string) (uint64, uint64) { if pid == "" { return 0, 0 } data, err := os.ReadFile(fmt.Sprintf("/proc/%s/io", pid)) if err != nil { return 0, 0 } var readBytes, writeBytes uint64 scanner := bufio.NewScanner(strings.NewReader(string(data))) for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "read_bytes:") { readBytes, _ = strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "read_bytes:")), 10, 64) } else if strings.HasPrefix(line, "write_bytes:") { writeBytes, _ = strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "write_bytes:")), 10, 64) } } return readBytes, writeBytes } // getContainerUptimeSeconds returns how long the container has been running (in seconds). func (m *Manager) getContainerUptimeSeconds(lxcName string) float64 { pid := m.getContainerInitPID(lxcName) if pid == "" { return 0 } // Read process starttime from /proc/PID/stat (field 22 after the closing paren) statContent := readFile(fmt.Sprintf("/proc/%s/stat", pid)) if statContent == "" { return 0 } // Find the closing paren of comm field, then we need field 22 after that parenIdx := strings.LastIndex(statContent, ")") if parenIdx < 0 { return 0 } fields := strings.Fields(statContent[parenIdx+2:]) if len(fields) < 20 { return 0 } // starttime is the 20th field after the comm (since state=0, ppid=1, ...) starttime := fields[19] ticks, err := strconv.ParseInt(starttime, 10, 64) if err != nil { return 0 } // Get system uptime uptimeContent := readFile("/proc/uptime") if uptimeContent == "" { return 0 } uptimeParts := strings.Fields(uptimeContent) if len(uptimeParts) < 1 { return 0 } systemUptime, err := strconv.ParseFloat(uptimeParts[0], 64) if err != nil { return 0 } // Get clock ticks per second (usually 100) clkTck := int64(100) clkTckContent := readFile("/proc/stat") if clkTckContent != "" { for _, line := range strings.Split(clkTckContent, "\n") { if strings.HasPrefix(line, "btime ") { // Can use this to double-check but not needed break } } } processUptime := float64(ticks) / float64(clkTck) containerUptime := systemUptime - processUptime if containerUptime < 0 { containerUptime = 0 } return containerUptime } func readFile(path string) string { data, err := os.ReadFile(path) if err != nil { return "" } return string(data) } func tailFile(path string, maxLines int) string { data, err := os.ReadFile(path) if err != nil { return "" } lines := strings.Split(strings.TrimSpace(string(data)), "\n") if len(lines) > maxLines { lines = lines[len(lines)-maxLines:] } return strings.Join(lines, "\n") } // GetContainerMemoryCgroupUsage returns memory.usage_in_bytes for a container, for usage-only queries. func (m *Manager) GetContainerMemoryCgroupUsage(id int) int64 { c := config.FindContainer(id) if c == nil { return 0 } return readIntCommand(fmt.Sprintf( "cat /sys/fs/cgroup/lxc/%[1]s/memory.current 2>/dev/null || "+ "cat /sys/fs/cgroup/lxc.payload.%[1]s/memory.current 2>/dev/null || "+ "cat /sys/fs/cgroup/memory/lxc/%[1]s/memory.usage_in_bytes 2>/dev/null || echo 0", shellQuote(c.LxcName()))) } func shellQuote(s string) string { return fmt.Sprintf("'%s'", strings.ReplaceAll(s, "'", "'\\''")) } func readIntCommand(cmdStr string) int64 { cmd := exec.Command("sh", "-c", cmdStr) out, err := cmd.Output() if err != nil { return 0 } val, _ := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64) return val } func generateRandomString(length int) string { b := make([]byte, length) rand.Read(b) return hex.EncodeToString(b)[:length] } // lastTrafficSnapshot stores previous network byte counts for delta calculation var ( lastTrafficSnapshot = map[string]trafficSample{} lastTrafficSnapshotMu sync.Mutex ) type trafficSample struct { RXBytes uint64 TXBytes uint64 } // AccumulateTraffic tracks container network traffic usage (delta-based, called periodically) func (m *Manager) AccumulateTraffic() { currentMonth := time.Now().Format("2006-01") lastTrafficSnapshotMu.Lock() defer lastTrafficSnapshotMu.Unlock() changed := false for i := range config.AppConfig.Containers { c := &config.AppConfig.Containers[i] if c.Status != "running" { // Remove snapshot for stopped containers delete(lastTrafficSnapshot, c.LxcName()) continue } if c.IsKVM() { continue } // Reset if new month if c.TrafficResetDate != currentMonth { c.TrafficUsedRX = 0 c.TrafficUsedTX = 0 c.TrafficResetDate = currentMonth delete(lastTrafficSnapshot, c.LxcName()) changed = true } rx, tx := m.getContainerNetworkBytes(c.LxcName()) prev, exists := lastTrafficSnapshot[c.LxcName()] // Only add the DELTA (increment since last snapshot) if exists && rx >= prev.RXBytes && tx >= prev.TXBytes { deltaRX := int64(rx - prev.RXBytes) deltaTX := int64(tx - prev.TXBytes) if deltaRX > 0 || deltaTX > 0 { c.TrafficUsedRX += deltaRX c.TrafficUsedTX += deltaTX changed = true } } lastTrafficSnapshot[c.LxcName()] = trafficSample{RXBytes: rx, TXBytes: tx} } if changed { config.SaveConfig() } } // GetTrafficInfo returns traffic usage info for a container func (m *Manager) GetTrafficInfo(id int) map[string]interface{} { c := config.FindContainer(id) if c == nil { return nil } currentMonth := time.Now().Format("2006-01") if c.TrafficResetDate != currentMonth { c.TrafficUsedRX = 0 c.TrafficUsedTX = 0 c.TrafficResetDate = currentMonth config.SaveConfig() } // Accumulate new delta since last call lastTrafficSnapshotMu.Lock() if c.Status == "running" { rx, tx := m.getContainerNetworkBytes(c.LxcName()) prev, exists := lastTrafficSnapshot[c.LxcName()] if exists && rx >= prev.RXBytes && tx >= prev.TXBytes { c.TrafficUsedRX += int64(rx - prev.RXBytes) c.TrafficUsedTX += int64(tx - prev.TXBytes) config.SaveConfig() } lastTrafficSnapshot[c.LxcName()] = trafficSample{RXBytes: rx, TXBytes: tx} } lastTrafficSnapshotMu.Unlock() totalUsed := c.TrafficUsedRX + c.TrafficUsedTX limitGB := 0 usedPct := 0.0 if c.TrafficMode == "in_out" { limitGB = c.TrafficInGB + c.TrafficOutGB inUsed := float64(c.TrafficUsedRX) outUsed := float64(c.TrafficUsedTX) inLimit := float64(c.TrafficInGB) * 1073741824 outLimit := float64(c.TrafficOutGB) * 1073741824 inPct := 0.0 outPct := 0.0 if c.TrafficInGB > 0 { inPct = inUsed / inLimit * 100 } if c.TrafficOutGB > 0 { outPct = outUsed / outLimit * 100 } usedPct = inPct if outPct > usedPct { usedPct = outPct } } else { limitGB = c.MonthlyTrafficGB if limitGB > 0 { usedPct = float64(totalUsed) / float64(limitGB*1073741824) * 100 } } return map[string]interface{}{ "total_used_bytes": totalUsed, "rx_used_bytes": c.TrafficUsedRX, "tx_used_bytes": c.TrafficUsedTX, "mode": c.TrafficMode, "limit_gb": limitGB, "in_limit_gb": c.TrafficInGB, "out_limit_gb": c.TrafficOutGB, "used_pct": usedPct, "reset_date": c.TrafficResetDate, } } // ToJSON converts data to JSON bytes func ToJSON(v interface{}) ([]byte, error) { return json.MarshalIndent(v, "", " ") }