45 Commits

Author SHA1 Message Date
jpattWPC
07ea02d246 Add HTTP config download option 2023-10-15 11:39:54 -05:00
jpattWPC
2476778fe6 Update README.md 2023-10-13 21:01:01 -05:00
jpattWPC
9d7540248b Add ability to disable remote-viewer kiosk mode 2023-10-13 16:04:19 -05:00
jpattWPC
cd83be7680 Add ability to auto-connect VMID 2023-10-13 14:22:08 -05:00
jpattWPC
2bac614194 Add password reset command 2023-10-13 13:42:33 -05:00
jpattWPC
3762af5d8c Bump Release 2023-10-10 09:49:20 -05:00
joshpatten
ed2b8220f1 Fully quality env path 2023-10-07 15:36:42 -05:00
jpattWPC
4a4f8df5f6 Merge pull request #72 from BrickMyPhone/main
Update vdiclient.py
2023-09-30 08:03:41 -05:00
BrickMyPhone
ff44f186c2 Update vdiclient.py
#Fixed Window sizing problem when having more than 5 VMs
2023-09-22 11:31:03 +02:00
joshpatten
e8bdbdf67c Merge pull request #54 from cinderblockgames/main
Fixing link to virt-manager.
2023-09-21 13:26:18 -05:00
joshpatten
c2fe33eda5 Merge branch 'main' into main 2023-09-21 13:26:08 -05:00
joshpatten
fb981b8a51 Merge pull request #63 from hsrzq/main
Change the python3 env path
2023-09-21 13:24:58 -05:00
jpattWPC
b32d38338b Add requests checking 2023-09-21 13:21:10 -05:00
jpattWPC
e848a938df Add window sizing option 2023-09-14 20:35:27 -05:00
jpattWPC
e8d936b297 Version Upgrade
- Add current VM state to VM list
- Add option for reset button to be enabled in INI file
- Disable Connect button when VM is in a transition state
2023-09-07 15:52:18 -05:00
TechQI
8dff999823 Change the python3 env path 2023-06-25 10:27:47 +08:00
joshpatten
a80805d69a Fixed virt-manager link 2023-04-19 17:12:16 -05:00
cinderblockgames
48c1e6f9d3 Fixing link to virt-manager. 2023-03-22 23:44:40 -04:00
jpattWPC
b382d69126 Fix #51 2023-03-10 09:22:10 -06:00
jpattWPC
715dfef72d Add python3-tk to apt install 2023-03-08 09:14:43 -06:00
jpattWPC
fbe00ec782 Bump release version 2023-02-15 08:54:29 -06:00
joshpatten
7bc5966cd6 Merge pull request #47 from bekema/main
Prevent exceptions when a node is offline
2023-02-15 08:50:14 -06:00
Hessel Bekema
9942361877 only list vms for nodes that are online; prevents KeyError crashes 2023-02-15 23:12:51 +11:00
Hessel Bekema
7148fc85ed bind enter to the log in button 2023-02-15 23:10:09 +11:00
jpattWPC
c2221da4c2 Fix typo 2023-02-03 15:57:12 -06:00
jpattWPC
de4088773b Update install instructions 2023-02-03 15:54:49 -06:00
jpattWPC
de3ab9ea68 Enhance auto-refresh logic 2023-02-03 15:47:10 -06:00
jpattWPC
57ffa48257 Add VM list refresh
- Add ability to query new VMs every 10 seconds, and redraw the window if a different list is found.
- Remove vestigial DPI checking code.
2023-02-03 15:13:27 -06:00
jpattWPC
a7f15ac8b9 Require 64 bit support 2023-02-01 11:10:50 -06:00
jpattWPC
696b8c7cd2 Remove PySimpleGuiQT support
- Remove PySimpleGUIQt support, as it requires breaking changes for Python 3.11
- Build MSI based off Python 3.11 (significantly smaller due to removing Qt requirement)
- Bump version to 1.1.0
2023-02-01 11:06:05 -06:00
jpattWPC
ef95a822c5 Merge pull request #36 from aacater/main
check if template key exists
2022-10-24 10:40:51 -05:00
Alex Cater
26c6ca6bc8 check if template key exists 2022-10-23 22:09:04 -08:00
jpattWPC
9518f65c05 Fix build environment, version bump 2022-10-19 11:32:23 -05:00
jpattWPC
02df87523d Merge pull request #30 from aacater/main
Option to filter guests by type
2022-10-19 11:19:58 -05:00
aacater
09a8d11c96 option to filter guest types 2022-09-09 17:45:51 -08:00
aacater
d643cf85f4 skip templates 2022-09-09 17:44:28 -08:00
jpattWPC
f9be014ea2 API Token Login
Add full support for passwordless token-only login.
2022-08-19 22:16:08 -05:00
jpattWPC
ad31d37364 Update MSI version 2022-08-19 21:28:27 -05:00
jpattWPC
b11817a997 Custom parameter support
Add support for custom remote-viewer paramters: https://www.mankier.com/1/remote-viewer
2022-08-19 21:12:03 -05:00
jpattWPC
3f3323710e Merge pull request #27 from rayksland/patch-1
Update vdiclient.ini.example
2022-08-16 11:57:13 -05:00
rayksland
e2f0b26e40 Update vdiclient.ini.example
grammatical edit. 'it's' is a contraction of 'it is' whereas 'its' is the possessive form of 'it.'
2022-08-15 12:06:42 -07:00
jpattWPC
c78ef81994 Update release number 2022-07-19 10:39:58 -05:00
jpattWPC
44b0bfae59 Work around http redireect issue 2022-07-19 10:37:26 -05:00
jpattWPC
5db945dced Add fullscreen toggle 2022-07-11 14:07:32 -05:00
jpattWPC
34b4d010f5 Add last ditch config read 2022-07-11 13:54:10 -05:00
7 changed files with 416 additions and 138 deletions

