package main // License: MIT // Copyright 2025 - Robert Strutts import ( "crypto/tls" "encoding/json" "flag" "io/ioutil" "net/http" "net/url" "gopkg.in/yaml.v3" "os" "os/exec" "strings" "text/template" "bytes" "fmt" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/widget" ) // New struct for SPICE connection file type SpiceConfig struct { Host string Port int TLSPort int Password string Title string ProxyHost string HostSubject string Cert string } type ProxmoxAuth struct { Data struct { CSRFPreventionToken string `json:"CSRFPreventionToken"` Ticket string `json:"ticket"` Username string `json:"username"` } `json:"data"` } // ProxmoxConfig holds the Proxmox API configuration type ProxmoxConfig struct { BaseURL string APIToken string CSRFToken string Node string VMID string } type SpicePermissions struct { Data struct { Permissions map[string]json.RawMessage `json:"permissions"` User string `json:"user"` } `json:"data"` } type VM struct { VMID int `json:"vmid"` Name string `json:"name"` Status string `json:"status"` Node string `json:"node"` } type VMResponse struct { Data []VM `json:"data"` } type VMList struct { Data []struct { VMID int `json:"vmid"` Name string `json:"name"` Status string `json:"status"` Node string `json:"node"` CPU float64 `json:"cpu"` MaxMem int64 `json:"maxmem"` Mem int64 `json:"mem"` DiskWrite int64 `json:"diskwrite"` DiskRead int64 `json:"diskread"` NetIn int64 `json:"netin"` NetOut int64 `json:"netout"` Template int `json:"template"` Uptime int64 `json:"uptime"` } `json:"data"` } type VMSpiceInfo struct { Data struct { Port int `json:"port"` Ticket string `json:"password"` Host string `json:"host"` TLSPort int `json:"tls-port"` Certificate string `json:"ca"` ProxyHost string `json:"proxy"` HostSubject string `json:"host-subject"` Toggle string `json:"toggle-fullscreen"` Release string `json:"release-cursor"` } `json:"data"` } type Config struct { hostname string username string password string realm string vmid string node string port string } type ConfigFile struct { Host string `yaml:"host"` Port string `yaml:"port"` UserName string `yaml:"username"` Realm string `yaml:"realm"` VMID string `yaml:"vmid"` Node string `yaml:"node"` } func generateSpiceConfig(spiceInfo *VMSpiceInfo, vmid string) (string, error) { tmpl := `[virt-viewer] type=spice host={{.Host}} password={{.Password}} tls-port={{.TLSPort}} title={{.Title}} proxy={{.ProxyHost}} host-subject={{.HostSubject}} toggle-fullscreen=Shift+F11 release-cursor=Ctrl+Alt+R secure-attention=Ctrl+Alt+Ins enable-smartcard=0 enable-usbredir=1 fullscreen=0 ca={{.Cert}} delete-this-file=1 ` config := SpiceConfig{ Host: spiceInfo.Data.Host, Port: spiceInfo.Data.Port, TLSPort: spiceInfo.Data.TLSPort, Password: spiceInfo.Data.Ticket, Title: fmt.Sprintf("VM %s - SPICE", vmid), ProxyHost: spiceInfo.Data.ProxyHost, HostSubject: spiceInfo.Data.HostSubject, Cert: spiceInfo.Data.Certificate, } var configContent bytes.Buffer t := template.Must(template.New("spice").Parse(tmpl)) if err := t.Execute(&configContent, config); err != nil { return "", fmt.Errorf("failed to generate config: %v", err) } // Create temporary file tmpfile, err := ioutil.TempFile("", "spice-*.vv") if err != nil { return "", fmt.Errorf("failed to create temp file: %v", err) } // Write config to file if err := ioutil.WriteFile(tmpfile.Name(), configContent.Bytes(), 0600); err != nil { return "", fmt.Errorf("failed to write config: %v", err) } return tmpfile.Name(), nil } func getAuthTicket(config Config, client *http.Client) (*ProxmoxAuth, error) { data := url.Values{} data.Set("username", config.username) data.Set("password", config.password) data.Set("realm", config.realm) apiURL := fmt.Sprintf("https://%s:%s/api2/json/access/ticket", config.hostname, config.port) req, err := http.NewRequest("POST", apiURL, strings.NewReader(data.Encode())) if err != nil { return nil, fmt.Errorf("failed to create auth request: %v", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("authentication request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := ioutil.ReadAll(resp.Body) return nil, fmt.Errorf("auth failed with status %d: %s", resp.StatusCode, string(body)) } body, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %v", err) } var auth ProxmoxAuth if err := json.Unmarshal(body, &auth); err != nil { return nil, fmt.Errorf("failed to parse auth response: %v", err) } if auth.Data.Ticket == "" || auth.Data.CSRFPreventionToken == "" { return nil, fmt.Errorf("invalid authentication response: missing ticket or token") } return &auth, nil } func getSpicePermissions(config Config, auth *ProxmoxAuth, client *http.Client) (*SpicePermissions, error) { apiURL := fmt.Sprintf("https://%s:%s/api2/json/access/permissions", config.hostname, config.port) req, err := http.NewRequest("GET", apiURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %v", err) } req.Header.Set("Accept", "application/json") req.Header.Set("Cookie", fmt.Sprintf("PVEAuthCookie=%s", auth.Data.Ticket)) req.Header.Set("CSRFPreventionToken", auth.Data.CSRFPreventionToken) resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("permissions request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := ioutil.ReadAll(resp.Body) return nil, fmt.Errorf("permission check failed with status %d: %s", resp.StatusCode, string(body)) } body, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %v", err) } var perms SpicePermissions if err := json.Unmarshal(body, &perms); err != nil { return nil, fmt.Errorf("failed to parse permissions response: %v", err) } return &perms, nil } func getVMList(config Config, auth *ProxmoxAuth, client *http.Client) (*VMList, error) { apiURL := fmt.Sprintf("https://%s:%s/api2/json/cluster/resources", config.hostname, config.port) req, err := http.NewRequest("GET", apiURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %v", err) } // Add query parameters for VMs only q := req.URL.Query() q.Add("type", "vm") req.URL.RawQuery = q.Encode() req.Header.Set("Accept", "application/json") req.Header.Set("Cookie", fmt.Sprintf("PVEAuthCookie=%s", auth.Data.Ticket)) req.Header.Set("CSRFPreventionToken", auth.Data.CSRFPreventionToken) resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("VM list request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := ioutil.ReadAll(resp.Body) return nil, fmt.Errorf("VM list request failed with status %d: %s", resp.StatusCode, string(body)) } body, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %v", err) } var vms VMList if err := json.Unmarshal(body, &vms); err != nil { return nil, fmt.Errorf("failed to parse VM list response: %v", err) } return &vms, nil } func getVMSpiceInfo(config Config, auth *ProxmoxAuth, client *http.Client) (*VMSpiceInfo, error) { if config.vmid == "" || config.node == "" { return nil, fmt.Errorf("VMID and node are required for SPICE info") } apiURL := fmt.Sprintf("https://%s:%s/api2/json/nodes/%s/qemu/%s/spiceproxy", config.hostname, config.port, config.node, config.vmid) req, err := http.NewRequest("POST", apiURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %v", err) } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Cookie", fmt.Sprintf("PVEAuthCookie=%s", auth.Data.Ticket)) req.Header.Set("CSRFPreventionToken", auth.Data.CSRFPreventionToken) resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("SPICE info request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := ioutil.ReadAll(resp.Body) return nil, fmt.Errorf("SPICE info request failed with status %d: %s", resp.StatusCode, string(body)) } body, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %v", err) } // fmt.Printf("\n%s\n\n", string(body)) var spiceInfo VMSpiceInfo if err := json.Unmarshal(body, &spiceInfo); err != nil { return nil, fmt.Errorf("failed to parse SPICE info response: %v", err) } // Set default values for optional fields if not provided if spiceInfo.Data.Toggle == "" { spiceInfo.Data.Toggle = "shift+f11" } if spiceInfo.Data.Release == "" { spiceInfo.Data.Release = "shift+f12" } // Extract the host and port from the URL part colonIndex := strings.LastIndex(spiceInfo.Data.ProxyHost, ":") if colonIndex == -1 { host := fmt.Sprintf("http://%s:3128", config.hostname) spiceInfo.Data.ProxyHost = host } else { // Get the port number starting from the last ':' port := spiceInfo.Data.ProxyHost[colonIndex:] host := fmt.Sprintf("http://%s%s", config.hostname, port) spiceInfo.Data.ProxyHost = host } return &spiceInfo, nil } func launchSpiceViewer(configFile string) error { // Check if remote-viewer is installed _, err := exec.LookPath("remote-viewer") if err != nil { return fmt.Errorf("remote-viewer not found. Please install virt-viewer package") } // Launch remote-viewer with the config file cmd := exec.Command("remote-viewer", configFile) // cat cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { return fmt.Errorf("failed to start remote-viewer: %v", err) } return nil } func doConnect(config Config, auth *ProxmoxAuth, client *http.Client, vmid string, node string, myVMWindow fyne.Window) error { //dialog.ShowInformation("Connect", fmt.Sprintf("Connecting to VM %s on node %s...", vmid, node), myVMWindow) // If VMID and node are provided, get SPICE connection info spiceInfo, err := getVMSpiceInfo(config, auth, client) if err != nil { fmt.Errorf("failed to get SPICE info: %v", err) return err } fmt.Printf("\nSPICE Connection Info for VM %s:\n", vmid) fmt.Printf("Host: %s\nTLS Port: %d\n", spiceInfo.Data.Host, spiceInfo.Data.TLSPort) fmt.Println("\nLaunching SPICE viewer...") configFile, err := generateSpiceConfig(spiceInfo, vmid) if err != nil { fmt.Errorf("failed to generate SPICE config: %v", err) return err } if err := launchSpiceViewer(configFile); err != nil { fmt.Errorf("failed to launch SPICE viewer: %v", err) return err } fmt.Println("SPICE viewer launched successfully!") return nil } func fetchVMList(config Config, auth *ProxmoxAuth, client *http.Client) ([]map[string]string, error) { // Get VM list and status vms, err := getVMList(config, auth, client) if err != nil { return nil, fmt.Errorf("failed to get VM list: %v", err) } // Process VM data var vmList []map[string]string for _, vm := range vms.Data { vmList = append(vmList, map[string]string{ "vmid": fmt.Sprintf("%d", vm.VMID), "name": vm.Name, "status": vm.Status, "node": vm.Node, }) } return vmList, nil } // sendPostRequest sends a POST request to the Proxmox API func sendPostRequest(url, apiToken string, CSRFToken string, client *http.Client) error { req, err := http.NewRequest("POST", url, bytes.NewBuffer(nil)) if err != nil { return fmt.Errorf("failed to create request: %w", err) } // Set headers for API authentication req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Cookie", fmt.Sprintf("PVEAuthCookie=%s", apiToken)) req.Header.Set("CSRFPreventionToken", CSRFToken) resp, err := client.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := ioutil.ReadAll(resp.Body) return fmt.Errorf("API error: %s, status: %d", body, resp.StatusCode) } return nil } // PowerOn powers on a VM func PowerOn(config ProxmoxConfig, client *http.Client) error { url := fmt.Sprintf("%s/nodes/%s/qemu/%s/status/start", config.BaseURL, config.Node, config.VMID) return sendPostRequest(url, config.APIToken, config.CSRFToken, client) } // PowerOff powers off a VM func PowerOff(config ProxmoxConfig, client *http.Client) error { url := fmt.Sprintf("%s/nodes/%s/qemu/%s/status/stop", config.BaseURL, config.Node, config.VMID) return sendPostRequest(url, config.APIToken, config.CSRFToken, client) } func connectToSpice(myVMWindow fyne.Window, config Config, shouldConnect bool) error { tr := &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } client := &http.Client{Transport: tr} // Get authentication ticket auth, err := getAuthTicket(config, client) if err != nil { return fmt.Errorf("authentication failed: %v", err) } // Get SPICE permissions perms, err := getSpicePermissions(config, auth, client) if err != nil { return fmt.Errorf("failed to get permissions: %v", err) } fmt.Println("\nSPICE-related permissions:") for path, permissions := range perms.Data.Permissions { if strings.Contains(strings.ToLower(path), "spice") { fmt.Printf("Path: %s\nPermissions: %v\n\n", path, permissions) } } // VM data vmList, err := fetchVMList(config, auth, client) if err != nil { return fmt.Errorf("VM list error: %v", err) } // Variable to track selected VM var selectedVM map[string]string // Declare listWidget to use it inside the initialization var listWidget *widget.List // List widget to display VMs listWidget = widget.NewList( func() int { return len(vmList) }, func() fyne.CanvasObject { // Create a horizontal box with a label and a button label := widget.NewLabel("") button := widget.NewButton("", nil) return container.NewHBox(label, button) }, func(id widget.ListItemID, item fyne.CanvasObject) { vm := vmList[id] label := item.(*fyne.Container).Objects[0].(*widget.Label) button := item.(*fyne.Container).Objects[1].(*widget.Button) // Set the label text label.SetText(fmt.Sprintf("VMID: %s | Name: %s | Node: %s | Status: %s", vm["vmid"], vm["name"], vm["node"], vm["status"])) // Set the button label and action if strings.TrimSpace(vm["status"]) == "running" { button.SetText("Power Off") } else { button.SetText("Power On") } button.OnTapped = func() { configAPI := ProxmoxConfig{ BaseURL: fmt.Sprintf("https://%s:%s/api2/json", config.hostname, config.port), APIToken: auth.Data.Ticket, CSRFToken: auth.Data.CSRFPreventionToken, Node: vm["node"], VMID: vm["vmid"], } // Toggle the VM's status if vm["status"] == "Running" { // Power Off the VM err = PowerOff(configAPI, client) if err != nil { fmt.Printf("Failed to power off VM: %v\n", err) } else { fmt.Println("VM powered off successfully!") vmList[id]["status"] = "Stopped" // Refresh the list to reflect changes listWidget.Refresh() } } else { // Power On the VM err := PowerOn(configAPI, client) if err != nil { fmt.Printf("Failed to power on VM: %v\n", err) } else { fmt.Println("VM powered on successfully!") vmList[id]["status"] = "Running" // Refresh the list to reflect changes listWidget.Refresh() } } } }, ) // Handle selection listWidget.OnSelected = func(id widget.ListItemID) { selectedVM = vmList[id] } // Connect button functionality connectButton := widget.NewButton("Connect", func() { if selectedVM == nil { if config.vmid != "" && config.node != "" { vmid := config.vmid node := config.node err := doConnect(config, auth, client, vmid, node, myVMWindow) if err != nil { dialog.ShowError(err, myVMWindow) return } } else { dialog.ShowInformation("No Selection", "Please select a VM from the list.", myVMWindow) return } } else { vmid, node := selectedVM["vmid"], selectedVM["node"] config.vmid = vmid config.node = node err := doConnect(config, auth, client, vmid, node, myVMWindow) if err != nil { dialog.ShowError(err, myVMWindow) return } } }) // Refresh button functionality refreshButton := widget.NewButton("Refresh", func() { newVmList, err := fetchVMList(config, auth, client) if err != nil { dialog.ShowError(err, myVMWindow) return } vmList = newVmList selectedVM = nil // Clear selection on refresh listWidget.Refresh() }) // Layout with full-screen list buttons := container.NewHBox(refreshButton, connectButton) content := container.NewBorder(buttons, nil, nil, nil, container.NewMax(listWidget)) // Full-screen list myVMWindow.SetContent(content) return nil } func main() { cli_configfilename := flag.String("config", "", "Config YAML file") cli_hostname := flag.String("host", "", "Proxmox host address") cli_port := flag.String("port", "8006", "Proxmox Port #") cli_username := flag.String("user", "", "Username") cli_realm := flag.String("realm", "pve", "Authentication realm (default: pve)") cli_vmid := flag.String("vmid", "", "VM ID (future use)") cli_node := flag.String("node", "", "Node name (optional)") cli_connect := flag.Bool("connect", true, "Launch SPICE connection (requires remote-viewer)") flag.Parse() label_hostname := "" label_port := "" label_username := "" label_realm := "" label_vmid := "" label_node := "" if *cli_configfilename != "" { // Read the config file data, err := os.ReadFile(*cli_configfilename) if err != nil { fmt.Printf("Error reading config file: %v\n", err) os.Exit(1) } // Use ConfigFile struct to hold our configuration in myconfig var myconfig ConfigFile // Unmarshal the YAML data into our struct err = yaml.Unmarshal(data, &myconfig) if err != nil { fmt.Printf("Error parsing YAML: %v\n", err) os.Exit(1) } label_hostname = myconfig.Host if myconfig.Port != "" { label_port = myconfig.Port } else { label_port = *cli_port } label_username = myconfig.UserName if myconfig.Realm != "" { label_realm = myconfig.Realm } else { label_realm = *cli_realm } label_vmid = myconfig.VMID label_node = myconfig.Node } else { label_hostname = *cli_hostname label_port = *cli_port label_username = *cli_username label_realm = *cli_realm label_vmid = *cli_vmid label_node = *cli_node } myApp := app.New() myWindow := myApp.NewWindow("Proxmox SPICE Viewer") // Create entry fields for host IP, username, and password hostEntry := widget.NewEntry() hostEntry.Text = label_hostname // Set default value hostEntry.SetPlaceHolder("Enter Host IP") portEntry := widget.NewEntry() portEntry.Text = label_port // Set default value portEntry.SetPlaceHolder("Enter Port #") // Create username and password entry fields usernameEntry := widget.NewEntry() usernameEntry.Text = label_username usernameEntry.SetPlaceHolder("Enter Username") passwordEntry := widget.NewPasswordEntry() passwordEntry.SetPlaceHolder("Enter Password") // Create a dropdown for realm selection realmOptions := []string{"pve", "pam"} realmSelect := widget.NewSelect(realmOptions, nil) realmSelect.SetSelected(label_realm) // Create a login button loginButton := widget.NewButton("Login", func() { hostname := hostEntry.Text port := portEntry.Text username := usernameEntry.Text password := passwordEntry.Text realm := realmSelect.Selected // Validate hostname if hostname == "" { dialog.ShowError(fmt.Errorf("host IP cannot be empty"), myWindow) return } if port == "" { dialog.ShowError(fmt.Errorf("host Port cannot be empty"), myWindow) return } // Validate username if username == "" { dialog.ShowError(fmt.Errorf("username cannot be empty"), myWindow) return } // Validate password if password == "" { dialog.ShowError(fmt.Errorf("password cannot be empty"), myWindow) return } if realm == "" { dialog.ShowError(fmt.Errorf("Realm cannot be empty"), myWindow) return } // If all validations pass, proceed with login //dialog.ShowInformation("Login Attempt", // fmt.Sprintf("Attempting to login with:\nHost: %s\nUsername: %s\nRealm: %s", // hostname, username, realm), // myWindow) }) // Create connect button connectButton := widget.NewButton("Connect to SPICE", func() { hostname := hostEntry.Text port := portEntry.Text username := usernameEntry.Text password := passwordEntry.Text realm := realmSelect.Selected if hostname == "" { dialog.ShowError(fmt.Errorf("Please login first"), myWindow) return } //dialog.ShowInformation("Connecting", fmt.Sprintf("Attempting to connect to host: %s", hostEntry.Text), myWindow) config := Config{ hostname: hostname, port: port, username: username, password: password, realm: realm, vmid: label_vmid, node: label_node, } myVMWindow := myApp.NewWindow("VM Manager") myVMWindow.Resize(fyne.NewSize(900, 900)) if err := connectToSpice(myVMWindow, config, *cli_connect); err != nil { fmt.Printf("Error: %v\n", err) os.Exit(1) } myVMWindow.Show() return }) // Create a container and set the layout content := container.NewVBox( widget.NewLabel("Proxmox SPICE Viewer Login"), hostEntry, portEntry, usernameEntry, passwordEntry, widget.NewLabel("Select Realm:"), realmSelect, loginButton, connectButton, ) // Set the content of the window and display it myWindow.SetContent(content) myWindow.Resize(fyne.NewSize(400, 300)) myWindow.ShowAndRun() }