package config import ( "crypto/rand" "encoding/hex" "fmt" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "sync" "time" "golang.org/x/crypto/bcrypt" ) // PortMapping represents a port mapping rule type PortMapping struct { ContainerPort int `json:"container_port"` HostPort int `json:"host_port"` HostIP string `json:"host_ip,omitempty"` Protocol string `json:"protocol"` Description string `json:"description"` } type FirewallRule struct { ID string `json:"id"` Network string `json:"network,omitempty"` // "ipv4", "ipv6", or "all"; empty defaults to "ipv4" Direction string `json:"direction"` // "in" or "out" Protocol string `json:"protocol"` // "tcp", "udp", "icmp", "all" Port string `json:"port"` // "" = all, "22", "80,443", "8000-9000" SourceIP string `json:"source_ip"` // "" = any Action string `json:"action"` // "ACCEPT" or "DROP" Description string `json:"description"` Enabled bool `json:"enabled"` } type PublicIPv4Assignment struct { Address string `json:"address"` Interface string `json:"interface,omitempty"` PrefixLen int `json:"prefix_len,omitempty"` Gateway string `json:"gateway,omitempty"` } type IPv6Assignment struct { Address string `json:"address"` PrefixLen int `json:"prefix_len"` Interface string `json:"interface,omitempty"` } type PublicIPv6Prefix struct { Address string `json:"address"` Prefix string `json:"prefix,omitempty"` PrefixLen int `json:"prefix_len"` Interface string `json:"interface,omitempty"` Gateway string `json:"gateway,omitempty"` } // SavedTask for persisting task queue across restarts type SavedTask struct { ID string `json:"id"` Type string `json:"type"` ContainerID int `json:"container_id"` ContainerName string `json:"container_name"` Status string `json:"status"` Error string `json:"error,omitempty"` CreatedAt string `json:"created_at"` TemplateID string `json:"template_id,omitempty"` Config string `json:"config,omitempty"` User string `json:"user,omitempty"` IP string `json:"ip,omitempty"` UserAgent string `json:"user_agent,omitempty"` } // SavedLoginLog for persisting login logs type SavedLoginLog struct { Time string `json:"time"` Username string `json:"username"` IP string `json:"ip"` UserAgent string `json:"user_agent"` Success bool `json:"success"` } // AuditLog represents an operation log entry type AuditLog struct { Time string `json:"time"` Action string `json:"action"` Target string `json:"target"` Detail string `json:"detail"` User string `json:"user"` IP string `json:"ip,omitempty"` UserAgent string `json:"user_agent,omitempty"` Success *bool `json:"success,omitempty"` Error string `json:"error,omitempty"` } type VMReadinessCheck struct { Key string `json:"key"` Label string `json:"label"` OK bool `json:"ok"` Detail string `json:"detail,omitempty"` } // Container represents an LXC container configuration type Container struct { ID int `json:"id"` UUID string `json:"uuid"` Name string `json:"name"` Virtualization string `json:"virtualization,omitempty"` LXCName string `json:"lxc_name,omitempty"` KVMName string `json:"kvm_name,omitempty"` DiskImage string `json:"disk_image,omitempty"` StoragePoolID string `json:"storage_pool_id,omitempty"` StoragePath string `json:"storage_path,omitempty"` MACAddress string `json:"mac_address,omitempty"` Template string `json:"template"` VCPU float64 `json:"vcpu"` 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 TrafficUsedRX int64 `json:"traffic_used_rx"` TrafficUsedTX int64 `json:"traffic_used_tx"` TrafficResetDate string `json:"traffic_reset_date"` IOSpeedMBps int `json:"io_speed_mbps"` IOReadMBps int `json:"io_read_mbps"` IOWriteMBps int `json:"io_write_mbps"` Status string `json:"status"` RestoreOnHostBoot bool `json:"restore_on_host_boot,omitempty"` IP string `json:"ip"` 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"` PublicIPv4s []PublicIPv4Assignment `json:"public_ipv4s,omitempty"` IPv6 string `json:"ipv6"` IPv6PrefixLen int `json:"ipv6_prefix_len"` IPv6Interface string `json:"ipv6_interface"` IPv6Addresses []IPv6Assignment `json:"ipv6_addresses,omitempty"` VNCPort int `json:"vnc_port"` SSHPort int `json:"ssh_port"` SSHPassword string `json:"ssh_password"` SSHHostKey string `json:"ssh_host_key,omitempty"` PortMappings []PortMapping `json:"port_mappings"` PortMappingLimit int `json:"port_mapping_limit"` FirewallEnabled bool `json:"firewall_enabled"` FirewallDefaultAction string `json:"firewall_default_action"` FirewallRules []FirewallRule `json:"firewall_rules"` AllowedImageIDs []string `json:"allowed_image_ids,omitempty"` ImageLimitConfigured bool `json:"image_limit_configured,omitempty"` SnapshotLimit int `json:"snapshot_limit"` CreatedAt string `json:"created_at"` ExpiresAt string `json:"expires_at"` SnapshotScheduleEnabled bool `json:"snapshot_schedule_enabled"` SnapshotScheduleIntervalHours int `json:"snapshot_schedule_interval_hours"` SnapshotScheduleTime string `json:"snapshot_schedule_time"` SnapshotScheduleLastRun string `json:"snapshot_schedule_last_run"` SnapshotScheduleNextRun string `json:"snapshot_schedule_next_run"` SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"` PolicyBlocked bool `json:"policy_blocked"` PolicyBlockedReason string `json:"policy_blocked_reason,omitempty"` PolicyBlockedAt string `json:"policy_blocked_at,omitempty"` } const ( VirtualizationLXC = "lxc" VirtualizationKVM = "kvm" LANIPv4ModeDHCP = "dhcp" LANIPv4ModeStatic = "static" ) func NormalizeVirtualization(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case VirtualizationKVM: return VirtualizationKVM default: return VirtualizationLXC } } func (c *Container) Runtime() string { return NormalizeVirtualization(c.Virtualization) } func (c *Container) IsKVM() bool { return c.Runtime() == VirtualizationKVM } func (c *Container) UsesLANDHCP() bool { return strings.EqualFold(strings.TrimSpace(c.LANIPv4Mode), LANIPv4ModeDHCP) } func (c *Container) UsesLANStaticIPv4() bool { return strings.EqualFold(strings.TrimSpace(c.LANIPv4Mode), LANIPv4ModeStatic) } func (c *Container) UsesLANIPv4() bool { return c.UsesLANDHCP() || c.UsesLANStaticIPv4() } func normalizeStoragePools() bool { if AppConfig == nil { return false } changed := false result := make([]StoragePool, 0, len(AppConfig.StoragePools)) seen := map[string]bool{} defaultSeen := map[string]bool{} for _, pool := range AppConfig.StoragePools { pool.ID = strings.TrimSpace(pool.ID) pool.Name = strings.TrimSpace(pool.Name) pool.Path = filepath.Clean(strings.TrimSpace(pool.Path)) pool.MountPoint = filepath.Clean(strings.TrimSpace(pool.MountPoint)) if pool.MountPoint == "." { pool.MountPoint = "" } if pool.MountPoint != "" { managedPath := managedStoragePoolPath(pool.MountPoint) if pool.Path != managedPath { pool.Path = managedPath changed = true } } if pool.ID == "" { pool.ID = storagePoolIDFromName(pool.Name, pool.Path) changed = true } if pool.Name == "" { pool.Name = pool.ID changed = true } if pool.Path == "." || !filepath.IsAbs(pool.Path) || seen[pool.ID] { changed = true continue } seen[pool.ID] = true pool.ContentTypes = normalizeStorageContentTypes(pool.ContentTypes) pool.DefaultContents = normalizeStorageContentTypes(pool.DefaultContents) allowed := map[string]bool{} for _, content := range pool.ContentTypes { allowed[content] = true } defaults := make([]string, 0, len(pool.DefaultContents)) for _, content := range pool.DefaultContents { if !allowed[content] || defaultSeen[content] { changed = true continue } defaultSeen[content] = true defaults = append(defaults, content) } pool.DefaultContents = defaults if pool.ContentTypes == nil { pool.ContentTypes = []string{} } result = append(result, pool) } if len(result) != len(AppConfig.StoragePools) { changed = true } AppConfig.StoragePools = result return changed } func managedStoragePoolPath(mountPoint string) string { mountPoint = filepath.Clean(strings.TrimSpace(mountPoint)) if mountPoint == string(os.PathSeparator) { return filepath.Join(string(os.PathSeparator), "var", "lib", "clicd") } return filepath.Join(mountPoint, "clicd") } func storagePoolIDFromName(name, path string) string { base := strings.ToLower(strings.TrimSpace(name)) if base == "" { base = filepath.Base(filepath.Clean(path)) } replacer := strings.NewReplacer(" ", "-", "_", "-", ".", "-", "/", "-") base = replacer.Replace(base) base = strings.Trim(base, "-") if base == "" { base = "storage" } return base } func normalizeStorageContentTypes(values []string) []string { if len(values) == 0 { return nil } valid := map[string]bool{ StorageContentLXC: true, StorageContentKVM: true, StorageContentImages: true, StorageContentSnapshots: true, StorageContentBackups: true, } seen := map[string]bool{} result := []string{} for _, value := range values { next := strings.ToLower(strings.TrimSpace(value)) if !valid[next] || seen[next] { continue } seen[next] = true result = append(result, next) } return result } func StoragePoolsForContent(content string) []StoragePool { if AppConfig == nil { return nil } content = strings.ToLower(strings.TrimSpace(content)) result := []StoragePool{} for _, pool := range AppConfig.StoragePools { if !pool.Enabled || !storagePoolAllows(pool, content) { continue } result = append(result, pool) } return result } func StoragePoolByID(id string) *StoragePool { if AppConfig == nil { return nil } id = strings.TrimSpace(id) for i := range AppConfig.StoragePools { if AppConfig.StoragePools[i].ID == id { return &AppConfig.StoragePools[i] } } return nil } func StoragePoolAllowsContent(pool StoragePool, content string) bool { return storagePoolAllows(pool, strings.ToLower(strings.TrimSpace(content))) } func StoragePathForContent(content, fallback string) string { if pool := DefaultStoragePoolForContent(content); pool != nil { return pool.Path } return fallback } // PreferredStoragePoolForContent returns the configured default without doing // filesystem probes. Use SelectStoragePoolForContent for new writes. func PreferredStoragePoolForContent(content string) *StoragePool { if AppConfig == nil { return nil } content = strings.ToLower(strings.TrimSpace(content)) for i := range AppConfig.StoragePools { pool := &AppConfig.StoragePools[i] if !pool.Enabled || !storagePoolAllows(*pool, content) { continue } for _, item := range pool.DefaultContents { if item == content { return pool } } } for i := range AppConfig.StoragePools { pool := &AppConfig.StoragePools[i] if pool.Enabled && storagePoolAllows(*pool, content) { return pool } } return nil } func DefaultStoragePoolForContent(content string) *StoragePool { pool, _ := SelectStoragePoolForContent(content, "", 0) return pool } const storagePoolFreeReserveBytes int64 = 256 * 1024 * 1024 type storagePoolCandidate struct { pool *StoragePool freeBytes int64 isDefault bool } // SelectStoragePoolForContent picks a writable mounted pool. The requested or // configured default pool is preferred while it has enough space; remaining // pools are tried by available space from largest to smallest. func SelectStoragePoolForContent(content, requestedPoolID string, requiredBytes int64) (*StoragePool, error) { if AppConfig == nil { return nil, fmt.Errorf("storage configuration is not loaded") } content = strings.ToLower(strings.TrimSpace(content)) requestedPoolID = strings.TrimSpace(requestedPoolID) if requiredBytes < 0 { requiredBytes = 0 } requiredFree := requiredBytes + storagePoolFreeReserveBytes candidates := make([]storagePoolCandidate, 0, len(AppConfig.StoragePools)) configured := 0 for i := range AppConfig.StoragePools { pool := &AppConfig.StoragePools[i] if !pool.Enabled || !storagePoolAllows(*pool, content) { continue } configured++ freeBytes, available := probeStoragePoolFreeBytes(*pool) if !available { continue } candidate := storagePoolCandidate{pool: pool, freeBytes: freeBytes} for _, item := range pool.DefaultContents { if item == content { candidate.isDefault = true break } } candidates = append(candidates, candidate) } if configured == 0 { return nil, fmt.Errorf("no storage disk is enabled for %s", storageContentLabel(content)) } if len(candidates) == 0 { return nil, fmt.Errorf("all storage disks enabled for %s are unavailable or unmounted", storageContentLabel(content)) } sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].freeBytes > candidates[j].freeBytes }) preferred := func(match func(storagePoolCandidate) bool) *StoragePool { for _, candidate := range candidates { if match(candidate) && candidate.freeBytes >= requiredFree { return candidate.pool } } return nil } if requestedPoolID != "" { if pool := preferred(func(candidate storagePoolCandidate) bool { return candidate.pool.ID == requestedPoolID }); pool != nil { return pool, nil } } if pool := preferred(func(candidate storagePoolCandidate) bool { return candidate.isDefault }); pool != nil { return pool, nil } if pool := preferred(func(storagePoolCandidate) bool { return true }); pool != nil { return pool, nil } return nil, fmt.Errorf("storage disks enabled for %s do not have enough free space", storageContentLabel(content)) } var probeStoragePoolFreeBytes = storagePoolFreeBytes func storagePoolFreeBytes(pool StoragePool) (int64, bool) { if strings.TrimSpace(pool.Path) == "" { return 0, false } if _, err := os.Stat(pool.Path); err != nil { if !os.IsNotExist(err) || filepath.Clean(pool.MountPoint) != string(os.PathSeparator) { return 0, false } if err := os.MkdirAll(pool.Path, 0755); err != nil { return 0, false } } if mountPoint := strings.TrimSpace(pool.MountPoint); mountPoint != "" { out, err := exec.Command("findmnt", "-n", "-o", "TARGET", "--target", pool.Path).Output() if err != nil || filepath.Clean(strings.TrimSpace(string(out))) != filepath.Clean(mountPoint) { return 0, false } } out, err := exec.Command("df", "-B1", "-P", pool.Path).Output() if err != nil { return 0, false } lines := strings.Split(strings.TrimSpace(string(out)), "\n") if len(lines) < 2 { return 0, false } fields := strings.Fields(lines[len(lines)-1]) if len(fields) < 4 { return 0, false } freeBytes, err := strconv.ParseInt(fields[3], 10, 64) return freeBytes, err == nil } func storageContentLabel(content string) string { switch content { case StorageContentLXC: return "LXC containers" case StorageContentKVM: return "KVM disks" case StorageContentImages: return "image cache" case StorageContentSnapshots: return "snapshots" case StorageContentBackups: return "backups" default: return content } } func storagePoolAllows(pool StoragePool, content string) bool { for _, item := range pool.ContentTypes { if item == content { return true } } return false } func (c *Container) NormalizeNetworkAssignments() bool { changed := false lanMode := strings.ToLower(strings.TrimSpace(c.LANIPv4Mode)) if lanMode != "" && lanMode != LANIPv4ModeDHCP && lanMode != LANIPv4ModeStatic { lanMode = "" } if c.LANIPv4Mode != lanMode { c.LANIPv4Mode = lanMode changed = true } lanInterface := strings.TrimSpace(c.LANInterface) if c.LANInterface != lanInterface { c.LANInterface = lanInterface changed = true } lanAddress := strings.TrimSpace(c.LANIPv4Address) if c.LANIPv4Address != lanAddress { c.LANIPv4Address = lanAddress changed = true } lanGateway := strings.TrimSpace(c.LANIPv4Gateway) if c.LANIPv4Gateway != lanGateway { c.LANIPv4Gateway = lanGateway changed = true } if c.LANIPv4Mode == LANIPv4ModeDHCP { if c.LANIPv4Address != "" { c.LANIPv4Address = "" changed = true } } else if c.LANIPv4Mode != LANIPv4ModeStatic { if c.LANIPv4Address != "" || c.LANIPv4PrefixLen != 0 || c.LANIPv4Gateway != "" { c.LANIPv4Address = "" c.LANIPv4PrefixLen = 0 c.LANIPv4Gateway = "" changed = true } } seenIPv4 := map[string]bool{} filteredIPv4 := make([]PublicIPv4Assignment, 0, len(c.PublicIPv4s)) for _, item := range c.PublicIPv4s { item.Address = strings.TrimSpace(item.Address) item.Interface = strings.TrimSpace(item.Interface) item.Gateway = strings.TrimSpace(item.Gateway) if item.Address == "" || seenIPv4[item.Address] { if item.Address != "" { changed = true } continue } seenIPv4[item.Address] = true filteredIPv4 = append(filteredIPv4, item) } if len(filteredIPv4) != len(c.PublicIPv4s) { changed = true } c.PublicIPv4s = filteredIPv4 seenIPv6 := map[string]bool{} filteredIPv6 := make([]IPv6Assignment, 0, len(c.IPv6Addresses)+1) for _, item := range c.IPv6Addresses { item.Address = strings.TrimSpace(item.Address) item.Interface = strings.TrimSpace(item.Interface) if item.Address == "" || seenIPv6[item.Address] { if item.Address != "" { changed = true } continue } seenIPv6[item.Address] = true filteredIPv6 = append(filteredIPv6, item) } if strings.TrimSpace(c.IPv6) != "" && !seenIPv6[c.IPv6] { filteredIPv6 = append([]IPv6Assignment{{ Address: c.IPv6, PrefixLen: c.IPv6PrefixLen, Interface: c.IPv6Interface, }}, filteredIPv6...) changed = true } if len(filteredIPv6) != len(c.IPv6Addresses) { changed = true } c.IPv6Addresses = filteredIPv6 if len(c.IPv6Addresses) > 0 { first := c.IPv6Addresses[0] if c.IPv6 != first.Address || c.IPv6PrefixLen != first.PrefixLen || c.IPv6Interface != first.Interface { c.IPv6 = first.Address c.IPv6PrefixLen = first.PrefixLen c.IPv6Interface = first.Interface changed = true } } else if c.IPv6 != "" || c.IPv6PrefixLen != 0 || c.IPv6Interface != "" { c.IPv6 = "" c.IPv6PrefixLen = 0 c.IPv6Interface = "" changed = true } return changed } func (c *Container) PublicIPv4Addresses() []string { values := make([]string, 0, len(c.PublicIPv4s)) for _, item := range c.PublicIPv4s { if item.Address != "" { values = append(values, item.Address) } } return values } func (c *Container) PrimaryPublicIPv4() string { if len(c.PublicIPv4s) == 0 { return "" } return c.PublicIPv4s[0].Address } func (c *Container) IPv6AddressStrings() []string { values := make([]string, 0, len(c.IPv6Addresses)) for _, item := range c.IPv6Addresses { if item.Address != "" { values = append(values, item.Address) } } if len(values) == 0 && c.IPv6 != "" { values = append(values, c.IPv6) } return values } // LxcName returns the internal LXC container name (ct-{id}) func (c *Container) LxcName() string { if c.LXCName != "" { return c.LXCName } return fmt.Sprintf("ct-%d", c.ID) } // VirshName returns the internal libvirt domain name for KVM instances. func (c *Container) VirshName() string { if c.KVMName != "" { return c.KVMName } return fmt.Sprintf("vm-%d", c.ID) } // SubUser represents a sub-user with access to specific containers type ApiKeyConfig struct { ID string `json:"id"` Name string `json:"name"` KeyHash string `json:"key_hash"` Prefix string `json:"prefix"` IPWhitelist string `json:"ip_whitelist"` CreatedAt string `json:"created_at"` LastUsed string `json:"last_used"` Scopes []string `json:"scopes,omitempty"` ExpiresAt string `json:"expires_at,omitempty"` Disabled bool `json:"disabled,omitempty"` ContainerUUIDs []string `json:"container_uuids,omitempty"` LastUsedIP string `json:"last_used_ip,omitempty"` } // DeleteApiKey removes an API key by ID func DeleteApiKey(id string) { filtered := make([]ApiKeyConfig, 0, len(AppConfig.ApiKeys)) for _, k := range AppConfig.ApiKeys { if k.ID != id { filtered = append(filtered, k) } } AppConfig.ApiKeys = filtered SaveConfig() } type SubUser struct { ID string `json:"id"` Username string `json:"username"` Password string `json:"password,omitempty"` PassHash string `json:"pass_hash"` ContainerNames []string `json:"container_names"` ContainerUUIDs []string `json:"container_uuids,omitempty"` AllowedImageIDs []string `json:"allowed_image_ids,omitempty"` ImageLimitConfigured bool `json:"image_limit_configured,omitempty"` Token string `json:"-"` AccessCode string `json:"access_code"` CreatedAt string `json:"created_at"` TokenVersion int `json:"token_version"` } type Snapshot struct { ID string `json:"id"` ContainerID int `json:"container_id"` ContainerName string `json:"container_name"` LXCName string `json:"lxc_name"` CreatedAt string `json:"created_at"` CreatedBy string `json:"created_by"` Scheduled bool `json:"scheduled"` Path string `json:"path"` SizeBytes int64 `json:"size_bytes"` } const ( SSLModeDisabled = "disabled" SSLModeLetsEncrypt = "letsencrypt" SSLModeSelfSigned = "self_signed" SSLModeUploaded = "uploaded" ) type SSLConfig struct { Enabled bool `json:"enabled"` Mode string `json:"mode"` Target string `json:"target"` Email string `json:"email,omitempty"` CertPath string `json:"cert_path,omitempty"` KeyPath string `json:"key_path,omitempty"` LastIssuedAt string `json:"last_issued_at,omitempty"` LastError string `json:"last_error,omitempty"` } const ( StorageContentLXC = "lxc" StorageContentKVM = "kvm" StorageContentImages = "images" StorageContentSnapshots = "snapshots" StorageContentBackups = "backups" ) type StoragePool struct { ID string `json:"id"` Name string `json:"name"` Path string `json:"path"` MountPoint string `json:"mount_point,omitempty"` ContentTypes []string `json:"content_types"` DefaultContents []string `json:"default_contents,omitempty"` Enabled bool `json:"enabled"` } func defaultPrimaryStoragePool() StoragePool { contents := []string{ StorageContentLXC, StorageContentKVM, StorageContentImages, StorageContentSnapshots, StorageContentBackups, } return StoragePool{ ID: "disk-root", Name: "system (/)", Path: "/var/lib/clicd", MountPoint: "/", ContentTypes: append([]string(nil), contents...), DefaultContents: append([]string(nil), contents...), Enabled: true, } } // ClicdConfig is the main configuration structure type ClicdConfig struct { AdminUser string `json:"admin_user"` AdminPassHash string `json:"admin_pass_hash"` JWTSecret string `json:"jwt_secret"` Port int `json:"port"` DataDir string `json:"data_dir"` Containers []Container `json:"containers"` NextContainerID int `json:"next_container_id"` NextVNCPort int `json:"next_vnc_port"` NextSSHPort int `json:"next_ssh_port"` NATPortStart int `json:"nat_port_start"` NATPortEnd int `json:"nat_port_end"` LXCNATSubnet string `json:"lxc_nat_subnet"` KVMNATSubnet string `json:"kvm_nat_subnet"` SetupComplete bool `json:"setup_complete"` SubUsers []SubUser `json:"sub_users"` ApiKeys []ApiKeyConfig `json:"api_keys"` AuditLogs []AuditLog `json:"audit_logs"` Tasks []SavedTask `json:"tasks"` LoginLogs []SavedLoginLog `json:"login_logs"` EnabledImages []string `json:"enabled_images"` CustomKVMImages []CustomKVMImage `json:"custom_kvm_images"` CustomLXCImages []CustomLXCImage `json:"custom_lxc_images"` Snapshots []Snapshot `json:"snapshots"` PublicIPv4Pool []PublicIPv4Assignment `json:"public_ipv4_pool"` PublicIPv6Prefixes []PublicIPv6Prefix `json:"public_ipv6_prefixes"` WebSSHAllowedOrigins []string `json:"webssh_allowed_origins"` PanelAccessPolicy PanelAccessPolicy `json:"panel_access_policy"` SecurityAutoShutdown bool `json:"security_auto_shutdown"` TaskConcurrency int `json:"task_concurrency"` Language string `json:"language"` SSL SSLConfig `json:"ssl"` SSLCertificates map[string]SSLConfig `json:"ssl_certificates"` StoragePools []StoragePool `json:"storage_pools"` } const ( KVMProvisionerLinuxCloudInit = "linux-cloud-init" KVMProvisionerWindows10 = "windows-10" KVMProvisionerWindows11 = "windows-11" ) // CustomKVMImage is an administrator-defined KVM image source. type CustomKVMImage struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Distro string `json:"distro"` Release string `json:"release"` Arch string `json:"arch"` URL string `json:"url"` Provisioner string `json:"provisioner"` SHA256 string `json:"sha256,omitempty"` CreatedAt string `json:"created_at"` } // CustomLXCImage is an administrator-defined LXC rootfs archive source. type CustomLXCImage struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Distro string `json:"distro"` Release string `json:"release"` Arch string `json:"arch"` URL string `json:"url"` SHA256 string `json:"sha256,omitempty"` CreatedAt string `json:"created_at"` } var configPath string var AppConfig *ClicdConfig var allocationMu sync.Mutex const DefaultSnapshotLimit = 3 const ( DefaultTaskConcurrency = 2 MaxTaskConcurrency = 16 ) const ( DefaultNATPortStart = 20000 DefaultNATPortEnd = 65535 ) func getConfigPath() string { if configPath != "" { return configPath } home, err := os.UserHomeDir() if err != nil { home = "/root" } return filepath.Join(home, ".clicd", "config.json") } func SetConfigPath(path string) { configPath = path } func getDataDir() string { home, err := os.UserHomeDir() if err != nil { home = "/root" } return filepath.Join(home, ".clicd") } func generateRandomString(length int) string { b := make([]byte, length) rand.Read(b) return hex.EncodeToString(b)[:length] } func generateUUIDString() string { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return generateRandomString(32) } b[6] = (b[6] & 0x0f) | 0x40 b[8] = (b[8] & 0x3f) | 0x80 return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) } // NewContainerUUID returns a UUID that is unique within the current config. func NewContainerUUID() string { for { uuid := generateUUIDString() if FindContainerByUUID(uuid) == nil { return uuid } } } // InitConfig initializes or loads the configuration func InitConfig() (*ClicdConfig, error) { cfgPath := getConfigPath() dataDir := getDataDir() if err := os.MkdirAll(filepath.Dir(cfgPath), 0700); err != nil { return nil, fmt.Errorf("failed to create data directory: %v", err) } if err := os.MkdirAll(dataDir, 0700); err != nil { return nil, fmt.Errorf("failed to create data directory: %v", err) } if err := openConfigDB(); err != nil { return nil, err } cfg, ok, err := loadConfigFromDB() if err != nil { return nil, err } if ok { AppConfig = cfg changed := normalizeConfigDefaults(dataDir) if migrateLoadedConfig() { changed = true } if changed { if err := SaveConfig(); err != nil { return nil, err } } return AppConfig, nil } legacy, ok, err := loadLegacyJSONConfig(cfgPath) if err != nil { return nil, err } if ok { AppConfig = legacy normalizeConfigDefaults(dataDir) migrateLoadedConfig() // Always save legacy JSON data into SQLite. if err := SaveConfig(); err != nil { return nil, err } return AppConfig, nil } adminUser := "admin" adminPass := generateRandomString(16) jwtSecret := generateRandomString(32) hash, err := bcrypt.GenerateFromPassword([]byte(adminPass), bcrypt.DefaultCost) if err != nil { return nil, fmt.Errorf("failed to hash password: %v", err) } AppConfig = &ClicdConfig{ AdminUser: adminUser, AdminPassHash: string(hash), JWTSecret: jwtSecret, Port: 8999, DataDir: dataDir, Containers: []Container{}, NextContainerID: 1, NextVNCPort: 5900, NextSSHPort: 22000, NATPortStart: DefaultNATPortStart, NATPortEnd: DefaultNATPortEnd, LXCNATSubnet: configuredSubnetValue("", "CLICD_LXC_SUBNET", DefaultLXCNATSubnet), KVMNATSubnet: configuredSubnetValue("", "CLICD_KVM_SUBNET", DefaultKVMNATSubnet), SetupComplete: false, SubUsers: []SubUser{}, AuditLogs: []AuditLog{}, Tasks: []SavedTask{}, LoginLogs: []SavedLoginLog{}, Snapshots: []Snapshot{}, PublicIPv4Pool: []PublicIPv4Assignment{}, PublicIPv6Prefixes: []PublicIPv6Prefix{}, WebSSHAllowedOrigins: []string{}, PanelAccessPolicy: PanelAccessPolicy{ AllowedSources: []string{}, TrustedProxies: []string{}, }, TaskConcurrency: DefaultTaskConcurrency, StoragePools: []StoragePool{defaultPrimaryStoragePool()}, } if err := SaveConfig(); err != nil { return nil, err } fmt.Println("\n========================================") fmt.Println(" CLICD - LXC Container Manager") fmt.Println("========================================") fmt.Printf(" Username: %s\n", adminUser) fmt.Printf(" Password: %s\n", adminPass) fmt.Println("========================================") fmt.Println(" Please save these credentials!") fmt.Println(" Web Interface: http://0.0.0.0:8999") fmt.Println("========================================") fmt.Println() return AppConfig, nil } func normalizeConfigDefaults(dataDir string) bool { changed := false if AppConfig.Port == 0 { AppConfig.Port = 8999 changed = true } if AppConfig.NextVNCPort == 0 { AppConfig.NextVNCPort = 5900 changed = true } if AppConfig.NextSSHPort == 0 { AppConfig.NextSSHPort = 22000 changed = true } if normalizeNATPortRangeDefaults() { changed = true } if normalizeNATNetworkDefaults() { changed = true } if AppConfig.NextContainerID == 0 { AppConfig.NextContainerID = 1 changed = true } if normalized := NormalizeTaskConcurrency(AppConfig.TaskConcurrency); AppConfig.TaskConcurrency != normalized { AppConfig.TaskConcurrency = normalized changed = true } if AppConfig.DataDir == "" { AppConfig.DataDir = dataDir changed = true } if AppConfig.Containers == nil { AppConfig.Containers = make([]Container, 0) changed = true } if AppConfig.Snapshots == nil { AppConfig.Snapshots = make([]Snapshot, 0) changed = true } if AppConfig.PublicIPv4Pool == nil { AppConfig.PublicIPv4Pool = make([]PublicIPv4Assignment, 0) changed = true } if AppConfig.PublicIPv6Prefixes == nil { AppConfig.PublicIPv6Prefixes = make([]PublicIPv6Prefix, 0) changed = true } if AppConfig.WebSSHAllowedOrigins == nil { AppConfig.WebSSHAllowedOrigins = make([]string, 0) changed = true } else if normalized, err := NormalizeAllowedOrigins(AppConfig.WebSSHAllowedOrigins); err == nil && strings.Join(normalized, "\n") != strings.Join(AppConfig.WebSSHAllowedOrigins, "\n") { AppConfig.WebSSHAllowedOrigins = normalized changed = true } if normalized, err := NormalizePanelAccessPolicy(AppConfig.PanelAccessPolicy); err == nil { if !panelAccessPoliciesEqual(AppConfig.PanelAccessPolicy, normalized) { AppConfig.PanelAccessPolicy = normalized changed = true } } else { AppConfig.PanelAccessPolicy = PanelAccessPolicy{ AllowedSources: []string{}, TrustedProxies: []string{}, } changed = true } if len(AppConfig.StoragePools) == 0 { AppConfig.StoragePools = []StoragePool{defaultPrimaryStoragePool()} changed = true } if normalizeStoragePools() { changed = true } if AppConfig.SubUsers == nil { AppConfig.SubUsers = make([]SubUser, 0) changed = true } if AppConfig.ApiKeys == nil { AppConfig.ApiKeys = make([]ApiKeyConfig, 0) changed = true } else { for i := range AppConfig.ApiKeys { if len(AppConfig.ApiKeys[i].Scopes) == 0 { AppConfig.ApiKeys[i].Scopes = []string{"*"} changed = true } } } if AppConfig.AuditLogs == nil { AppConfig.AuditLogs = make([]AuditLog, 0) changed = true } if AppConfig.Tasks == nil { AppConfig.Tasks = make([]SavedTask, 0) changed = true } if AppConfig.LoginLogs == nil { AppConfig.LoginLogs = make([]SavedLoginLog, 0) changed = true } if AppConfig.EnabledImages == nil { AppConfig.EnabledImages = make([]string, 0) changed = true } if AppConfig.CustomKVMImages == nil { AppConfig.CustomKVMImages = make([]CustomKVMImage, 0) changed = true } if AppConfig.CustomLXCImages == nil { AppConfig.CustomLXCImages = make([]CustomLXCImage, 0) changed = true } if AppConfig.Language == "" { AppConfig.Language = "zh" changed = true } if AppConfig.Language != "zh" && AppConfig.Language != "en" { AppConfig.Language = "zh" changed = true } if normalizeSSLDefaults() { changed = true } return changed } func NormalizeTaskConcurrency(value int) int { if value <= 0 { return DefaultTaskConcurrency } if value > MaxTaskConcurrency { return MaxTaskConcurrency } return value } func NormalizeLanguage(language string) string { switch strings.ToLower(strings.TrimSpace(language)) { case "en", "en-us", "en_us", "english": return "en" default: return "zh" } } func normalizeSSLDefaults() bool { changed := false previousMode := AppConfig.SSL.Mode AppConfig.SSL.Mode = NormalizeSSLMode(AppConfig.SSL.Mode) if AppConfig.SSL.Mode != previousMode { changed = true } if AppConfig.SSL.Mode == SSLModeDisabled { if AppConfig.SSL.Enabled { changed = true } AppConfig.SSL.Enabled = false } if AppConfig.SSLCertificates == nil { AppConfig.SSLCertificates = map[string]SSLConfig{} changed = true } for mode, cert := range AppConfig.SSLCertificates { cert.Mode = NormalizeSSLMode(cert.Mode) if cert.Mode == SSLModeDisabled { delete(AppConfig.SSLCertificates, mode) changed = true continue } if AppConfig.SSLCertificates[cert.Mode] != cert { changed = true } AppConfig.SSLCertificates[cert.Mode] = cert if mode != cert.Mode { delete(AppConfig.SSLCertificates, mode) changed = true } } if AppConfig.SSL.Mode != SSLModeDisabled && AppConfig.SSL.CertPath != "" && AppConfig.SSL.KeyPath != "" { cert := AppConfig.SSL cert.Enabled = false if AppConfig.SSLCertificates[cert.Mode] != cert { changed = true } AppConfig.SSLCertificates[cert.Mode] = cert } return changed } func NormalizeSSLMode(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case SSLModeLetsEncrypt: return SSLModeLetsEncrypt case SSLModeSelfSigned: return SSLModeSelfSigned case SSLModeUploaded: return SSLModeUploaded default: return SSLModeDisabled } } func migrateLoadedConfig() bool { changed := ensureContainerUUIDs() if ensureContainerVirtualization() { changed = true } if ensureContainerPortMappingLimits() { changed = true } if ensureContainerSnapshotLimits() { changed = true } if ensureContainerNetworkAssignments() { changed = true } if ensureContainerResourceAliases() { changed = true } if ensureContainerSnapshotScheduleDefaults() { changed = true } if migrateSubUsers() { changed = true } if removeLegacyVNCMappings() { changed = true } return changed } func ensureContainerVirtualization() bool { changed := false for i := range AppConfig.Containers { next := NormalizeVirtualization(AppConfig.Containers[i].Virtualization) if AppConfig.Containers[i].Virtualization != next { AppConfig.Containers[i].Virtualization = next changed = true } } return changed } func ensureContainerSnapshotScheduleDefaults() bool { changed := false for i := range AppConfig.Containers { if AppConfig.Containers[i].SnapshotScheduleEnabled && AppConfig.Containers[i].SnapshotScheduleIntervalHours < 24 { AppConfig.Containers[i].SnapshotScheduleIntervalHours = 24 changed = true } if AppConfig.Containers[i].SnapshotScheduleEnabled && AppConfig.Containers[i].SnapshotScheduleTime == "" { AppConfig.Containers[i].SnapshotScheduleTime = "03:00" changed = true } } return changed } func ensureContainerUUIDs() bool { changed := false used := make(map[string]bool) for i := range AppConfig.Containers { uuid := AppConfig.Containers[i].UUID if uuid == "" || used[uuid] { for { uuid = generateUUIDString() if !used[uuid] { break } } AppConfig.Containers[i].UUID = uuid changed = true } used[uuid] = true } return changed } func ensureContainerPortMappingLimits() bool { changed := false for i := range AppConfig.Containers { if AppConfig.Containers[i].PortMappingLimit < 0 { limit := len(AppConfig.Containers[i].PortMappings) if limit < 2 { limit = 2 } AppConfig.Containers[i].PortMappingLimit = limit changed = true } else if AppConfig.Containers[i].PortMappingLimit == 0 && len(AppConfig.Containers[i].PortMappings) > 0 { AppConfig.Containers[i].PortMappingLimit = len(AppConfig.Containers[i].PortMappings) changed = true } } return changed } func ensureContainerSnapshotLimits() bool { changed := false for i := range AppConfig.Containers { if AppConfig.Containers[i].SnapshotLimit <= 0 { AppConfig.Containers[i].SnapshotLimit = DefaultSnapshotLimit changed = true } } return changed } func ensureContainerNetworkAssignments() bool { changed := false for i := range AppConfig.Containers { if AppConfig.Containers[i].NormalizeNetworkAssignments() { changed = true } } return changed } func ensureContainerResourceAliases() bool { changed := false for i := range AppConfig.Containers { if NormalizeContainerResourceAliases(&AppConfig.Containers[i]) { changed = true } } return changed } func NormalizeContainerResourceAliases(c *Container) bool { if c == nil { return false } changed := false if c.NetworkBWMbps < 0 { c.NetworkBWMbps = 0 changed = true } if c.NetworkDownMbps < 0 { c.NetworkDownMbps = 0 changed = true } if c.NetworkUpMbps < 0 { c.NetworkUpMbps = 0 changed = true } if c.NetworkDownMbps == 0 && c.NetworkUpMbps == 0 && c.NetworkBWMbps > 0 { c.NetworkDownMbps = c.NetworkBWMbps c.NetworkUpMbps = c.NetworkBWMbps changed = true } nextNetworkBW := LegacySymmetricLimit(c.NetworkDownMbps, c.NetworkUpMbps) if c.NetworkBWMbps != nextNetworkBW { c.NetworkBWMbps = nextNetworkBW changed = true } if c.IOSpeedMBps < 0 { c.IOSpeedMBps = 0 changed = true } if c.IOReadMBps < 0 { c.IOReadMBps = 0 changed = true } if c.IOWriteMBps < 0 { c.IOWriteMBps = 0 changed = true } if c.IOReadMBps == 0 && c.IOWriteMBps == 0 && c.IOSpeedMBps > 0 { c.IOReadMBps = c.IOSpeedMBps c.IOWriteMBps = c.IOSpeedMBps changed = true } nextIO := LegacySymmetricLimit(c.IOReadMBps, c.IOWriteMBps) if c.IOSpeedMBps != nextIO { c.IOSpeedMBps = nextIO changed = true } return changed } func LegacySymmetricLimit(a, b int) int { if a < 0 { a = 0 } if b < 0 { b = 0 } if a == b { return a } if a == 0 { return b } if b == 0 { return a } if a < b { return a } return b } func migrateSubUsers() bool { changed := false for i := range AppConfig.SubUsers { su := &AppConfig.SubUsers[i] if su.PassHash == "" && su.Password != "" { if hash, err := bcrypt.GenerateFromPassword([]byte(su.Password), bcrypt.DefaultCost); err == nil { su.PassHash = string(hash) changed = true } } if su.Token != "" { su.Token = "" changed = true } if len(su.ContainerUUIDs) == 0 && len(su.ContainerNames) > 0 { for _, name := range su.ContainerNames { if c := FindContainerByName(name); c != nil && c.UUID != "" { su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID) } } if len(su.ContainerUUIDs) > 0 { changed = true } } } return changed } func appendUniqueString(values []string, value string) []string { for _, existing := range values { if existing == value { return values } } return append(values, value) } func NormalizeSnapshotLimit(limit int) int { if limit <= 0 { return DefaultSnapshotLimit } return limit } func ContainerSnapshotLimit(c *Container) int { if c == nil { return DefaultSnapshotLimit } return NormalizeSnapshotLimit(c.SnapshotLimit) } func removeLegacyVNCMappings() bool { changed := false for i := range AppConfig.Containers { mappings := AppConfig.Containers[i].PortMappings if len(mappings) == 0 { continue } filtered := mappings[:0] for _, pm := range mappings { isLegacyVNC := strings.EqualFold(pm.Description, "VNC") || pm.ContainerPort == 5901 if isLegacyVNC { changed = true continue } filtered = append(filtered, pm) } AppConfig.Containers[i].PortMappings = filtered } return changed } // SaveConfig saves configuration to disk func SaveConfig() error { return saveConfigToDB() } func ListCustomKVMImages() []CustomKVMImage { allocationMu.Lock() defer allocationMu.Unlock() if AppConfig == nil { return nil } return append([]CustomKVMImage(nil), AppConfig.CustomKVMImages...) } func AddCustomKVMImage(image CustomKVMImage) error { allocationMu.Lock() defer allocationMu.Unlock() for _, existing := range AppConfig.CustomKVMImages { if existing.ID == image.ID { return fmt.Errorf("custom KVM image %q already exists", image.ID) } } AppConfig.CustomKVMImages = append(AppConfig.CustomKVMImages, image) if err := SaveConfig(); err != nil { AppConfig.CustomKVMImages = AppConfig.CustomKVMImages[:len(AppConfig.CustomKVMImages)-1] return err } return nil } func RemoveCustomKVMImage(id string) (bool, error) { allocationMu.Lock() defer allocationMu.Unlock() filtered := make([]CustomKVMImage, 0, len(AppConfig.CustomKVMImages)) found := false for _, image := range AppConfig.CustomKVMImages { if image.ID == id { found = true continue } filtered = append(filtered, image) } if !found { return false, nil } previous := AppConfig.CustomKVMImages AppConfig.CustomKVMImages = filtered if err := SaveConfig(); err != nil { AppConfig.CustomKVMImages = previous return false, err } return true, nil } func ListCustomLXCImages() []CustomLXCImage { allocationMu.Lock() defer allocationMu.Unlock() if AppConfig == nil { return nil } return append([]CustomLXCImage(nil), AppConfig.CustomLXCImages...) } func AddCustomLXCImage(image CustomLXCImage) error { allocationMu.Lock() defer allocationMu.Unlock() for _, existing := range AppConfig.CustomLXCImages { if existing.ID == image.ID { return fmt.Errorf("custom LXC image %q already exists", image.ID) } } AppConfig.CustomLXCImages = append(AppConfig.CustomLXCImages, image) if err := SaveConfig(); err != nil { AppConfig.CustomLXCImages = AppConfig.CustomLXCImages[:len(AppConfig.CustomLXCImages)-1] return err } return nil } func RemoveCustomLXCImage(id string) (bool, error) { allocationMu.Lock() defer allocationMu.Unlock() filtered := make([]CustomLXCImage, 0, len(AppConfig.CustomLXCImages)) found := false for _, image := range AppConfig.CustomLXCImages { if image.ID == id { found = true continue } filtered = append(filtered, image) } if !found { return false, nil } previous := AppConfig.CustomLXCImages AppConfig.CustomLXCImages = filtered if err := SaveConfig(); err != nil { AppConfig.CustomLXCImages = previous return false, err } return true, nil } // AddContainer adds a container to the config func AddContainer(c Container) { allocationMu.Lock() defer allocationMu.Unlock() if c.UUID == "" { c.UUID = NewContainerUUID() } c.Virtualization = NormalizeVirtualization(c.Virtualization) NormalizeContainerResourceAliases(&c) AppConfig.Containers = append(AppConfig.Containers, c) SaveConfig() } // AllocateContainerID allocates a new container ID func AllocateContainerID() int { allocationMu.Lock() defer allocationMu.Unlock() id := AppConfig.NextContainerID AppConfig.NextContainerID++ SaveConfig() return id } // RemoveContainer removes a container from config by ID func RemoveContainer(id int) bool { for i, c := range AppConfig.Containers { if c.ID == id { removeSubUserContainerAccess(c.Name, c.UUID) removeContainerSnapshotMetadata(id) // Clear snapshot schedule for this container clearContainerSnapshotSchedule(&AppConfig.Containers[i]) AppConfig.Containers = append(AppConfig.Containers[:i], AppConfig.Containers[i+1:]...) SaveConfig() return true } } return false } func clearContainerSnapshotSchedule(c *Container) { c.SnapshotScheduleEnabled = false c.SnapshotScheduleIntervalHours = 0 c.SnapshotScheduleTime = "" c.SnapshotScheduleLastRun = "" c.SnapshotScheduleNextRun = "" c.SnapshotScheduleCreatedBy = "" } func AddSnapshot(snapshot Snapshot) { AppConfig.Snapshots = append(AppConfig.Snapshots, snapshot) SaveConfig() } func FindSnapshot(id string) *Snapshot { for i := range AppConfig.Snapshots { if AppConfig.Snapshots[i].ID == id { return &AppConfig.Snapshots[i] } } return nil } func RemoveSnapshot(id string) bool { for i := range AppConfig.Snapshots { if AppConfig.Snapshots[i].ID == id { AppConfig.Snapshots = append(AppConfig.Snapshots[:i], AppConfig.Snapshots[i+1:]...) SaveConfig() return true } } return false } func ContainerSnapshots(containerID int) []Snapshot { result := make([]Snapshot, 0) for _, snapshot := range AppConfig.Snapshots { if snapshot.ContainerID == containerID { result = append(result, snapshot) } } return result } func removeContainerSnapshotMetadata(containerID int) { filtered := make([]Snapshot, 0, len(AppConfig.Snapshots)) for _, snapshot := range AppConfig.Snapshots { if snapshot.ContainerID != containerID { filtered = append(filtered, snapshot) } } AppConfig.Snapshots = filtered } func RemoveSubUserContainerAccess(containerName string, containerUUID string) { removeSubUserContainerAccess(containerName, containerUUID) SaveConfig() } func removeSubUserContainerAccess(containerName string, containerUUID string) { if containerName == "" && containerUUID == "" || len(AppConfig.SubUsers) == 0 { return } filteredUsers := make([]SubUser, 0, len(AppConfig.SubUsers)) for _, su := range AppConfig.SubUsers { filteredNames := make([]string, 0, len(su.ContainerNames)) for _, name := range su.ContainerNames { if name != containerName { filteredNames = append(filteredNames, name) } } filteredUUIDs := make([]string, 0, len(su.ContainerUUIDs)) for _, uuid := range su.ContainerUUIDs { if uuid != containerUUID { filteredUUIDs = append(filteredUUIDs, uuid) } } if len(filteredNames) == 0 && len(filteredUUIDs) == 0 { continue } su.ContainerNames = filteredNames su.ContainerUUIDs = filteredUUIDs filteredUsers = append(filteredUsers, su) } AppConfig.SubUsers = filteredUsers } // FindContainer finds a container by ID func FindContainer(id int) *Container { for i, c := range AppConfig.Containers { if c.ID == id { return &AppConfig.Containers[i] } } return nil } // FindContainerByUUID finds a container by UUID. func FindContainerByUUID(uuid string) *Container { for i, c := range AppConfig.Containers { if c.UUID == uuid { return &AppConfig.Containers[i] } } return nil } // FindContainerByName finds a container by name func FindContainerByName(name string) *Container { for i, c := range AppConfig.Containers { if c.Name == name { return &AppConfig.Containers[i] } } return nil } // FindContainerByIdentifier finds a container by ID, UUID, or name. func FindContainerByIdentifier(identifier string) *Container { if id, err := strconv.Atoi(identifier); err == nil { if c := FindContainer(id); c != nil { return c } } if c := FindContainerByUUID(identifier); c != nil { return c } return FindContainerByName(identifier) } // UpdateContainerStatus updates container status by ID func UpdateContainerStatus(id int, status string) { c := FindContainer(id) if c != nil { c.Status = status SaveConfig() } } func UpdateContainerStatusAndRestore(id int, status string, restoreOnHostBoot bool) { c := FindContainer(id) if c != nil { c.Status = status c.RestoreOnHostBoot = restoreOnHostBoot SaveConfig() } } func SetContainerRestoreOnHostBoot(id int, restore bool) { c := FindContainer(id) if c != nil { c.RestoreOnHostBoot = restore SaveConfig() } } func SetContainerPolicyBlock(id int, blocked bool, reason string) { c := FindContainer(id) if c == nil { return } c.PolicyBlocked = blocked if blocked { c.PolicyBlockedReason = reason c.PolicyBlockedAt = time.Now().Format("2006-01-02 15:04:05") } else { c.PolicyBlockedReason = "" c.PolicyBlockedAt = "" } SaveConfig() } // UpdateVNC refreshes all container statuses func UpdateVNC(containers []Container) { AppConfig.Containers = containers SaveConfig() } func NormalizeNATPortRange(start, end int) (int, int, error) { if start == 0 && end == 0 { return DefaultNATPortStart, DefaultNATPortEnd, nil } if start == 0 { start = DefaultNATPortStart } if end == 0 { end = DefaultNATPortEnd } if start < 1 || start > 65535 { return 0, 0, fmt.Errorf("NAT port start must be 1-65535") } if end < 1 || end > 65535 { return 0, 0, fmt.Errorf("NAT port end must be 1-65535") } if start > end { return 0, 0, fmt.Errorf("NAT port start cannot be greater than end") } return start, end, nil } func NATPortRange() (int, int) { if AppConfig == nil { return DefaultNATPortStart, DefaultNATPortEnd } start, end, err := NormalizeNATPortRange(AppConfig.NATPortStart, AppConfig.NATPortEnd) if err != nil { return DefaultNATPortStart, DefaultNATPortEnd } return start, end } func NATPortCapacity() int { start, end := NATPortRange() return end - start + 1 } func NATPortInRange(port int) bool { start, end := NATPortRange() return port >= start && port <= end } func SetNATPortRange(start, end int) error { start, end, err := NormalizeNATPortRange(start, end) if err != nil { return err } AppConfig.NATPortStart = start AppConfig.NATPortEnd = end if AppConfig.NextSSHPort < start || AppConfig.NextSSHPort > end { AppConfig.NextSSHPort = start } return SaveConfig() } func normalizeNATPortRangeDefaults() bool { if AppConfig == nil { return false } start, end, err := NormalizeNATPortRange(AppConfig.NATPortStart, AppConfig.NATPortEnd) if err != nil { start, end = DefaultNATPortStart, DefaultNATPortEnd } changed := AppConfig.NATPortStart != start || AppConfig.NATPortEnd != end AppConfig.NATPortStart = start AppConfig.NATPortEnd = end if AppConfig.NextSSHPort < start || AppConfig.NextSSHPort > end { AppConfig.NextSSHPort = start changed = true } return changed } // AllocateSSHPort allocates a new SSH port, skipping ports already used by any container func AllocateSSHPort() (int, error) { return AllocateSSHPortExcluding(nil) } // AllocateSSHPortExcluding allocates a management port while reserving // user-requested NAT host ports for the container being created. func AllocateSSHPortExcluding(excluded []int) (int, error) { allocationMu.Lock() defer allocationMu.Unlock() candidate, err := previewSSHPortExcluding(excluded) if err != nil { return 0, err } start, end := NATPortRange() AppConfig.NextSSHPort = candidate + 1 if AppConfig.NextSSHPort > end { AppConfig.NextSSHPort = start } SaveConfig() return candidate, nil } // PreviewSSHPortExcluding returns the management port that the allocator would // choose without advancing or persisting the allocation cursor. func PreviewSSHPortExcluding(excluded []int) (int, error) { allocationMu.Lock() defer allocationMu.Unlock() return previewSSHPortExcluding(excluded) } func previewSSHPortExcluding(excluded []int) (int, error) { used := collectAllHostPorts() for _, port := range excluded { if port > 0 { used[port] = true } } start, end := NATPortRange() port := AppConfig.NextSSHPort if port < start || port > end { port = start } capacity := end - start + 1 for i := 0; i < capacity; i++ { candidate := start + ((port - start + i) % capacity) if used[candidate] { continue } return candidate, nil } return 0, fmt.Errorf("no free NAT4 host port in configured range %d-%d", start, end) } // collectAllHostPorts collects all host ports used by any container (LXC + KVM) func collectAllHostPorts() map[int]bool { used := map[int]bool{} for _, c := range AppConfig.Containers { for _, pm := range c.PortMappings { used[pm.HostPort] = true } } return used } // IsValidContainerName checks if container name is valid (no duplicate check needed, ID is primary key) func IsValidContainerName(name string) bool { return IsValidContainerNameSyntax(name) } // IsValidContainerNameSyntax checks only the container name format. func IsValidContainerNameSyntax(name string) bool { if len(name) == 0 || len(name) > 63 { return false } // Only allow alphanumeric, hyphens, underscores for _, c := range name { if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') { return false } } return true } // AddAuditLog adds an audit log entry func AddAuditLog(action, target, detail, user string) { log := AuditLog{ Time: time.Now().Format("2006-01-02 15:04:05"), Action: action, Target: target, Detail: detail, User: user, } AppConfig.AuditLogs = append(AppConfig.AuditLogs, log) if len(AppConfig.AuditLogs) > 500 { AppConfig.AuditLogs = AppConfig.AuditLogs[len(AppConfig.AuditLogs)-500:] } SaveConfig() } func AddAuditLogFull(action, target, detail, user, ip, userAgent string, success bool, errMsg string) { s := success log := AuditLog{ Time: time.Now().Format("2006-01-02 15:04:05"), Action: action, Target: target, Detail: detail, User: user, IP: ip, UserAgent: userAgent, Success: &s, Error: errMsg, } AppConfig.AuditLogs = append(AppConfig.AuditLogs, log) if len(AppConfig.AuditLogs) > 500 { AppConfig.AuditLogs = AppConfig.AuditLogs[len(AppConfig.AuditLogs)-500:] } SaveConfig() } // SaveTasks persists the task queue to config func SaveTasks(tasks []SavedTask) { AppConfig.Tasks = tasks SaveConfig() } // AddLoginLog persists a login log entry func AddLoginLog(username, ip, userAgent string, success bool) { log := SavedLoginLog{ Time: time.Now().Format("2006-01-02 15:04:05 MST"), Username: username, IP: ip, UserAgent: userAgent, Success: success, } AppConfig.LoginLogs = append(AppConfig.LoginLogs, log) if len(AppConfig.LoginLogs) > 200 { AppConfig.LoginLogs = AppConfig.LoginLogs[len(AppConfig.LoginLogs)-200:] } SaveConfig() } // ResetAdminPassword resets the admin password from CLI func ResetAdminPassword(newPassword string) error { hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) if err != nil { return err } AppConfig.AdminPassHash = string(hash) return SaveConfig() } // CleanStaleContainers removes containers from config if their LXC directory doesn't exist func CleanStaleContainers() { valid := make([]Container, 0) changed := false for _, c := range AppConfig.Containers { if c.IsKVM() { if c.DiskImage == "" { valid = append(valid, c) continue } if _, err := os.Stat(c.DiskImage); os.IsNotExist(err) { fmt.Printf("Cleaning stale KVM config: %s (disk image not found)\n", c.VirshName()) changed = true continue } valid = append(valid, c) continue } lxcDir := "/var/lib/lxc/" + c.LxcName() if _, err := os.Stat(lxcDir); os.IsNotExist(err) { fmt.Printf("Cleaning stale container config: %s (LXC dir not found)\n", c.LxcName()) changed = true continue } valid = append(valid, c) } if changed { AppConfig.Containers = valid SaveConfig() } }