package main import ( "bytes" "encoding/json" "errors" "fmt" "os" "os/exec" "regexp" "strconv" "strings" ) const version = "V0.2.0" var ( tmpDir string numeric = regexp.MustCompile(`^[0-9]+$`) snapName = regexp.MustCompile(`^[A-Za-z0-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) message( "Development software — do not use in production", "This toolkit is still in development and has not been validated for production use.\n\n" + "Use only in a test environment. Actions may affect VMs, containers, storage, or cluster state.", ) mainMenu() } 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 menu(title, prompt string, height, width, menuHeight int, items ...string) string { args := []string{ "--title", title, "--menu", prompt, strconv.Itoa(height), strconv.Itoa(width), strconv.Itoa(menuHeight), } args = append(args, items...) return whiptail(args...) } func message(title, text string) { whiptail("--title", title, "--msgbox", text, "8", "70") } func confirmAction(text string) bool { cmd := exec.Command("whiptail", "--title", "Confirm", "--yesno", text, "10", "70") cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() == nil } 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) || value == "0" { message("Invalid ID", "Enter a non-zero numeric VM or container ID.") return "", false } return value, true } func askSnapshotName() (string, bool) { value, ok := inputValue("Snapshot name", "Enter a snapshot name (letters, digits, _, ., or -):") if !ok { return "", false } if value == "" || !snapName.MatchString(value) { message("Invalid snapshot name", "Use only letters, digits, underscores, periods, and hyphens.") return "", false } return value, true } func terminalSize(name string, fallback int) int { out, err := exec.Command("tput", name).Output() if err != nil { return fallback } value, err := strconv.Atoi(strings.TrimSpace(string(out))) if err != nil || value <= 0 { return fallback } return value } func displayText(title, text string) { outputPath := tmpDir + "/output.txt" if err := os.WriteFile(outputPath, []byte(text), 0600); err != nil { message("Error", "Could not write command output to the temporary file.") return } height := terminalSize("lines", 24) - 4 width := terminalSize("cols", 80) - 4 if height < 12 { height = 12 } if width < 50 { width = 50 } whiptail("--scrolltext", "--title", title, "--textbox", outputPath, strconv.Itoa(height), strconv.Itoa(width)) } func showOutput(title, name string, args ...string) { output, err := exec.Command(name, args...).CombinedOutput() status := 0 if err != nil { status = 1 var exitErr *exec.ExitError if errors.As(err, &exitErr) { status = exitErr.ExitCode() } } text := string(output) if text == "" { text = fmt.Sprintf("Command completed with exit status %d; no output.\n", status) } else if status != 0 { text += fmt.Sprintf("\nCommand exited with status %d.\n", status) } displayText(title, text) } func showJSON(title, name string, args ...string) { output, err := exec.Command(name, args...).CombinedOutput() if err != nil { text := string(output) if text == "" { text = err.Error() } displayText(title, text) return } var formatted bytes.Buffer if err := json.Indent(&formatted, output, "", " "); err != nil { displayText(title, "Could not format JSON:\n"+err.Error()+"\n\n"+string(output)) return } displayText(title, formatted.String()+"\n") } func showMemory(title, id string) { output, err := exec.Command("qm", "config", id).CombinedOutput() if err != nil { displayText(title, string(output)+"\n"+err.Error()) return } var matches []string for _, line := range strings.Split(string(output), "\n") { if strings.HasPrefix(line, "memory:") { matches = append(matches, line) } } if len(matches) == 0 { matches = []string{"No memory setting found."} } displayText(title, strings.Join(matches, "\n")+"\n") } func showResourceNames(title, resourceType string) { output, err := exec.Command( "pvesh", "get", "/cluster/resources", "--output-format", "json", ).CombinedOutput() if err != nil { displayText(title, string(output)) return } var resources []struct { Type string `json:"type"` Name string `json:"name"` } if err := json.Unmarshal(output, &resources); err != nil { displayText(title, "Could not parse pvesh output:\n"+err.Error()) return } var names []string for _, resource := range resources { if resource.Type == resourceType { names = append(names, resource.Name) } } if len(names) == 0 { displayText(title, "No matching resources found.\n") return } displayText(title, strings.Join(names, "\n")+"\n") } func vmMenu() { for { choice := menu( "Virtual Machines (QEMU)", "Choose an action:", 20, 76, 12, "list", "List VMs (qm list)", "names", "List VM names", "status", "Show VM status", "config", "Show VM configuration", "memory", "Show VM memory setting", "start", "Start a VM", "shutdown", "Gracefully shut down a VM", "reboot", "Reboot a VM", "snapshots", "VM snapshots", "unlock", "Unlock a VM", "back", "Back", ) switch choice { case "list": if requireCommand("qm") { showOutput("VM list", "qm", "list") } case "names": if requireCommand("pvesh") { showResourceNames("VM names", "qemu") } case "status", "config", "memory", "unlock", "start", "shutdown", "reboot", "snapshots": if !requireCommand("qm") { continue } id, ok := askID("Enter the VMID:") if !ok { continue } switch choice { case "status": showOutput("VM "+id+" status", "qm", "status", id) case "config": showOutput("VM "+id+" configuration", "qm", "config", id) case "memory": showMemory("VM "+id+" memory", id) case "unlock": if confirmAction("Unlock VM " + id + "?\n\nOnly do this after confirming no task is still running for this VM.") { showOutput("Unlock VM "+id, "qm", "unlock", id) } case "start", "shutdown", "reboot": runGuestAction("qm", id, choice, "VM") case "snapshots": snapshotMenu("qm", id, "VM") } case "back", "": return } } } func containerMenu() { for { choice := menu( "Containers (LXC)", "Choose an action:", 18, 76, 10, "list", "List containers (pct list)", "names", "List container names", "status", "Show container status", "config", "Show container configuration", "start", "Start a container", "shutdown", "Gracefully shut down a container", "reboot", "Reboot a container", "snapshots", "Container snapshots", "unlock", "Unlock a container", "back", "Back", ) switch choice { case "list": if requireCommand("pct") { showOutput("Container list", "pct", "list") } case "names": if requireCommand("pvesh") { showResourceNames("Container names", "lxc") } case "status", "config", "unlock", "start", "shutdown", "reboot", "snapshots": if !requireCommand("pct") { continue } id, ok := askID("Enter the container ID:") if !ok { continue } switch choice { case "status": showOutput("Container "+id+" status", "pct", "status", id) case "config": showOutput("Container "+id+" configuration", "pct", "config", id) case "unlock": if confirmAction("Unlock container " + id + "?\n\nOnly do this after confirming no task is still running for this container.") { showOutput("Unlock container "+id, "pct", "unlock", id) } case "start", "shutdown", "reboot": runGuestAction("pct", id, choice, "container") case "snapshots": snapshotMenu("pct", id, "container") } case "back", "": return } } } func runGuestAction(command, id, action, kind string) { prompt := fmt.Sprintf("%s %s %s?", action, kind, id) if action == "shutdown" { prompt += "\n\nThis requests a graceful shutdown." } if !confirmAction(prompt) { return } showOutput(strings.Title(action)+" "+kind+" "+id, command, action, id) } func snapshotMenu(command, id, kind string) { for { choice := menu( kind+" snapshots", "Choose an action:", 12, 72, 5, "list", "List snapshots", "create", "Create snapshot", "delete", "Delete snapshot", "back", "Back", ) switch choice { case "list": showOutput(kind+" "+id+" snapshots", command, "listsnapshot", id) case "create": name, ok := askSnapshotName() if !ok { continue } if confirmAction("Create snapshot '" + name + "' for " + kind + " " + id + "?\n\nSnapshots are not a substitute for backups and may consume storage.") { showOutput("Create snapshot "+name, command, "snapshot", id, name) } case "delete": name, ok := askSnapshotName() if !ok { continue } if confirmAction("Permanently delete snapshot '" + name + "' from " + kind + " " + id + "?\n\nThis cannot be undone.") { showOutput("Delete snapshot "+name, command, "delsnapshot", id, name) } 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 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\nConfirm 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", "Choose a report:", 14, 72, 6, "status", "Proxmox storage status (pvesm status)", "pvs", "Physical volumes (pvs)", "vgs", "Volume groups (vgs)", "lvs", "Logical volumes (lvs)", "back", "Back", ) switch choice { case "status": if requireCommand("pvesm") { showOutput("Proxmox storage status", "pvesm", "status") } case "pvs": if requireCommand("pvs") { showOutput("Physical volumes", "pvs") } case "vgs": if requireCommand("vgs") { showOutput("Volume groups", "vgs") } case "lvs": if requireCommand("lvs") { showOutput("Logical volumes", "lvs") } case "back", "": return } } } func resourcesMenu() { for { choice := menu( "Cluster Resources", "Choose a resource type to display as formatted JSON:", 16, 76, 8, "vm", "Virtual machines", "storage", "Storage", "node", "Nodes", "sdn", "SDN", "back", "Back", ) switch choice { case "vm", "storage", "node", "sdn": if requireCommand("pvesh") { showJSON( "Cluster "+choice+" resources", "pvesh", "get", "/cluster/resources", "--type", choice, "--output-format", "json", ) } case "back", "": return } } } func clusterMenu() { for { choice := menu( "Cluster and Node", "Choose a report:", 14, 72, 6, "status", "Cluster status (pvecm status)", "nodes", "List nodes", "tasks", "Recent cluster tasks", "back", "Back", ) switch choice { case "status": if requireCommand("pvecm") { showOutput("Cluster status", "pvecm", "status") } case "nodes": if requireCommand("pvesh") { showJSON("Cluster nodes", "pvesh", "get", "/nodes", "--output-format", "json") } case "tasks": if requireCommand("pvesh") { showJSON( "Recent cluster tasks", "pvesh", "get", "/cluster/tasks", "--limit", "50", "--output-format", "json", ) } case "back", "": return } } } func mainMenu() { for { choice := menu( "Proxmox VE Toolkit - DEVELOPMENT ONLY - NOT FOR PRODUCTION "+version, "Select a task:", 22, 76, 14, "vms", "Virtual machines (QEMU)", "containers", "Containers (LXC)", "backup", "Back up a VM or container (vzdump)", "restore", "Restore a VM or container", "cluster", "Cluster and node reports", "version", "Proxmox version (pveversion -v)", "storage", "Storage / LVM reports", "perf", "Host performance test (pveperf)", "subscription", "Subscription status", "dab", "Debian Appliance Builder help", "resources", "Cluster resources (formatted JSON)", "quit", "Exit", ) switch choice { case "vms": vmMenu() case "containers": containerMenu() case "backup": backupMenu() case "restore": restoreMenu() case "cluster": clusterMenu() 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 "resources": resourcesMenu() case "quit", "": return } } }