package main import ( "bytes" "fmt" "os" "os/exec" "regexp" "strings" ) var ( tmpDir string numeric = regexp.MustCompile(`^[0-9]+$`) ) func main() { if os.Geteuid() != 0 { fmt.Fprintf(os.Stderr, "Run this program as root, for example: sudo %s\n", os.Args[0]) os.Exit(1) } if _, err := exec.LookPath("whiptail"); err != nil { fmt.Fprintln(os.Stderr, "whiptail is required. Install it with: apt install whiptail") os.Exit(1) } var err error tmpDir, err = os.MkdirTemp("", "proxmox-toolkit-*") if err != nil { fmt.Fprintf(os.Stderr, "Could not create temporary directory: %v\n", err) os.Exit(1) } defer os.RemoveAll(tmpDir) mainMenu() } // menu displays a whiptail menu and returns the selected tag. // An empty selection means the user cancelled or pressed Esc. func menu(title, prompt string, height, width, menuHeight int, items ...string) string { args := []string{ "--title", title, "--menu", prompt, fmt.Sprint(height), fmt.Sprint(width), fmt.Sprint(menuHeight), } args = append(args, items...) return whiptail(args...) } // dialog runs whiptail and captures its result through file descriptor 3. // The menu itself continues to use the terminal. func whiptail(args ...string) string { readEnd, writeEnd, err := os.Pipe() if err != nil { return "" } defer readEnd.Close() args = append([]string{"--output-fd", "3"}, args...) cmd := exec.Command("whiptail", args...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.ExtraFiles = []*os.File{writeEnd} err = cmd.Run() writeEnd.Close() var result bytes.Buffer _, _ = result.ReadFrom(readEnd) if err != nil { return "" } return strings.TrimSpace(result.String()) } func message(title, text string) { whiptail("--title", title, "--msgbox", text, "8", "70") } func confirm(text string) bool { return whiptail("--title", "Confirm", "--yesno", text, "10", "70") == "" } // Yes/no dialogs also return an empty string on success, so use this // separate helper to distinguish Yes from No or Esc by the process status. func confirmAction(text string) bool { readEnd, writeEnd, err := os.Pipe() if err != nil { return false } defer readEnd.Close() cmd := exec.Command( "whiptail", "--title", "Confirm", "--yesno", text, "10", "70", ) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.ExtraFiles = []*os.File{writeEnd} err = cmd.Run() writeEnd.Close() _, _ = readEnd.Read(make([]byte, 1)) return err == nil } func input(title, prompt string) (string, bool) { value := whiptail("--title", title, "--inputbox", prompt, "9", "72") // An empty value is valid for some prompts, so cancellation is // determined by whiptail itself in inputValue below when needed. return value, true } func inputValue(title, prompt string) (string, bool) { readEnd, writeEnd, err := os.Pipe() if err != nil { return "", false } defer readEnd.Close() cmd := exec.Command("whiptail", "--output-fd", "3", "--title", title, "--inputbox", prompt, "9", "72") cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.ExtraFiles = []*os.File{writeEnd} err = cmd.Run() writeEnd.Close() var result bytes.Buffer _, _ = result.ReadFrom(readEnd) if err != nil { return "", false } return strings.TrimSpace(result.String()), true } func requireCommand(name string) bool { if _, err := exec.LookPath(name); err != nil { message("Command unavailable", fmt.Sprintf("The command '%s' is not installed or not available on this node.", name)) return false } return true } func askID(prompt string) (string, bool) { value, ok := inputValue("Enter ID", prompt) if !ok { return "", false } if !numeric.MatchString(value) { message("Invalid ID", "Enter a numeric VM or container ID.") return "", false } return value, true } func showOutput(title string, name string, args ...string) { outputPath := tmpDir + "/output.txt" output, err := os.Create(outputPath) if err != nil { message("Error", "Could not create a temporary output file.") return } cmd := exec.Command(name, args...) cmd.Stdout = output cmd.Stderr = output runErr := cmd.Run() _ = output.Close() data, _ := os.ReadFile(outputPath) if len(data) == 0 { status := 0 if runErr != nil { status = 1 } data = []byte(fmt.Sprintf("Command completed with exit status %d; no output.\n", status)) } else if runErr != nil { data = append(data, []byte("\nCommand exited with an error.\n")...) } _ = os.WriteFile(outputPath, data, 0600) whiptail("--title", title, "--textbox", outputPath, "24", "85") } func vmMenu() { for { choice := menu("Virtual Machines (QEMU)", "Choose an action:", 16, 72, 8, "list", "List VMs (qm list)", "config", "Show VM configuration", "memory", "Show VM memory setting", "unlock", "Unlock a VM", "back", "Back", ) switch choice { case "list": if requireCommand("qm") { showOutput("VM list", "qm", "list") } case "config", "memory", "unlock": if !requireCommand("qm") { continue } id, ok := askID("Enter the VMID:") if !ok { continue } switch choice { case "config": showOutput("VM "+id+" configuration", "qm", "config", id) case "memory": showMemory("VM "+id+" memory", "qm", "config", id, "^memory:") case "unlock": if confirmAction("Unlock VM " + id + "?\n\nOnly do this if you have confirmed that no task is still running for this VM.") { showOutput("Unlock VM "+id, "qm", "unlock", id) } } case "back", "": return } } } func showMemory(title, command string, args ...string) { output, err := exec.Command(command, args[:len(args)-1]...).CombinedOutput() if err != nil { showOutput(title, command, args[:len(args)-1]...) return } var matches []string for _, line := range strings.Split(string(output), "\n") { if matched, _ := regexp.MatchString(args[len(args)-1], line); matched { matches = append(matches, line) } } if len(matches) == 0 { matches = []string{"No memory setting found."} } path := tmpDir + "/output.txt" _ = os.WriteFile(path, []byte(strings.Join(matches, "\n")+"\n"), 0600) whiptail("--title", title, "--textbox", path, "24", "85") } func containerMenu() { for { choice := menu("Containers (LXC)", "Choose an action:", 14, 72, 6, "list", "List containers (pct list)", "config", "Show container configuration", "unlock", "Unlock a container", "back", "Back", ) switch choice { case "list": if requireCommand("pct") { showOutput("Container list", "pct", "list") } case "config", "unlock": if !requireCommand("pct") { continue } id, ok := askID("Enter the container ID:") if !ok { continue } if choice == "config" { showOutput("Container "+id+" configuration", "pct", "config", id) } else if confirmAction("Unlock container " + id + "?\n\nOnly do this if you have confirmed that no task is still running for this container.") { showOutput("Unlock container "+id, "pct", "unlock", id) } case "back", "": return } } } func backupMenu() { if !requireCommand("vzdump") { return } id, ok := askID("Enter the VM or container ID to back up:") if !ok { return } if confirmAction("Start a backup for ID " + id + " using vzdump defaults?\n\nReview your node's backup configuration and storage capacity first.") { showOutput("Backup ID "+id, "vzdump", id) } } func restoreMenu() { choice := menu("Restore", "Choose what to restore:", 12, 72, 4, "vm", "Restore a QEMU VM (qmrestore)", "ct", "Restore a container (pct restore)", "back", "Back", ) if choice != "vm" && choice != "ct" { return } id, ok := askID("Enter the new VMID or container ID:") if !ok { return } archive, ok := inputValue("Backup archive", "Enter the full path to the vzdump archive:") if !ok { return } info, err := os.Stat(archive) if archive == "" || err != nil || !info.Mode().IsRegular() { message("Archive not found", "That file does not exist or is not a regular file.") return } if !confirmAction("Restore '" + archive + "' as ID " + id + "?\n\nRestoring can create or overwrite resources. Confirm the target ID and archive carefully.") { return } if choice == "vm" { if requireCommand("qmrestore") { showOutput("Restore VM "+id, "qmrestore", archive, id) } } else if requireCommand("pct") { showOutput("Restore container "+id, "pct", "restore", id, archive) } } func storageMenu() { for { choice := menu("Storage / LVM", "Choose a read-only report:", 14, 72, 6, "pvs", "Physical volumes (pvs)", "vgs", "Volume groups (vgs)", "lvs", "Logical volumes (lvs)", "back", "Back", ) switch choice { case "pvs", "vgs", "lvs": if requireCommand(choice) { showOutput(map[string]string{ "pvs": "Physical volumes", "vgs": "Volume groups", "lvs": "Logical volumes", }[choice], choice) } case "back", "": return } } } func mainMenu() { for { choice := menu("Proxmox VE Toolkit", "Select a task:", 21, 76, 13, "vms", "Virtual machines (QEMU)", "containers", "Containers (LXC)", "backup", "Back up a VM or container (vzdump)", "restore", "Restore a VM or container", "cluster", "Cluster status (pvecm status)", "version", "Proxmox version (pveversion -v)", "storage", "Storage / LVM reports", "perf", "Host performance test (pveperf)", "subscription", "Subscription status", "dab", "Debian Appliance Builder help", "quit", "Exit", ) switch choice { case "vms": vmMenu() case "containers": containerMenu() case "backup": backupMenu() case "restore": restoreMenu() case "cluster": if requireCommand("pvecm") { showOutput("Cluster status", "pvecm", "status") } case "version": if requireCommand("pveversion") { showOutput("Proxmox VE version", "pveversion", "-v") } case "storage": storageMenu() case "perf": if requireCommand("pveperf") && confirmAction("Run pveperf?\n\nThis can cause temporary disk activity.") { showOutput("Host performance", "pveperf") } case "subscription": if requireCommand("pvesubscription") { showOutput("Subscription status", "pvesubscription", "get") } case "dab": if requireCommand("dab") { showOutput("Debian Appliance Builder help", "dab", "help") } case "quit", "": return } } }