diff --git a/.gitignore b/.gitignore index 68e6c36..443c42f 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,4 @@ Thumbs.db linux.txt push-release.ps1 deploy.ps1 +backend/clicd diff --git a/backend/go.mod b/backend/go.mod index 0058ba0..c0b5c40 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,8 @@ module clicd -go 1.22.0 +go 1.23.0 + +toolchain go1.24.5 require ( github.com/golang-jwt/jwt/v5 v5.2.1 @@ -9,4 +11,23 @@ require ( golang.org/x/term v0.28.0 ) -require golang.org/x/sys v0.29.0 +require ( + golang.org/x/sys v0.31.0 + modernc.org/sqlite v1.29.10 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.61.13 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.8.2 // indirect + modernc.org/strutil v1.2.1 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum index 14d30e9..8f490f6 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,10 +1,61 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w= +golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= +golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= +golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= +golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= +modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0= +modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo= +modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw= +modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8= +modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI= +modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.29.10 h1:3u93dz83myFnMilBGCOLbr+HjklS6+5rJLx4q86RDAg= +modernc.org/sqlite v1.29.10/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/internal/cli/cli.go b/backend/internal/cli/cli.go index 91c9d69..13f48b7 100644 --- a/backend/internal/cli/cli.go +++ b/backend/internal/cli/cli.go @@ -681,7 +681,7 @@ func cliImportExistingContainers() { func cliUninstall(reader *bufio.Reader) { fmt.Println("\n--- 卸载 CLICD ---") fmt.Println("将删除 CLICD 服务和 /usr/local/bin/clicd。") - fmt.Println("同时会删除 /root/.clicd、/var/lib/lxc 下全部 LXC 容器,以及 /var/cache/lxc 镜像缓存。") + fmt.Println("同时会删除 /root/.clicd、/var/lib/lxc、/var/lib/clicd、镜像缓存、备份、临时文件、/swapfile 和 CLICD 网络规则。") if os.Geteuid() != 0 { fmt.Println("卸载需要 root 权限。") @@ -696,6 +696,10 @@ func cliUninstall(reader *bufio.Reader) { } destroyAllLXCContainers() + destroyAllKVMDomains() + cleanupCLICDNetworking() + removeCLICDHostHooks() + removeCLICDQuotaRecords() stopAndRemoveService() removePath("/usr/local/bin/clicd") removePath("/etc/sysctl.d/99-clicd.conf") @@ -703,13 +707,18 @@ func cliUninstall(reader *bufio.Reader) { removePath("/var/log/clicd.err") removePath("/root/.clicd") removePath("/var/lib/lxc") + removePath("/var/lib/clicd") removePath("/var/cache/lxc") + removePath("/var/cache/clicd") + removePath("/root/clicd-backups") + removeCLICDTmpFiles() + removeCLICDSwapfile() reloadSysctl() fmt.Println() fmt.Println("CLICD 已卸载。") - fmt.Println("服务、二进制、配置、容器和 LXC 镜像缓存均已删除。") + fmt.Println("服务、二进制、配置、容器/虚拟机、本地镜像、缓存、备份、临时文件和 CLICD 网络规则均已删除。") } func destroyAllLXCContainers() { @@ -730,6 +739,151 @@ func destroyAllLXCContainers() { } } +func destroyAllKVMDomains() { + if !commandExists("virsh") { + return + } + out, err := exec.Command("virsh", "list", "--all", "--name").Output() + if err != nil { + return + } + for _, line := range strings.Split(string(out), "\n") { + name := strings.TrimSpace(line) + if isCLICDKVMDomain(name) { + removeKVMDomain(name) + } + } +} + +func isCLICDKVMDomain(name string) bool { + if !strings.HasPrefix(name, "vm-") || len(name) <= len("vm-") { + return false + } + for _, r := range strings.TrimPrefix(name, "vm-") { + if r < '0' || r > '9' { + return false + } + } + if dirExists("/var/lib/clicd/kvm/instances/" + name) { + return true + } + out, err := exec.Command("virsh", "dumpxml", name).Output() + return err == nil && strings.Contains(string(out), "/var/lib/clicd/kvm/") +} + +func removeKVMDomain(name string) { + fmt.Printf("Removing KVM domain %s...\n", name) + runQuiet("virsh", "destroy", name) + if runCommandOK("virsh", "undefine", name, "--remove-all-storage", "--nvram") { + return + } + if runCommandOK("virsh", "undefine", name, "--nvram") { + return + } + runQuiet("virsh", "undefine", name) +} + +func cleanupCLICDNetworking() { + removeCLICDNATRules() + for _, bridge := range []string{"lxcbr0", "virbr0"} { + deleteFilterRule("FORWARD", "-i", bridge, "-j", "ACCEPT") + deleteFilterRule("FORWARD", "-o", bridge, "-j", "ACCEPT") + deleteFilterRule("FORWARD", "-i", bridge, "-o", bridge, "-j", "ACCEPT") + deleteIP6TablesBridgeRules(bridge) + } +} + +func removeCLICDNATRules() { + if commandExists("iptables") { + for { + out, err := exec.Command("sh", "-c", "iptables -t nat -L PREROUTING -n --line-numbers 2>/dev/null | grep 'clicd-' | awk '{print $1}' | head -n 1").Output() + line := strings.TrimSpace(string(out)) + if err != nil || line == "" { + break + } + if !runCommandOK("iptables", "-t", "nat", "-D", "PREROUTING", line) { + break + } + } + deleteNATRule("POSTROUTING", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE") + deleteNATRule("POSTROUTING", "-s", "192.168.122.0/24", "-o", "eth+", "-j", "MASQUERADE") + } +} + +func deleteNATRule(args ...string) { + fullArgs := append([]string{"-t", "nat", "-D"}, args...) + for runCommandOK("iptables", fullArgs...) { + } +} + +func deleteFilterRule(args ...string) { + fullArgs := append([]string{"-D"}, args...) + for runCommandOK("iptables", fullArgs...) { + } +} + +func deleteIP6TablesBridgeRules(bridge string) { + if !commandExists("ip6tables") { + return + } + for { + cmd := fmt.Sprintf("ip6tables -S FORWARD 2>/dev/null | grep -- %s | sed 's/^-A /-D /' | head -n 1", shellQuote(bridge)) + out, err := exec.Command("sh", "-c", cmd).Output() + rule := strings.TrimSpace(string(out)) + if err != nil || rule == "" { + return + } + if !runCommandOK("sh", "-c", "ip6tables "+rule) { + return + } + } +} + +func removeCLICDHostHooks() { + runQuiet("systemctl", "stop", "clicd-kvm-ipv6.service") + runQuiet("systemctl", "disable", "clicd-kvm-ipv6.service") + runQuiet("rc-service", "clicd-kvm-ipv6", "stop") + runQuiet("rc-update", "del", "clicd-kvm-ipv6", "default") + removePath("/usr/local/sbin/clicd-kvm-ipv6-init") + removePath("/etc/systemd/system/clicd-kvm-ipv6.service") + removePath("/etc/local.d/clicd-kvm-ipv6.start") + removePath("/etc/network/if-up.d/clicd-kvm-ipv6") +} + +func removeCLICDQuotaRecords() { + for _, path := range []string{"/etc/projects", "/etc/projid"} { + data, err := os.ReadFile(path) + if err != nil { + continue + } + var kept []string + for _, line := range strings.Split(string(data), "\n") { + if strings.TrimSpace(line) == "" || strings.Contains(line, "clicd-") { + continue + } + kept = append(kept, line) + } + _ = os.WriteFile(path, []byte(strings.Join(kept, "\n")+"\n"), 0644) + } +} + +func removeCLICDTmpFiles() { + for _, pattern := range []string{"/tmp/clicd-*", "/tmp/clicd.*"} { + matches, _ := filepath.Glob(pattern) + for _, path := range matches { + removePath(path) + } + } +} + +func removeCLICDSwapfile() { + if !fileExists("/swapfile") { + return + } + runQuiet("swapoff", "/swapfile") + removePath("/swapfile") +} + func removeLXCContainerPath(path string) { unmountPathTree(path) detachLoopDevices(path) @@ -819,6 +973,16 @@ func removePath(path string) { fmt.Printf("Removed %s\n", path) } +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func dirExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + func reloadSysctl() { if commandExists("sysctl") { runQuiet("sysctl", "--system") @@ -830,10 +994,18 @@ func commandExists(name string) bool { return err == nil } +func runCommandOK(name string, args ...string) bool { + return exec.Command(name, args...).Run() == nil +} + func runQuiet(name string, args ...string) { _ = exec.Command(name, args...).Run() } +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" +} + func restartWebPanelForConfigChange() { if err := restartService("clicd"); err != nil { fmt.Printf("Web 面板重载跳过: %v\n", err) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index dcff610..dac7399 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -3,7 +3,6 @@ package config import ( "crypto/rand" "encoding/hex" - "encoding/json" "fmt" "os" "path/filepath" @@ -281,69 +280,91 @@ func InitConfig() (*ClicdConfig, error) { dataDir := getDataDir() if err := os.MkdirAll(filepath.Dir(cfgPath), 0700); err != nil { - return nil, fmt.Errorf("failed to create config directory: %v", err) + 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 + } - if _, err := os.Stat(cfgPath); os.IsNotExist(err) { - // First run: generate new config - 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) + cfg, ok, err := loadConfigFromDB() + if err != nil { + return nil, err + } + if ok { + AppConfig = cfg + normalizeConfigDefaults(dataDir) + if migrateLoadedConfig() { + if err := SaveConfig(); err != nil { + return nil, err + } } - - AppConfig = &ClicdConfig{ - AdminUser: adminUser, - AdminPassHash: string(hash), - JWTSecret: jwtSecret, - Port: 8999, - DataDir: dataDir, - Containers: []Container{}, - NextContainerID: 1, - NextVNCPort: 5900, - NextSSHPort: 22000, - SetupComplete: false, - SubUsers: []SubUser{}, - AuditLogs: []AuditLog{}, - Tasks: []SavedTask{}, - LoginLogs: []SavedLoginLog{}, - Snapshots: []Snapshot{}, - } - - 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 } - // Load existing config - data, err := os.ReadFile(cfgPath) + legacy, ok, err := loadLegacyJSONConfig(cfgPath) if err != nil { - return nil, fmt.Errorf("failed to read config: %v", err) + return nil, err + } + if ok { + AppConfig = legacy + normalizeConfigDefaults(dataDir) + if migrateLoadedConfig() { + // Save below persists normalized legacy data into SQLite. + } + if err := SaveConfig(); err != nil { + return nil, err + } + return AppConfig, nil } - AppConfig = &ClicdConfig{} - if err := json.Unmarshal(data, AppConfig); err != nil { - return nil, fmt.Errorf("failed to parse config: %v", err) + 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, + SetupComplete: false, + SubUsers: []SubUser{}, + AuditLogs: []AuditLog{}, + Tasks: []SavedTask{}, + LoginLogs: []SavedLoginLog{}, + Snapshots: []Snapshot{}, + } + + 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) { if AppConfig.Port == 0 { AppConfig.Port = 8999 } @@ -365,6 +386,27 @@ func InitConfig() (*ClicdConfig, error) { if AppConfig.Snapshots == nil { AppConfig.Snapshots = make([]Snapshot, 0) } + if AppConfig.SubUsers == nil { + AppConfig.SubUsers = make([]SubUser, 0) + } + if AppConfig.ApiKeys == nil { + AppConfig.ApiKeys = make([]ApiKeyConfig, 0) + } + if AppConfig.AuditLogs == nil { + AppConfig.AuditLogs = make([]AuditLog, 0) + } + if AppConfig.Tasks == nil { + AppConfig.Tasks = make([]SavedTask, 0) + } + if AppConfig.LoginLogs == nil { + AppConfig.LoginLogs = make([]SavedLoginLog, 0) + } + if AppConfig.EnabledImages == nil { + AppConfig.EnabledImages = make([]string, 0) + } +} + +func migrateLoadedConfig() bool { changed := ensureContainerUUIDs() if ensureContainerVirtualization() { changed = true @@ -384,13 +426,7 @@ func InitConfig() (*ClicdConfig, error) { if removeLegacyVNCMappings() { changed = true } - if changed { - if err := SaveConfig(); err != nil { - return nil, err - } - } - - return AppConfig, nil + return changed } func ensureContainerVirtualization() bool { @@ -541,11 +577,7 @@ func removeLegacyVNCMappings() bool { // SaveConfig saves configuration to disk func SaveConfig() error { - data, err := json.MarshalIndent(AppConfig, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal config: %v", err) - } - return os.WriteFile(getConfigPath(), data, 0600) + return saveConfigToDB() } // AddContainer adds a container to the config diff --git a/backend/internal/config/store_sqlite.go b/backend/internal/config/store_sqlite.go new file mode 100644 index 0000000..f15c617 --- /dev/null +++ b/backend/internal/config/store_sqlite.go @@ -0,0 +1,865 @@ +package config + +import ( + "database/sql" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + _ "modernc.org/sqlite" +) + +var ( + dbMu sync.Mutex + db *sql.DB +) + +type savedTaskConfig struct { + Name string `json:"name"` + Virtualization string `json:"virtualization,omitempty"` + TemplateID string `json:"template_id"` + VCPU float64 `json:"vcpu"` + CPUPercent int `json:"cpu_percent"` + RAMMB int `json:"ram_mb"` + DiskGB int `json:"disk_gb"` + NetworkBWMbps int `json:"network_bw_mbps"` + MonthlyTrafficGB int `json:"monthly_traffic_gb"` + TrafficMode string `json:"traffic_mode"` + TrafficInGB int `json:"traffic_in_gb"` + TrafficOutGB int `json:"traffic_out_gb"` + IOSpeedMBps int `json:"io_speed_mbps"` + ExtraPorts []int `json:"extra_ports"` + PortMappingCount int `json:"port_mapping_count"` + SnapshotLimit int `json:"snapshot_limit"` + AssignIPv6 bool `json:"assign_ipv6"` + ExpiresAt string `json:"expires_at"` +} + +func parseSavedTaskConfig(raw string) savedTaskConfig { + if raw == "" { + return savedTaskConfig{} + } + var cfg savedTaskConfig + _ = json.Unmarshal([]byte(raw), &cfg) + return cfg +} + +func encodeSavedTaskConfig(cfg savedTaskConfig) string { + data, err := json.Marshal(cfg) + if err != nil { + return "" + } + return string(data) +} + +func getDBPath() string { + cfgPath := getConfigPath() + ext := filepath.Ext(cfgPath) + if ext == "" { + return cfgPath + ".db" + } + return strings.TrimSuffix(cfgPath, ext) + ".db" +} + +func openConfigDB() error { + if db != nil { + return nil + } + dbPath := getDBPath() + if err := os.MkdirAll(filepath.Dir(dbPath), 0700); err != nil { + return fmt.Errorf("failed to create database directory: %v", err) + } + next, err := sql.Open("sqlite", dbPath) + if err != nil { + return fmt.Errorf("failed to open sqlite database: %v", err) + } + next.SetMaxOpenConns(1) + next.SetMaxIdleConns(1) + + for _, stmt := range []string{ + "PRAGMA journal_mode=WAL", + "PRAGMA synchronous=NORMAL", + "PRAGMA busy_timeout=5000", + "PRAGMA foreign_keys=ON", + } { + if _, err := next.Exec(stmt); err != nil { + _ = next.Close() + return fmt.Errorf("failed to initialize sqlite pragma: %v", err) + } + } + + db = next + return ensureSchema() +} + +func ensureSchema() error { + stmts := []string{ + `CREATE TABLE IF NOT EXISTS app_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS containers ( + id INTEGER PRIMARY KEY, + uuid TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + virtualization TEXT, + lxc_name TEXT, + kvm_name TEXT, + disk_image TEXT, + mac_address TEXT, + template TEXT, + vcpu REAL, + ram_mb INTEGER, + disk_gb INTEGER, + network_bw_mbps INTEGER, + monthly_traffic_gb INTEGER, + traffic_mode TEXT, + traffic_in_gb INTEGER, + traffic_out_gb INTEGER, + traffic_used_rx INTEGER, + traffic_used_tx INTEGER, + traffic_reset_date TEXT, + io_speed_mbps INTEGER, + status TEXT, + ip TEXT, + ipv6 TEXT, + ipv6_prefix_len INTEGER, + ipv6_interface TEXT, + vnc_port INTEGER, + ssh_port INTEGER, + ssh_password TEXT, + ssh_host_key TEXT, + port_mapping_limit INTEGER, + snapshot_limit INTEGER, + created_at TEXT, + expires_at TEXT, + snapshot_schedule_enabled INTEGER, + snapshot_schedule_interval_hours INTEGER, + snapshot_schedule_time TEXT, + snapshot_schedule_last_run TEXT, + snapshot_schedule_next_run TEXT, + snapshot_schedule_created_by TEXT, + policy_blocked INTEGER, + policy_blocked_reason TEXT, + policy_blocked_at TEXT + )`, + `CREATE TABLE IF NOT EXISTS port_mappings ( + container_id INTEGER NOT NULL, + position INTEGER NOT NULL, + container_port INTEGER NOT NULL, + host_port INTEGER NOT NULL, + protocol TEXT, + description TEXT, + PRIMARY KEY (container_id, position) + )`, + `CREATE TABLE IF NOT EXISTS sub_users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password TEXT, + pass_hash TEXT, + access_code TEXT, + created_at TEXT, + token_version INTEGER + )`, + `CREATE TABLE IF NOT EXISTS sub_user_container_names ( + sub_user_id TEXT NOT NULL, + position INTEGER NOT NULL, + container_name TEXT NOT NULL, + PRIMARY KEY (sub_user_id, position) + )`, + `CREATE TABLE IF NOT EXISTS sub_user_container_uuids ( + sub_user_id TEXT NOT NULL, + position INTEGER NOT NULL, + container_uuid TEXT NOT NULL, + PRIMARY KEY (sub_user_id, position) + )`, + `CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + name TEXT, + key_hash TEXT, + prefix TEXT, + ip_whitelist TEXT, + created_at TEXT, + last_used TEXT + )`, + `CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + time TEXT, + action TEXT, + target TEXT, + detail TEXT, + user TEXT, + ip TEXT, + user_agent TEXT, + success_set INTEGER, + success INTEGER, + error TEXT + )`, + `CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + type TEXT, + container_id INTEGER, + container_name TEXT, + status TEXT, + error TEXT, + created_at TEXT, + template_id TEXT, + user TEXT, + cfg_name TEXT, + cfg_virtualization TEXT, + cfg_template_id TEXT, + cfg_vcpu REAL, + cfg_cpu_percent INTEGER, + cfg_ram_mb INTEGER, + cfg_disk_gb INTEGER, + cfg_network_bw_mbps INTEGER, + cfg_monthly_traffic_gb INTEGER, + cfg_traffic_mode TEXT, + cfg_traffic_in_gb INTEGER, + cfg_traffic_out_gb INTEGER, + cfg_io_speed_mbps INTEGER, + cfg_port_mapping_count INTEGER, + cfg_snapshot_limit INTEGER, + cfg_assign_ipv6 INTEGER, + cfg_expires_at TEXT + )`, + `CREATE TABLE IF NOT EXISTS task_extra_ports ( + task_id TEXT NOT NULL, + position INTEGER NOT NULL, + port INTEGER NOT NULL, + PRIMARY KEY (task_id, position) + )`, + `CREATE TABLE IF NOT EXISTS login_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + time TEXT, + username TEXT, + ip TEXT, + user_agent TEXT, + success INTEGER + )`, + `CREATE TABLE IF NOT EXISTS enabled_images ( + position INTEGER PRIMARY KEY, + image_id TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS snapshots ( + id TEXT PRIMARY KEY, + container_id INTEGER, + container_name TEXT, + lxc_name TEXT, + created_at TEXT, + created_by TEXT, + scheduled INTEGER, + path TEXT, + size_bytes INTEGER + )`, + } + for _, stmt := range stmts { + if _, err := db.Exec(stmt); err != nil { + return fmt.Errorf("failed to create sqlite schema: %v", err) + } + } + return nil +} + +func loadConfigFromDB() (*ClicdConfig, bool, error) { + meta := map[string]string{} + rows, err := db.Query("SELECT key, value FROM app_meta") + if err != nil { + return nil, false, err + } + defer rows.Close() + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, false, err + } + meta[k] = v + } + if err := rows.Err(); err != nil { + return nil, false, err + } + if meta["admin_user"] == "" { + return nil, false, nil + } + + cfg := &ClicdConfig{ + AdminUser: meta["admin_user"], + AdminPassHash: meta["admin_pass_hash"], + JWTSecret: meta["jwt_secret"], + Port: atoi(meta["port"]), + DataDir: meta["data_dir"], + NextContainerID: atoi(meta["next_container_id"]), + NextVNCPort: atoi(meta["next_vnc_port"]), + NextSSHPort: atoi(meta["next_ssh_port"]), + SetupComplete: atob(meta["setup_complete"]), + SecurityAutoShutdown: atob(meta["security_auto_shutdown"]), + } + + if cfg.Containers, err = loadContainers(); err != nil { + return nil, false, err + } + if cfg.SubUsers, err = loadSubUsers(); err != nil { + return nil, false, err + } + if cfg.ApiKeys, err = loadAPIKeys(); err != nil { + return nil, false, err + } + if cfg.AuditLogs, err = loadAuditLogs(); err != nil { + return nil, false, err + } + if cfg.Tasks, err = loadTasks(); err != nil { + return nil, false, err + } + if cfg.LoginLogs, err = loadLoginLogs(); err != nil { + return nil, false, err + } + if cfg.EnabledImages, err = loadEnabledImages(); err != nil { + return nil, false, err + } + if cfg.Snapshots, err = loadSnapshots(); err != nil { + return nil, false, err + } + return cfg, true, nil +} + +func saveConfigToDB() error { + if db == nil { + return fmt.Errorf("sqlite database is not initialized") + } + dbMu.Lock() + defer dbMu.Unlock() + + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + for _, table := range []string{ + "port_mappings", + "sub_user_container_names", + "sub_user_container_uuids", + "containers", + "sub_users", + "api_keys", + "audit_logs", + "task_extra_ports", + "tasks", + "login_logs", + "enabled_images", + "snapshots", + "app_meta", + } { + if _, err := tx.Exec("DELETE FROM " + table); err != nil { + return err + } + } + + if err := saveMeta(tx); err != nil { + return err + } + if err := saveContainers(tx); err != nil { + return err + } + if err := saveSubUsers(tx); err != nil { + return err + } + if err := saveAPIKeys(tx); err != nil { + return err + } + if err := saveAuditLogs(tx); err != nil { + return err + } + if err := saveTasksDB(tx); err != nil { + return err + } + if err := saveLoginLogs(tx); err != nil { + return err + } + if err := saveEnabledImages(tx); err != nil { + return err + } + if err := saveSnapshots(tx); err != nil { + return err + } + return tx.Commit() +} + +func saveMeta(tx *sql.Tx) error { + values := map[string]string{ + "admin_user": AppConfig.AdminUser, + "admin_pass_hash": AppConfig.AdminPassHash, + "jwt_secret": AppConfig.JWTSecret, + "port": strconv.Itoa(AppConfig.Port), + "data_dir": AppConfig.DataDir, + "next_container_id": strconv.Itoa(AppConfig.NextContainerID), + "next_vnc_port": strconv.Itoa(AppConfig.NextVNCPort), + "next_ssh_port": strconv.Itoa(AppConfig.NextSSHPort), + "setup_complete": btoa(AppConfig.SetupComplete), + "security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown), + "schema_version": "1", + "updated_at": time.Now().Format("2006-01-02 15:04:05"), + } + for k, v := range values { + if _, err := tx.Exec("INSERT INTO app_meta(key, value) VALUES (?, ?)", k, v); err != nil { + return err + } + } + return nil +} + +func saveContainers(tx *sql.Tx) error { + for _, c := range AppConfig.Containers { + if _, err := tx.Exec(`INSERT INTO containers ( + id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template, + vcpu, ram_mb, disk_gb, network_bw_mbps, monthly_traffic_gb, traffic_mode, traffic_in_gb, + traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date, io_speed_mbps, + status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password, + ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at, + snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time, + snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by, + policy_blocked, policy_blocked_reason, policy_blocked_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.MACAddress, c.Template, + c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB, + c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate, c.IOSpeedMBps, + c.Status, c.IP, c.IPv6, c.IPv6PrefixLen, c.IPv6Interface, c.VNCPort, c.SSHPort, c.SSHPassword, + c.SSHHostKey, c.PortMappingLimit, c.SnapshotLimit, c.CreatedAt, c.ExpiresAt, + boolInt(c.SnapshotScheduleEnabled), c.SnapshotScheduleIntervalHours, c.SnapshotScheduleTime, + c.SnapshotScheduleLastRun, c.SnapshotScheduleNextRun, c.SnapshotScheduleCreatedBy, + boolInt(c.PolicyBlocked), c.PolicyBlockedReason, c.PolicyBlockedAt, + ); err != nil { + return err + } + for i, pm := range c.PortMappings { + if _, err := tx.Exec(`INSERT INTO port_mappings(container_id, position, container_port, host_port, protocol, description) + VALUES (?, ?, ?, ?, ?, ?)`, c.ID, i, pm.ContainerPort, pm.HostPort, pm.Protocol, pm.Description); err != nil { + return err + } + } + } + return nil +} + +func saveSubUsers(tx *sql.Tx) error { + for _, su := range AppConfig.SubUsers { + if _, err := tx.Exec(`INSERT INTO sub_users(id, username, password, pass_hash, access_code, created_at, token_version) + VALUES (?, ?, ?, ?, ?, ?, ?)`, su.ID, su.Username, su.Password, su.PassHash, su.AccessCode, su.CreatedAt, su.TokenVersion); err != nil { + return err + } + for i, name := range su.ContainerNames { + if _, err := tx.Exec(`INSERT INTO sub_user_container_names(sub_user_id, position, container_name) VALUES (?, ?, ?)`, su.ID, i, name); err != nil { + return err + } + } + for i, uuid := range su.ContainerUUIDs { + if _, err := tx.Exec(`INSERT INTO sub_user_container_uuids(sub_user_id, position, container_uuid) VALUES (?, ?, ?)`, su.ID, i, uuid); err != nil { + return err + } + } + } + return nil +} + +func saveAPIKeys(tx *sql.Tx) error { + for _, k := range AppConfig.ApiKeys { + if _, err := tx.Exec(`INSERT INTO api_keys(id, name, key_hash, prefix, ip_whitelist, created_at, last_used) + VALUES (?, ?, ?, ?, ?, ?, ?)`, k.ID, k.Name, k.KeyHash, k.Prefix, k.IPWhitelist, k.CreatedAt, k.LastUsed); err != nil { + return err + } + } + return nil +} + +func saveAuditLogs(tx *sql.Tx) error { + for _, log := range AppConfig.AuditLogs { + successSet := 0 + success := 0 + if log.Success != nil { + successSet = 1 + if *log.Success { + success = 1 + } + } + if _, err := tx.Exec(`INSERT INTO audit_logs(time, action, target, detail, user, ip, user_agent, success_set, success, error) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, log.Time, log.Action, log.Target, log.Detail, log.User, log.IP, log.UserAgent, successSet, success, log.Error); err != nil { + return err + } + } + return nil +} + +func saveTasksDB(tx *sql.Tx) error { + for _, task := range AppConfig.Tasks { + cfg := parseSavedTaskConfig(task.Config) + if _, err := tx.Exec(`INSERT INTO tasks( + id, type, container_id, container_name, status, error, created_at, template_id, user, + cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb, + cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb, + cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit, + cfg_assign_ipv6, cfg_expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, + cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB, + cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB, + cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, cfg.SnapshotLimit, + boolInt(cfg.AssignIPv6), cfg.ExpiresAt, + ); err != nil { + return err + } + for i, port := range cfg.ExtraPorts { + if _, err := tx.Exec(`INSERT INTO task_extra_ports(task_id, position, port) VALUES (?, ?, ?)`, task.ID, i, port); err != nil { + return err + } + } + } + return nil +} + +func saveLoginLogs(tx *sql.Tx) error { + for _, log := range AppConfig.LoginLogs { + if _, err := tx.Exec(`INSERT INTO login_logs(time, username, ip, user_agent, success) VALUES (?, ?, ?, ?, ?)`, + log.Time, log.Username, log.IP, log.UserAgent, boolInt(log.Success)); err != nil { + return err + } + } + return nil +} + +func saveEnabledImages(tx *sql.Tx) error { + for i, id := range AppConfig.EnabledImages { + if _, err := tx.Exec(`INSERT INTO enabled_images(position, image_id) VALUES (?, ?)`, i, id); err != nil { + return err + } + } + return nil +} + +func saveSnapshots(tx *sql.Tx) error { + for _, snapshot := range AppConfig.Snapshots { + if _, err := tx.Exec(`INSERT INTO snapshots(id, container_id, container_name, lxc_name, created_at, created_by, scheduled, path, size_bytes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, snapshot.ID, snapshot.ContainerID, snapshot.ContainerName, snapshot.LXCName, snapshot.CreatedAt, snapshot.CreatedBy, boolInt(snapshot.Scheduled), snapshot.Path, snapshot.SizeBytes); err != nil { + return err + } + } + return nil +} + +func loadContainers() ([]Container, error) { + rows, err := db.Query(`SELECT + id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template, + vcpu, ram_mb, disk_gb, network_bw_mbps, monthly_traffic_gb, traffic_mode, traffic_in_gb, + traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date, io_speed_mbps, + status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password, + ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at, + snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time, + snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by, + policy_blocked, policy_blocked_reason, policy_blocked_at + FROM containers ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + + result := []Container{} + for rows.Next() { + var c Container + var scheduleEnabled, policyBlocked int + if err := rows.Scan( + &c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &c.MACAddress, &c.Template, + &c.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB, + &c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate, &c.IOSpeedMBps, + &c.Status, &c.IP, &c.IPv6, &c.IPv6PrefixLen, &c.IPv6Interface, &c.VNCPort, &c.SSHPort, &c.SSHPassword, + &c.SSHHostKey, &c.PortMappingLimit, &c.SnapshotLimit, &c.CreatedAt, &c.ExpiresAt, + &scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime, + &c.SnapshotScheduleLastRun, &c.SnapshotScheduleNextRun, &c.SnapshotScheduleCreatedBy, + &policyBlocked, &c.PolicyBlockedReason, &c.PolicyBlockedAt, + ); err != nil { + return nil, err + } + c.SnapshotScheduleEnabled = scheduleEnabled != 0 + c.PolicyBlocked = policyBlocked != 0 + result = append(result, c) + } + if err := rows.Err(); err != nil { + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + for i := range result { + result[i].PortMappings, err = loadPortMappings(result[i].ID) + if err != nil { + return nil, err + } + } + return result, nil +} + +func loadPortMappings(containerID int) ([]PortMapping, error) { + rows, err := db.Query(`SELECT container_port, host_port, protocol, description FROM port_mappings WHERE container_id = ? ORDER BY position`, containerID) + if err != nil { + return nil, err + } + defer rows.Close() + result := []PortMapping{} + for rows.Next() { + var pm PortMapping + if err := rows.Scan(&pm.ContainerPort, &pm.HostPort, &pm.Protocol, &pm.Description); err != nil { + return nil, err + } + result = append(result, pm) + } + return result, rows.Err() +} + +func loadSubUsers() ([]SubUser, error) { + rows, err := db.Query(`SELECT id, username, password, pass_hash, access_code, created_at, token_version FROM sub_users ORDER BY created_at, id`) + if err != nil { + return nil, err + } + defer rows.Close() + result := []SubUser{} + for rows.Next() { + var su SubUser + if err := rows.Scan(&su.ID, &su.Username, &su.Password, &su.PassHash, &su.AccessCode, &su.CreatedAt, &su.TokenVersion); err != nil { + return nil, err + } + result = append(result, su) + } + if err := rows.Err(); err != nil { + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + for i := range result { + result[i].ContainerNames, err = loadStringList("sub_user_container_names", "container_name", "sub_user_id", result[i].ID) + if err != nil { + return nil, err + } + result[i].ContainerUUIDs, err = loadStringList("sub_user_container_uuids", "container_uuid", "sub_user_id", result[i].ID) + if err != nil { + return nil, err + } + } + return result, nil +} + +func loadStringList(table, valueColumn, keyColumn, key string) ([]string, error) { + rows, err := db.Query(fmt.Sprintf(`SELECT %s FROM %s WHERE %s = ? ORDER BY position`, valueColumn, table, keyColumn), key) + if err != nil { + return nil, err + } + defer rows.Close() + values := []string{} + for rows.Next() { + var value string + if err := rows.Scan(&value); err != nil { + return nil, err + } + values = append(values, value) + } + return values, rows.Err() +} + +func loadAPIKeys() ([]ApiKeyConfig, error) { + rows, err := db.Query(`SELECT id, name, key_hash, prefix, ip_whitelist, created_at, last_used FROM api_keys ORDER BY created_at, id`) + if err != nil { + return nil, err + } + defer rows.Close() + result := []ApiKeyConfig{} + for rows.Next() { + var k ApiKeyConfig + if err := rows.Scan(&k.ID, &k.Name, &k.KeyHash, &k.Prefix, &k.IPWhitelist, &k.CreatedAt, &k.LastUsed); err != nil { + return nil, err + } + result = append(result, k) + } + return result, rows.Err() +} + +func loadAuditLogs() ([]AuditLog, error) { + rows, err := db.Query(`SELECT time, action, target, detail, user, ip, user_agent, success_set, success, error FROM audit_logs ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + result := []AuditLog{} + for rows.Next() { + var log AuditLog + var successSet, success int + if err := rows.Scan(&log.Time, &log.Action, &log.Target, &log.Detail, &log.User, &log.IP, &log.UserAgent, &successSet, &success, &log.Error); err != nil { + return nil, err + } + if successSet != 0 { + value := success != 0 + log.Success = &value + } + result = append(result, log) + } + return result, rows.Err() +} + +func loadTasks() ([]SavedTask, error) { + rows, err := db.Query(`SELECT + id, type, container_id, container_name, status, error, created_at, template_id, user, + cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb, + cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb, + cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit, + cfg_assign_ipv6, cfg_expires_at + FROM tasks ORDER BY created_at, id`) + if err != nil { + return nil, err + } + defer rows.Close() + result := []SavedTask{} + configs := []savedTaskConfig{} + for rows.Next() { + var t SavedTask + var cfg savedTaskConfig + var assignIPv6 int + if err := rows.Scan( + &t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, + &cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB, + &cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB, + &cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &cfg.SnapshotLimit, + &assignIPv6, &cfg.ExpiresAt, + ); err != nil { + return nil, err + } + cfg.AssignIPv6 = assignIPv6 != 0 + result = append(result, t) + configs = append(configs, cfg) + } + if err := rows.Err(); err != nil { + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + for i := range result { + configs[i].ExtraPorts, err = loadTaskExtraPorts(result[i].ID) + if err != nil { + return nil, err + } + result[i].Config = encodeSavedTaskConfig(configs[i]) + } + return result, nil +} + +func loadTaskExtraPorts(taskID string) ([]int, error) { + rows, err := db.Query(`SELECT port FROM task_extra_ports WHERE task_id = ? ORDER BY position`, taskID) + if err != nil { + return nil, err + } + defer rows.Close() + result := []int{} + for rows.Next() { + var port int + if err := rows.Scan(&port); err != nil { + return nil, err + } + result = append(result, port) + } + return result, rows.Err() +} + +func loadLoginLogs() ([]SavedLoginLog, error) { + rows, err := db.Query(`SELECT time, username, ip, user_agent, success FROM login_logs ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + result := []SavedLoginLog{} + for rows.Next() { + var log SavedLoginLog + var success int + if err := rows.Scan(&log.Time, &log.Username, &log.IP, &log.UserAgent, &success); err != nil { + return nil, err + } + log.Success = success != 0 + result = append(result, log) + } + return result, rows.Err() +} + +func loadEnabledImages() ([]string, error) { + rows, err := db.Query(`SELECT image_id FROM enabled_images ORDER BY position`) + if err != nil { + return nil, err + } + defer rows.Close() + result := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + result = append(result, id) + } + return result, rows.Err() +} + +func loadSnapshots() ([]Snapshot, error) { + rows, err := db.Query(`SELECT id, container_id, container_name, lxc_name, created_at, created_by, scheduled, path, size_bytes FROM snapshots ORDER BY created_at, id`) + if err != nil { + return nil, err + } + defer rows.Close() + result := []Snapshot{} + for rows.Next() { + var snapshot Snapshot + var scheduled int + if err := rows.Scan(&snapshot.ID, &snapshot.ContainerID, &snapshot.ContainerName, &snapshot.LXCName, &snapshot.CreatedAt, &snapshot.CreatedBy, &scheduled, &snapshot.Path, &snapshot.SizeBytes); err != nil { + return nil, err + } + snapshot.Scheduled = scheduled != 0 + result = append(result, snapshot) + } + return result, rows.Err() +} + +func loadLegacyJSONConfig(path string) (*ClicdConfig, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, false, nil + } + if err != nil { + return nil, false, fmt.Errorf("failed to read legacy config: %v", err) + } + cfg := &ClicdConfig{} + if err := json.Unmarshal(data, cfg); err != nil { + return nil, false, fmt.Errorf("failed to parse legacy config: %v", err) + } + return cfg, true, nil +} + +func boolInt(value bool) int { + if value { + return 1 + } + return 0 +} + +func btoa(value bool) string { + if value { + return "1" + } + return "0" +} + +func atob(value string) bool { + return value == "1" || strings.EqualFold(value, "true") +} + +func atoi(value string) int { + n, _ := strconv.Atoi(value) + return n +} diff --git a/backend/internal/config/store_sqlite_test.go b/backend/internal/config/store_sqlite_test.go new file mode 100644 index 0000000..a3a77de --- /dev/null +++ b/backend/internal/config/store_sqlite_test.go @@ -0,0 +1,126 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) { + resetConfigStoreForTest(t) + + dir := t.TempDir() + t.Cleanup(func() { + resetConfigStoreForTest(t) + }) + legacyPath := filepath.Join(dir, "config.json") + SetConfigPath(legacyPath) + + legacy := ClicdConfig{ + AdminUser: "admin", + AdminPassHash: "hash", + JWTSecret: "secret", + Port: 8999, + DataDir: dir, + NextContainerID: 2, + NextVNCPort: 5900, + NextSSHPort: 22000, + Containers: []Container{{ + ID: 1, + UUID: "uuid-1", + Name: "ct1", + Virtualization: "lxc", + Template: "debian-12", + Status: "running", + PortMappingLimit: 2, + SnapshotLimit: 3, + PortMappings: []PortMapping{{ + ContainerPort: 22, + HostPort: 22001, + Protocol: "tcp", + Description: "SSH", + }}, + }}, + AuditLogs: []AuditLog{{ + Time: "2026-06-07 17:29:00", + Action: "security_horizontal_scan", + Target: "ct1", + Detail: "[medium] 可疑横向探测", + User: "system", + }}, + LoginLogs: []SavedLoginLog{{ + Time: "2026-06-07 17:29:01 CST", + Username: "admin", + IP: "127.0.0.1", + UserAgent: "test", + Success: true, + }}, + Tasks: []SavedTask{{ + ID: "task-1", + Type: "create", + ContainerName: "ct2", + Status: "pending", + CreatedAt: "2026-06-07 17:29:02", + Config: `{"name":"ct2","template_id":"debian-12","vcpu":1,"ram_mb":512,"disk_gb":5,"extra_ports":[80,443],"assign_ipv6":true}`, + }}, + EnabledImages: []string{"debian-12"}, + Snapshots: []Snapshot{{ + ID: "snap-1", + ContainerID: 1, + ContainerName: "ct1", + LXCName: "ct-1", + CreatedAt: "2026-06-07 17:30:00", + Path: filepath.Join(dir, "snap-1"), + }}, + } + data, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(legacyPath, data, 0600); err != nil { + t.Fatal(err) + } + + cfg, err := InitConfig() + if err != nil { + t.Fatal(err) + } + if len(cfg.Containers) != 1 || len(cfg.Containers[0].PortMappings) != 1 { + t.Fatalf("legacy config was not migrated: %+v", cfg.Containers) + } + if len(cfg.Tasks) != 1 || !strings.Contains(cfg.Tasks[0].Config, `"extra_ports":[80,443]`) { + t.Fatalf("task config was not restored from sqlite columns: %+v", cfg.Tasks) + } + if _, err := os.Stat(filepath.Join(dir, "config.db")); err != nil { + t.Fatalf("sqlite database was not created: %v", err) + } + + cfg.Containers[0].Status = "stopped" + if err := SaveConfig(); err != nil { + t.Fatal(err) + } + + resetConfigStoreForTest(t) + SetConfigPath(legacyPath) + cfg, err = InitConfig() + if err != nil { + t.Fatal(err) + } + if got := cfg.Containers[0].Status; got != "stopped" { + t.Fatalf("expected sqlite value to win after migration, got %q", got) + } +} + +func resetConfigStoreForTest(t *testing.T) { + t.Helper() + if db != nil { + if err := db.Close(); err != nil { + t.Fatal(err) + } + db = nil + } + AppConfig = nil + configPath = "" +} diff --git a/backend/internal/server/web/.gitkeep b/backend/internal/server/web/.gitkeep new file mode 100644 index 0000000..30259b2 --- /dev/null +++ b/backend/internal/server/web/.gitkeep @@ -0,0 +1 @@ + diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 397ba3d..2cf5a69 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -27,6 +27,23 @@ interface SidebarProps { onToggle: () => void } +function GitHubIcon({ className = '' }: { className?: string }) { + return ( + + ) +} + export default function Sidebar({ collapsed, onToggle }: SidebarProps) { const navigate = useNavigate() const location = useLocation() @@ -239,9 +256,29 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) { {version && (
{collapsed ? ( - v{version.split('.').slice(0, 2).join('.')} + + + ) : ( - v{version} +
+ + + CLICD + + v{version} +
)}
)} diff --git a/install.sh b/install.sh index 654455f..8bd3203 100644 --- a/install.sh +++ b/install.sh @@ -138,6 +138,157 @@ remove_lxc_container_dir() { log "Removed $container_dir" } +remove_kvm_domain() { + domain="$1" + case "$domain" in + vm-[0-9]*) + ;; + *) + return + ;; + esac + suffix="${domain#vm-}" + case "$suffix" in + ""|*[!0-9]*) + return + ;; + esac + if [ ! -d "/var/lib/clicd/kvm/instances/$domain" ] && + ! virsh dumpxml "$domain" 2>/dev/null | grep -q '/var/lib/clicd/kvm/'; then + return + fi + + log "Removing KVM domain $domain..." + virsh destroy "$domain" >/dev/null 2>&1 || true + virsh undefine "$domain" --remove-all-storage --nvram >/dev/null 2>&1 || + virsh undefine "$domain" --nvram >/dev/null 2>&1 || + virsh undefine "$domain" >/dev/null 2>&1 || + true +} + +destroy_clicd_kvm_domains() { + if ! has_cmd virsh; then + return + fi + + log "Destroying CLICD KVM domains..." + virsh list --all --name 2>/dev/null | while IFS= read -r domain; do + [ -n "$domain" ] || continue + remove_kvm_domain "$domain" + done +} + +delete_iptables_lines() { + table="$1" + chain="$2" + pattern="$3" + if ! has_cmd iptables; then + return + fi + + while :; do + line="$(iptables -t "$table" -L "$chain" -n --line-numbers 2>/dev/null | awk -v pat="$pattern" '$0 ~ pat {print $1; exit}')" + [ -n "$line" ] || break + iptables -t "$table" -D "$chain" "$line" >/dev/null 2>&1 || break + done +} + +delete_iptables_rule() { + table="$1" + shift + if ! has_cmd iptables; then + return + fi + + while iptables -t "$table" -D "$@" >/dev/null 2>&1; do + : + done +} + +delete_filter_rule() { + if ! has_cmd iptables; then + return + fi + + while iptables -D "$@" >/dev/null 2>&1; do + : + done +} + +delete_ip6tables_bridge_rules() { + if ! has_cmd ip6tables; then + return + fi + + for bridge in lxcbr0 virbr0; do + while :; do + rule="$(ip6tables -S FORWARD 2>/dev/null | grep -- "$bridge" | sed 's/^-A /-D /' | head -n 1)" + [ -n "$rule" ] || break + # shellcheck disable=SC2086 + ip6tables $rule >/dev/null 2>&1 || break + done + done +} + +cleanup_clicd_networking() { + log "Cleaning CLICD firewall and bridge rules..." + delete_iptables_lines nat PREROUTING 'clicd-' + delete_iptables_rule nat POSTROUTING -s 10.0.3.0/24 -o eth+ -j MASQUERADE + delete_iptables_rule nat POSTROUTING -s 192.168.122.0/24 -o eth+ -j MASQUERADE + + for bridge in lxcbr0 virbr0; do + delete_filter_rule FORWARD -i "$bridge" -j ACCEPT + delete_filter_rule FORWARD -o "$bridge" -j ACCEPT + delete_filter_rule FORWARD -i "$bridge" -o "$bridge" -j ACCEPT + done + delete_ip6tables_bridge_rules +} + +remove_clicd_host_hooks() { + if has_cmd systemctl; then + systemctl stop clicd-kvm-ipv6.service >/dev/null 2>&1 || true + systemctl disable clicd-kvm-ipv6.service >/dev/null 2>&1 || true + fi + if has_cmd rc-service; then + rc-service clicd-kvm-ipv6 stop >/dev/null 2>&1 || true + fi + if has_cmd rc-update; then + rc-update del clicd-kvm-ipv6 default >/dev/null 2>&1 || true + fi + + remove_path /usr/local/sbin/clicd-kvm-ipv6-init + remove_path /etc/systemd/system/clicd-kvm-ipv6.service + remove_path /etc/local.d/clicd-kvm-ipv6.start + remove_path /etc/network/if-up.d/clicd-kvm-ipv6 +} + +remove_clicd_quota_records() { + for file in /etc/projects /etc/projid; do + [ -f "$file" ] || continue + tmp="${file}.clicd-clean.$$" + grep -v 'clicd-' "$file" > "$tmp" || true + cat "$tmp" > "$file" + rm -f "$tmp" + log "Cleaned CLICD quota records from $file" + done +} + +remove_clicd_tmp_files() { + for path in /tmp/clicd-* /tmp/clicd.*; do + [ -e "$path" ] || [ -L "$path" ] || continue + rm -rf "$path" + log "Removed $path" + done +} + +remove_clicd_swapfile() { + if [ ! -e /swapfile ]; then + return + fi + swapoff /swapfile >/dev/null 2>&1 || true + remove_path /swapfile +} + uninstall_clicd() { log "Uninstalling CLICD..." @@ -158,6 +309,10 @@ uninstall_clicd() { [ -d "$container_dir" ] || continue remove_lxc_container_dir "$container_dir" done + destroy_clicd_kvm_domains + cleanup_clicd_networking + remove_clicd_host_hooks + remove_clicd_quota_records remove_path /etc/systemd/system/clicd.service remove_path /etc/init.d/clicd @@ -168,7 +323,13 @@ uninstall_clicd() { remove_path /root/.clicd unmount_path_tree /var/lib/lxc remove_path /var/lib/lxc + unmount_path_tree /var/lib/clicd + remove_path /var/lib/clicd remove_path /var/cache/lxc + remove_path /var/cache/clicd + remove_path /root/clicd-backups + remove_clicd_tmp_files + remove_clicd_swapfile if has_cmd systemctl; then systemctl daemon-reload >/dev/null 2>&1 || true @@ -182,7 +343,9 @@ uninstall_clicd() { echo "=====================================" echo " CLICD Uninstalled" echo "=====================================" - echo " Removed service, binary, config, containers, and LXC image cache." + echo " Removed service, binary, SQLite/config data, LXC containers," + echo " CLICD KVM domains, VM images, image caches, firewall rules," + echo " host hooks, quota records, temp files, backups, and swapfile." echo "=====================================" } @@ -220,9 +383,16 @@ install_apk() { bridge-utils \ iproute2 \ iptables \ - dnsmasq + dnsmasq \ + dbus \ + qemu-system-x86_64 \ + qemu-img \ + libvirt \ + libvirt-daemon \ + libvirt-client \ + libvirt-qemu - for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs; do + for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs cloud-utils genisoimage xorriso; do apk add --no-cache "$pkg" >/dev/null 2>&1 || log "Optional package not installed: $pkg" done } @@ -251,10 +421,14 @@ install_apt() { xfsprogs \ dnsmasq-base \ qemu-kvm \ + qemu-utils \ libvirt-daemon-system \ libvirt-clients \ + cloud-image-utils \ + genisoimage \ + xorriso \ virtinst \ - virt-manager + ovmf } enable_el_repos() { @@ -290,9 +464,19 @@ install_dnf() { quota \ e2fsprogs \ xfsprogs \ - dnsmasq + dnsmasq \ + qemu-kvm \ + qemu-img \ + libvirt \ + libvirt-daemon-kvm \ + libvirt-client \ + virt-install \ + cloud-utils \ + genisoimage - dnf install -y lxcfs >/dev/null 2>&1 || log "Optional package not installed: lxcfs" + for pkg in lxcfs xorriso edk2-ovmf; do + dnf install -y "$pkg" >/dev/null 2>&1 || log "Optional package not installed: $pkg" + done } install_yum() { @@ -315,9 +499,19 @@ install_yum() { quota \ e2fsprogs \ xfsprogs \ - dnsmasq + dnsmasq \ + qemu-kvm \ + qemu-img \ + libvirt \ + libvirt-daemon-kvm \ + libvirt-client \ + virt-install \ + cloud-utils \ + genisoimage - yum install -y lxcfs >/dev/null 2>&1 || log "Optional package not installed: lxcfs" + for pkg in lxcfs xorriso edk2-ovmf; do + yum install -y "$pkg" >/dev/null 2>&1 || log "Optional package not installed: $pkg" + done } install_dependencies() { @@ -355,6 +549,15 @@ install_dependencies() { has_cmd lxc-create || die "lxc-create is still missing after dependency installation." has_cmd iptables || die "iptables is still missing after dependency installation." has_cmd ip || die "iproute2/ip command is still missing after dependency installation." + has_cmd virsh || die "virsh is still missing after dependency installation." + has_cmd qemu-img || die "qemu-img is still missing after dependency installation." + has_cmd cloud-localds || die "cloud-localds is still missing after dependency installation." + if ! has_cmd genisoimage && ! has_cmd mkisofs && ! has_cmd xorriso; then + die "one of genisoimage, mkisofs, or xorriso is required for Windows KVM setup." + fi + if [ ! -e /dev/kvm ]; then + log "Warning: /dev/kvm was not found. KVM VMs require hardware virtualization or nested virtualization." + fi } configure_kernel_networking() { @@ -370,14 +573,17 @@ EOF sysctl --system >/dev/null 2>&1 || true } -setup_lxc_services() { - log "Configuring LXC services..." +setup_runtime_services() { + log "Configuring LXC and KVM services..." if is_systemd; then systemctl enable --now lxcfs >/dev/null 2>&1 || true systemctl enable --now lxc-net >/dev/null 2>&1 || true systemctl enable --now lxc >/dev/null 2>&1 || true systemctl enable --now libvirtd >/dev/null 2>&1 || true + systemctl enable --now virtqemud >/dev/null 2>&1 || true + systemctl enable --now virtqemud.socket >/dev/null 2>&1 || true + systemctl enable --now virtlogd.socket >/dev/null 2>&1 || true return fi @@ -388,8 +594,12 @@ setup_lxc_services() { rc-service lxc start >/dev/null 2>&1 || true rc-update add lxcfs default >/dev/null 2>&1 || true rc-service lxcfs start >/dev/null 2>&1 || true + rc-update add dbus default >/dev/null 2>&1 || true + rc-service dbus start >/dev/null 2>&1 || true rc-update add libvirtd default >/dev/null 2>&1 || true rc-service libvirtd start >/dev/null 2>&1 || true + rc-update add virtlogd default >/dev/null 2>&1 || true + rc-service virtlogd start >/dev/null 2>&1 || true return fi @@ -474,8 +684,9 @@ install_binary() { install_systemd_service() { cat > /etc/systemd/system/clicd.service << 'EOF' [Unit] -Description=CLICD - LXC Container Manager -After=network.target lxc.service +Description=CLICD - LXC/KVM Container Manager +After=network.target lxc.service libvirtd.service virtqemud.service +Wants=libvirtd.service [Service] Type=simple @@ -498,7 +709,7 @@ install_openrc_service() { #!/sbin/openrc-run name="CLICD" -description="CLICD - LXC Container Manager" +description="CLICD - LXC/KVM Container Manager" command="/usr/local/bin/clicd" command_args="server" command_background=true @@ -508,7 +719,7 @@ error_log="/var/log/clicd.err" depend() { need net - after lxc + after lxc libvirtd } EOF @@ -552,13 +763,13 @@ print_summary() { grep -E "Username:|Password:" /var/log/clicd.log /var/log/clicd.err 2>/dev/null || true fi echo "" - echo "If no password is shown, this server already had /root/.clicd/config.json." + echo "If no password is shown, this server already had /root/.clicd/config.db." echo "The existing admin password cannot be recovered from the bcrypt hash." } install_dependencies configure_kernel_networking -setup_lxc_services +setup_runtime_services setup_subids try_enable_project_quota download_release_if_needed