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.
This commit is contained in:
jpattWPC 2023-10-15 19:04:22 -05:00
parent 2d20f1a414
commit f407057fcb
3 changed files with 365 additions and 76 deletions

2
dist/vdiclient.json vendored
View file

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

View file

@ -23,7 +23,17 @@ guest_type = both
#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
@ -31,6 +41,35 @@ 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
@ -41,10 +80,6 @@ tls_verify = false
# 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

@ -9,6 +9,7 @@ import argparse
import random
import sys
import os
import json
import subprocess
from time import sleep
from io import StringIO
@ -16,30 +17,23 @@ 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
inidebug = False
addl_params = None
imagefile = None
kiosk = False
viewer_kiosk = True
fullscreen = True
verify_ssl = True
inidebug = False
show_reset = False
show_hibernate = False
addl_params = None
pwresetcmd = None
auto_vmid = None
current_hostset = 'DEFAULT'
title = 'VDI Login'
hosts = {}
theme = 'LightBlue'
guest_type = 'both'
width = None
@ -123,37 +117,99 @@ def loadconfig(config_location = None, config_type='file', config_username = Non
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:
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 '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:
win_popup_button(f'Unable to read supplied configuration:\nNo `Hosts` section defined!', 'OK')
return False
else:
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]
@ -189,22 +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])])
if G.pwresetcmd:
layout[-1].append(sg.Button('Password Reset', 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):
@ -454,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']
@ -466,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
@ -486,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)
@ -505,12 +751,17 @@ 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.pwresetcmd, shell=True)
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:
@ -530,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():
@ -629,26 +880,29 @@ def main():
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.auto_vmid:
if G.hosts[G.current_hostset]['auto_vmid']:
vms = getvms()
for row in vms:
if row['vmid'] == G.auto_vmid:
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.auto_vmid} found!', 'OK')
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