2
0
mirror of https://github.com/xcat2/confluent.git synced 2026-01-11 18:42:29 +00:00

Implement plugin managed VNC

To extend beyond the OpenBmc wrapped dialect of VNC,
provide mechanism for plugins to provide
arbitrary cookie, password, url, and protocols parameters.

Implement for ProxMox.
This commit is contained in:
Jarrod Johnson
2025-05-30 15:48:15 -04:00
parent 8d8db070eb
commit a1a144d211
2 changed files with 191 additions and 69 deletions

View File

@@ -1,4 +1,5 @@
import confluent.vinzmanager as vinzmanager
import codecs
import confluent.util as util
import confluent.messages as msg
@@ -29,6 +30,42 @@ class RetainedIO(io.BytesIO):
self.resultbuffer = self.getbuffer()
super().close()
class KvmConnection:
def __init__(self, consdata):
#self.ws = WrappedWebSocket(host=bmc)
#self.ws.set_verify_callback(kv)
ticket = consdata['ticket']
user = consdata['user']
port = consdata['port']
urlticket = urlparse.quote(ticket)
host = consdata['host']
guest = consdata['guest']
pac = consdata['pac'] # fortunately, we terminate this on our end, but it does kind of reduce the value of the
# 'ticket' approach, as the general cookie must be provided as cookie along with the VNC ticket
hosturl = host
if ':' in hosturl:
hosturl = '[' + hosturl + ']'
self.url = f'/api2/json/nodes/{host}/{guest}/vncwebsocket?port={port}&vncticket={urlticket}'
self.fprint = consdata['fprint']
self.cookies = {
'PVEAuthCookie': pac,
}
self.protos = ['binary']
self.host = host
self.portnum = 8006
self.password = consdata['ticket']
class KvmConnHandler:
def __init__(self, pmxclient, node):
self.pmxclient = pmxclient
self.node = node
def connect(self):
consdata = self.pmxclient.get_vm_ikvm(self.node)
consdata['fprint'] = self.pmxclient.fprint
return KvmConnection(consdata)
class WrappedWebSocket(wso):
def set_verify_callback(self, callback):
@@ -175,6 +212,7 @@ class PmxApiClient:
pass
self.server = server
self.wc = webclient.SecureHTTPConnection(server, port=8006, verifycallback=cv)
self.fprint = configmanager.get_node_attributes(server, 'pubkeys.tls').get(server, {}).get('pubkeys.tls', {}).get('value', None)
self.vmmap = {}
self.login()
self.vmlist = {}
@@ -243,11 +281,15 @@ class PmxApiClient:
yield msg.KeyValueData({'inventory': invitems}, vm)
def get_vm_ikvm(self, vm):
return self.get_vm_consproxy(vm, 'vnc')
def get_vm_serial(self, vm):
# This would be termproxy
# Example url
return self.get_vm_consproxy(vm, 'term')
def get_vm_consproxy(self, vm, constype):
host, guest = self.get_vm(vm)
rsp = self.wc.grab_json_response_with_status(f'/api2/json/nodes/{host}/{guest}/termproxy', method='POST')
rsp = self.wc.grab_json_response_with_status(f'/api2/json/nodes/{host}/{guest}/{constype}proxy', method='POST')
consdata = rsp[0]['data']
consdata['server'] = self.server
consdata['host'] = host
@@ -372,20 +414,12 @@ def retrieve(nodes, element, configmanager, inputdata):
for rsp in currclient.get_vm_inventory(node):
yield rsp
elif element == ['console', 'ikvm_methods']:
dsc = {'ikvm_methods': ['screenshot']}
dsc = {'ikvm_methods': ['vnc']}
yield msg.KeyValueData(dsc, node)
elif element == ['console', 'ikvm_screenshot']:
# good background for the webui, and kitty
imgdata = RetainedIO()
imgformat = currclient.get_screenshot(node, imgdata)
imgdata = imgdata.getvalue()
if imgdata:
yield msg.ScreenShot(imgdata, node, imgformat=imgformat)
yield msg.ConfluentNodeError(node, "vnc available, screenshot not available")
return
def update(nodes, element, configmanager, inputdata):
clientsbynode = prep_proxmox_clients(nodes, configmanager)
@@ -397,11 +431,29 @@ def update(nodes, element, configmanager, inputdata):
elif element == ['boot', 'nextdevice']:
currclient.set_vm_bootdev(node, inputdata.bootdevice(node))
yield msg.BootDevice(node, currclient.get_vm_bootdev(node))
elif element == ['console', 'ikvm']:
try:
currclient = clientsbynode[node]
url = vinzmanager.get_url(node, inputdata, nodeparmcallback=KvmConnHandler(currclient, node).connect)
except Exception as e:
print(repr(e))
return
yield msg.ChildCollection(url)
return
# assume this is only console for now
def create(nodes, element, configmanager, inputdata):
clientsbynode = prep_proxmox_clients(nodes, configmanager)
for node in nodes:
if element == ['console', 'ikvm']:
try:
currclient = clientsbynode[node]
url = vinzmanager.get_url(node, inputdata, nodeparmcallback=KvmConnHandler(currclient, node).connect)
except Exception as e:
print(repr(e))
return
yield msg.ChildCollection(url)
return
serialdata = clientsbynode[node].get_vm_serial(node)
return PmxConsole(serialdata, node, configmanager, clientsbynode[node])

