public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
To: pve-devel@lists.proxmox.com
Subject: [RFC pve-manager 06/13] ui: add kyber console
Date: Tue, 25 Aug 2026 13:34:31 +0200	[thread overview]
Message-ID: <20260825113442.947620-7-alexandre.derumier@groupe-cyllene.com> (raw)
In-Reply-To: <20260825113442.947620-1-alexandre.derumier@groupe-cyllene.com>

Signed-off-by: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
---
 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 @@
+<!DOCTYPE HTML>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <title>Kyber console</title>
+    <!--
+      Relative URLs resolve into the client's own directory rather than against
+      this page, which is served from '/'. The SDK loads a few assets by bare
+      name - the audio worklet among them - and without this they would be
+      looked for at the document root and 404 there.
+    -->
+    <base href="/kyber/">
+    <style>
+      html, body {
+          margin: 0;
+          height: 100%;
+          background: #000;
+          color: #ddd;
+          font: 13px/1.5 system-ui, sans-serif;
+          overflow: hidden;
+      }
+      #container {
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          height: 100%;
+      }
+      #container canvas {
+          display: block;
+          outline: none;
+      }
+      #status {
+          position: fixed;
+          inset: 0;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          padding: 0 2em;
+          text-align: center;
+          background: #000;
+          white-space: pre-wrap;
+      }
+      #status[hidden] { display: none; }
+      #status.failed { color: #ff8080; }
+        /* The control bar is noVNC's, down to its images: a console should not
+       look like a different product depending on which one it is. Only the
+       rules this page needs are copied - loading noVNC's stylesheet whole
+       would bring its layout with it. */
+    #kyber_control_bar_anchor {
+      position: fixed;
+      top: 0;
+      left: 0;
+      height: 100%;
+      z-index: 10;
+      transition: 0.5s ease-in-out;
+    }
+    #kyber_control_bar {
+      position: relative;
+      left: -100%;
+      height: 100%;
+      padding: 5px;
+      background-color: #1c2331;
+      border-radius: 0 12px 12px 0;
+      transition: 0.5s ease-in-out;
+      display: flex;
+      flex-direction: column;
+      gap: 6px;
+    }
+    #kyber_control_bar.kyber_open { left: 0; }
+    #kyber_control_bar_handle {
+      position: absolute;
+      left: -15px;
+      top: 0;
+      transform: translateY(35px);
+      width: calc(100% + 30px);
+      height: 50px;
+      z-index: -1;
+      cursor: pointer;
+      border-radius: 6px;
+      background-color: #1c2331;
+      background-image: url("/novnc/app/images/handle_bg.svg");
+      background-repeat: no-repeat;
+      background-position: right;
+      box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.5);
+    }
+    #kyber_control_bar_handle:after {
+      content: "";
+      transition: transform 0.1s ease-in-out;
+      background: url("/novnc/app/images/handle.svg");
+      position: absolute;
+      top: 22px;
+      right: 5px;
+      width: 5px;
+      height: 6px;
+    }
+    #kyber_control_bar.kyber_open #kyber_control_bar_handle:after {
+      transform: translateX(1px) rotate(180deg);
+    }
+    .kyber_button {
+      min-width: 36px;
+      padding: 4px;
+      border: 1px solid rgba(255, 255, 255, 0.2);
+      border-radius: 6px;
+      background-color: transparent;
+      color: #fff;
+      cursor: pointer;
+      font-size: 11px;
+      line-height: 1.2;
+    }
+    .kyber_button:hover { background-color: rgba(255, 255, 255, 0.1); }
+    .kyber_button img { width: 24px; height: 24px; display: block; margin: auto; }
+    #kyber_power_menu {
+      display: none;
+      position: absolute;
+      left: 100%;
+      margin-left: 8px;
+      background-color: #1c2331;
+      border-radius: 6px;
+      padding: 5px;
+      box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.5);
+    }
+    #kyber_power_menu.kyber_open { display: flex; flex-direction: column; gap: 4px; }
+    #kyber_power_menu .kyber_button { white-space: nowrap; text-align: left; }
+    #container:fullscreen,
+    :fullscreen #container {
+      width: 100vw;
+      height: 100vh;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      background: #000;
+    }
+</style>
+  </head>
+  <body>
+    <div id="kyber_control_bar_anchor">
+      <div id="kyber_control_bar">
+        <div id="kyber_control_bar_handle"></div>
+        <button class="kyber_button" id="kyber_ctrl_alt_del" title="Send Ctrl-Alt-Del">
+          <img src="/novnc/app/images/esc.svg" alt=""><span>C-A-D</span>
+        </button>
+        <button class="kyber_button" id="kyber_fullscreen" title="Fullscreen">
+          <img src="/novnc/app/images/fullscreen.svg" alt="">
+        </button>
+        <button class="kyber_button" id="kyber_power" title="Power">
+          <img src="/novnc/app/images/power.svg" alt="">
+        </button>
+        <div id="kyber_power_menu">
+          <button class="kyber_button" data-power="start">Start</button>
+          <button class="kyber_button" data-power="shutdown">Shutdown</button>
+          <button class="kyber_button" data-power="reboot">Reboot</button>
+          <button class="kyber_button" data-power="reset">Reset</button>
+          <button class="kyber_button" data-power="stop">Stop</button>
+        </div>
+      </div>
+    </div>
+    <div id="container"></div>
+    <div id="status">Connecting…</div>
+
+    <script type="module">
+      import init, { AuthCredentials, Client, StreamingConfigBuilder }
+          from '/kyber/kyclient_wasm.js';
+
+      const params = new URLSearchParams(window.location.search);
+      const node = params.get('node');
+      const vmid = params.get('vmid');
+      const vmname = params.get('vmname');
+
+      document.title = vmname ? `${vmname} (${vmid}) - Kyber console`
+                              : `VM ${vmid} - Kyber console`;
+
+      const statusEl = document.getElementById('status');
+      const container = document.getElementById('container');
+
+      const setStatus = (text, failed) => {
+          statusEl.textContent = text;
+          statusEl.classList.toggle('failed', !!failed);
+          statusEl.hidden = false;
+      };
+
+      let client = null;
+      let streamingConfig = null;
+
+      function createCanvas() {
+          const canvas = document.createElement('canvas');
+          canvas.id = 'canvas';
+          canvas.width = 1024;
+          canvas.height = 768;
+          // Without this every keystroke goes to the document, not the guest.
+          canvas.tabIndex = 0;
+          container.appendChild(canvas);
+          canvas.focus();
+          return canvas;
+      }
+
+      function deleteCanvas() {
+          document.getElementById('canvas')?.remove();
+      }
+
+      // The canvas cannot be reused: its control was transferred to an
+      // offscreen canvas in the worker, one way.
+      async function restartStreaming() {
+          deleteCanvas();
+          createCanvas();
+          await client.start_streaming('canvas', streamingConfig.clone());
+      }
+
+      // A guest resize shows up as the host video process stopping; the stream
+      // must be rebuilt, since the decoder cannot change codec configuration
+      // mid-stream. Rate limited against a host stuck in a start/die loop.
+      // The stream's own geometry, which is what the canvas is sized from.
+      let videoSize = null;
+
+      // Native size in a window, scaled up to fill the screen in fullscreen -
+      // aspect ratio kept, so a 4:3 guest letterboxes rather than distorts.
+      function fitCanvas() {
+          const canvas = document.getElementById('canvas');
+          if (!canvas || !videoSize) {
+              return;
+          }
+          let { width, height } = videoSize;
+          if (document.fullscreenElement) {
+              const scale = Math.min(
+                  window.innerWidth / width,
+                  window.innerHeight / height,
+              );
+              width = Math.floor(width * scale);
+              height = Math.floor(height * scale);
+          }
+          canvas.style.width = `${width}px`;
+          canvas.style.height = `${height}px`;
+      }
+
+      const VIDEO_RESTART_MIN_INTERVAL_MS = 2000;
+      let lastVideoRestart = 0;
+
+      async function onClientEvent(name, event) {
+          switch (name) {
+              case 'streamer_pipeline_event':
+                  // Not 'streaming_stopped': the SDK emits that only for a
+                  // stop this client asked for. stop_streaming() comes back as
+                  // one below, which rebuilds canvas and stream.
+                  if (event.type === 'video' && event.stage === 'process'
+                      && event.event === 'stopped' && client !== null) {
+                      const now = Date.now();
+                      if (now - lastVideoRestart < VIDEO_RESTART_MIN_INTERVAL_MS) {
+                          console.warn('Host video stopped again too soon; not restarting');
+                          break;
+                      }
+                      lastVideoRestart = now;
+                      setStatus('Display changed, restarting…');
+                      client.stop_streaming();
+                  }
+                  break;
+              case 'controlplane_connected':
+                  statusEl.hidden = true;
+                  await client.start_streaming('canvas', streamingConfig.clone());
+                  break;
+              case 'canvas_resized':
+                  videoSize = { width: event.width, height: event.height };
+                  fitCanvas();
+                  break;
+              case 'streaming_stopped':
+                  // The session is still up, so pick the new stream up.
+                  await restartStreaming();
+                  statusEl.hidden = true;
+                  break;
+              case 'reconnecting':
+                  setStatus(`Reconnecting (${event.attempt}/${event.maxAttempts})…`);
+                  break;
+              case 'reconnected':
+                  statusEl.hidden = true;
+                  if (event.shouldRestartStreaming) {
+                      await restartStreaming();
+                  }
+                  break;
+              case 'reconnection_failed':
+                  setStatus('Connection lost. Close this window and open the console again.', true);
+                  deleteCanvas();
+                  break;
+              case 'stopped':
+                  setStatus(`Console stopped: ${event.reason()}`, true);
+                  deleteCanvas();
+                  break;
+          }
+      }
+
+      async function boot() {
+          if (!node || !vmid) {
+              throw new Error('missing node or vmid');
+          }
+
+          await init();
+
+          // Proxmox checks its ACL here and mints the token. Nothing is in the
+          // URL, so a copied link grants nothing.
+          const base = `/api2/json/nodes/${node}/qemu/${vmid}`;
+          const res = await fetch(`${base}/kyberproxy`, {
+              method: 'POST',
+              credentials: 'same-origin',
+              headers: { CSRFPreventionToken: '[% token %]' },
+          });
+          if (!res.ok) {
+              const detail = await res.text().catch(() => '');
+              throw new Error(`could not start the console (${res.status}) ${detail}`);
+          }
+          const { data } = await res.json();
+
+          // Control plane only: pveproxy forwards this prefix to
+          // pvekyberproxy and on to the VM's controller. The data plane goes
+          // straight to the UDP port start_mux names - pveproxy speaks no QUIC.
+          const basePath = `${base}/kyber`;
+
+          const builder = new StreamingConfigBuilder();
+          builder.protocol = 'kymux';
+          builder.codec = 'h264';
+          builder.bitrate = 20000000;
+          builder.display_index = 0;
+          builder.inputs = true;
+          builder.clipboard = true;
+          // Carried when the VM has an audio device on a 'dbus' audiodev.
+          // The controller asks the AV service, which says no when there is
+          // none, and the client simply gets no audio stream.
+          builder.audio = true;
+          // Needs nvenc; this encoder is libx264.
+          builder.intra_refresh = false;
+          streamingConfig = builder.build();
+
+          createCanvas();
+
+          client = await Client.new(
+              window.location.hostname,
+              window.location.port || '443',
+              AuthCredentials.jwt(data.ticket),
+              onClientEvent,
+              false, // metrics
+              'none', // metrics mode
+              null, // live metrics callback
+              true, // reconnect on its own
+              basePath,
+          );
+
+          client.connect();
+      }
+
+      // The bar retracts like noVNC's: the handle is always reachable, the bar
+      // itself only when asked for.
+      const controlBar = document.getElementById('kyber_control_bar');
+      const powerMenu = document.getElementById('kyber_power_menu');
+      document.getElementById('kyber_control_bar_handle').addEventListener('click', () => {
+          controlBar.classList.toggle('kyber_open');
+          if (!controlBar.classList.contains('kyber_open')) {
+              powerMenu.classList.remove('kyber_open');
+          }
+      });
+
+      // Ctrl+Alt+Del cannot be captured by any web page - the OS takes it
+      // before a browser sees it - so it is injected as scancodes instead.
+      document.getElementById('kyber_ctrl_alt_del').addEventListener('click', () => {
+          try {
+              client?.send_ctrl_alt_del();
+          } catch (err) {
+              setStatus(`Could not send Ctrl-Alt-Del: ${err.message ?? err}`, true);
+          }
+      });
+
+      // Fullscreen takes the keyboard with it. Without the lock, combinations
+      // the browser and desktop claim - Ctrl+W, Alt+Tab, F11 - never reach the
+      // guest. It is granted only to a fullscreen document, and has to be
+      // released again or it outlives the console.
+      async function enterFullscreen() {
+          const el = document.documentElement;
+          await el.requestFullscreen();
+          try {
+              await navigator.keyboard?.lock?.();
+          } catch (err) {
+              console.warn('keyboard lock refused:', err);
+          }
+          document.getElementById('canvas')?.focus();
+      }
+
+      document.getElementById('kyber_fullscreen').addEventListener('click', async () => {
+          try {
+              if (document.fullscreenElement) {
+                  await document.exitFullscreen();
+              } else {
+                  await enterFullscreen();
+              }
+          } catch (err) {
+              setStatus(`Fullscreen failed: ${err.message ?? err}`, true);
+          }
+      });
+
+      document.addEventListener('fullscreenchange', () => {
+          if (!document.fullscreenElement) {
+              navigator.keyboard?.unlock?.();
+          }
+          fitCanvas();
+      });
+      window.addEventListener('resize', fitCanvas);
+
+      document.getElementById('kyber_power').addEventListener('click', () => {
+          powerMenu.classList.toggle('kyber_open');
+      });
+
+      // Power goes through the API rather than the guest, so it works when the
+      // guest does not answer - which is the case the buttons exist for.
+      for (const button of powerMenu.querySelectorAll('[data-power]')) {
+          button.addEventListener('click', async () => {
+              const action = button.dataset.power;
+              powerMenu.classList.remove('kyber_open');
+              setStatus(`${button.textContent}…`);
+              try {
+                  const res = await fetch(
+                      `/api2/json/nodes/${node}/qemu/${vmid}/status/${action}`,
+                      {
+                          method: 'POST',
+                          credentials: 'same-origin',
+                          headers: { CSRFPreventionToken: '[% token %]' },
+                      },
+                  );
+                  if (!res.ok) {
+                      throw new Error(`${res.status} ${await res.text().catch(() => '')}`);
+                  }
+                  statusEl.hidden = true;
+              } catch (err) {
+                  setStatus(`${button.textContent} failed: ${err.message ?? err}`, true);
+              }
+          });
+      }
+
+      boot().catch((err) => {
+          console.error(err);
+          setStatus(`Kyber console failed: ${err.message ?? err}`, true);
+      });
+    </script>
+  </body>
+</html>
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




  parent reply	other threads:[~2026-08-25 11:36 UTC|newest]

