28 Commits

Author SHA1 Message Date
jpattWPC
6d250faed5 Fix inidebug window 2023-11-24 17:44:44 -06:00
joshpatten
4966609e45 Merge pull request #75 from dariomolinari/patch-1
Update README.md
2023-10-17 16:00:35 -05:00
Dario Molinari
b7491f9a97 Update README.md
Added a paragraph for Fedora/CentOS/RHEL
2023-10-17 16:26:13 +01:00
jpattWPC
3e69e77044 Update README.md
Change python version
2023-10-15 20:01:41 -05:00
jpattWPC
d95c96cf25 Update README.md 2023-10-15 19:17:27 -05:00
jpattWPC
865496bf79 Update README 2023-10-15 19:15:47 -05:00
jpattWPC
f407057fcb Add Multi-Cluster Support
Please review changes to the INI file. Legacy format is still supported, but format must be changed if you wish to use multiple clusters.
2023-10-15 19:04:22 -05:00
jpattWPC
2d20f1a414 Bugfix: URL required
If --config_type is http and url is not passed in --config_location, show error and exit
2023-10-15 12:10:25 -05:00
jpattWPC
59f11ffdeb Update README.md 2023-10-15 12:02:49 -05:00
jpattWPC
bc30fcb4c6 Update README.md 2023-10-15 12:01:41 -05:00
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
7 changed files with 658 additions and 149 deletions

View File

