Files
CLICD/backend/internal/cli/cli.go
T
2026-06-08 16:18:24 +08:00

1347 lines
35 KiB
Go

package cli
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"clicd/internal/config"
"clicd/internal/lxc"
"clicd/internal/version"
)
var manager = lxc.NewManager()
const (
clicdBackupDir = "/root/clicd-backups"
clicdNewBinaryPath = "/usr/local/bin/clicd.new"
)
// Run starts the CLI interface.
func Run() {
reader := bufio.NewReader(os.Stdin)
for {
if _, err := config.InitConfig(); err != nil {
fmt.Printf("重新加载配置失败: %v\n", err)
waitEnter(reader)
}
clearScreen()
printMenu()
fmt.Print("\n请选择操作 [1-12,0/q]: ")
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
switch strings.ToLower(input) {
case "1":
clearScreen()
cliListContainers()
waitEnter(reader)
case "2":
clearScreen()
cliCreateContainer(reader)
waitEnter(reader)
case "3":
clearScreen()
cliStartContainer(reader)
waitEnter(reader)
case "4":
clearScreen()
cliStopContainer(reader)
waitEnter(reader)
case "5":
clearScreen()
cliRestartContainer(reader)
waitEnter(reader)
case "6":
clearScreen()
cliDeleteContainer(reader)
waitEnter(reader)
case "7":
clearScreen()
cliReinstallContainer(reader)
waitEnter(reader)
case "8":
clearScreen()
cliResetPassword(reader)
waitEnter(reader)
case "9":
clearScreen()
cliToggleWebPanel()
waitEnter(reader)
case "10":
clearScreen()
cliImportExistingContainers()
waitEnter(reader)
case "11":
clearScreen()
cliUpgradeSystem(reader)
waitEnter(reader)
case "12":
clearScreen()
cliUninstall(reader)
return
case "0":
clearScreen()
cliShowInfo()
waitEnter(reader)
case "q", "exit", "quit":
fmt.Println("再见")
return
default:
fmt.Println("无效选择")
}
}
}
func printMenu() {
webStatus := "启动"
if isWebPanelRunning() {
webStatus = "停止"
}
fmt.Println()
fmt.Println(" ==========================================")
fmt.Println(" CLICD - LXC 容器管理器")
fmt.Println(" ==========================================")
fmt.Println()
fmt.Printf(" Web 面板: %s (端口 %d)\n", func() string {
if isWebPanelRunning() {
return "运行中"
}
return "已停止"
}(), config.AppConfig.Port)
fmt.Printf(" 当前版本: %s\n", version.Current())
fmt.Println()
fmt.Println(" 1. 查看容器列表")
fmt.Println(" 2. 创建容器")
fmt.Println(" 3. 开机容器")
fmt.Println(" 4. 关机容器")
fmt.Println(" 5. 重启容器")
fmt.Println(" 6. 删除容器")
fmt.Println(" 7. 重装容器系统")
fmt.Println(" 8. 重置 Web 管理员密码")
fmt.Printf(" 9. %s Web 面板\n", webStatus)
fmt.Println(" 10. 导入现有 LXC 容器")
fmt.Println(" 11. 检查并升级 CLICD")
fmt.Println(" 12. 卸载 CLICD")
fmt.Println(" 0. 系统信息")
fmt.Println(" q. 退出")
}
func cliListContainers() {
containers, err := manager.ListContainers()
if err != nil {
fmt.Printf("获取容器列表失败: %v\n", err)
return
}
if len(containers) == 0 {
fmt.Println("\n暂无容器")
return
}
fmt.Println()
fmt.Printf("%-18s %-10s %-18s %-6s %-10s %-10s %-16s\n", "名称", "状态", "镜像", "vCPU", "内存(MB)", "磁盘(GB)", "SSH")
fmt.Println(strings.Repeat("-", 94))
for _, c := range containers {
ssh := "-"
if c.SSHPort > 0 {
ssh = fmt.Sprintf("%d->22", c.SSHPort)
}
fmt.Printf("%-18s %-10s %-18s %-6.2f %-10d %-10d %-16s\n",
c.Name, c.Status, c.Template, c.VCPU, c.RAMMB, c.DiskGB, ssh)
}
}
func cliCreateContainer(reader *bufio.Reader) {
fmt.Println("\n--- 创建容器 ---")
name := promptString(reader, "容器名称", "")
if name == "" {
fmt.Println("容器名称不能为空")
return
}
templates := lxc.GetTemplates()
fmt.Println("\n可用镜像:")
for i, template := range templates {
fmt.Printf(" %d. %s\n", i+1, template.Name)
}
tmplIdx := promptInt(reader, fmt.Sprintf("镜像 [1-%d]", len(templates)), 1)
if tmplIdx < 1 || tmplIdx > len(templates) {
fmt.Println("镜像选择无效")
return
}
cfg := lxc.ContainerConfig{
Name: name,
TemplateID: templates[tmplIdx-1].ID,
VCPU: promptFloat(reader, "vCPU", 1),
RAMMB: promptInt(reader, "内存 (MB)", 512),
DiskGB: promptInt(reader, "磁盘 (GB)", 10),
NetworkBWMbps: promptInt(reader, "网络带宽 (Mbps)", 100),
MonthlyTrafficGB: promptInt(reader, "月流量 (GB)", 1000),
IOSpeedMBps: promptInt(reader, "IO 速度 (MB/s)", 500),
ExtraPorts: promptPortList(reader, "额外 NAT 端口,多个用逗号分隔"),
}
fmt.Printf("\n正在创建容器 %s ...\n", name)
if err := manager.CreateContainer(cfg); err != nil {
fmt.Printf("创建失败: %v\n", err)
return
}
container := config.FindContainerByName(name)
fmt.Printf("容器 %s 创建成功\n", name)
if container != nil {
fmt.Print(formatSSHAccess(container.SSHPort))
}
restartWebPanelForConfigChange()
}
func formatSSHAccess(sshPort int) string {
if sshPort <= 0 {
return "SSH: root, 端口未分配。密码已保存,请在 Web 面板中查看或重置。\n"
}
return fmt.Sprintf("SSH: root, port %d -> 22。密码已保存,请在 Web 面板中查看或重置。\n", sshPort)
}
func cliStartContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "开机")
if id == 0 {
return
}
if err := manager.StartContainer(id); err != nil {
fmt.Printf("开机失败: %v\n", err)
return
}
fmt.Printf("容器 %s 已开机\n", name)
}
func cliStopContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "关机")
if id == 0 {
return
}
if err := manager.StopContainer(id); err != nil {
fmt.Printf("关机失败: %v\n", err)
return
}
fmt.Printf("容器 %s 已关机\n", name)
}
func cliRestartContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "重启")
if id == 0 {
return
}
if err := manager.RestartContainer(id); err != nil {
fmt.Printf("重启失败: %v\n", err)
return
}
fmt.Printf("容器 %s 已重启\n", name)
}
func cliDeleteContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "删除")
if id == 0 {
return
}
confirm := promptString(reader, fmt.Sprintf("确认删除容器 %s?输入 yes 继续", name), "no")
if strings.ToLower(confirm) != "yes" {
fmt.Println("已取消")
return
}
if err := manager.DestroyContainer(id); err != nil {
fmt.Printf("删除失败: %v\n", err)
return
}
fmt.Printf("容器 %s 已删除\n", name)
restartWebPanelForConfigChange()
}
func cliReinstallContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "重装")
if id == 0 {
return
}
templates := lxc.GetTemplates()
fmt.Println("\n可用镜像:")
for i, template := range templates {
fmt.Printf(" %d. %s\n", i+1, template.Name)
}
tmplIdx := promptInt(reader, fmt.Sprintf("镜像 [1-%d]", len(templates)), 1)
if tmplIdx < 1 || tmplIdx > len(templates) {
fmt.Println("镜像选择无效")
return
}
confirm := promptString(reader, fmt.Sprintf("确认重装容器 %s?输入 yes 继续", name), "no")
if strings.ToLower(confirm) != "yes" {
fmt.Println("已取消")
return
}
if err := manager.ReinstallContainer(id, templates[tmplIdx-1].ID); err != nil {
fmt.Printf("重装失败: %v\n", err)
return
}
fmt.Printf("容器 %s 已重装\n", name)
restartWebPanelForConfigChange()
}
func cliResetPassword(reader *bufio.Reader) {
newPass := promptString(reader, "新的管理员密码(至少 6 位)", "")
if len(newPass) < 6 {
fmt.Println("密码至少需要 6 位")
return
}
confirm := promptString(reader, "确认密码", "")
if newPass != confirm {
fmt.Println("两次输入的密码不一致")
return
}
if err := config.ResetAdminPassword(newPass); err != nil {
fmt.Printf("重置失败: %v\n", err)
return
}
fmt.Println("管理员密码已重置。")
restartWebPanelForConfigChange()
}
func cliToggleWebPanel() {
if isWebPanelRunning() {
if err := stopService("clicd"); err != nil {
fmt.Printf("停止 Web 面板失败: %v\n", err)
return
}
fmt.Println("Web 面板已停止,LXC 容器不会受影响。")
return
}
if err := startService("clicd"); err != nil {
fmt.Printf("启动 Web 面板失败: %v\n", err)
return
}
fmt.Println("Web 面板已启动")
}
type githubRelease struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
HTMLURL string `json:"html_url"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
}
func cliUpgradeSystem(reader *bufio.Reader) {
fmt.Println("\n--- 检查并升级 CLICD ---")
fmt.Println("升级只会替换 /usr/local/bin/clicd,并保留 /root/.clicd 里的配置、容器数据和任务记录。")
if os.Geteuid() != 0 {
fmt.Println("升级需要 root 权限。请使用: sudo clicd cli")
return
}
repo := strings.TrimSpace(os.Getenv("CLICD_REPO"))
if repo == "" {
repo = version.Repo
}
current := version.Current()
fmt.Printf("当前版本: %s\n", current)
fmt.Printf("检查仓库: https://github.com/%s\n", repo)
release, err := fetchLatestRelease(repo)
if err != nil {
fmt.Printf("检查 GitHub 最新版本失败: %v\n", err)
return
}
latest := strings.TrimSpace(release.TagName)
if latest == "" {
fmt.Println("GitHub Release 没有 tag_name,无法判断最新版本。")
return
}
fmt.Printf("最新版本: %s\n", latest)
if release.HTMLURL != "" {
fmt.Printf("发布页面: %s\n", release.HTMLURL)
}
assetURL := findReleaseAsset(release, "clicd-linux-amd64.tar.gz")
if assetURL == "" {
fmt.Println("最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。")
return
}
if sameVersion(current, latest) {
fmt.Println("当前已经是最新版本。")
confirm := promptString(reader, "是否仍然重新安装最新版本?输入 reinstall 继续", "no")
if strings.ToLower(confirm) != "reinstall" {
fmt.Println("已取消。")
return
}
} else {
confirm := promptString(reader, "输入 upgrade 开始升级", "no")
if strings.ToLower(confirm) != "upgrade" {
fmt.Println("已取消。")
return
}
}
if err := upgradeFromReleaseAsset(assetURL, latest); err != nil {
fmt.Printf("升级失败: %v\n", err)
return
}
fmt.Printf("升级完成: %s -> %s\n", current, latest)
fmt.Println("原有数据已保留,Web 服务已重启。")
}
func fetchLatestRelease(repo string) (*githubRelease, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
setGitHubRequestHeaders(req)
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req)
if err != nil {
if fallback, fallbackErr := fetchLatestReleaseFallback(repo); fallbackErr == nil {
return fallback, nil
}
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
apiErr := fmt.Errorf("GitHub API 返回 %s: %s", resp.Status, strings.TrimSpace(string(body)))
if fallback, fallbackErr := fetchLatestReleaseFallback(repo); fallbackErr == nil {
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
fmt.Println("GitHub API 被限流,已切换到备用检查方式。")
} else {
fmt.Println("GitHub API 不可用,已切换到备用检查方式。")
}
return fallback, nil
}
return nil, apiErr
}
var release githubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, err
}
return &release, nil
}
func fetchLatestReleaseFallback(repo string) (*githubRelease, error) {
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://github.com/%s/releases/latest", repo), nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "clicd-updater/"+version.Current())
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("GitHub releases/latest 返回 %s", resp.Status)
}
tag := latestTagFromPath(resp.Request.URL.Path)
if tag == "" {
return nil, fmt.Errorf("无法从 GitHub releases/latest 跳转结果解析最新版本")
}
const assetName = "clicd-linux-amd64.tar.gz"
return &githubRelease{
TagName: tag,
Name: tag,
HTMLURL: fmt.Sprintf("https://github.com/%s/releases/tag/%s", repo, tag),
Assets: []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}{
{
Name: assetName,
BrowserDownloadURL: fmt.Sprintf("https://github.com/%s/releases/latest/download/%s", repo, assetName),
},
},
}, nil
}
func latestTagFromPath(path string) string {
const marker = "/releases/tag/"
idx := strings.Index(path, marker)
if idx < 0 {
return ""
}
tag := strings.TrimSpace(path[idx+len(marker):])
if slash := strings.Index(tag, "/"); slash >= 0 {
tag = tag[:slash]
}
return tag
}
func setGitHubRequestHeaders(req *http.Request) {
req.Header.Set("User-Agent", "clicd-updater/"+version.Current())
token := strings.TrimSpace(os.Getenv("CLICD_GITHUB_TOKEN"))
if token == "" {
token = strings.TrimSpace(os.Getenv("GITHUB_TOKEN"))
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
}
func findReleaseAsset(release *githubRelease, name string) string {
for _, asset := range release.Assets {
if asset.Name == name && asset.BrowserDownloadURL != "" {
return asset.BrowserDownloadURL
}
}
return ""
}
func upgradeFromReleaseAsset(assetURL, latest string) error {
tmpDir, err := os.MkdirTemp("", "clicd-upgrade-*")
if err != nil {
return err
}
defer os.RemoveAll(tmpDir)
archivePath := filepath.Join(tmpDir, "clicd-linux-amd64.tar.gz")
fmt.Println("正在下载升级包...")
if err := downloadFile(assetURL, archivePath); err != nil {
return err
}
fmt.Println("正在解压升级包...")
if out, err := exec.Command("tar", "-xzf", archivePath, "-C", tmpDir).CombinedOutput(); err != nil {
return fmt.Errorf("解压失败: %v, output: %s", err, string(out))
}
newBinary, err := findFile(tmpDir, "clicd")
if err != nil {
return err
}
backupDir := clicdBackupDir
if err := os.MkdirAll(backupDir, 0700); err != nil {
return err
}
backupName := fmt.Sprintf("clicd.%s.%s", safeReleaseBackupComponent(latest), time.Now().Format("20060102-150405"))
if _, err := os.Stat("/usr/local/bin/clicd"); err == nil {
backupPath, err := copyFileToBackup("/usr/local/bin/clicd", backupName, 0755)
if err != nil {
return fmt.Errorf("备份旧二进制失败: %w", err)
}
fmt.Printf("旧版本已备份: %s\n", backupPath)
}
fmt.Println("正在替换二进制...")
if err := stopService("clicd"); err != nil {
fmt.Printf("停止 Web 服务失败,继续尝试替换: %v\n", err)
}
tmpBin := clicdNewBinaryPath
if err := copyFileToUpgradeTemp(newBinary, 0755); err != nil {
return err
}
if err := os.Rename(tmpBin, "/usr/local/bin/clicd"); err != nil {
return err
}
if err := os.Chmod("/usr/local/bin/clicd", 0755); err != nil {
return err
}
if err := restartService("clicd"); err != nil {
return fmt.Errorf("二进制已替换,但重启 Web 服务失败: %w", err)
}
return nil
}
func downloadFile(url, dest string) error {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
setGitHubRequestHeaders(req)
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("下载失败,HTTP %s", resp.Status)
}
out, err := os.OpenFile(dest, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
func findFile(root, name string) (string, error) {
var found string
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if d.Name() == name {
found = path
return filepath.SkipAll
}
return nil
})
if err != nil {
return "", err
}
if found == "" {
return "", fmt.Errorf("升级包内未找到 clicd 二进制")
}
return found, nil
}
func copyFileToBackup(src, fileName string, mode os.FileMode) (string, error) {
if fileName == "" || strings.Contains(fileName, "/") || strings.Contains(fileName, "\\") || strings.Contains(fileName, "..") {
return "", fmt.Errorf("unsafe backup file name: %s", fileName)
}
dst := filepath.Join(clicdBackupDir, fileName)
out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return "", err
}
if err := copyIntoOpenFile(src, out, mode); err != nil {
return "", err
}
return dst, nil
}
func copyFileToUpgradeTemp(src string, mode os.FileMode) error {
out, err := os.OpenFile(clicdNewBinaryPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return err
}
return copyIntoOpenFile(src, out, mode)
}
func copyIntoOpenFile(src string, out *os.File, mode os.FileMode) error {
in, err := os.Open(src)
if err != nil {
out.Close()
return err
}
defer in.Close()
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
if err := out.Chmod(mode); err != nil {
out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
return nil
}
func safeReleaseBackupComponent(tag string) string {
tag = strings.TrimPrefix(strings.TrimSpace(tag), "v")
var b strings.Builder
for _, r := range tag {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' {
b.WriteRune(r)
continue
}
b.WriteByte('_')
}
component := strings.Trim(b.String(), "._-")
if component == "" {
return "unknown"
}
if len(component) > 64 {
return component[:64]
}
return component
}
func sameVersion(current, latest string) bool {
c := strings.TrimPrefix(strings.TrimSpace(strings.ToLower(current)), "v")
l := strings.TrimPrefix(strings.TrimSpace(strings.ToLower(latest)), "v")
return c != "" && c == l
}
func isWebPanelRunning() bool {
if commandExists("systemctl") {
cmd := exec.Command("systemctl", "is-active", "clicd")
output, err := cmd.Output()
if err == nil && strings.TrimSpace(string(output)) == "active" {
return true
}
}
if commandExists("rc-service") {
cmd := exec.Command("rc-service", "clicd", "status")
return cmd.Run() == nil
}
return false
}
func cliImportExistingContainers() {
fmt.Println("\n--- 导入现有 LXC 容器 ---")
fmt.Println("将 /var/lib/lxc 里的容器导入 CLICD 配置。")
fmt.Println("导入后会保留真实 LXC 名称,Web 和 CLI 都能管理同一个容器。")
imported, err := manager.ImportExistingClicdContainers()
if err != nil {
fmt.Printf("导入失败: %v\n", err)
return
}
if len(imported) == 0 {
fmt.Println("没有发现新的 ct-* 容器。")
return
}
fmt.Printf("已导入 %d 个容器:\n", len(imported))
for _, c := range imported {
fmt.Printf(" [%d] %s [%s]\n", c.ID, c.Name, c.Status)
}
restartWebPanelForConfigChange()
}
func cliUninstall(reader *bufio.Reader) {
fmt.Println("\n--- 卸载 CLICD ---")
fmt.Println("将删除 CLICD 服务和 /usr/local/bin/clicd。")
fmt.Println("同时会删除 /root/.clicd、/var/lib/lxc、/var/lib/clicd、镜像缓存、备份、临时文件、/swapfile 和 CLICD 网络规则。")
if os.Geteuid() != 0 {
fmt.Println("卸载需要 root 权限。")
fmt.Println("请运行: sudo clicd cli --no-web")
return
}
confirm := promptString(reader, "输入 uninstall 继续卸载", "no")
if strings.ToLower(confirm) != "uninstall" {
fmt.Println("已取消")
return
}
destroyAllLXCContainers()
destroyAllKVMDomains()
cleanupCLICDNetworking()
removeCLICDHostHooks()
removeCLICDQuotaRecords()
stopAndRemoveService()
removePath("/usr/local/bin/clicd")
removePath("/etc/sysctl.d/99-clicd.conf")
removePath("/var/log/clicd.log")
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("服务、二进制、配置、容器/虚拟机、本地镜像、缓存、备份、临时文件和 CLICD 网络规则均已删除。")
}
func destroyAllLXCContainers() {
entries, err := os.ReadDir("/var/lib/lxc")
if err != nil {
return
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
fmt.Printf("Destroying LXC container %s...\n", name)
runQuiet("lxc-stop", "-n", name, "-k")
runQuiet("lxc-destroy", "-n", name, "-f")
removeLXCContainerPath("/var/lib/lxc/" + name)
}
}
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()
cleanupCLICDIPv6Runtime()
cleanupCLICDIPv6BridgeRoutes()
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 cleanupCLICDIPv6Runtime() {
if config.AppConfig == nil {
return
}
for _, c := range config.AppConfig.Containers {
cleanupCLICDContainerIPv6(c)
}
}
func cleanupCLICDContainerIPv6(c config.Container) {
bridge := "lxcbr0"
if c.IsKVM() {
bridge = "virbr0"
}
mac := strings.ToLower(strings.TrimSpace(c.MACAddress))
if mac != "" && bridge == "virbr0" {
deleteIP6FilterRule("FORWARD", "-i", bridge, "-m", "mac", "--mac-source", mac, "-j", "DROP")
}
if strings.TrimSpace(c.IPv6) == "" {
return
}
addr := strings.TrimSpace(c.IPv6)
if slash := strings.Index(addr, "/"); slash >= 0 {
addr = addr[:slash]
}
source := strings.TrimSpace(c.IPv6)
if !strings.Contains(source, "/") {
source += "/128"
}
deleteIP6NATSource(source)
deleteIP6FilterRule("FORWARD", "-i", bridge, "-s", source, "-j", "ACCEPT")
deleteIP6FilterRule("FORWARD", "-o", bridge, "-d", source, "-j", "ACCEPT")
if mac != "" && bridge == "virbr0" {
deleteIP6FilterRule("FORWARD", "-i", bridge, "-m", "mac", "--mac-source", mac, "-s", source, "-j", "ACCEPT")
deleteIP6FilterRule("FORWARD", "-i", bridge, "-m", "mac", "--mac-source", mac, "-j", "DROP")
}
runQuiet("ip", "-6", "route", "del", source, "dev", bridge)
if strings.TrimSpace(c.IPv6Interface) != "" {
runQuiet("ip", "-6", "neigh", "del", "proxy", addr, "dev", c.IPv6Interface)
}
}
func cleanupCLICDIPv6BridgeRoutes() {
if !commandExists("ip") {
return
}
for _, bridge := range []string{"lxcbr0", "virbr0"} {
out, err := exec.Command("ip", "-6", "route", "show", "dev", bridge).Output()
if err == nil {
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) == 0 || !strings.HasSuffix(fields[0], "/128") {
continue
}
source := fields[0]
addr := strings.TrimSuffix(source, "/128")
deleteIP6NATSource(source)
deleteIP6FilterRule("FORWARD", "-i", bridge, "-s", source, "-j", "ACCEPT")
deleteIP6FilterRule("FORWARD", "-o", bridge, "-d", source, "-j", "ACCEPT")
removeProxyNDPForAddress(addr)
runQuiet("ip", "-6", "route", "del", source, "dev", bridge)
}
}
runQuiet("ip", "-6", "addr", "del", "fe80::1/64", "dev", bridge)
}
}
func removeProxyNDPForAddress(addr string) {
out, err := exec.Command("ip", "-6", "neigh", "show", "proxy").Output()
if err != nil {
return
}
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) == 0 || fields[0] != addr {
continue
}
for i := 0; i+1 < len(fields); i++ {
if fields[i] == "dev" {
runQuiet("ip", "-6", "neigh", "del", "proxy", addr, "dev", fields[i+1])
}
}
}
}
func deleteIP6NATSource(source string) {
if !commandExists("ip6tables") || strings.TrimSpace(source) == "" {
return
}
for {
out, err := exec.Command("ip6tables", "-t", "nat", "-S", "POSTROUTING").Output()
if err != nil {
return
}
deleted := false
for _, line := range strings.Split(string(out), "\n") {
if !strings.Contains(line, "-s "+source) || !strings.Contains(line, " -j MASQUERADE") {
continue
}
fields := strings.Fields(line)
if len(fields) == 0 || fields[0] != "-A" {
continue
}
fields[0] = "-D"
args := append([]string{"-t", "nat"}, fields...)
deleted = runCommandOK("ip6tables", args...)
break
}
if !deleted {
return
}
}
}
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 deleteIP6FilterRule(args ...string) {
fullArgs := append([]string{"-D"}, args...)
for runCommandOK("ip6tables", 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)
if err := os.RemoveAll(path); err == nil {
fmt.Printf("Removed %s\n", path)
return
}
runQuiet("fuser", "-km", path+"/rootfs")
runQuiet("fuser", "-km", path)
unmountPathTree(path)
detachLoopDevices(path)
removePath(path)
}
func unmountPathTree(path string) {
if commandExists("findmnt") {
out, err := exec.Command("findmnt", "-R", "-n", "-o", "TARGET", path).Output()
if err == nil {
mounts := strings.Split(strings.TrimSpace(string(out)), "\n")
for i := len(mounts) - 1; i >= 0; i-- {
mountpoint := strings.TrimSpace(mounts[i])
if mountpoint != "" {
runQuiet("umount", "-R", "-l", mountpoint)
runQuiet("umount", "-l", mountpoint)
}
}
}
}
runQuiet("umount", "-R", "-l", path+"/rootfs")
runQuiet("umount", "-l", path+"/rootfs")
runQuiet("umount", "-R", "-l", path)
runQuiet("umount", "-l", path)
}
func detachLoopDevices(path string) {
if !commandExists("losetup") {
return
}
images := []string{path + "/rootfs.img"}
if entries, err := os.ReadDir(path); err == nil {
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".img") {
images = append(images, path+"/"+entry.Name())
}
}
}
for _, image := range images {
out, err := exec.Command("losetup", "-j", image).Output()
if err != nil {
continue
}
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if idx := strings.Index(line, ":"); idx > 0 {
runQuiet("losetup", "-d", line[:idx])
}
}
}
}
func stopAndRemoveService() {
if commandExists("systemctl") {
runQuiet("systemctl", "stop", "clicd")
runQuiet("systemctl", "disable", "clicd")
removePath("/etc/systemd/system/clicd.service")
runQuiet("systemctl", "daemon-reload")
runQuiet("systemctl", "reset-failed", "clicd")
}
if commandExists("rc-service") {
runQuiet("rc-service", "clicd", "stop")
}
if commandExists("rc-update") {
runQuiet("rc-update", "del", "clicd", "default")
}
removePath("/etc/init.d/clicd")
}
func removePath(path string) {
if _, err := os.Lstat(path); os.IsNotExist(err) {
return
}
if err := os.RemoveAll(path); err != nil {
fmt.Printf("Failed to remove %s: %v\n", path, err)
return
}
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")
}
}
func commandExists(name string) bool {
_, err := exec.LookPath(name)
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)
return
}
fmt.Println("Web 面板已重载并应用配置变更。")
}
func stopService(name string) error {
if commandExists("systemctl") {
return exec.Command("systemctl", "stop", name).Run()
}
if commandExists("rc-service") {
return exec.Command("rc-service", name, "stop").Run()
}
return fmt.Errorf("no supported service manager found")
}
func startService(name string) error {
if commandExists("systemctl") {
return exec.Command("systemctl", "start", name).Run()
}
if commandExists("rc-service") {
return exec.Command("rc-service", name, "start").Run()
}
return fmt.Errorf("no supported service manager found")
}
func restartService(name string) error {
if commandExists("systemctl") {
return exec.Command("systemctl", "restart", name).Run()
}
if commandExists("rc-service") {
return exec.Command("rc-service", name, "restart").Run()
}
return fmt.Errorf("no supported service manager found")
}
func cliShowInfo() {
containers, err := manager.ListContainers()
if err != nil {
fmt.Printf("读取容器状态失败: %v\n", err)
}
total := len(containers)
running := 0
for _, container := range containers {
if container.Status == "running" {
running++
}
}
fmt.Println("\n--- 系统信息 ---")
fmt.Printf("CLICD 版本: %s\n", version.Current())
fmt.Printf("Web 端口: %d\n", config.AppConfig.Port)
fmt.Printf("管理员用户: %s\n", config.AppConfig.AdminUser)
fmt.Printf("容器总数: %d\n", total)
fmt.Printf("运行中: %d\n", running)
fmt.Printf("已停止: %d\n", total-running)
if hostname, err := os.Hostname(); err == nil {
fmt.Printf("主机名: %s\n", hostname)
}
cmd := exec.Command("lxc-info", "--version")
output, err := cmd.Output()
if err == nil {
fmt.Printf("LXC 版本: %s", string(output))
}
}
func selectContainer(reader *bufio.Reader, action string) (int, string) {
containers, err := manager.ListContainers()
if err != nil {
fmt.Printf("获取容器列表失败: %v\n", err)
return 0, ""
}
if len(containers) == 0 {
fmt.Println("暂无可用容器")
return 0, ""
}
fmt.Printf("\n--- 选择要%s的容器 ---\n", action)
for i, container := range containers {
fmt.Printf(" %d. [%d] %s [%s]\n", i+1, container.ID, container.Name, container.Status)
}
idx := promptInt(reader, "容器", 0)
if idx < 1 || idx > len(containers) {
fmt.Println("选择无效")
return 0, ""
}
c := containers[idx-1]
return c.ID, c.Name
}
func promptString(reader *bufio.Reader, label string, fallback string) string {
if fallback == "" {
fmt.Printf("%s: ", label)
} else {
fmt.Printf("%s [%s]: ", label, fallback)
}
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
if input == "" {
return fallback
}
return input
}
func promptInt(reader *bufio.Reader, label string, fallback int) int {
input := promptString(reader, label, strconv.Itoa(fallback))
value, err := strconv.Atoi(input)
if err != nil || value < 0 {
return fallback
}
return value
}
func promptFloat(reader *bufio.Reader, label string, fallback float64) float64 {
input := promptString(reader, label, strconv.FormatFloat(fallback, 'f', -1, 64))
value, err := strconv.ParseFloat(input, 64)
if err != nil || value < 0 {
return fallback
}
return value
}
func clearScreen() {
fmt.Print("\033[H\033[2J")
}
func waitEnter(reader *bufio.Reader) {
fmt.Print("\n按 Enter 返回菜单...")
reader.ReadString('\n')
}
func promptPortList(reader *bufio.Reader, label string) []int {
input := promptString(reader, label, "")
if input == "" {
return nil
}
var ports []int
for _, part := range strings.Split(input, ",") {
value, err := strconv.Atoi(strings.TrimSpace(part))
if err != nil || value <= 0 || value > 65535 {
fmt.Printf("忽略无效端口: %s\n", strings.TrimSpace(part))
continue
}
ports = append(ports, value)
}
return ports
}