mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
38debab1aa
- Implement tests for custom KVM and LXC image creation, ensuring invalid sources and architecture mismatches are rejected. - Introduce access policy management in CLI, allowing configuration of allowed sources and trusted proxies. - Add NAT network configuration with validation for RFC1918 compliance and subnet parsing. - Create panel access policy management, including normalization and evaluation of access decisions based on client IPs and forwarded headers. - Develop middleware for enforcing access policies in the server, returning appropriate responses for allowed and denied requests. - Enhance custom image downloading and validation, ensuring integrity and security of downloaded root filesystem archives. - Include comprehensive tests for all new functionalities to ensure reliability and correctness.
2077 lines
58 KiB
Go
2077 lines
58 KiB
Go
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()
|
|
}
|
|
}
|