@@ -2,21 +2,71 @@
This project's focus is to create a simple VDI client intended for mass deployment. This VDI client connects directly to Proxmox VE and allows users to connect (via Spice) to any VMs they have permission to access.
Defining multiple Proxmox clusters is possible and can allow end users to easily select which 'server group' they wish to connect to:
![Login Screen](screenshots/login.png)
![Login Screen with OTP](screenshots/login-totp.png)
![VDI View](screenshots/vdiview.png)
## Configuration File
PVE VDI Client **REQUIRES** a configuration file to function. The client searches for this file in the following locations unless overridden with [command line options](#command-line-usage):
* Windows
* %APPDATA%\VDIClient\vdiclient.ini
* %PROGRAMFILES%\VDIClient\vdiclient.ini
* Linux
* ~/.config/VDIClient/vdiclient.ini
* /etc/vdiclient/vdiclient.ini
* /usr/local/etc/vdiclient/vdiclient.ini
Please refer to [vdiclient.ini.example](https://github.com/joshpatten/PVE-VDIClient/blob/main/vdiclient.ini.example) for all available config file options
If you encounter any issues feel free to submit an issue report.
## Proxmox Permission Requirements
Users that are accessing VDI instances need to have the following permissions assigned for each VM they access:
* VM.PowerMgmt
* VM.Console
* VM.Audit
## 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)
If `--config_type http` is selected, pass the URL in the `--config_location` parameter
## 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
If you need to customize the installation, such as to sign the executable and MSI, you may download and install the [WIX toolset](https://wixtoolset.org/releases/) and use the build_vdiclient.bat file to build a new MSI.
you will need to download the latest 3.10 python release, and run the following commands to install the necessary packages:
you will need to download the latest 3.12 python release, and run the following commands to install the necessary packages:
requirements.bat
@@ -32,26 +82,28 @@ Run the following commands on a Debian/Ubuntu Linux system to install the approp
cp vdiclient.py /usr/local/bin
chmod +x /usr/local/bin/vdiclient.py
## Configuration File
## Fedora/CentOS/RHEL Installation
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:
Run the following commands on a Debian/Ubuntu Linux system to install the appropriate prerequisites
* Windows
* %APPDATA%\VDIClient\vdiclient.ini
* %PROGRAMFILES%\VDIClient\vdiclient.ini
* Linux
* ~/.config/VDIClient/vdiclient.ini
* /etc/vdiclient/vdiclient.ini
* /usr/local/etc/vdiclient/vdiclient.ini
dnf install python3-pip python3-tkinter virt-viewer git
git clone https://github.com/joshpatten/PVE-VDIClient.git
cd ./PVE-VDIClient/
chmod +x requirements.sh
./requirements.sh
cp vdiclient.py /usr/local/bin
chmod +x /usr/local/bin/vdiclient.py
Please refer to **vdiclient.ini.example** for all available config file options
## Build Debian/Ubuntu Linux Binary
If you encounter any issues feel free to submit an issue report.
Run the following commands if you wish to build a binary on a Debian/Ubuntu Linux system
## Proxmox Permission Requirements
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
Users that are accessing VDI instances need to have the following permissions assigned for each VM they access:
* VM.PowerMgmt
* VM.Console
* VM.Audit
Once pyinstaller has finished your binary will be located in dist/vdiclient

2
dist/vdiclient.json vendored
View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -11,12 +11,29 @@ logo = vdiclient.png
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
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]
# PVE-VDIClient supports multiple clusters. Define them with sections that start with Hosts. followed by the name
# you wish to display to your end users. This example is Hosts.PVE which would display PVE to your users
[Hosts.PVE]
# JSON dictionary of servers in the cluster
# Format is 'IP/FQDN': PORT
# NOTE: MAKE SURE THAT ALL LINES ARE INDENTED
hostpool = {
"10.10.10.100" : 8006,
"10.10.10.111" : 8006,
"pve1.example.com" : 8006
}
# This is the authentication backend that will be used to authenticate
auth_backend = pve
# If enabled, 2FA TOTP entry dialog will show
@@ -24,16 +41,45 @@ auth_totp = false
# If disabled, TLS certificate will not be checked
tls_verify = false
# User name (if using token)
# NOTE: If only one cluster is defined, this will auto-login
# If user, token_name, and token_value are set
#user = user
# API Token Name
#token_name = dvi
# API Token Value
#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
# An additional cluster definition
#[Hosts.PVE2]
# JSON dictionary of servers in the cluster
# Format is 'IP/FQDN': PORT
#hostpool = {
# "10.10.10.100" : 8006,
# "10.10.10.111" : 8006,
# "pve1.example.com" : 8006
# }
# This is the authentication backend that will be used to authenticate
#auth_backend = pve
# If enabled, 2FA TOTP entry dialog will show
#auth_totp = false
# If disabled, TLS certificate will not be checked
#tls_verify = false
# User name (if using token)
# NOTE: If only one cluster is defined, this will auto-login
#user = user
# API Token Name
#token_name = dvi
# API Token Value
#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 are entered as `IP/FQDN = Port`
10.10.10.100 = 8006
pve1.example.com = 8006
[SpiceProxyRedirect]
# The Spice Proxy provided by the Proxmox API may need to have its host/port rewritten

View File

@@ -1,14 +1,15 @@
#!/usr/bin/python3
#!/usr/bin/env python3
import proxmoxer # pip install proxmoxer
import PySimpleGUI as sg # pip install PySimpleGUI
gui = 'TK'
import requests
from datetime import datetime
from configparser import ConfigParser
import argparse
import random
import sys
import copy
import os
import json
import subprocess
from time import sleep
from io import StringIO
@@ -16,66 +17,78 @@ from io import StringIO
class G:
hostpool = []
spiceproxy_conv = {}
proxmox = None
icon = None
vvcmd = None
scaling = 1
#########
title = 'VDI Login'
backend = 'pve'
user = ""
token_name = None
token_value = None
totp = False
imagefile = None
kiosk = False
fullscreen = True
verify_ssl = True
icon = None
inidebug = False
addl_params = None
imagefile = None
kiosk = False
viewer_kiosk = True
fullscreen = True
show_reset = False
show_hibernate = False
current_hostset = 'DEFAULT'
title = 'VDI Login'
hosts = {}
theme = 'LightBlue'
guest_type = 'both'
width = None
height = None
def loadconfig(config_location = None):
if config_location:
config = ConfigParser(delimiters='=')
try:
config.read(config_location)
except Exception as e:
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):
# 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')
def loadconfig(config_location = None, config_type='file', config_username = None, config_password = None, ssl_verify = True):
config = ConfigParser(delimiters='=')
if config_type == 'file':
if config_location:
if not os.path.isfile(config_location):
win_popup_button(f'Unable to read supplied configuration:\n{config_location} does not exist!', 'OK')
return False
elif os.name == 'posix': #Linux
config_location = os.path.expanduser('~/.config/VDIClient/vdiclient.ini')
if not os.path.exists(config_location):
config_location = '/etc/vdiclient/vdiclient.ini'
if not os.path.exists(config_location):
config_location = '/usr/local/etc/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
config = ConfigParser(delimiters='=')
else:
if os.name == 'nt': # Windows
config_list = [
f'{os.getenv("APPDATA")}\\VDIClient\\vdiclient.ini',
f'{os.getenv("PROGRAMFILES")}\\VDIClient\\vdiclient.ini',
f'{os.getenv("PROGRAMFILES(x86)")}\\VDIClient\\vdiclient.ini',
'C:\\Program Files\\VDIClient\\vdiclient.ini'
]
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:
config.read(config_location)
except Exception as e:
win_popup_button(f'Unable to read configuration file:\n{e!r}', 'OK')
config_location = None
return False
elif config_type == 'http':
if not config_location:
win_popup_button('--config_type http defined, yet no URL provided in --config_location parameter!', 'OK')
return False
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:
win_popup_button(f'Unable to read supplied configuration:\nNo `General` section defined!', 'OK')
win_popup_button('Unable to read supplied configuration:\nNo `General` section defined!', 'OK')
return False
else:
if 'title' in config['General']:
@@ -90,37 +103,113 @@ def loadconfig(config_location = None):
G.imagefile = config['General']['logo']
if 'kiosk' in config['General']:
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']:
G.inidebug = config['General'].getboolean('inidebug')
if 'guest_type' in config['General']:
G.guest_type = config['General']['guest_type']
if not 'Authentication' in config:
win_popup_button(f'Unable to read supplied configuration:\nNo `Authentication` section defined!', 'OK')
return False
else:
if 'auth_backend' in config['Authentication']:
G.backend = config['Authentication']['auth_backend']
if 'auth_totp' in config['Authentication']:
G.totp = config['Authentication'].getboolean('auth_totp')
if 'tls_verify' in config['Authentication']:
G.verify_ssl = config['Authentication'].getboolean('tls_verify')
if 'user' in config['Authentication']:
G.user = config['Authentication']['user']
if 'token_name' in config['Authentication']:
G.token_name = config['Authentication']['token_name']
if 'token_value' in config['Authentication']:
G.token_value = config['Authentication']['token_value']
if not 'Hosts' in config:
win_popup_button(f'Unable to read supplied configuration:\nNo `Hosts` section defined!', 'OK')
return False
else:
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 'Authentication' in config: #Legacy configuration
G.hosts['DEFAULT'] = {
'hostpool' : [],
'backend' : 'pve',
'user' : "",
'token_name' : None,
'token_value' : None,
'totp' : False,
'verify_ssl' : True,
'pwresetcmd' : None,
'auto_vmid' : None,
'knock_seq': []
}
if not 'Hosts' in config:
win_popup_button(f'Unable to read supplied configuration:\nNo `Hosts` section defined!', 'OK')
return False
for key in config['Hosts']:
G.hostpool.append({
G.hosts['DEFAULT']['hostpool'].append({
'host': key,
'port': int(config['Hosts'][key])
})
if 'auth_backend' in config['Authentication']:
G.hosts['DEFAULT']['backend'] = config['Authentication']['auth_backend']
if 'user' in config['Authentication']:
G.hosts['DEFAULT']['user'] = config['Authentication']['user']
if 'token_name' in config['Authentication']:
G.hosts['DEFAULT']['token_name'] = config['Authentication']['token_name']
if 'token_value' in config['Authentication']:
G.hosts['DEFAULT']['token_value'] = config['Authentication']['token_value']
if 'auth_totp' in config['Authentication']:
G.hosts['DEFAULT']['totp'] = config['Authentication'].getboolean('auth_totp')
if 'tls_verify' in config['Authentication']:
G.hosts['DEFAULT']['verify_ssl'] = config['Authentication'].getboolean('tls_verify')
if 'pwresetcmd' in config['Authentication']:
G.hosts['DEFAULT']['pwresetcmd'] = config['Authentication']['pwresetcmd']
if 'auto_vmid' in config['Authentication']:
G.hosts['DEFAULT']['auto_vmid'] = config['Authentication'].getint('auto_vmid')
if 'knock_seq' in config['Authentication']:
try:
G.hosts['DEFAULT']['knock_seq'] = json.loads(config['Authentication']['knock_seq'])
except Exception as e:
win_popup_button(f'Knock sequence not valid JSON, skipping!\n{e!r}', 'OK')
else: # New style config
i = 0
for section in config.sections():
if section.startswith('Hosts.'):
_, group = section.split('.', 1)
if i == 0:
G.current_hostset = group
G.hosts[group] = {
'hostpool' : [],
'backend' : 'pve',
'user' : "",
'token_name' : None,
'token_value' : None,
'totp' : False,
'verify_ssl' : True,
'pwresetcmd' : None,
'auto_vmid' : None,
'knock_seq': []
}
try:
hostjson = json.loads(config[section]['hostpool'])
except Exception as e:
win_popup_button(f"Error: could not parse hostpool in section {section}:\n{e!r}", "OK")
return False
for key, value in hostjson.items():
G.hosts[group]['hostpool'].append({
'host': key,
'port': int(value)
})
if 'auth_backend' in config[section]:
G.hosts[group]['backend'] = config[section]['auth_backend']
if 'user' in config[section]:
G.hosts[group]['user'] = config[section]['user']
if 'token_name' in config[section]:
G.hosts[group]['token_name'] = config[section]['token_name']
if 'token_value' in config[section]:
G.hosts[group]['token_value'] = config[section]['token_value']
if 'auth_totp' in config[section]:
G.hosts[group]['totp'] = config[section].getboolean('auth_totp')
if 'tls_verify' in config[section]:
G.hosts[group]['verify_ssl'] = config[section].getboolean('tls_verify')
if 'pwresetcmd' in config[section]:
G.hosts[group]['pwresetcmd'] = config[section]['pwresetcmd']
if 'auto_vmid' in config[section]:
G.hosts[group]['auto_vmid'] = config[section].getint('auto_vmid')
if 'knock_seq' in config[section]:
try:
G.hosts[group]['knock_seq'] = json.loads(config[section]['knock_seq'])
except Exception as e:
win_popup_button(f'Knock sequence not valid JSON, skipping!\n{e!r}', 'OK')
i += 1
if 'SpiceProxyRedirect' in config:
for key in config['SpiceProxyRedirect']:
G.spiceproxy_conv[key] = config['SpiceProxyRedirect'][key]
@@ -132,11 +221,14 @@ def loadconfig(config_location = None):
def win_popup(message):
layout = [
[sg.Text(message)]
[sg.Text(message, key='-TXT-')]
]
window = sg.Window('Message', layout, return_keyboard_events=True, no_titlebar=True, keep_on_top=True, finalize=True)
window = sg.Window('Message', layout, return_keyboard_events=True, no_titlebar=True, keep_on_top=True, finalize=True, )
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
def win_popup_button(message, button):
@@ -153,20 +245,192 @@ def win_popup_button(message, button):
return
def setmainlayout():
readonly = False
if G.hosts[G.current_hostset]['user'] and G.hosts[G.current_hostset]['token_name'] and G.hosts[G.current_hostset]['token_value']:
readonly = True
layout = []
if G.imagefile:
layout.append([sg.Image(G.imagefile), sg.Text(G.title, size =(18*G.scaling, 1*G.scaling), justification='c', font=["Helvetica", 18])])
layout.append(
[
sg.Image(G.imagefile),
sg.Text(
G.title,
size = (
18*G.scaling,
1*G.scaling
),
justification = 'c',
font = [
"Helvetica",
18
]
)
]
)
else:
layout.append([sg.Text(G.title, size =(30*G.scaling, 1*G.scaling), justification='c', font=["Helvetica", 18])])
layout.append([sg.Text("Username", size =(12*G.scaling, 1*G.scaling), font=["Helvetica", 12]), sg.InputText(default_text=G.user,key='-username-', font=["Helvetica", 12])])
layout.append([sg.Text("Password", size =(12*G.scaling, 1*G.scaling),font=["Helvetica", 12]), sg.InputText(key='-password-', password_char='*', font=["Helvetica", 12])])
layout.append(
[
sg.Text(
G.title,
size = (
30*G.scaling,
1*G.scaling
),
justification='c',
font = [
"Helvetica",
18
]
)
]
)
if G.totp:
layout.append([sg.Text("OTP Key", size =(12*G.scaling, 1), font=["Helvetica", 12]), sg.InputText(key='-totp-', font=["Helvetica", 12])])
if len(G.hosts) > 1:
groups = []
for key, _ in G.hosts.items():
groups.append(key)
layout.append(
[
sg.Text(
"Server Group:",
size = (
12*G.scaling,
1*G.scaling
),
font = [
"Helvetica",
12
]
),
sg.Combo(
groups,
G.current_hostset,
key = '-group-',
font = [
"Helvetica",
12
],
readonly = True,
enable_events = True
)
]
)
layout.append(
[
sg.Text(
"Username",
size = (
12*G.scaling,
1*G.scaling
),
font = [
"Helvetica",
12
]
),
sg.InputText(
default_text = G.hosts[G.current_hostset]['user'],
key = '-username-',
font = [
"Helvetica",
12
],
readonly = readonly
)
]
)
layout.append(
[
sg.Text(
"Password",
size = (
12*G.scaling,
1*G.scaling
),
font = [
"Helvetica",
12
]
),
sg.InputText(
key='-password-',
password_char='*',
font = [
"Helvetica",
12
],
readonly = readonly
)
]
)
if G.hosts[G.current_hostset]['totp']:
layout.append(
[
sg.Text(
"OTP Key",
size = (
12*G.scaling,
1
),
font = [
"Helvetica",
12
]
),
sg.InputText(
key = '-totp-',
font = [
"Helvetica",
12
]
)
]
)
if G.kiosk:
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:
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.hosts[G.current_hostset]['pwresetcmd']:
layout[-1].append(
sg.Button(
'Password Reset',
font = [
"Helvetica",
14
]
)
)
return layout
def getvms(listonly = False):
@@ -197,6 +461,9 @@ def getvms(listonly = False):
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):
layout = []
@@ -208,11 +475,38 @@ def setvmlayout(vms):
layoutcolumn = []
for vm in vms:
if not vm["status"] == "unknown":
vmkeyname = f'-VM|{vm["vmid"]}-'
connkeyname = f'-CONN|{vm["vmid"]}-'
layoutcolumn.append([sg.Text(vm['name'], font=["Helvetica", 14], size=(22*G.scaling, 1*G.scaling)), 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()])
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:
for row in layoutcolumn:
layout.append(row)
@@ -221,7 +515,7 @@ def setvmlayout(vms):
def iniwin(inistring):
inilayout = [
[sg.Multiline(default_text=inistring, size=(800*G.scaling, 600*G.scaling))]
[sg.Multiline(default_text=inistring, size=(100, 40))]
]
iniwindow = sg.Window('INI debug', inilayout)
while True:
@@ -231,14 +525,56 @@ def iniwin(inistring):
iniwindow.close()
return True
def vmaction(vmnode, vmid, vmtype):
def vmaction(vmnode, vmid, vmtype, action='connect'):
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')
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':
startpop = win_popup(f'Starting {vmstatus["name"]}...')
sleep(.1)
try:
if vmtype == 'qemu':
jobid = G.proxmox.nodes(vmnode).qemu(str(vmid)).status.start.post(timeout=28)
@@ -271,6 +607,8 @@ def vmaction(vmnode, vmid, vmtype):
if startpop:
startpop.close()
return status
if action == 'reload':
return
try:
if vmtype == 'qemu':
spiceconfig = G.proxmox.nodes(vmnode).qemu(str(vmid)).spiceproxy.post()
@@ -301,7 +639,7 @@ def vmaction(vmnode, vmid, vmtype):
closed = iniwin(inistring)
connpop = win_popup(f'Connecting to {vmstatus["name"]}...')
pcmd = [G.vvcmd]
if G.kiosk:
if G.kiosk and G.viewer_kiosk:
pcmd.append('--kiosk')
pcmd.append('--kiosk-quit')
pcmd.append('on-disconnect')
@@ -342,9 +680,9 @@ def setcmd():
sys.exit()
def pveauth(username, passwd=None, totp=None):
random.shuffle(G.hostpool)
random.shuffle(G.hosts[G.current_hostset]['hostpool'])
err = None
for hostinfo in G.hostpool:
for hostinfo in G.hosts[G.current_hostset]['hostpool']:
host = hostinfo['host']
if 'port' in hostinfo:
port = hostinfo['port']
@@ -354,12 +692,32 @@ def pveauth(username, passwd=None, totp=None):
authenticated = False
if not connected and not authenticated:
try:
if G.token_name and G.token_value:
G.proxmox = proxmoxer.ProxmoxAPI(host, user=f'{username}@{G.backend}',token_name=G.token_name,token_value=G.token_value, verify_ssl=G.verify_ssl, port=port)
if G.hosts[G.current_hostset]['token_name'] and G.hosts[G.current_hostset]['token_value']:
G.proxmox = proxmoxer.ProxmoxAPI(
host,
user=f"{username}@{G.hosts[G.current_hostset]['backend']}",
token_name=G.hosts[G.current_hostset]['token_name'],
token_value=G.hosts[G.current_hostset]['token_value'],
verify_ssl=G.hosts[G.current_hostset]['verify_ssl'],
port=port
)
elif totp:
G.proxmox = proxmoxer.ProxmoxAPI(host, user=f'{username}@{G.backend}', otp=totp, password=passwd, verify_ssl=G.verify_ssl, port=port)
G.proxmox = proxmoxer.ProxmoxAPI(
host,
user=f"{username}@{G.hosts[G.current_hostset]['backend']}",
otp=totp,
password=passwd,
verify_ssl=G.hosts[G.current_hostset]['verify_ssl'],
port=port
)
else:
G.proxmox = proxmoxer.ProxmoxAPI(host, user=f'{username}@{G.backend}', password=passwd, verify_ssl=G.verify_ssl, port=port)
G.proxmox = proxmoxer.ProxmoxAPI(
host,
user=f"{username}@{G.hosts[G.current_hostset]['backend']}",
password=passwd,
verify_ssl=G.hosts[G.current_hostset]['verify_ssl'],
port=port
)
connected = True
authenticated = True
return connected, authenticated, err
@@ -374,18 +732,18 @@ def pveauth(username, passwd=None, totp=None):
def loginwindow():
layout = setmainlayout()
if G.user and G.token_name and G.token_value: # We need to skip the login
if G.hosts[G.current_hostset]['user'] and G.hosts[G.current_hostset]['token_name'] and G.hosts[G.current_hostset]['token_value'] and len(G.hosts) == 1: # We need to skip the login
popwin = win_popup("Please wait, authenticating...")
connected, authenticated, error = pveauth(G.user)
connected, authenticated, error = pveauth(G.hosts[G.current_hostset]['user'])
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')
return False
return False, False
elif connected and not authenticated:
win_popup_button('Invalid username and/or password, please try again!', 'OK')
return False
return False, False
elif connected and authenticated:
return True
return True, False
else:
if G.icon:
window = sg.Window(G.title, layout, return_keyboard_events=True, resizable=False, no_titlebar=G.kiosk, icon=G.icon)
@@ -393,9 +751,19 @@ def loginwindow():
window = sg.Window(G.title, layout, return_keyboard_events=True, resizable=False, no_titlebar=G.kiosk)
while True:
event, values = window.read()
if event == '-group-' and values['-group-'] != G.current_hostset:
#Switch cluster
G.current_hostset = values['-group-']
window.close()
return False, True
if event == 'Cancel' or event == sg.WIN_CLOSED:
window.close()
return False
return False, False
elif event == 'Password Reset':
try:
subprocess.check_call(G.hosts[G.current_hostset]['pwresetcmd'], shell=True)
except Exception as e:
win_popup_button(f'Unable to open password reset command.\n\nError Info:\n{e}', 'OK')
else:
if event in ('Log In', '\r', 'special 16777220', 'special 16777221'):
popwin = win_popup("Please wait, authenticating...")
@@ -413,7 +781,7 @@ def loginwindow():
win_popup_button('Invalid username and/or password, please try again!', 'OK')
elif connected and authenticated:
window.close()
return True
return True, False
#break
def showvms():
@@ -426,25 +794,48 @@ def showvms():
win_popup_button('No desktop instances found, please consult with your system administrator', 'OK')
return False
layout = setvmlayout(vms)
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:
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()
while True:
if (datetime.now() - timer).total_seconds() > 10:
if (datetime.now() - timer).total_seconds() > 5:
timer = datetime.now()
newvmlist = getvms(listonly = True)
if vmlist != newvmlist:
vmlist = newvmlist.copy()
layout = setvmlayout(getvms())
window.close()
if G.icon:
window = sg.Window(G.title, layout, return_keyboard_events=True, finalize=True, resizable=False, no_titlebar=G.kiosk, icon=G.icon)
else:
window = sg.Window(G.title, layout, return_keyboard_events=True,finalize=True, resizable=False, no_titlebar=G.kiosk)
window.bring_to_front()
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):
window.close()
@@ -459,39 +850,59 @@ def showvms():
vmaction(vm['node'], vmid, vm['type'])
if not found:
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
def main():
G.scaling = 1 # TKinter requires integers
config_location = None
if len(sys.argv) > 1:
if sys.argv[1] == '--list_themes':
sg.preview_all_look_and_feel_themes()
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]
parser = argparse.ArgumentParser(description='Proxmox VDI Client')
parser.add_argument('--list_themes', help='List all available themes', action='store_true')
parser.add_argument('--config_type', help='Select config type (default: file)', choices=['file', 'http'], default='file')
parser.add_argument('--config_location', help='Specify the config location (default: search for config file)', default=None)
parser.add_argument('--config_username', help="HTTP basic authentication username (default: None)", default=None)
parser.add_argument('--config_password', help="HTTP basic authentication password (default: None)", default=None)
parser.add_argument('--ignore_ssl', help="HTTPS ignore SSL certificate errors (default: False)", action='store_false', default=True)
args = parser.parse_args()
if args.list_themes:
sg.preview_all_look_and_feel_themes()
return
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
sg.theme(G.theme)
loggedin = False
switching = False
while True:
if not loggedin:
loggedin = loginwindow()
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
loggedin, switching = loginwindow()
if not loggedin and not switching:
if G.hosts[G.current_hostset]['user'] and G.hosts[G.current_hostset]['token_name'] and G.hosts[G.current_hostset]['token_value']: # This means if we don't exit we'll be in an infinite loop
return 1
break
elif not loggedin and switching:
pass
else:
if G.hosts[G.current_hostset]['auto_vmid']:
vms = getvms()
for row in vms:
if row['vmid'] == G.hosts[G.current_hostset]['auto_vmid']:
vmaction(row['node'], row['vmid'], row['type'], action='connect')
return 0
win_popup_button(f"No VDI instance with ID {G.hosts[G.current_hostset]['auto_vmid']} found!", 'OK')
vmstat = showvms()
if not vmstat:
G.proxmox = None
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
if G.hosts[G.current_hostset]['user'] and G.hosts[G.current_hostset]['token_name'] and G.hosts[G.current_hostset]['token_value'] and len(G.hosts) == 1: # This means if we don't exit we'll be in an infinite loop
return 0
else:
return