Thread overview: 11+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-08-25 11:34 [RFC pve-http-server/qemu-server/pve-manager/pve-{qemu-kyber,kyberproxy, kyber-web,qemu-rdp,rdpproxy,rdp-web} 00/13] add rdp && kyber consoles for qemu over D-Bus display Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-http-server 01/13] anyevent : proxy a path prefix to a local http proxy Alexandre Derumier
2026-08-25 11:34 ` [RFC qemu-server 02/13] add D-Bus display support Alexandre Derumier
2026-08-25 11:34 ` [RFC qemu-server 03/13] add kyber display Alexandre Derumier
2026-08-25 11:34 ` [RFC qemu-server 04/13] add rdp display Alexandre Derumier
2026-08-25 11:34 ` [RFC qemu-server 05/13] add experimental kyber-gl display Alexandre Derumier
2026-08-25 11:34 ` Alexandre Derumier [this message]
2026-08-25 11:34 ` [RFC pve-manager 07/13] ui: add rdp console Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-kyber-web 10/13] add pve-kyber-web: console's webassembly client Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-qemu-rdp 11/13] Add pve-qemu-rdp: an RDP server for the console Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-rdpproxy 12/13] Add pve-rdpproxy Alexandre Derumier

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260825113442.947620-7-alexandre.derumier@groupe-cyllene.com \
    --to=alexandre.derumier@groupe-cyllene.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal