16 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
TechQI
8dff999823 Change the python3 env path 2023-06-25 10:27:47 +08:00
cinderblockgames
48c1e6f9d3 Fixing link to virt-manager. 2023-03-22 23:44:40 -04:00
4 changed files with 174 additions and 85 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
@@ -32,6 +54,20 @@ Run the following commands on a Debian/Ubuntu Linux system to install the approp
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:

2
dist/vdiclient.json vendored
View File

@@ -1,6 +1,6 @@
{ {
"upgrade_guid" : "46cbad92-353e-4b28-9bee-83950991dad8", "upgrade_guid" : "46cbad92-353e-4b28-9bee-83950991dad8",
"version" : "1.2.01", "version" : "1.3.01",
"product_name" : "VDI Client", "product_name" : "VDI Client",
"manufacturer" : "Josh Patten", "manufacturer" : "Josh Patten",
"name" : "VDI Client", "name" : "VDI Client",

View File

@@ -11,12 +11,17 @@ logo = vdiclient.png
kiosk = False kiosk = False
# Enable/Disable Fullscreen mode (not applicable in Kiosk mode) # Enable/Disable Fullscreen mode (not applicable in Kiosk mode)
fullscreen = True 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 # Select which guest types to display. Acceptable values: both, lxc, qemu
guest_type = both guest_type = both
# Show VM option for resetting VM # Show VM option for resetting VM
#show_reset = True #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
@@ -31,6 +36,10 @@ tls_verify = false
#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`

View File

@@ -1,13 +1,13 @@
#!/usr/bin/python3 #!/usr/bin/env python3
import proxmoxer # pip install proxmoxer import proxmoxer # pip install proxmoxer
import PySimpleGUI as sg # pip install PySimpleGUI import PySimpleGUI as sg # pip install PySimpleGUI
gui = 'TK' gui = 'TK'
import requests import requests
from datetime import datetime from datetime import datetime
from configparser import ConfigParser from configparser import ConfigParser
import argparse
import random import random
import sys import sys
import copy
import os import os
import subprocess import subprocess
from time import sleep from time import sleep
@@ -19,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
######### #########
@@ -30,52 +31,65 @@ class G:
totp = False totp = False
imagefile = None imagefile = None
kiosk = False kiosk = False
viewer_kiosk = True
fullscreen = True fullscreen = True
verify_ssl = True verify_ssl = True
icon = None
inidebug = False inidebug = False
show_reset = False show_reset = False
show_hibernate = False show_hibernate = False
addl_params = None addl_params = None
pwresetcmd = None
auto_vmid = None
theme = 'LightBlue' theme = 'LightBlue'
guest_type = 'both' guest_type = 'both'
width = None
height = None
def loadconfig(config_location = None):
if config_location: def loadconfig(config_location = None, config_type='file', config_username = None, config_password = None, ssl_verify = True):
config = ConfigParser(delimiters='=') config = ConfigParser(delimiters='=')
try: if config_type == 'file':
config.read(config_location) if config_location:
except Exception as e: if not os.path.isfile(config_location):
win_popup_button(f'Unable to read supplied configuration:\n{e!r}', 'OK') win_popup_button(f'Unable to read supplied configuration:\n{config_location} does not exist!', '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):
# Last ditch effort
config_location = 'C:\\Program Files\\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
@@ -92,6 +106,8 @@ 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']: if 'fullscreen' in config['General']:
G.fullscreen = config['General'].getboolean('fullscreen') G.fullscreen = config['General'].getboolean('fullscreen')
if 'inidebug' in config['General']: if 'inidebug' in config['General']:
@@ -100,6 +116,10 @@ def loadconfig(config_location = None):
G.guest_type = config['General']['guest_type'] G.guest_type = config['General']['guest_type']
if 'show_reset' in config['General']: if 'show_reset' in config['General']:
G.show_reset = config['General'].getboolean('show_reset') 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
@@ -111,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
@@ -174,6 +200,8 @@ def setmainlayout():
layout.append([sg.Button("Log In", font=["Helvetica", 14], bind_return_key=True)]) layout.append([sg.Button("Log In", font=["Helvetica", 14], bind_return_key=True)])
else: else:
layout.append([sg.Button("Log In", font=["Helvetica", 14], bind_return_key=True), 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(listonly = False): def getvms(listonly = False):
@@ -204,6 +232,9 @@ def getvms(listonly = False):
except proxmoxer.core.ResourceException as e: except proxmoxer.core.ResourceException as e:
win_popup_button(f"Unable to display list of VMs:\n {e!r}", 'OK') win_popup_button(f"Unable to display list of VMs:\n {e!r}", 'OK')
return False 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 = []
@@ -215,7 +246,6 @@ def setvmlayout(vms):
layoutcolumn = [] layoutcolumn = []
for vm in vms: for vm in vms:
if not vm["status"] == "unknown": if not vm["status"] == "unknown":
print(vm)
vmkeyname = f'-VM|{vm["vmid"]}-' vmkeyname = f'-VM|{vm["vmid"]}-'
connkeyname = f'-CONN|{vm["vmid"]}-' connkeyname = f'-CONN|{vm["vmid"]}-'
resetkeyname = f'-RESET|{vm["vmid"]}-' resetkeyname = f'-RESET|{vm["vmid"]}-'
@@ -247,7 +277,7 @@ def setvmlayout(vms):
layoutcolumn.append(tmplayout) 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)
@@ -380,7 +410,7 @@ def vmaction(vmnode, vmid, vmtype, action='connect'):
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')
@@ -475,6 +505,11 @@ def loginwindow():
if event == 'Cancel' or event == sg.WIN_CLOSED: if event == 'Cancel' or event == sg.WIN_CLOSED:
window.close() window.close()
return False return False
elif event == 'Password Reset':
try:
subprocess.check_call(G.pwresetcmd, shell=True)
except Exception as e:
win_popup_button(f'Unable to open password reset command.\n\nError Info:\n{e}', 'OK')
else: else:
if event in ('Log In', '\r', 'special 16777220', 'special 16777221'): if event in ('Log In', '\r', 'special 16777220', 'special 16777221'):
popwin = win_popup("Please wait, authenticating...") popwin = win_popup("Please wait, authenticating...")
@@ -505,45 +540,47 @@ def showvms():
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, finalize=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, finalize=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() timer = datetime.now()
while True: while True:
if (datetime.now() - timer).total_seconds() > 5: if (datetime.now() - timer).total_seconds() > 5:
timer = datetime.now() timer = datetime.now()
newvmlist = getvms(listonly = True) newvmlist = getvms(listonly = True)
if vmlist != newvmlist: if newvmlist:
vmlist = newvmlist.copy() if vmlist != newvmlist:
vms = getvms() vmlist = newvmlist.copy()
layout = setvmlayout(vms) vms = getvms()
window.close() if vms:
if G.icon: layout = setvmlayout(vms)
window = sg.Window(G.title, layout, return_keyboard_events=True, finalize=True, resizable=False, no_titlebar=G.kiosk, icon=G.icon) window.close()
else: if G.icon:
window = sg.Window(G.title, layout, return_keyboard_events=True,finalize=True, resizable=False, no_titlebar=G.kiosk) 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)
window.bring_to_front()
else: # Refresh existing vm status
newvms = getvms()
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: else:
state = vm['status'] window = sg.Window(G.title, layout, return_keyboard_events=True,finalize=True, resizable=False, no_titlebar=G.kiosk, size=(G.width, G.height))
window[connkeyname].update(disabled=False) window.bring_to_front()
else: else: # Refresh existing vm status
window[connkeyname].update(disabled=False) newvms = getvms()
window[vmkeyname].update(f"State: {state}") 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) event, values = window.read(timeout = 1000)
if event in ('Logout', None): if event in ('Logout', None):
@@ -573,19 +610,19 @@ def showvms():
def main(): def main():
G.scaling = 1 # TKinter requires integers G.scaling = 1 # TKinter requires integers
config_location = None parser = argparse.ArgumentParser(description='Proxmox VDI Client')
if len(sys.argv) > 1: parser.add_argument('--list_themes', help='List all available themes', action='store_true')
if sys.argv[1] == '--list_themes': parser.add_argument('--config_type', help='Select config type (default: file)', choices=['file', 'http'], default='file')
sg.preview_all_look_and_feel_themes() parser.add_argument('--config_location', help='Specify the config location (default: search for config file)', default=None)
return parser.add_argument('--config_username', help="HTTP basic authentication username (default: None)", default=None)
if sys.argv[1] == '--config': parser.add_argument('--config_password', help="HTTP basic authentication password (default: None)", default=None)
if len(sys.argv) < 3: parser.add_argument('--ignore_ssl', help="HTTPS ignore SSL certificate errors (default: False)", action='store_false', default=True)
win_popup_button('No config file provided with `--config` parameter.\nPlease provide location of config file!', 'OK') args = parser.parse_args()
return if args.list_themes:
else: sg.preview_all_look_and_feel_themes()
config_location = sys.argv[2] return
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
@@ -597,6 +634,13 @@ def main():
return 1 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