From a1a144d211eff058bdfd8f580baa5b400c4b550f Mon Sep 17 00:00:00 2001 From: Jarrod Johnson Date: Fri, 30 May 2025 15:48:15 -0400 Subject: [PATCH] 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. --- .../plugins/hardwaremanagement/proxmox.py | 80 ++++++-- confluent_server/confluent/vinzmanager.py | 180 ++++++++++++------ 2 files changed, 191 insertions(+), 69 deletions(-) diff --git a/confluent_server/confluent/plugins/hardwaremanagement/proxmox.py b/confluent_server/confluent/plugins/hardwaremanagement/proxmox.py index bbe4ae9c..af5ff149 100644 --- a/confluent_server/confluent/plugins/hardwaremanagement/proxmox.py +++ b/confluent_server/confluent/plugins/hardwaremanagement/proxmox.py @@ -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]) diff --git a/confluent_server/confluent/vinzmanager.py b/confluent_server/confluent/vinzmanager.py index f9511676..8462ac6e 100644 --- a/confluent_server/confluent/vinzmanager.py +++ b/confluent_server/confluent/vinzmanager.py @@ -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()