Proxmox Spice - Remote Viewer App, written in Go Lang.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
spice/spice.go

801 lines
24 KiB

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()
}