· 修复了一些已知问题

· 增加了局域网DHCP IP分配适配
· 完善了多盘兼容支持 #17
This commit is contained in:
MengMengCode
2026-07-18 19:44:32 +08:00
parent 3dabd93d2f
commit ebba97f1d6
34 changed files with 3309 additions and 417 deletions
+188 -45
View File
@@ -18,6 +18,7 @@ import (
"strconv"
"strings"
"sync"
"syscall"
"time"
"clicd/internal/config"
@@ -227,44 +228,53 @@ func NewManager() *Manager {
// 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"`
LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"`
LANInterface string `json:"lan_interface,omitempty"`
LANIPv4Address string `json:"lan_ipv4_address,omitempty"`
LANIPv4PrefixLen int `json:"lan_ipv4_prefix_len,omitempty"`
LANIPv4Gateway string `json:"lan_ipv4_gateway,omitempty"`
SnapshotLimit int `json:"snapshot_limit"`
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
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"`
Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
TemplateID string `json:"template_id"`
StoragePoolID string `json:"storage_pool_id,omitempty"`
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"`
LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"`
LANInterface string `json:"lan_interface,omitempty"`
LANIPv4Address string `json:"lan_ipv4_address,omitempty"`
LANIPv4PrefixLen int `json:"lan_ipv4_prefix_len,omitempty"`
LANIPv4Gateway string `json:"lan_ipv4_gateway,omitempty"`
SnapshotLimit int `json:"snapshot_limit"`
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
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"`
Progress func(stage, detail string) `json:"-"`
}
// ReportProgress reports a best-effort creation phase to the task queue.
func (cfg ContainerConfig) ReportProgress(stage, detail string) {
if cfg.Progress != nil {
cfg.Progress(stage, detail)
}
}
func (cfg *ContainerConfig) NormalizeResourceAliases() {
@@ -324,6 +334,7 @@ func (cfg ContainerConfig) WantsLANIPv4() bool {
// CreateContainer creates a new LXC container. Uses ct-{id} as LXC name internally.
func (m *Manager) CreateContainer(cfg ContainerConfig) error {
cfg.NormalizeResourceAliases()
cfg.ReportProgress("preparing", "检查模板与创建参数")
tmpl := FindTemplate(cfg.TemplateID)
if tmpl == nil {
return fmt.Errorf("template not found: %s", cfg.TemplateID)
@@ -369,6 +380,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
fmt.Printf("Creating LXC container: %s (ID=%d, template: %s/%s/%s)\n",
lxcName, id, tmpl.Distro, tmpl.Release, tmpl.Arch)
cfg.ReportProgress("rootfs", "下载模板并创建基础文件系统")
args := []string{"-n", lxcName, "-t", "download", "--",
"-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch}
if tmpl.Variant != "" {
@@ -380,10 +392,19 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
return fmt.Errorf("lxc-create failed: %v, output: %s", err, string(output))
}
cfg.ReportProgress("storage", "复制容器数据到存储磁盘")
storagePoolID, storagePath, err := m.moveContainerToStoragePool(lxcName, cfg.StoragePoolID)
if err != nil {
_ = m.cleanupContainerStorage(lxcName)
return err
}
cfg.ReportProgress("disk", "创建容量限制磁盘并复制 rootfs")
if err := m.applyDiskLimit(lxcName, cfg.DiskGB); err != nil {
_ = m.cleanupContainerStorage(lxcName)
return err
}
cfg.ReportProgress("resources", "配置 CPU、内存与网络限制")
if cfg.WantsLANIPv4() {
iface, err := m.applyLANIPv4Config(lxcName, cfg)
if err != nil {
@@ -399,6 +420,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
return err
}
cfg.ReportProgress("addresses", "分配 IPv4、IPv6 与 NAT 端口")
publicIPv4s, err := AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4)
if err != nil {
_ = m.cleanupContainerStorage(lxcName)
@@ -472,6 +494,8 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
Name: cfg.Name,
Virtualization: config.VirtualizationLXC,
LXCName: lxcName,
StoragePoolID: storagePoolID,
StoragePath: storagePath,
Template: cfg.TemplateID,
VCPU: cfg.VCPU,
RAMMB: cfg.RAMMB,
@@ -509,16 +533,19 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
ExpiresAt: cfg.ExpiresAt,
}
container.NormalizeNetworkAssignments()
cfg.ReportProgress("metadata", "保存容器配置")
config.AddContainer(container)
// Pre-configure network and SSH in the rootfs before first boot.
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
cfg.ReportProgress("network", "写入容器网络配置")
m.preconfigureNetwork(rootfsPath, cfg)
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)
}
}
cfg.ReportProgress("ssh", "安装并配置 SSH 服务")
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)
}
@@ -530,6 +557,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
}
}
cfg.ReportProgress("permissions", "转换非特权容器文件权限")
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
_ = m.cleanupContainerStorage(lxcName)
config.RemoveContainer(id)
@@ -538,6 +566,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
// Set root password AFTER shiftRootfsForUnprivileged,
// otherwise /etc/shadow ownership breaks and SSHD cannot authenticate.
cfg.ReportProgress("credentials", "设置容器登录凭据")
if err := m.setRootfsPassword(rootfsPath, sshPassword); err != nil {
fmt.Printf("Warning: failed to set root password in %s: %v\n", lxcName, err)
}
@@ -1128,6 +1157,78 @@ func (m *Manager) applyLoopbackDiskLimit(lxcName string, diskGB int) error {
return nil
}
func (m *Manager) moveContainerToStoragePool(lxcName string, requestedPoolID string) (string, string, error) {
sourceDir := filepath.Join(m.LxcPath, lxcName)
requiredBytes := dirSizeBytes(sourceDir)
pool, err := config.SelectStoragePoolForContent(config.StorageContentLXC, requestedPoolID, requiredBytes)
if err != nil {
return "", "", err
}
targetRoot := filepath.Join(pool.Path, "lxc")
targetDir := filepath.Join(targetRoot, lxcName)
sourceAbs, err := filepath.Abs(sourceDir)
if err != nil {
return "", "", err
}
targetAbs, err := filepath.Abs(targetDir)
if err != nil {
return "", "", err
}
if sourceAbs == targetAbs {
return pool.ID, targetAbs, nil
}
if err := os.MkdirAll(targetRoot, 0755); err != nil {
return "", "", err
}
if _, err := os.Lstat(targetDir); err == nil {
return "", "", fmt.Errorf("target storage directory already exists: %s", targetDir)
}
if err := moveLXCStorageDirectory(sourceDir, targetDir); err != nil {
return "", "", err
}
return pool.ID, targetAbs, nil
}
func moveLXCStorageDirectory(sourceDir, targetDir string) error {
if err := os.Rename(sourceDir, targetDir); err == nil {
if err := os.Symlink(targetDir, sourceDir); err != nil {
_ = os.Rename(targetDir, sourceDir)
return fmt.Errorf("failed to create LXC storage symlink: %v", err)
}
return nil
} else if !errors.Is(err, syscall.EXDEV) {
return fmt.Errorf("failed to move LXC container to storage pool: %v", err)
}
if err := copyTree(sourceDir, targetDir); err != nil {
_ = os.RemoveAll(targetDir)
return fmt.Errorf("failed to copy LXC container to storage pool: %v", err)
}
backupDir := sourceDir + fmt.Sprintf(".storage-move-%d", time.Now().UnixNano())
if err := os.Rename(sourceDir, backupDir); err != nil {
_ = os.RemoveAll(targetDir)
return fmt.Errorf("failed to finalize LXC storage move: %v", err)
}
if err := os.Symlink(targetDir, sourceDir); err != nil {
_ = os.Rename(backupDir, sourceDir)
_ = os.RemoveAll(targetDir)
return fmt.Errorf("failed to create LXC storage symlink: %v", err)
}
if err := os.RemoveAll(backupDir); err != nil {
fmt.Printf("Warning: LXC storage moved but source cleanup failed: %v\n", err)
}
return nil
}
func storagePoolAllowsContent(pool config.StoragePool, content string) bool {
for _, item := range pool.ContentTypes {
if item == content {
return true
}
}
return false
}
func (m *Manager) ensureDiskImageMounted(lxcName string) error {
containerDir := filepath.Join(m.LxcPath, lxcName)
rootfsPath := filepath.Join(containerDir, "rootfs")
@@ -1183,15 +1284,30 @@ func diskImageMounted(lxcName, rootfsPath string) bool {
if err != nil {
return false
}
targetAbs, err := filepath.Abs(strings.TrimSpace(target))
if err != nil {
return false
return sameFilesystemPath(strings.TrimSpace(target), rootfsPath)
}
func sameFilesystemPath(left, right string) bool {
leftInfo, leftErr := os.Stat(left)
rightInfo, rightErr := os.Stat(right)
if leftErr == nil && rightErr == nil && os.SameFile(leftInfo, rightInfo) {
return true
}
rootfsAbs, err := filepath.Abs(rootfsPath)
if err != nil {
return false
canonical := func(path string) (string, error) {
absolute, err := filepath.Abs(path)
if err != nil {
return "", err
}
resolved, err := filepath.EvalSymlinks(absolute)
if err == nil {
absolute = resolved
}
return filepath.Clean(absolute), nil
}
return targetAbs == rootfsAbs
leftPath, leftErr := canonical(left)
rightPath, rightErr := canonical(right)
return leftErr == nil && rightErr == nil && leftPath == rightPath
}
func applyXFSProjectQuota(rootfsPath, lxcName string, diskGB int) error {
@@ -2745,6 +2861,17 @@ func (m *Manager) cleanupContainerStorage(lxcName string) error {
if _, err := os.Stat(cleanPath); os.IsNotExist(err) {
return nil
}
var linkedTarget string
if info, err := os.Lstat(cleanPath); err == nil && info.Mode()&os.ModeSymlink != 0 {
if target, err := os.Readlink(cleanPath); err == nil {
if !filepath.IsAbs(target) {
target = filepath.Join(filepath.Dir(cleanPath), target)
}
if abs, err := filepath.Abs(target); err == nil && lxcStorageTargetAllowed(abs) {
linkedTarget = abs
}
}
}
exec.Command("lxc-stop", "-n", lxcName, "-k").Run()
exec.Command("lxc-destroy", "-n", lxcName, "-f").Run()
m.detachContainerMounts(cleanPath)
@@ -2756,9 +2883,25 @@ func (m *Manager) cleanupContainerStorage(lxcName string) error {
if err := os.RemoveAll(cleanPath); err != nil {
return fmt.Errorf("failed to remove container directory %s: %v", cleanPath, err)
}
if linkedTarget != "" {
m.detachContainerMounts(linkedTarget)
m.detachContainerLoopDevices(linkedTarget)
_ = os.RemoveAll(linkedTarget)
}
return nil
}
func lxcStorageTargetAllowed(path string) bool {
for _, pool := range config.StoragePoolsForContent(config.StorageContentLXC) {
root := filepath.Join(pool.Path, "lxc")
rel, err := filepath.Rel(root, path)
if err == nil && rel != "." && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) {
return true
}
}
return false
}
func (m *Manager) detachContainerMounts(containerDir string) {
out, err := exec.Command("findmnt", "-R", "-n", "-o", "TARGET", containerDir).Output()
if err != nil {
+23
View File
@@ -121,6 +121,29 @@ func TestManagedPrlimitLinesDoNotSetNproc(t *testing.T) {
}
}
func TestSameFilesystemPathResolvesContainerStorageSymlink(t *testing.T) {
base := t.TempDir()
storageContainer := filepath.Join(base, "storage", "ct-1")
rootfs := filepath.Join(storageContainer, "rootfs")
if err := os.MkdirAll(rootfs, 0755); err != nil {
t.Fatal(err)
}
lxcPath := filepath.Join(base, "lxc")
if err := os.MkdirAll(lxcPath, 0755); err != nil {
t.Fatal(err)
}
containerLink := filepath.Join(lxcPath, "ct-1")
if err := os.Symlink(storageContainer, containerLink); err != nil {
t.Skipf("directory symlinks are unavailable: %v", err)
}
linkedRootfs := filepath.Join(containerLink, "rootfs")
if !sameFilesystemPath(rootfs, linkedRootfs) {
t.Fatalf("sameFilesystemPath(%q, %q) = false, want true", rootfs, linkedRootfs)
}
}
func TestAppendMissingSeccompRulesAddsFutexMitigationOnce(t *testing.T) {
base := "2\ndenylist\n[all]\nopen_by_handle_at errno 1\n"
+44 -6
View File
@@ -16,7 +16,7 @@ import (
var snapshotMu sync.Mutex
func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int, storagePoolID ...string) (config.Snapshot, error) {
snapshotMu.Lock()
defer snapshotMu.Unlock()
@@ -42,12 +42,21 @@ func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotat
if _, err := os.Stat(containerDir); err != nil {
return config.Snapshot{}, fmt.Errorf("container storage not found: %v", err)
}
pool, err := config.SelectStoragePoolForContent(
config.StorageContentSnapshots,
firstString(storagePoolID),
dirSizeBytes(containerDir),
)
if err != nil {
return config.Snapshot{}, err
}
now := time.Now()
snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000"))
// Use container ID instead of lxcName to avoid collision when containers are recreated
snapshotDir := filepath.Join(snapshotBaseDir(), strconv.Itoa(id), snapshotID)
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil {
baseDir := filepath.Join(pool.Path, "snapshots")
snapshotDir := filepath.Join(baseDir, strconv.Itoa(id), snapshotID)
if err := safePathUnder(snapshotDir, baseDir); err != nil {
return config.Snapshot{}, err
}
if err := os.MkdirAll(snapshotDir, 0700); err != nil {
@@ -100,7 +109,7 @@ func (m *Manager) DeleteSnapshot(id string) error {
func (m *Manager) deleteSnapshotLocked(snapshot config.Snapshot) error {
if snapshot.Path != "" {
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
if err := safeSnapshotPath(snapshot.Path); err != nil {
return err
}
if err := os.RemoveAll(snapshot.Path); err != nil {
@@ -122,7 +131,7 @@ func (m *Manager) RestoreSnapshot(id string) error {
if snapshot.Path == "" {
return fmt.Errorf("snapshot path is empty")
}
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
if err := safeSnapshotPath(snapshot.Path); err != nil {
return err
}
if _, err := os.Stat(snapshot.Path); err != nil {
@@ -295,7 +304,33 @@ func (m *Manager) prepareContainerForColdCopy(id int, lxcName string, containerD
}
func snapshotBaseDir() string {
return filepath.Join(config.AppConfig.DataDir, "snapshots")
return snapshotBaseDirForPool("")
}
func snapshotBaseDirForPool(poolID string) string {
if pool, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, poolID, 0); err == nil {
return filepath.Join(pool.Path, "snapshots")
}
return ""
}
func safeSnapshotPath(path string) error {
if err := safePathUnder(path, filepath.Join(config.AppConfig.DataDir, "snapshots")); err == nil {
return nil
}
for _, pool := range config.StoragePoolsForContent(config.StorageContentSnapshots) {
if err := safePathUnder(path, filepath.Join(pool.Path, "snapshots")); err == nil {
return nil
}
}
return fmt.Errorf("unsafe snapshot path: %s", path)
}
func firstString(values []string) string {
if len(values) == 0 {
return ""
}
return strings.TrimSpace(values[0])
}
func copyTree(src string, dst string) error {
@@ -313,6 +348,9 @@ func copyTree(src string, dst string) error {
}
func dirSizeBytes(path string) int64 {
if resolved, err := filepath.EvalSymlinks(path); err == nil {
path = resolved
}
out, err := exec.Command("du", "-s", "-B1", path).Output()
if err != nil {
return 0