From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id 719961FF0B7 for ; Tue, 25 Aug 2026 13:10:04 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 5D4FA216DB; Tue, 25 Aug 2026 13:09:16 +0200 (CEST) From: Alexandre Derumier To: pve-devel@lists.proxmox.com subject: SPAM: [RFC pve-manager 06/13] ui: add kyber console Date: Tue, 25 Aug 2026 13:08:38 +0200 Message-ID: <20260825110849.2967694-7-alexandre.derumier@groupe-cyllene.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260825110849.2967694-1-alexandre.derumier@groupe-cyllene.com> References: <20260825110849.2967694-1-alexandre.derumier@groupe-cyllene.com> MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 6 AWL 0.066 Adjusted score from AWL reputation of From: address CONTENT_AFTER_HTML 0.001 More content after HTML close tag + other spam signs DMARC_QUAR 0.1 DMARC quarantine policy HEADER_FROM_DIFFERENT_DOMAINS 0.25 From and EnvelopeFrom 2nd level mail domains are different KAM_DMARC_QUARANTINE 4 DKIM has Failed or SPF has failed on the message and the domain has a DMARC quarantine policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) KAM_LAZY_DOMAIN_SECURITY 1 Sending domain does not have any anti-forgery methods RDNS_NONE 1.274 Delivered to internal network by a host with no rDNS SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_NONE 0.001 SPF: sender does not publish an SPF Record Message-ID-Hash: L4FAL5G7GWWGDLTQEQ4TM5FXDQZONK3B X-Message-ID-Hash: L4FAL5G7GWWGDLTQEQ4TM5FXDQZONK3B X-MailFrom: root@formationkvm1.odiso.net X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header CC: Alexandre Derumier X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: From: Alexandre Derumier Signed-off-by: Alexandre Derumier --- Makefile | 2 +- PVE/HTTPServer.pm | 4 +- PVE/Service/pveproxy.pm | 53 +++- kyber-web/Makefile | 22 ++ kyber-web/index.html.tpl | 438 +++++++++++++++++++++++++++ www/manager6/Utils.js | 30 +- www/manager6/button/ConsoleButton.js | 22 ++ www/manager6/qemu/AudioEdit.js | 3 + www/manager6/qemu/Config.js | 9 +- www/manager6/qemu/DisplayEdit.js | 4 +- 10 files changed, 581 insertions(+), 6 deletions(-) create mode 100644 kyber-web/Makefile create mode 100644 kyber-web/index.html.tpl diff --git a/Makefile b/Makefile index 6c8b82e..b7dfc7f 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ DSC=$(PACKAGE)_$(DEB_VERSION).dsc DEB=$(PACKAGE)_$(DEB_VERSION)_all.deb DESTDIR= -SUBDIRS = aplinfo PVE bin www services configs network-hooks test templates +SUBDIRS = aplinfo PVE bin www services configs kyber-web network-hooks test templates all: $(SUBDIRS) set -e && for i in $(SUBDIRS); do $(MAKE) -C $$i; done diff --git a/PVE/HTTPServer.pm b/PVE/HTTPServer.pm index 34403e8..c3fef48 100755 --- a/PVE/HTTPServer.pm +++ b/PVE/HTTPServer.pm @@ -120,11 +120,13 @@ sub auth_handler { $isUpload = 1; } + my $is_kyber_console = $rel_uri =~ m|^/nodes/[^/]+/qemu/\d+/kyber/|; + # Skip CSRF check for file upload (difficult to pass CSRF header with native html forms). # Also skip the check with API tokens, as one of the design goals of API tokens was to # provide stateless API access without requiring round-trips to get such CSRF tokens. # CSRF-prevention also does not make much sense outside of the browser context. - if ($method ne 'GET' && !($api_token || $isUpload)) { + if ($method ne 'GET' && !($api_token || $isUpload || $is_kyber_console)) { my $euid = $>; PVE::AccessControl::verify_csrf_prevention_token($username, $token) if $euid != 0; } diff --git a/PVE/Service/pveproxy.pm b/PVE/Service/pveproxy.pm index dfdd014..bcc1353 100755 --- a/PVE/Service/pveproxy.pm +++ b/PVE/Service/pveproxy.pm @@ -20,6 +20,10 @@ use PVE::Cluster; use PVE::Daemon; use PVE::DataCenterConfig; use PVE::HTTPServer; +use HTTP::Status qw(HTTP_BAD_REQUEST); +use PVE::Exception; +use PVE::INotify; +use PVE::RPCEnvironment; use PVE::SafeSyslog; use PVE::pvecfg; use PVE::Tools; @@ -53,6 +57,7 @@ my $basedirs = { fontlogos => '/usr/share/fonts-font-logos', i18n => '/usr/share/pve-i18n', manager => '/usr/share/pve-manager', + kyber => '/usr/share/pve-kyber-web', novnc => '/usr/share/novnc-pve', yew_mobile => '/usr/share/pve-yew-mobile-gui', i18n_yew => '/usr/share/pve-yew-mobile-i18n', @@ -60,6 +65,41 @@ my $basedirs = { xtermjs => '/usr/share/pve-xtermjs', }; +my $kyber_console_prefix = qr!^/api2/json/nodes/([^/]+)/qemu/(\d+)/kyber(/.*)$!; +my $kyber_proxy_socket = '/run/pvekyberproxy.sock'; + +my sub check_console_access { + my ($auth, $console_type, $node, $vmid) = @_; + + PVE::Exception::raise( + "the $console_type console for VM $vmid must be opened on node '$node'\n", + code => HTTP_BAD_REQUEST, + ) if $node ne PVE::INotify::nodename(); + + my $rpcenv = PVE::RPCEnvironment::get(); + $rpcenv->check($auth->{userid}, "/vms/$vmid", ['VM.Console']); + + return; +} + +sub console_proxy { + my ($server, $reqstate, $auth, $method, $path) = @_; + + if (my ($node, $vmid, $rest) = $path =~ $kyber_console_prefix) { + check_console_access($auth, 'Kyber', $node, $vmid); + + my $target = $path; + + if (my $query = $reqstate->{request}->url->query()) { + $target .= "?$query"; + } + + return { socket => $kyber_proxy_socket, path => $target, tls => 0 }; + } + + return undef; +} + sub init { my ($self) = @_; @@ -77,6 +117,7 @@ sub init { my $dirs = {}; + add_dirs($dirs, '/kyber/' => "$basedirs->{kyber}/"); add_dirs($dirs, '/novnc/' => "$basedirs->{novnc}/"); add_dirs($dirs, '/pve-docs/' => "$basedirs->{docs}/"); add_dirs($dirs, '/pve-docs/api-viewer/extjs/' => "$basedirs->{extjs}/"); @@ -134,6 +175,7 @@ sub init { }, }, dirs => $dirs, + local_http_proxy_handler => \&console_proxy, }; if (defined($proxyconf->{DHPARAMS})) { @@ -247,6 +289,7 @@ sub get_index { (is_phone($r->header('User-Agent')) && (!defined($args->{mobile}) || $args->{mobile})) || $args->{mobile}; + my $kyber = defined($args->{console}) && $args->{kyber}; my $novnc = defined($args->{console}) && $args->{novnc}; my $xtermjs = defined($args->{console}) && $args->{xtermjs}; @@ -291,7 +334,9 @@ sub get_index { # by default, load the normal index my $dir = $basedirs->{manager}; - if ($novnc) { + if ($kyber) { + $dir = $basedirs->{kyber}; + } elsif ($novnc) { $dir = $basedirs->{novnc}; } elsif ($xtermjs) { $dir = $basedirs->{xtermjs}; @@ -305,6 +350,12 @@ sub get_index { $template->process("$dir/index.html.tpl", $vars, \$page) || die $template->error(), "\n"; my $headers = HTTP::Headers->new(Content_Type => "text/html; charset=utf-8"); + + if ($kyber) { + $headers->header('Cross-Origin-Opener-Policy' => 'same-origin'); + $headers->header('Cross-Origin-Embedder-Policy' => 'require-corp'); + } + my $resp = HTTP::Response->new(200, "OK", $headers, $page); return $resp; diff --git a/kyber-web/Makefile b/kyber-web/Makefile new file mode 100644 index 0000000..28b07a0 --- /dev/null +++ b/kyber-web/Makefile @@ -0,0 +1,22 @@ +include ../defines.mk + +KYBERDIR = $(DESTDIR)/usr/share/pve-kyber-web + +all: + +.PHONY: install +install: index.html.tpl + install -d $(KYBERDIR) + install -m 0644 index.html.tpl $(KYBERDIR)/index.html.tpl +# The client itself - kyclient_wasm.js, kyclient_wasm_bg.wasm and the audio +# worklet beside them - is built from kyber-web and shipped by its own package +# rather than vendored here. It is AGPL-3.0-or-later, same as pve-manager, but +# it is a separate upstream with its own release cadence and a wasm toolchain +# that has no business in this build. +# +# The page drives that SDK directly and needs nothing else from it: no bundle, +# no stylesheets, and none of the demo client's UI. + +.PHONY: clean distclean +distclean: clean +clean: diff --git a/kyber-web/index.html.tpl b/kyber-web/index.html.tpl new file mode 100644 index 0000000..31f5e76 --- /dev/null +++ b/kyber-web/index.html.tpl @@ -0,0 +1,438 @@ + + + + + Kyber console + + + + + +
+
+
+ + + +
+ + + + + +
+
+
+
+
Connecting…
+ + + + diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js index c86a00c..44ffdfe 100644 --- a/www/manager6/Utils.js +++ b/www/manager6/Utils.js @@ -656,6 +656,7 @@ Ext.define('PVE.Utils', { serial3: gettext('Serial terminal') + ' 3', virtio: 'VirtIO-GPU', 'virtio-gl': 'VirGL GPU', + kyber: 'Kyber', none: Proxmox.Utils.noneText, }, @@ -1452,6 +1453,8 @@ Ext.define('PVE.Utils', { PVE.Utils.openVNCViewer(consoleType, vmid, nodename, vmname, cmd); } else if (viewer === 'xtermjs') { Proxmox.Utils.openXtermJsViewer(consoleType, vmid, nodename, vmname, cmd); + } else if (viewer === 'kyber') { + PVE.Utils.openKyberViewer(consoleType, vmid, nodename, vmname); } else if (viewer === 'vv') { let url = '/nodes/' + nodename + '/spiceshell'; let params = { @@ -1475,16 +1478,22 @@ Ext.define('PVE.Utils', { }, defaultViewer: function (consoles, type) { - var allowSpice, allowXtermjs; + var allowSpice, allowXtermjs, allowKyber; if (consoles === true) { allowSpice = true; allowXtermjs = true; + allowKyber = true; } else if (typeof consoles === 'object') { allowSpice = consoles.spice; allowXtermjs = !!consoles.xtermjs; + allowKyber = !!consoles.kyber; } let dv = PVE.UIOptions.options.console || (type === 'kvm' ? 'vv' : 'xtermjs'); + // A Kyber display serves no VNC, so nothing else can show it. + if (allowKyber) { + return 'kyber'; + } if (dv === 'vv' && !allowSpice) { dv = allowXtermjs ? 'xtermjs' : 'html5'; } else if (dv === 'xtermjs' && !allowXtermjs) { @@ -1515,6 +1524,24 @@ Ext.define('PVE.Utils', { } }, + // The Kyber console streams the display over its own controller rather + // than through noVNC, so it gets a window of its own. Everything it + // needs is fetched by that page from kyberproxy; nothing is passed in + // the URL, so a copied link grants nothing on its own. + openKyberViewer: function (vmtype, vmid, nodename, vmname) { + let url = Ext.Object.toQueryString({ + console: vmtype, + kyber: 1, + vmid: vmid, + vmname: vmname, + node: nodename, + }); + let nw = window.open('?' + url, '_blank', 'innerWidth=1280,innerheight=800'); + if (nw) { + nw.focus(); + } + }, + openSpiceViewer: function (url, params) { var downloadWithName = function (uri, name) { var link = Ext.DomHelper.append(document.body, { @@ -1590,6 +1617,7 @@ Ext.define('PVE.Utils', { let consoles = { spice: !!conf.spice, xtermjs: !!conf.serial, + kyber: !!conf.kyber, }; PVE.Utils.openDefaultConsoleWindow(consoles, 'kvm', vmid, nodename, vmname); }, diff --git a/www/manager6/button/ConsoleButton.js b/www/manager6/button/ConsoleButton.js index d64f280..63377e7 100644 --- a/www/manager6/button/ConsoleButton.js +++ b/www/manager6/button/ConsoleButton.js @@ -12,6 +12,8 @@ Ext.define('PVE.button.ConsoleButton', { enableSpice: true, enableXtermjs: true, + // Off unless a VM says otherwise, so other guests show it greyed out. + enableKyber: false, nodename: undefined, @@ -33,6 +35,13 @@ Ext.define('PVE.button.ConsoleButton', { me.down('#xtermjs').setDisabled(!enable); }, + setEnableKyber: function (enable) { + var me = this; + + me.enableKyber = enable; + me.down('#kybermenu').setDisabled(!enable); + }, + handler: function () { // main, general, handler let me = this; @@ -40,6 +49,7 @@ Ext.define('PVE.button.ConsoleButton', { { spice: me.enableSpice, xtermjs: me.enableXtermjs, + kyber: me.enableKyber, }, me.consoleType, me.vmid, @@ -84,6 +94,18 @@ Ext.define('PVE.button.ConsoleButton', { view.openConsole(button.type); }, }, + { + xtype: 'menuitem', + itemId: 'kybermenu', + text: 'Kyber', + type: 'kyber', + iconCls: 'fa fa-fw fa-desktop', + disabled: true, + handler: function (button) { + let view = this.up('button'); + view.openConsole(button.type); + }, + }, { text: 'xterm.js', itemId: 'xtermjs', diff --git a/www/manager6/qemu/AudioEdit.js b/www/manager6/qemu/AudioEdit.js index ba588ed..49f03e1 100644 --- a/www/manager6/qemu/AudioEdit.js +++ b/www/manager6/qemu/AudioEdit.js @@ -35,6 +35,9 @@ Ext.define('PVE.qemu.AudioInputPanel', { fieldLabel: gettext('Backend Driver'), comboItems: [ ['spice', 'SPICE'], + // Puts the guest's audio on the same D-Bus as its display, + // which is where the Kyber and RDP consoles read it. + ['dbus', gettext('D-Bus (Kyber/RDP console)')], ['none', `${Proxmox.Utils.NoneText} (${gettext('Dummy Device')})`], ], }, diff --git a/www/manager6/qemu/Config.js b/www/manager6/qemu/Config.js index 842d35d..bcf54d1 100644 --- a/www/manager6/qemu/Config.js +++ b/www/manager6/qemu/Config.js @@ -232,9 +232,10 @@ Ext.define('PVE.qemu.Config', { disabled: !caps.vms['VM.Console'], hidden: template, consoleType: 'kvm', - // disable spice/xterm for default action until status api call succeeded + // disable spice/xterm/kyber for default action until status api call succeeded enableSpice: false, enableXtermjs: false, + enableKyber: false, consoleName: vm.name, nodename: nodename, vmid: vmid, @@ -458,6 +459,7 @@ Ext.define('PVE.qemu.Config', { var qmpstatus; var spice = false; var xtermjs = false; + var kyber = false; var lock; var rec; @@ -475,6 +477,10 @@ Ext.define('PVE.qemu.Config', { spice = !!s.data.get('spice'); xtermjs = !!s.data.get('serial'); + // Reported by the status API only for a display set to + // 'kyber', which is also the only case with a controller + // behind it. + kyber = !!s.data.get('kyber'); } rec = s.data.get('tags'); @@ -496,6 +502,7 @@ Ext.define('PVE.qemu.Config', { consoleBtn.setEnableSpice(spice); consoleBtn.setEnableXtermJS(xtermjs); + consoleBtn.setEnableKyber(kyber); statusTxt.update({ lock: lock }); diff --git a/www/manager6/qemu/DisplayEdit.js b/www/manager6/qemu/DisplayEdit.js index 3f583ad..79e1ea2 100644 --- a/www/manager6/qemu/DisplayEdit.js +++ b/www/manager6/qemu/DisplayEdit.js @@ -26,7 +26,9 @@ Ext.define('PVE.qemu.DisplayInputPanel', { return '4'; } else if (val === 'std' || val.match(/^qxl\d?$/) || val === 'vmware') { return '16'; - } else if (val.match(/^virtio/)) { + } else if (val.match(/^virtio/) || val === 'kyber') { + // kyber is a virtio-vga underneath, so it takes the same + // memory as one. return '256'; } else if (get('matchNonGUIOption')) { return 'N/A'; -- 2.55.0