View File

@@ -47,7 +47,9 @@ def assure_vinz():
startingup = False
_unix_by_nodename = {}
def get_url(nodename, inputdata):
_nodeparms = {}
def get_url(nodename, inputdata, nodeparmcallback=None):
_nodeparms[nodename] = nodeparmcallback
method = inputdata.inputbynode[nodename]
assure_vinz()
if method == 'wss':
@@ -89,56 +91,120 @@ def close_session(sessionid):
'X-XSRF-TOKEN': wc.cookies['XSRF-TOKEN']})
def send_grant(conn, nodename):
cfg = configmanager.ConfigManager(None)
c = cfg.get_node_attributes(
nodename,
['secret.hardwaremanagementuser',
'secret.hardwaremanagementpassword',
'hardwaremanagement.manager'], decrypt=True)
bmcuser = c.get(nodename, {}).get(
'secret.hardwaremanagementuser', {}).get('value', None)
bmcpass = c.get(nodename, {}).get(
'secret.hardwaremanagementpassword', {}).get('value', None)
bmc = c.get(nodename, {}).get(
'hardwaremanagement.manager', {}).get('value', None)
if bmcuser and bmcpass and bmc:
kv = util.TLSCertVerifier(cfg, nodename,
'pubkeys.tls_hardwaremanager').verify_cert
wc = webclient.SecureHTTPConnection(bmc, 443, verifycallback=kv)
if not isinstance(bmcuser, str):
bmcuser = bmcuser.decode()
if not isinstance(bmcpass, str):
bmcpass = bmcpass.decode()
rsp = wc.grab_json_response_with_status(
'/login', {'data': [bmcuser, bmcpass]},
headers={'Content-Type': 'application/json',
'Accept': 'application/json'})
sessionid = wc.cookies['SESSION']
sessiontok = wc.cookies['XSRF-TOKEN']
def send_grant(conn, nodename, rqtype):
parmcallback = _nodeparms.get(nodename, None)
cookies = {}
protos = []
passwd = None
sessionid = os.urandom(8).hex()
while sessionid in _usersessions:
sessionid = os.urandom(8).hex()
if parmcallback: # plugin that handles the specifics of the vnc wrapping
if rqtype == 1:
raise Exception("Plugin managed login data not supported with legacy grant request")
cxnmgr = parmcallback()
_usersessions[sessionid] = {
'webclient': wc,
'cxnmgr': cxnmgr,
'nodename': nodename,
}
url = '/kvm/0'
fprintinfo = cfg.get_node_attributes(nodename, 'pubkeys.tls_hardwaremanager')
fprint = fprintinfo.get(
nodename, {}).get('pubkeys.tls_hardwaremanager', {}).get('value', None)
if not fprint:
return
url = cxnmgr.url
fprint = cxnmgr.fprint
cookies = cxnmgr.cookies
protos = cxnmgr.protos
host = cxnmgr.host
portnum = cxnmgr.portnum
passwd = cxnmgr.password
#url, fprint, cookies, protos = parmcallback(nodename)
else:
# original openbmc dialect
portnum = 443
cfg = configmanager.ConfigManager(None)
c = cfg.get_node_attributes(
nodename,
['secret.hardwaremanagementuser',
'secret.hardwaremanagementpassword',
'hardwaremanagement.manager'], decrypt=True)
bmcuser = c.get(nodename, {}).get(
'secret.hardwaremanagementuser', {}).get('value', None)
bmcpass = c.get(nodename, {}).get(
'secret.hardwaremanagementpassword', {}).get('value', None)
host = c.get(nodename, {}).get(
'hardwaremanagement.manager', {}).get('value', None)
if bmcuser and bmcpass and host:
kv = util.TLSCertVerifier(cfg, nodename,
'pubkeys.tls_hardwaremanager').verify_cert
wc = webclient.SecureHTTPConnection(host, 443, verifycallback=kv)
if not isinstance(bmcuser, str):
bmcuser = bmcuser.decode()
if not isinstance(bmcpass, str):
bmcpass = bmcpass.decode()
rsp = wc.grab_json_response_with_status(
'/login', {'data': [bmcuser, bmcpass]},
headers={'Content-Type': 'application/json',
'Accept': 'application/json'})
cookies['SESSION'] = wc.cookies['SESSION']
cookies['XSRF-TOKEN'] = wc.cookies['XSRF-TOKEN']
if rqtype == 1:
# unfortunately, the original protocol failed to
# provide a means for separate tracking bmc side
# and confluent side
# chances are pretty good still
sessionid = wc.cookies['SESSION']
sessiontok = wc.cookies['XSRF-TOKEN']
protos.append(sessiontok)
_usersessions[sessionid] = {
'webclient': wc,
'nodename': nodename,
}
url = '/kvm/0'
fprintinfo = cfg.get_node_attributes(nodename, 'pubkeys.tls_hardwaremanager')
fprint = fprintinfo.get(
nodename, {}).get('pubkeys.tls_hardwaremanager', {}).get('value', None)
if not fprint:
return
if '$' in fprint:
fprint = fprint.split('$', 1)[1]
fprint = bytes.fromhex(fprint)
conn.send(struct.pack('!BI', 1, len(bmc)))
conn.send(bmc.encode())
conn.send(struct.pack('!I', len(sessionid)))
conn.send(sessionid.encode())
fprint = bytes.fromhex(fprint)
conn.send(struct.pack('!BI', rqtype, len(host)))
conn.send(host.encode())
conn.send(struct.pack('!I', len(sessionid)))
conn.send(sessionid.encode())
if rqtype == 1:
conn.send(struct.pack('!I', len(sessiontok)))
conn.send(sessiontok.encode())
conn.send(struct.pack('!I', len(fprint)))
conn.send(fprint)
conn.send(struct.pack('!I', len(url)))
conn.send(url.encode())
conn.send(b'\xff')
else: # newer TLV style protocol
conn.send(struct.pack('!H', portnum))
conn.send(struct.pack('!BI', 4, len(url)))
conn.send(url.encode())
for cook in cookies:
v = cookies[cook]
totlen = len(cook) + len(v) + 4
conn.send(struct.pack('!BIH', 1, totlen, len(cook.encode())))
conn.send(cook.encode())
conn.send(struct.pack('!H', len(v.encode())))
conn.send(v.encode())
for proto in protos:
conn.send(struct.pack('!BI', 2, len(proto.encode())))
conn.send(proto.encode())
conn.send(struct.pack('!BI', 3, len(fprint)))
conn.send(fprint)
if passwd:
conn.send(struct.pack('!BI', 5, len(passwd.encode()[:8])))
conn.send(passwd.encode()[:8])
conn.send(b'\xff')
def recv_exact(conn, n):
retdata = b''
while len(retdata) < n:
currdata = conn.recv(n - len(retdata))
if not currdata:
raise Exception("Error receiving")
retdata += currdata
return retdata
def evaluate_request(conn):
allow = False
@@ -149,33 +215,37 @@ def evaluate_request(conn):
pid, uid, gid = struct.unpack('iII', creds)
if uid != os.getuid():
return
rqcode, fieldlen = struct.unpack('!BI', conn.recv(5))
authtoken = conn.recv(fieldlen).decode()
rqcode, fieldlen = struct.unpack('!BI', recv_exact(conn, 5))
authtoken = recv_exact(conn, fieldlen).decode()
if authtoken != _vinztoken:
return
if rqcode == 2: # disconnect notification
fieldlen = struct.unpack('!I', conn.recv(4))[0]
sessionid = conn.recv(fieldlen).decode()
fieldlen = struct.unpack('!I', recv_exact(conn, 4))[0]
sessionid = recv_exact(conn, fieldlen).decode()
close_session(sessionid)
conn.recv(1) # digest 0xff
if rqcode == 1: # request for new connection
fieldlen = struct.unpack('!I', conn.recv(4))[0]
nodename = conn.recv(fieldlen).decode()
# if rqcode == 3: # new form connection request
# this will generalize things, to allow describing
# arbitrary cookies and subprotocols
# for the websocket connection
if rqcode in (1, 3): # request for new connection
fieldlen = struct.unpack('!I', recv_exact(conn, 4))[0]
nodename = recv_exact(conn, fieldlen).decode()
idtype = struct.unpack('!B', conn.recv(1))[0]
if idtype == 1:
usernum = struct.unpack('!I', conn.recv(4))[0]
usernum = struct.unpack('!I', recv_exact(conn, 4))[0]
if usernum == 0: # root is a special guy
send_grant(conn, nodename)
send_grant(conn, nodename, rqcode)
return
try:
authname = pwd.getpwuid(usernum).pw_name
except Exception:
return
elif idtype == 2:
fieldlen = struct.unpack('!I', conn.recv(4))[0]
sessionid = conn.recv(fieldlen)
fieldlen = struct.unpack('!I', conn.recv(4))[0]
sessiontok = conn.recv(fieldlen)
fieldlen = struct.unpack('!I', recv_exact(conn, 4))[0]
sessionid = recv_exact(conn, fieldlen)
fieldlen = struct.unpack('!I', recv_exact(conn, 4))[0]
sessiontok = recv_exact(conn, fieldlen)
try:
authname = httpapi.get_user_for_session(sessionid, sessiontok)
except Exception:
@@ -186,7 +256,7 @@ def evaluate_request(conn):
if authname:
allow = auth.authorize(authname, f'/nodes/{nodename}/console/ikvm')
if allow:
send_grant(conn, nodename)
send_grant(conn, nodename, rqcode)
finally:
conn.close()