View File

@@ -8,9 +8,31 @@ This project's focus is to create a simple VDI client intended for mass deployme
![VDI View](screenshots/vdiview.png) ![VDI View](screenshots/vdiview.png)
## Command Line Usage
No command line options are required for default behavior. The following command line options are available:
usage: vdiclient.py [-h] [--list_themes] [--config_type {file,http}] [--config_location CONFIG_LOCATION]
[--config_username CONFIG_USERNAME] [--config_password CONFIG_PASSWORD] [--ignore_ssl]
Proxmox VDI Client
options:
-h, --help show this help message and exit
--list_themes List all available themes
--config_type {file,http}
Select config type (default: file)
--config_location CONFIG_LOCATION
Specify the config location (default: search for config file)
--config_username CONFIG_USERNAME
HTTP basic authentication username (default: None)
--config_password CONFIG_PASSWORD
HTTP basic authentication password (default: None)
--ignore_ssl HTTPS ignore SSL certificate errors (default: False)
## Windows Installation ## Windows Installation
You **MUST** install virt-viewer prior to using PVE VDI client, you may download it from the [official Virtual Machine Manager](https://virt-manager.org/download/) site. You **MUST** install virt-viewer prior to using PVE VDI client, you may download it from the [official Virtual Machine Manager](https://virt-manager.org/download.html) site.
Please visit the [releases](https://github.com/joshpatten/PVE-VDIClient/releases) section to download a prebuilt MSI package Please visit the [releases](https://github.com/joshpatten/PVE-VDIClient/releases) section to download a prebuilt MSI package
@@ -24,12 +46,28 @@ you will need to download the latest 3.10 python release, and run the following
Run the following commands on a Debian/Ubuntu Linux system to install the appropriate prerequisites Run the following commands on a Debian/Ubuntu Linux system to install the appropriate prerequisites
apt install python3-pip virt-viewer apt install python3-pip python3-tk virt-viewer git
git clone https://github.com/joshpatten/PVE-VDIClient.git
cd ./PVE-VDIClient/
chmod +x requirements.sh chmod +x requirements.sh
./requirements.sh ./requirements.sh
cp vdiclient.py /usr/local/bin cp vdiclient.py /usr/local/bin
chmod +x /usr/local/bin/vdiclient.py chmod +x /usr/local/bin/vdiclient.py
## Build Debian/Ubuntu Linux Binary
Run the following commands if you wish to build a binary on a Debian/Ubuntu Linux system
apt install python3-pip python3-tk virt-viewer git
git clone https://github.com/joshpatten/PVE-VDIClient.git
cd ./PVE-VDIClient/
chmod +x requirements.sh
./requirements.sh
pip3 install pyinstaller
pyinstaller --onefile --noconsole --noconfirm --hidden-import proxmoxer.backends --hidden-import proxmoxer.backends.https --hidden-import proxmoxer.backends.https.AuthenticationError --hidden-import proxmoxer.core --hidden-import proxmoxer.core.ResourceException --hidden-import subprocess.TimeoutExpired --hidden-import subprocess.CalledProcessError --hidden-import requests.exceptions --hidden-import requests.exceptions.ReadTimeout --hidden-import requests.exceptions.ConnectTimeout --hidden-import requests.exceptions.ConnectionError vdiclient.py
Once pyinstaller has finished your binary will be located in dist/vdiclient
## Configuration File ## Configuration File
PVE VDI Client **REQUIRES** a configuration file to function. The client searches for this file in the following locations unless **--config** is specified on the commmand line: PVE VDI Client **REQUIRES** a configuration file to function. The client searches for this file in the following locations unless **--config** is specified on the commmand line:

7
dist/createmsi.py vendored
View File

@@ -304,7 +304,8 @@ class PackageGenerator:
}) })
def path_to_id(self, pathname): def path_to_id(self, pathname):
return pathname.replace('\\', '_').replace('/', '_').replace('#', '_').replace('-', '_') return pathname.replace('\\', '_').replace('/', '_').replace('#', '_').replace('-', '_').replace("+", "__")
def create_xml(self, nodes, current_dir, parent_xml_node, staging_dir): def create_xml(self, nodes, current_dir, parent_xml_node, staging_dir):
cur_node = nodes[current_dir] cur_node = nodes[current_dir]
if cur_node.files: if cur_node.files:
@@ -327,7 +328,7 @@ class PackageGenerator:
}) })
self.component_num += 1 self.component_num += 1
for f in cur_node.files: for f in cur_node.files:
file_id = self.path_to_id(os.path.join(current_dir, f)).replace("+", "__") file_id = self.path_to_id(os.path.join(current_dir, f))
ET.SubElement(comp_xml_node, 'File', { ET.SubElement(comp_xml_node, 'File', {
'Id': file_id, 'Id': file_id,
'Name': f, 'Name': f,
@@ -335,7 +336,7 @@ class PackageGenerator:
}) })
for dirname in cur_node.dirs: for dirname in cur_node.dirs:
dir_id = os.path.join(current_dir, dirname).replace('\\', '_').replace('/', '_') dir_id = self.path_to_id(os.path.join(current_dir, dirname))
dir_node = ET.SubElement(parent_xml_node, 'Directory', { dir_node = ET.SubElement(parent_xml_node, 'Directory', {
'Id': dir_id, 'Id': dir_id,
'Name': dirname, 'Name': dirname,

3
dist/vdiclient.json vendored
View File

@@ -1,10 +1,11 @@
{ {
"upgrade_guid" : "46cbad92-353e-4b28-9bee-83950991dad8", "upgrade_guid" : "46cbad92-353e-4b28-9bee-83950991dad8",
"version" : "1.0.4", "version" : "1.3.01",
"product_name" : "VDI Client", "product_name" : "VDI Client",
"manufacturer" : "Josh Patten", "manufacturer" : "Josh Patten",
"name" : "VDI Client", "name" : "VDI Client",
"name_base" : "vdiclient", "name_base" : "vdiclient",
"arch": 64,
"comments" : "This is the Proxmox VDI client. This client interfaces with Proxmox requires that virt-viewer be installed.", "comments" : "This is the Proxmox VDI client. This client interfaces with Proxmox requires that virt-viewer be installed.",
"installdir" : "VDIClient", "installdir" : "VDIClient",
"installscope" : "perMachine", "installscope" : "perMachine",

View File

@@ -1,6 +1,6 @@
@echo off @echo off
pip install pyinstaller pip install pyinstaller
pip install proxmoxer pip install proxmoxer
pip install PySimpleGUIQt pip install PySimpleGUI
pip install requests pip install requests
pip install pywin32 pip install pywin32

View File

@@ -1,6 +1,4 @@
#!/bin/bash #!/bin/bash
pip3 install proxmoxer pip3 install proxmoxer
pip3 install PySimpleGUIQt
# If PySimpleGUIQt fails, VDIClient will fall back to PySimpleGUI
pip3 install PySimpleGUI pip3 install PySimpleGUI
pip3 install requests pip3 install requests

View File

@@ -1,7 +1,7 @@
[General] [General]
# This is the title that is diplayed to the user # This is the title that is diplayed to the user
title = VDI Login title = VDI Login
# This is the PySimpleGui Theme that is used. Run pvevdi.py with flag `--list_themes` for a list of themes # This is the PySimpleGui Theme that is used. Run vdiclient.py with flag `--list_themes` for a list of themes
theme = LightBlue theme = LightBlue
# Program Icon # Program Icon
icon = vdiicon.ico icon = vdiicon.ico
@@ -9,8 +9,19 @@ icon = vdiicon.ico
logo = vdiclient.png logo = vdiclient.png
# Enable Kiosk mode, which does not allow the user to close anything # Enable Kiosk mode, which does not allow the user to close anything
kiosk = False kiosk = False
# Enable/Disable Fullscreen mode (not applicable in Kiosk mode)
fullscreen = True
# Disable viewer_kiosk mode if kiosk is set to true, this allows overriding remote_viewer kiosk mode
#viewer_kiosk = False
# Enable displaying SPICE ini file before opening virt-viewer # Enable displaying SPICE ini file before opening virt-viewer
inidebug = False inidebug = False
# Select which guest types to display. Acceptable values: both, lxc, qemu
guest_type = both
# Show VM option for resetting VM
#show_reset = True
# Set Window Dimensions. Only use if window isn't sizing properly
#window_width = 800
#window_height = 600
[Authentication] [Authentication]
# This is the authentication backend that will be used to authenticate # This is the authentication backend that will be used to authenticate
@@ -19,12 +30,16 @@ auth_backend = pve
auth_totp = false auth_totp = false
# If disabled, TLS certificate will not be checked # If disabled, TLS certificate will not be checked
tls_verify = false tls_verify = false
# User name # User name (if using token)
#user = user #user = user
# API Token Name # API Token Name
#token_name = dvi #token_name = dvi
#API Token Value # API Token Value
#token_value = xxx-x-x-x-xxx #token_value = xxx-x-x-x-xxx
# Password Reset Command Launch. Has to be full executable Command
#pwresetcmd = start chrome --app=http://pwreset.example.com
# Automatically connect to a VMID upon authentication
#auto_vmid = 100
[Hosts] [Hosts]
# Hosts are entered as `IP/FQDN = Port` # Hosts are entered as `IP/FQDN = Port`
@@ -32,8 +47,17 @@ tls_verify = false
pve1.example.com = 8006 pve1.example.com = 8006
[SpiceProxyRedirect] [SpiceProxyRedirect]
# The Spice Proxy provided by the Proxmox API may need to have it's host/port rewritten # The Spice Proxy provided by the Proxmox API may need to have its host/port rewritten
# These rewrite rules are written `IP:port = IP:port` # These rewrite rules are written `IP:port = IP:port`
# 1. Use the inidebug and read the current proxy=pve1.example.com:3128 # 1. Use the inidebug and read the current proxy=pve1.example.com:3128
# 2. Add your proxmox ip to the right side e.g. 123.123.123.123:6000 # 2. Add your proxmox ip to the right side e.g. 123.123.123.123:6000
pve1.example.com:3128 = 123.123.123.123:6000 pve1.example.com:3128 = 123.123.123.123:6000
#[AdditionalParameters]
# If you wish to define additional parameters to pass to virt-viewer you may define them here
# More parameter definitions here: https://www.mankier.com/1/remote-viewer
# Some Examples:
# Enable USB passthrough
#enable-usbredir = true
# Enable auto USB device sharing
#enable-usb-autoshare = true

View File

@@ -1,13 +1,11 @@
#!/usr/bin/python3 #!/usr/bin/env python3
import proxmoxer # pip install proxmoxer import proxmoxer # pip install proxmoxer
try: import PySimpleGUI as sg # pip install PySimpleGUI
import PySimpleGUIQt as sg # pip install PySimpleGUIQt gui = 'TK'
gui = 'QT'
except ImportError:
import PySimpleGUI as sg # pip install PySimpleGUI
gui = 'TK'
import requests import requests
from datetime import datetime
from configparser import ConfigParser from configparser import ConfigParser
import argparse
import random import random
import sys import sys
import os import os
@@ -21,6 +19,7 @@ class G:
hostpool = [] hostpool = []
spiceproxy_conv = {} spiceproxy_conv = {}
proxmox = None proxmox = None
icon = None
vvcmd = None vvcmd = None
scaling = 1 scaling = 1
######### #########
@@ -32,62 +31,65 @@ class G:
totp = False totp = False
imagefile = None imagefile = None
kiosk = False kiosk = False
viewer_kiosk = True
fullscreen = True
verify_ssl = True verify_ssl = True
icon = None
inidebug = False inidebug = False
show_reset = False
show_hibernate = False
addl_params = None
pwresetcmd = None
auto_vmid = None
theme = 'LightBlue' theme = 'LightBlue'
guest_type = 'both'
width = None
height = None
def get_dpi():
import ctypes
import win32api # pip install pywin32
shcore = ctypes.windll.shcore
monitors = win32api.EnumDisplayMonitors()
hresult = shcore.SetProcessDpiAwareness(2)
assert hresult == 0
dpiX = ctypes.c_uint()
dpiY = ctypes.c_uint()
for i, monitor in enumerate(monitors):
shcore.GetDpiForMonitor(
monitor[0].handle,
0,
ctypes.byref(dpiX),
ctypes.byref(dpiY)
)
return dpiX.value/96
def loadconfig(config_location = None): def loadconfig(config_location = None, config_type='file', config_username = None, config_password = None, ssl_verify = True):
if config_location: config = ConfigParser(delimiters='=')
config = ConfigParser(delimiters='=') if config_type == 'file':
try: if config_location:
config.read(config_location) if not os.path.isfile(config_location):
except Exception as e: win_popup_button(f'Unable to read supplied configuration:\n{config_location} does not exist!', 'OK')
win_popup_button(f'Unable to read supplied configuration:\n{e!r}', 'OK')
config_location = None
if not config_location:
if os.name == 'nt': # Windows
config_location = f'{os.getenv("APPDATA")}\\VDIClient\\vdiclient.ini'
if not os.path.exists(config_location):
config_location = f'{os.getenv("PROGRAMFILES")}\\VDIClient\\vdiclient.ini'
if not os.path.exists(config_location):
config_location = f'{os.getenv("PROGRAMFILES(x86)")}\\VDIClient\\vdiclient.ini'
if not os.path.exists(config_location):
win_popup_button(f'Unable to read supplied configuration from any location!', 'OK')
return False return False
elif os.name == 'posix': #Linux else:
config_location = os.path.expanduser('~/.config/VDIClient/vdiclient.ini') if os.name == 'nt': # Windows
if not os.path.exists(config_location): config_list = [
config_location = '/etc/vdiclient/vdiclient.ini' f'{os.getenv("APPDATA")}\\VDIClient\\vdiclient.ini',
if not os.path.exists(config_location): f'{os.getenv("PROGRAMFILES")}\\VDIClient\\vdiclient.ini',
config_location = '/usr/local/etc/vdiclient/vdiclient.ini' f'{os.getenv("PROGRAMFILES(x86)")}\\VDIClient\\vdiclient.ini',
if not os.path.exists(config_location): 'C:\\Program Files\\VDIClient\\vdiclient.ini'
win_popup_button(f'Unable to read supplied configuration from any location!', 'OK') ]
return False
config = ConfigParser(delimiters='=') elif os.name == 'posix': #Linux
config_list = [
os.path.expanduser('~/.config/VDIClient/vdiclient.ini'),
'/etc/vdiclient/vdiclient.ini',
'/usr/local/etc/vdiclient/vdiclient.ini'
]
for location in config_list:
if os.path.exists(location):
config_location = location
break
if not config_location:
win_popup_button(f'Unable to read supplied configuration from any location!', 'OK')
return False
try: try:
config.read(config_location) config.read(config_location)
except Exception as e: except Exception as e:
win_popup_button(f'Unable to read configuration file:\n{e!r}', 'OK') win_popup_button(f'Unable to read configuration file:\n{e!r}', 'OK')
config_location = None return False
elif config_type == 'http':
try:
if config_username and config_password:
r = requests.get(url=config_location, auth=(config_username, config_password), verify = ssl_verify)
else:
r = requests.get(url=config_location, verify = ssl_verify)
config.read_string(r.text)
except Exception as e:
win_popup_button(f"Unable to read configuration from URL!\n{e}", "OK")
return False
if not 'General' in config: if not 'General' in config:
win_popup_button(f'Unable to read supplied configuration:\nNo `General` section defined!', 'OK') win_popup_button(f'Unable to read supplied configuration:\nNo `General` section defined!', 'OK')
return False return False
@@ -104,8 +106,20 @@ def loadconfig(config_location = None):
G.imagefile = config['General']['logo'] G.imagefile = config['General']['logo']
if 'kiosk' in config['General']: if 'kiosk' in config['General']:
G.kiosk = config['General'].getboolean('kiosk') G.kiosk = config['General'].getboolean('kiosk')
if 'viewer_kiosk' in config['General']:
G.viewer_kiosk = config['General'].getboolean('viewer_kiosk')
if 'fullscreen' in config['General']:
G.fullscreen = config['General'].getboolean('fullscreen')
if 'inidebug' in config['General']: if 'inidebug' in config['General']:
G.inidebug = config['General'].getboolean('inidebug') G.inidebug = config['General'].getboolean('inidebug')
if 'guest_type' in config['General']:
G.guest_type = config['General']['guest_type']
if 'show_reset' in config['General']:
G.show_reset = config['General'].getboolean('show_reset')
if 'window_width' in config['General']:
G.width = config['General'].getint('window_width')
if 'window_height' in config['General']:
G.height = config['General'].getint('window_height')
if not 'Authentication' in config: if not 'Authentication' in config:
win_popup_button(f'Unable to read supplied configuration:\nNo `Authentication` section defined!', 'OK') win_popup_button(f'Unable to read supplied configuration:\nNo `Authentication` section defined!', 'OK')
return False return False
@@ -117,11 +131,17 @@ def loadconfig(config_location = None):
if 'tls_verify' in config['Authentication']: if 'tls_verify' in config['Authentication']:
G.verify_ssl = config['Authentication'].getboolean('tls_verify') G.verify_ssl = config['Authentication'].getboolean('tls_verify')
if 'user' in config['Authentication']: if 'user' in config['Authentication']:
G.user = config['Authentication']['user'] G.user = config['Authentication']['user']
if 'token_name' in config['Authentication']: if 'token_name' in config['Authentication']:
G.token_name = config['Authentication']['token_name'] G.token_name = config['Authentication']['token_name']
if 'token_value' in config['Authentication']: if 'token_value' in config['Authentication']:
G.token_value = config['Authentication']['token_value'] G.token_value = config['Authentication']['token_value']
if 'pwresetcmd' in config['Authentication']:
G.pwresetcmd = config['Authentication']['pwresetcmd']
if 'auto_vmid' in config['Authentication']:
G.auto_vmid = config['Authentication'].getint('auto_vmid')
if 'knock_ip' in config['Authentication']:
G.knock
if not 'Hosts' in config: if not 'Hosts' in config:
win_popup_button(f'Unable to read supplied configuration:\nNo `Hosts` section defined!', 'OK') win_popup_button(f'Unable to read supplied configuration:\nNo `Hosts` section defined!', 'OK')
return False return False
@@ -134,12 +154,22 @@ def loadconfig(config_location = None):
if 'SpiceProxyRedirect' in config: if 'SpiceProxyRedirect' in config:
for key in config['SpiceProxyRedirect']: for key in config['SpiceProxyRedirect']:
G.spiceproxy_conv[key] = config['SpiceProxyRedirect'][key] G.spiceproxy_conv[key] = config['SpiceProxyRedirect'][key]
if 'AdditionalParameters' in config:
G.addl_params = {}
for key in config['AdditionalParameters']:
G.addl_params[key] = config['AdditionalParameters'][key]
return True return True
def win_popup(message): def win_popup(message):
layout = [[sg.Text(message)]] layout = [
window = sg.Window('Message', layout, no_titlebar=True, keep_on_top=True, finalize=True) [sg.Text(message, key='-TXT-')]
]
window = sg.Window('Message', layout, return_keyboard_events=True, no_titlebar=True, keep_on_top=True, finalize=True, )
window.bring_to_front() window.bring_to_front()
_, _ = window.read(timeout=10) # Fixes a black screen bug
window['-TXT-'].update(message)
sleep(.15)
window['-TXT-'].update(message)
return window return window
def win_popup_button(message, button): def win_popup_button(message, button):
@@ -167,16 +197,44 @@ def setmainlayout():
if G.totp: if G.totp:
layout.append([sg.Text("OTP Key", size =(12*G.scaling, 1), font=["Helvetica", 12]), sg.InputText(key='-totp-', font=["Helvetica", 12])]) layout.append([sg.Text("OTP Key", size =(12*G.scaling, 1), font=["Helvetica", 12]), sg.InputText(key='-totp-', font=["Helvetica", 12])])
if G.kiosk: if G.kiosk:
layout.append([sg.Button("Log In", font=["Helvetica", 14])]) layout.append([sg.Button("Log In", font=["Helvetica", 14], bind_return_key=True)])
else: else:
layout.append([sg.Button("Log In", font=["Helvetica", 14]), sg.Button("Cancel", font=["Helvetica", 14])]) layout.append([sg.Button("Log In", font=["Helvetica", 14], bind_return_key=True), sg.Button("Cancel", font=["Helvetica", 14])])
if G.pwresetcmd:
layout[-1].append(sg.Button('Password Reset', font=["Helvetica", 14]))
return layout return layout
def getvms(): def getvms(listonly = False):
vms = [] vms = []
for vm in G.proxmox.cluster.resources.get(type='vm'): try:
vms.append(vm) nodes = []
return vms for node in G.proxmox.cluster.resources.get(type='node'):
if node['status'] == 'online':
nodes.append(node['node'])
for vm in G.proxmox.cluster.resources.get(type='vm'):
if vm['node'] not in nodes:
continue
if 'template' in vm and vm['template']:
continue
if G.guest_type == 'both' or G.guest_type == vm['type']:
if listonly:
vms.append(
{
'vmid': vm['vmid'],
'name': vm['name'],
'node': vm['node']
}
)
else:
vms.append(vm)
return vms
except proxmoxer.core.ResourceException as e:
win_popup_button(f"Unable to display list of VMs:\n {e!r}", 'OK')
return False
except requests.exceptions.ConnectionError as e:
print(f"Encountered error when querying proxmox: {e!r}")
return False
def setvmlayout(vms): def setvmlayout(vms):
layout = [] layout = []
@@ -188,11 +246,38 @@ def setvmlayout(vms):
layoutcolumn = [] layoutcolumn = []
for vm in vms: for vm in vms:
if not vm["status"] == "unknown": if not vm["status"] == "unknown":
vmkeyname = f'-VM|{vm["vmid"]}-'
connkeyname = f'-CONN|{vm["vmid"]}-' connkeyname = f'-CONN|{vm["vmid"]}-'
layoutcolumn.append([sg.Text(vm['name'], font=["Helvetica", 14]), sg.Button('Connect', font=["Helvetica", 14], key=connkeyname)]) resetkeyname = f'-RESET|{vm["vmid"]}-'
hiberkeyname = f'-HIBER|{vm["vmid"]}-'
state = 'stopped'
connbutton = sg.Button('Connect', font=["Helvetica", 14], key=connkeyname)
if vm['status'] == 'running':
if 'lock' in vm:
state = vm['lock']
if state in ('suspending', 'suspended'):
if state == 'suspended':
state = 'starting'
connbutton = sg.Button('Connect', font=["Helvetica", 14], key=connkeyname, disabled=True)
else:
state = vm['status']
tmplayout = [
sg.Text(vm['name'], font=["Helvetica", 14], size=(22*G.scaling, 1*G.scaling)),
sg.Text(f"State: {state}", font=["Helvetica", 0], size=(22*G.scaling, 1*G.scaling), key=vmkeyname),
connbutton
]
if G.show_reset:
tmplayout.append(
sg.Button('Reset', font=["Helvetica", 14], key=resetkeyname)
)
if G.show_hibernate:
tmplayout.append(
sg.Button('Hibernate', font=["Helvetica", 14], key=hiberkeyname)
)
layoutcolumn.append(tmplayout)
layoutcolumn.append([sg.HorizontalSeparator()]) layoutcolumn.append([sg.HorizontalSeparator()])
if len(vms) > 5: # We need a scrollbar if len(vms) > 5: # We need a scrollbar
layout.append([sg.Column(layoutcolumn, scrollable = True, size = [450*G.scaling, None] )]) layout.append([sg.Column(layoutcolumn, scrollable = True, size = [None, None] )])
else: else:
for row in layoutcolumn: for row in layoutcolumn:
layout.append(row) layout.append(row)
@@ -211,14 +296,56 @@ def iniwin(inistring):
iniwindow.close() iniwindow.close()
return True return True
def vmaction(vmnode, vmid, vmtype): def vmaction(vmnode, vmid, vmtype, action='connect'):
status = False status = False
if vmtype == 'qemu': if vmtype == 'qemu':
vmstatus = G.proxmox.nodes(vmnode).qemu(str(vmid)).status.get('current') vmstatus = G.proxmox.nodes(vmnode).qemu(str(vmid)).status.get('current')
else: # Not sure this is even a thing, but here it is... else: # Not sure this is even a thing, but here it is...
vmstatus = G.proxmox.nodes(vmnode).lxc(str(vmid)).status.get('current') vmstatus = G.proxmox.nodes(vmnode).lxc(str(vmid)).status.get('current')
if action == 'reload':
stoppop = win_popup(f'Stopping {vmstatus["name"]}...')
sleep(.1)
try:
if vmtype == 'qemu':
jobid = G.proxmox.nodes(vmnode).qemu(str(vmid)).status.stop.post(timeout=28)
else: # Not sure this is even a thing, but here it is...
jobid = G.proxmox.nodes(vmnode).lxc(str(vmid)).status.stop.post(timeout=28)
except proxmoxer.core.ResourceException as e:
stoppop.close()
win_popup_button(f"Unable to stop VM, please provide your system administrator with the following error:\n {e!r}", 'OK')
return False
running = True
i = 0
while running and i < 30:
try:
jobstatus = G.proxmox.nodes(vmnode).tasks(jobid).status.get()
except Exception:
# We ran into a query issue here, going to skip this round and try again
jobstatus = {}
if 'exitstatus' in jobstatus:
stoppop.close()
stoppop = None
if jobstatus['exitstatus'] != 'OK':
win_popup_button('Unable to stop VM, please contact your system administrator for assistance', 'OK')
return False
else:
running = False
status = True
sleep(1)
i += 1
if not status:
if stoppop:
stoppop.close()
return status
status = False
if vmtype == 'qemu':
vmstatus = G.proxmox.nodes(vmnode).qemu(str(vmid)).status.get('current')
else: # Not sure this is even a thing, but here it is...
vmstatus = G.proxmox.nodes(vmnode).lxc(str(vmid)).status.get('current')
sleep(.2)
if vmstatus['status'] != 'running': if vmstatus['status'] != 'running':
startpop = win_popup(f'Starting {vmstatus["name"]}...') startpop = win_popup(f'Starting {vmstatus["name"]}...')
sleep(.1)
try: try:
if vmtype == 'qemu': if vmtype == 'qemu':
jobid = G.proxmox.nodes(vmnode).qemu(str(vmid)).status.start.post(timeout=28) jobid = G.proxmox.nodes(vmnode).qemu(str(vmid)).status.start.post(timeout=28)
@@ -231,7 +358,11 @@ def vmaction(vmnode, vmid, vmtype):
running = False running = False
i = 0 i = 0
while running == False and i < 30: while running == False and i < 30:
jobstatus = G.proxmox.nodes(vmnode).tasks(jobid).status.get() try:
jobstatus = G.proxmox.nodes(vmnode).tasks(jobid).status.get()
except Exception:
# We ran into a query issue here, going to skip this round and try again
jobstatus = {}
if 'exitstatus' in jobstatus: if 'exitstatus' in jobstatus:
startpop.close() startpop.close()
startpop = None startpop = None
@@ -247,13 +378,19 @@ def vmaction(vmnode, vmid, vmtype):
if startpop: if startpop:
startpop.close() startpop.close()
return status return status
if vmtype == 'qemu': if action == 'reload':
spiceconfig = G.proxmox.nodes(vmnode).qemu(str(vmid)).spiceproxy.post() return
else: # Not sure this is even a thing, but here it is... try:
spiceconfig = G.proxmox.nodes(vmnode).lxc(str(vmid)).spiceproxy.post() if vmtype == 'qemu':
spiceconfig = G.proxmox.nodes(vmnode).qemu(str(vmid)).spiceproxy.post()
else: # Not sure this is even a thing, but here it is...
spiceconfig = G.proxmox.nodes(vmnode).lxc(str(vmid)).spiceproxy.post()
except proxmoxer.core.ResourceException as e:
win_popup_button(f"Unable to connect to VM {vmid}:\n{e!r}\nIs SPICE display configured for your VM?", 'OK')
return False
confignode = ConfigParser() confignode = ConfigParser()
confignode['virt-viewer'] = {} confignode['virt-viewer'] = {}
for key,value in spiceconfig.items(): for key, value in spiceconfig.items():
if key == 'proxy': if key == 'proxy':
val = value[7:].lower() val = value[7:].lower()
if val in G.spiceproxy_conv: if val in G.spiceproxy_conv:
@@ -262,6 +399,9 @@ def vmaction(vmnode, vmid, vmtype):
confignode['virt-viewer'][key] = f'{value}' confignode['virt-viewer'][key] = f'{value}'
else: else:
confignode['virt-viewer'][key] = f'{value}' confignode['virt-viewer'][key] = f'{value}'
if G.addl_params:
for key, value in G.addl_params.items():
confignode['virt-viewer'][key] = f'{value}'
inifile = StringIO('') inifile = StringIO('')
confignode.write(inifile) confignode.write(inifile)
inifile.seek(0) inifile.seek(0)
@@ -270,11 +410,11 @@ def vmaction(vmnode, vmid, vmtype):
closed = iniwin(inistring) closed = iniwin(inistring)
connpop = win_popup(f'Connecting to {vmstatus["name"]}...') connpop = win_popup(f'Connecting to {vmstatus["name"]}...')
pcmd = [G.vvcmd] pcmd = [G.vvcmd]
if G.kiosk: if G.kiosk and G.viewer_kiosk:
pcmd.append('--kiosk') pcmd.append('--kiosk')
pcmd.append('--kiosk-quit') pcmd.append('--kiosk-quit')
pcmd.append('on-disconnect') pcmd.append('on-disconnect')
else: elif G.fullscreen:
pcmd.append('--full-screen') pcmd.append('--full-screen')
pcmd.append('-') #We need it to listen on stdin pcmd.append('-') #We need it to listen on stdin
process = subprocess.Popen(pcmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE) process = subprocess.Popen(pcmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
@@ -310,7 +450,7 @@ def setcmd():
win_popup_button('Installation of virt-viewer missing, please install using `apt install virt-viewer`', 'OK') win_popup_button('Installation of virt-viewer missing, please install using `apt install virt-viewer`', 'OK')
sys.exit() sys.exit()
def pveauth(username, passwd, totp): def pveauth(username, passwd=None, totp=None):
random.shuffle(G.hostpool) random.shuffle(G.hostpool)
err = None err = None
for hostinfo in G.hostpool: for hostinfo in G.hostpool:
@@ -343,47 +483,106 @@ def pveauth(username, passwd, totp):
def loginwindow(): def loginwindow():
layout = setmainlayout() layout = setmainlayout()
if G.icon: if G.user and G.token_name and G.token_value: # We need to skip the login
window = sg.Window(G.title, layout, return_keyboard_events=True, resizable=False, no_titlebar=G.kiosk, icon=G.icon) popwin = win_popup("Please wait, authenticating...")
else: connected, authenticated, error = pveauth(G.user)
window = sg.Window(G.title, layout, return_keyboard_events=True, resizable=False, no_titlebar=G.kiosk) popwin.close()
while True: if not connected:
event, values = window.read() win_popup_button(f'Unable to connect to any VDI server, are you connected to the Internet?\nError Info: {error}', 'OK')
if event == 'Cancel' or event == sg.WIN_CLOSED:
window.close()
return False return False
elif connected and not authenticated:
win_popup_button('Invalid username and/or password, please try again!', 'OK')
return False
elif connected and authenticated:
return True
else:
if G.icon:
window = sg.Window(G.title, layout, return_keyboard_events=True, resizable=False, no_titlebar=G.kiosk, icon=G.icon)
else: else:
if event in ('Log In', '\r', 'special 16777220', 'special 16777221'): window = sg.Window(G.title, layout, return_keyboard_events=True, resizable=False, no_titlebar=G.kiosk)
popwin = win_popup("Please wait, authenticating...") while True:
user = values['-username-'] event, values = window.read()
passwd = values['-password-'] if event == 'Cancel' or event == sg.WIN_CLOSED:
totp = None window.close()
if '-totp-' in values: return False
if values['-totp-'] not in (None, ''): elif event == 'Password Reset':
totp = values['-totp-'] try:
connected, authenticated, error = pveauth(user, passwd, totp) subprocess.check_call(G.pwresetcmd, shell=True)
popwin.close() except Exception as e:
if not connected: win_popup_button(f'Unable to open password reset command.\n\nError Info:\n{e}', 'OK')
win_popup_button(f'Unable to connect to any VDI server, are you connected to the Internet?\nError Info: {error}', 'OK') else:
elif connected and not authenticated: if event in ('Log In', '\r', 'special 16777220', 'special 16777221'):
win_popup_button('Invalid username and/or password, please try again!', 'OK') popwin = win_popup("Please wait, authenticating...")
elif connected and authenticated: user = values['-username-']
window.close() passwd = values['-password-']
return True totp = None
#break if '-totp-' in values:
if values['-totp-'] not in (None, ''):
totp = values['-totp-']
connected, authenticated, error = pveauth(user, passwd=passwd, totp=totp)
popwin.close()
if not connected:
win_popup_button(f'Unable to connect to any VDI server, are you connected to the Internet?\nError Info: {error}', 'OK')
elif connected and not authenticated:
win_popup_button('Invalid username and/or password, please try again!', 'OK')
elif connected and authenticated:
window.close()
return True
#break
def showvms(): def showvms():
vms = getvms() vms = getvms()
vmlist = getvms(listonly=True)
newvmlist = vmlist.copy()
if vms == False:
return False
if len(vms) < 1: if len(vms) < 1:
win_popup_button('No desktop instances found, please consult with your system administrator', 'OK') win_popup_button('No desktop instances found, please consult with your system administrator', 'OK')
return False return False
layout = setvmlayout(vms) layout = setvmlayout(vms)
if G.icon: if G.icon:
window = sg.Window(G.title, layout, return_keyboard_events=True, resizable=False, no_titlebar=G.kiosk, icon=G.icon) window = sg.Window(G.title, layout, return_keyboard_events=True, finalize=True, resizable=False, no_titlebar=G.kiosk, size=(G.width, G.height), icon=G.icon)
else: else:
window = sg.Window(G.title, layout, return_keyboard_events=True, resizable=False, no_titlebar=G.kiosk) window = sg.Window(G.title, layout, return_keyboard_events=True, finalize=True, resizable=False, size=(G.width, G.height), no_titlebar=G.kiosk)
timer = datetime.now()
while True: while True:
event, values = window.read() if (datetime.now() - timer).total_seconds() > 5:
timer = datetime.now()
newvmlist = getvms(listonly = True)
if newvmlist:
if vmlist != newvmlist:
vmlist = newvmlist.copy()
vms = getvms()
if vms:
layout = setvmlayout(vms)
window.close()
if G.icon:
window = sg.Window(G.title, layout, return_keyboard_events=True, finalize=True, resizable=False, no_titlebar=G.kiosk, size=(G.width, G.height), icon=G.icon)
else:
window = sg.Window(G.title, layout, return_keyboard_events=True,finalize=True, resizable=False, no_titlebar=G.kiosk, size=(G.width, G.height))
window.bring_to_front()
else: # Refresh existing vm status
newvms = getvms()
if newvms:
for vm in newvms:
vmkeyname = f'-VM|{vm["vmid"]}-'
connkeyname = f'-CONN|{vm["vmid"]}-'
state = 'stopped'
if vm['status'] == 'running':
if 'lock' in vm:
state = vm['lock']
if state in ('suspending', 'suspended'):
window[connkeyname].update(disabled=True)
if state == 'suspended':
state = 'starting'
else:
state = vm['status']
window[connkeyname].update(disabled=False)
else:
window[connkeyname].update(disabled=False)
window[vmkeyname].update(f"State: {state}")
event, values = window.read(timeout = 1000)
if event in ('Logout', None): if event in ('Logout', None):
window.close() window.close()
return False return False
@@ -397,29 +596,33 @@ def showvms():
vmaction(vm['node'], vmid, vm['type']) vmaction(vm['node'], vmid, vm['type'])
if not found: if not found:
win_popup_button(f'VM {vm["name"]} no longer availble, please contact your system administrator', 'OK') win_popup_button(f'VM {vm["name"]} no longer availble, please contact your system administrator', 'OK')
elif event.startswith('-RESET'):
eventparams = event.split('|')
vmid = eventparams[1][:-1]
found = False
for vm in vms:
if str(vm['vmid']) == vmid:
found = True
vmaction(vm['node'], vmid, vm['type'], action='reload')
if not found:
win_popup_button(f'VM {vm["name"]} no longer availble, please contact your system administrator', 'OK')
return True return True
def main(): def main():
if os.name == 'nt' and gui == 'QT': G.scaling = 1 # TKinter requires integers
G.scaling = get_dpi() parser = argparse.ArgumentParser(description='Proxmox VDI Client')
else: parser.add_argument('--list_themes', help='List all available themes', action='store_true')
if gui == 'QT': parser.add_argument('--config_type', help='Select config type (default: file)', choices=['file', 'http'], default='file')
G.scaling = 1.0 #TODO FIXME: Figure out scaling on Linux parser.add_argument('--config_location', help='Specify the config location (default: search for config file)', default=None)
else: parser.add_argument('--config_username', help="HTTP basic authentication username (default: None)", default=None)
G.scaling = 1 # TKinter requires integers parser.add_argument('--config_password', help="HTTP basic authentication password (default: None)", default=None)
config_location = None parser.add_argument('--ignore_ssl', help="HTTPS ignore SSL certificate errors (default: False)", action='store_false', default=True)
if len(sys.argv) > 1: args = parser.parse_args()
if sys.argv[1] == '--list_themes': if args.list_themes:
sg.preview_all_look_and_feel_themes() sg.preview_all_look_and_feel_themes()
return return
if sys.argv[1] == '--config':
if len(sys.argv) < 3:
win_popup_button('No config file provided with `--config` parameter.\nPlease provide location of config file!', 'OK')
return
else:
config_location = sys.argv[2]
setcmd() setcmd()
if not loadconfig(config_location): if not loadconfig(config_location=args.config_location, config_type=args.config_type, config_username=args.config_username, config_password=args.config_password, ssl_verify=args.ignore_ssl):
return False return False
sg.theme(G.theme) sg.theme(G.theme)
loggedin = False loggedin = False
@@ -427,12 +630,25 @@ def main():
if not loggedin: if not loggedin:
loggedin = loginwindow() loggedin = loginwindow()
if not loggedin: if not loggedin:
if G.user and G.token_name and G.token_value: # This means if we don't exit we'll be in an infinite loop
return 1
break break
else: else:
if G.auto_vmid:
vms = getvms()
for row in vms:
if row['vmid'] == G.auto_vmid:
vmaction(row['node'], row['vmid'], row['type'], action='connect')
return 0
win_popup_button(f'No VDI instance with ID {G.auto_vmid} found!', 'OK')
vmstat = showvms() vmstat = showvms()
if not vmstat: if not vmstat:
G.proxmox = None G.proxmox = None
loggedin = False loggedin = False
if G.user and G.token_name and G.token_value: # This means if we don't exit we'll be in an infinite loop
return 0
else: else:
return return
sys.exit(main())
if __name__ == '__main__':
sys.exit(main())