From: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
To: pve-devel@lists.proxmox.com
Subject: [RFC pve-kyber-web 10/13] add pve-kyber-web: console's webassembly client
Date: Tue, 25 Aug 2026 13:34:35 +0200 [thread overview]
Message-ID: <20260825113442.947620-11-alexandre.derumier@groupe-cyllene.com> (raw)
In-Reply-To: <20260825113442.947620-1-alexandre.derumier@groupe-cyllene.com>
The console page use Kyber's WASM SDK directly, with some modifications
to make it work behind pveproxy:
- 0001 gives kyclient a base path, so the controller can be reached under
/api2/json/nodes/<node>/qemu/<vmid>/ rather than owning an origin.
- 0002 resolves the renderer worker's wasm glue against baseURI instead of
window.location.pathname.
- 0003 puts the kymux token in the WebTransport path, giving a proxy in
front of several VMs something to route on.
Signed-off-by: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
---
.gitignore | 4 +
.gitmodules | 3 +
Makefile | 118 +++++++++
debian/changelog | 7 +
debian/control | 21 ++
debian/copyright | 20 ++
debian/install | 1 +
debian/rules | 9 +
debian/source/format | 1 +
kyber-web | 1 +
.../0001-kyclient-support-a-base-path.patch | 248 ++++++++++++++++++
...renderer-worker-against-the-base-url.patch | 72 +++++
...kymux-token-in-the-webtransport-path.patch | 37 +++
13 files changed, 542 insertions(+)
create mode 100644 .gitignore
create mode 100644 .gitmodules
create mode 100644 Makefile
create mode 100644 debian/changelog
create mode 100644 debian/control
create mode 100644 debian/copyright
create mode 100644 debian/install
create mode 100755 debian/rules
create mode 100644 debian/source/format
create mode 160000 kyber-web
create mode 100644 patches/0001-kyclient-support-a-base-path.patch
create mode 100644 patches/0002-kywebplayer-resolve-the-renderer-worker-against-the-base-url.patch
create mode 100644 patches/0003-kyclient-put-the-kymux-token-in-the-webtransport-path.patch
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..dea6af5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+/sdk/
+*.deb
+*.buildinfo
+*.changes
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..b7a917b
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "kyber-web"]
+ path = kyber-web
+ url = https://gitlab.com/kyber/apps/kyber-web.git
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..ed833c4
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,118 @@
+include /usr/share/dpkg/architecture.mk
+include /usr/share/dpkg/pkg-info.mk
+
+PACKAGE=pve-kyber-web
+DEB=$(PACKAGE)_$(DEB_VERSION_UPSTREAM_REVISION)_all.deb
+DSC=$(PACKAGE)_$(DEB_VERSION).dsc
+BUILDDIR=$(PACKAGE)-$(DEB_VERSION_UPSTREAM)
+
+# Kyber's web client: AGPL-3.0-or-later like this package, but a separate
+# upstream with its own cadence, so it is cloned at build time rather than
+# vendored or carried as a submodule. This repo then holds nothing but
+# packaging, and the revision it builds is one greppable line rather than a
+# gitlink.
+#
+# Pinned by commit, so a moved tag cannot change what this builds.
+KYBER_WEB_DIR = kyber-web
+
+# The four files the console page loads. Not the demo client's bundle, its
+# sidebar or its metrics UI: the page drives the SDK directly, so none of that
+# is built and neither pnpm nor esbuild is needed.
+SDK_FILES = kyclient_wasm.js kyclient_wasm_bg.wasm audio_worklet.js spinlock.js
+
+all: $(DEB)
+
+# --- upstream ---------------------------------------------------------------
+# Fetched once, and left alone after that, following pve-qemu: a build must not
+# depend on re-fetching, and a checkout someone has been working in is theirs.
+#
+# The patch below is what needs the tree pristine, so it resets just the files
+# it touches rather than the whole submodule - which would discard the build
+# output beside them for nothing.
+.PHONY: submodule
+submodule:
+ifeq ($(shell test -f "$(KYBER_WEB_DIR)/Cargo.toml" && echo 1 || echo 0), 0)
+ git submodule update --init --recursive $(KYBER_WEB_DIR)
+endif
+
+# Applied from the kyber-web root: they span it and its nested kysdk submodule.
+.PHONY: patch
+patch: submodule
+ git -C $(KYBER_WEB_DIR) checkout --force -- .
+ git -C $(KYBER_WEB_DIR) submodule foreach --recursive --quiet 'git checkout --force -- .'
+ set -e; for p in $(CURDIR)/patches/000*.patch; do \
+ git -C $(KYBER_WEB_DIR) apply "$$p"; \
+ done
+
+# --- build ------------------------------------------------------------------
+# The version Cargo.lock pins, read at use rather than at parse: the checkout
+# does not exist yet when make reads this file.
+WASM_BINDGEN_VERSION = $(shell sed -n '/^name = "wasm-bindgen"$$/{n;s/^version = "\(.*\)"/\1/p;q}' $(KYBER_WEB_DIR)/Cargo.lock)
+
+# web_sys_unstable_apis is required for WebTransport, which is the whole data
+# plane. A wasm-bindgen CLI older or newer than the crate emits glue the wasm
+# rejects at instantiation, with no error until the console is opened.
+.PHONY: wasm
+wasm: patch
+ have=$$(wasm-bindgen --version | awk '{print $$2}'); \
+ want="$(WASM_BINDGEN_VERSION)"; \
+ if [ "$$have" != "$$want" ]; then \
+ echo "wasm-bindgen $$have found, Cargo.lock pins $$want" >&2; \
+ echo "install it with: cargo install --locked wasm-bindgen-cli@$$want" >&2; \
+ exit 1; \
+ fi
+ cd $(KYBER_WEB_DIR) && RUSTFLAGS=--cfg=web_sys_unstable_apis \
+ cargo build --target wasm32-unknown-unknown -p kyclient-wasm --release
+ cd $(KYBER_WEB_DIR) && wasm-bindgen --target web --no-typescript --out-dir html \
+ target/wasm32-unknown-unknown/release/kyclient_wasm.wasm
+ cd $(KYBER_WEB_DIR) && wasm-opt html/kyclient_wasm_bg.wasm \
+ -o html/kyclient_wasm_bg.wasm -Os -g
+ # The JS glue: worker entry points and the audio worklet.
+ cd $(KYBER_WEB_DIR)/kysdk/kyctl && ./build-wasm.sh -j $(CURDIR)/$(KYBER_WEB_DIR)/html
+
+# --- packaging --------------------------------------------------------------
+.PHONY: sdk
+sdk: wasm
+ rm -rf sdk && mkdir sdk
+ for f in $(SDK_FILES); do install -m 0644 "$(KYBER_WEB_DIR)/html/$$f" sdk/; done
+
+.PHONY: builddir
+builddir:
+ rm -rf $(BUILDDIR)
+ $(MAKE) $(BUILDDIR)
+
+$(BUILDDIR): sdk
+ rm -rf $@ $@.tmp
+ mkdir $@.tmp
+ cp -a sdk debian Makefile $@.tmp/
+ mv $@.tmp $@
+
+deb: $(DEB)
+$(DEB): $(BUILDDIR)
+ cd $(BUILDDIR); dpkg-buildpackage -b -us -uc
+ lintian $(DEB) || true
+
+# A source package, for sbuild and for review: Proxmox builds every package
+# this way, so it has to work even when the binary path is what gets used.
+.PHONY: dsc
+dsc:
+ rm -rf $(BUILDDIR) $(DSC)
+ $(MAKE) $(DSC)
+ lintian $(DSC)
+
+$(DSC): $(BUILDDIR)
+ cd $(BUILDDIR); dpkg-buildpackage -S -us -uc -d
+
+sbuild: $(DSC)
+ sbuild $<
+
+.PHONY: dinstall
+dinstall: deb
+ dpkg -i $(DEB)
+
+.PHONY: clean
+clean:
+ rm -rf *.deb *.changes *.dsc *.buildinfo *.build $(PACKAGE)-[0-9]*/ sdk/
+
+.PHONY: distclean
+distclean: clean
diff --git a/debian/changelog b/debian/changelog
new file mode 100644
index 0000000..ff9047b
--- /dev/null
+++ b/debian/changelog
@@ -0,0 +1,7 @@
+pve-kyber-web (0.27.0) trixie; urgency=medium
+
+ * Initial release: the Kyber WebAssembly client.
+ * Carries the fix for resolving the renderer worker against the document
+ base URL, without which the console renders black behind a path prefix.
+
+ -- Proxmox Support Team <support@proxmox.com> Wed, 19 Aug 2026 07:00:00 +0200
diff --git a/debian/control b/debian/control
new file mode 100644
index 0000000..2be348f
--- /dev/null
+++ b/debian/control
@@ -0,0 +1,21 @@
+Source: pve-kyber-web
+Section: admin
+Priority: optional
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Uploaders: Alexandre Derumier <aderumier@groupe-cyllene.com>
+Build-Depends: debhelper-compat (= 13),
+ binaryen,
+ git,
+Standards-Version: 4.7.0.0
+
+Package: pve-kyber-web
+Architecture: all
+Depends: ${misc:Depends},
+Description: Kyber streaming client for the Proxmox VE console
+ The WebAssembly client the Kyber console page drives: the wasm module, its
+ JavaScript glue and the audio worklet.
+ .
+ Built from kyber-web, which is a separate upstream with its own release
+ cadence and a wasm toolchain that has no business in the pve-manager build,
+ so it is packaged on its own and the console page in pve-manager loads it
+ from here.
diff --git a/debian/copyright b/debian/copyright
new file mode 100644
index 0000000..702a252
--- /dev/null
+++ b/debian/copyright
@@ -0,0 +1,20 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+Upstream-Name: pve-kyber-web
+
+Files: *
+Copyright: 2026 Proxmox Server Solutions GmbH <support@proxmox.com>
+License: AGPL-3.0-or-later
+
+License: AGPL-3.0-or-later
+ This program is free software: you can redistribute it and/or modify it under
+ the terms of the GNU Affero General Public License as published by the Free
+ Software Foundation, either version 3 of the License, or (at your option) any
+ later version.
+ .
+ This program is distributed in the hope that it will be useful, but WITHOUT
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+ details.
+ .
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
diff --git a/debian/install b/debian/install
new file mode 100644
index 0000000..701f265
--- /dev/null
+++ b/debian/install
@@ -0,0 +1 @@
+sdk/* usr/share/pve-kyber-web/
diff --git a/debian/rules b/debian/rules
new file mode 100755
index 0000000..2a8f910
--- /dev/null
+++ b/debian/rules
@@ -0,0 +1,9 @@
+#!/usr/bin/make -f
+%:
+ dh $@
+# The sdk/ directory is staged into the build dir before dpkg-buildpackage
+# runs, and the Makefile carried along with it has a distclean that removes
+# it. Nothing here is built or cleaned by dh.
+override_dh_auto_clean:
+override_dh_auto_build:
+override_dh_auto_test:
diff --git a/debian/source/format b/debian/source/format
new file mode 100644
index 0000000..89ae9db
--- /dev/null
+++ b/debian/source/format
@@ -0,0 +1 @@
+3.0 (native)
diff --git a/kyber-web b/kyber-web
new file mode 160000
index 0000000..ad610dc
--- /dev/null
+++ b/kyber-web
@@ -0,0 +1 @@
+Subproject commit ad610dc64b5dd5d88aecdbf5e56a21b49572307a
diff --git a/patches/0001-kyclient-support-a-base-path.patch b/patches/0001-kyclient-support-a-base-path.patch
new file mode 100644
index 0000000..4b9fb1d
--- /dev/null
+++ b/patches/0001-kyclient-support-a-base-path.patch
@@ -0,0 +1,248 @@
+From: Kyber/QEMU integration
+Subject: [PATCH] kyclient: let the controller be reached under a base path
+
+Client::new took a host and a port and built /session/login,
+/kymux/start_session and /websocket/<plane> from them, so the controller had to
+own the origin it was served on. Behind a reverse proxy it does not: Proxmox
+exposes it under /api2/json/nodes/<node>/qemu/<vmid>/, and there was no way to
+say so.
+
+Add an optional base_path, threaded through ConnectConfig and ConnectionParams.
+Every HTTP URL already derives from HttpClient::base_url, so those come along
+with one change; the websocket URLs are built from the host instead and are
+prefixed explicitly.
+
+That includes /ws, the control plane's own websocket in host_events - which is
+easy to miss, because it lives on the other side of the platform split from the
+three data-plane sockets and is opened later. Missing it does not fail at
+startup: the client logs in, fetches /capabilities and only then tries to open
+a websocket at the origin root, which behind a proxy names no VM. Found by
+running the client through pvekyberproxy.
+
+normalize_base_path accepts "/kyber", "kyber/" and "/kyber/" alike - a doubled
+or missing slash would otherwise show up as a 404 a long way from the setting
+that caused it.
+
+This is not Proxmox-specific: any reverse-proxied deployment needs it, which is
+why it is worth sending upstream rather than carrying here.
+---
+diff --git a/kyclient/src/http.rs b/kyclient/src/http.rs
+index bdf88b6..17d52f2 100644
+--- a/kysdk/kyctl/kyclient/src/http.rs
++++ b/kysdk/kyctl/kyclient/src/http.rs
+@@ -38,8 +38,20 @@ pub(crate) fn format_host_for_url(host: &str, port: u16) -> String {
+ }
+ }
+
++/// Trim a base path to the form the URL builders expect: a leading slash and
++/// no trailing one, or empty for the origin root. Accepting "/kyber",
++/// "kyber/" and "/kyber/" alike avoids a double or missing slash showing up
++/// as a 404 far from the setting that caused it.
++pub(crate) fn normalize_base_path(base_path: Option<&str>) -> String {
++ match base_path.map(str::trim).filter(|p| !p.is_empty() && *p != "/") {
++ None => String::new(),
++ Some(path) => format!("/{}", path.trim_matches('/')),
++ }
++}
++
+ pub(crate) struct HttpClient {
+ base_url: String,
++ base_path: String,
+ client: reqwest::Client,
+ host: String,
+ }
+@@ -47,12 +59,17 @@ pub(crate) struct HttpClient {
+ impl HttpClient {
+ pub(crate) fn new(conn_params: ConnectionParams) -> Result<Self> {
+ let host = platform_http::create_host(&conn_params);
+- let base_url = format!("https://{host}");
++ // Behind a reverse proxy the controller has no origin of its own, so
++ // every URL has to be built under the path it is exposed at rather
++ // than at the root.
++ let base_path = normalize_base_path(conn_params.base_path.as_deref());
++ let base_url = format!("https://{host}{base_path}");
+
+ let client = platform_http::create_reqwest(conn_params)?;
+
+ Ok(Self {
+ base_url,
++ base_path,
+ client,
+ host,
+ })
+@@ -74,6 +91,12 @@ impl HttpClient {
+ &self.base_url
+ }
+
++ /// The path prefix on its own, for the websocket URLs - those are built
++ /// from the host rather than from base_url, so they cannot reuse it.
++ pub(crate) fn base_path(&self) -> &str {
++ &self.base_path
++ }
++
+ pub(crate) fn peer_addr_from_response(
+ &self,
+ response: &reqwest::Response,
+diff --git a/kyclient/src/platform/desktop/host_events.rs b/kyclient/src/platform/desktop/host_events.rs
+index f8fd9e1..5345ff0 100644
+--- a/kysdk/kyctl/kyclient/src/platform/desktop/host_events.rs
++++ b/kysdk/kyctl/kyclient/src/platform/desktop/host_events.rs
+@@ -53,7 +53,11 @@ impl WsClient {
+ auth_token: &str,
+ ) -> Result<Self> {
+ let host = platform::http::create_host(conn_params);
+- let url = format!("wss://{host}/ws");
++ // The control plane's own websocket sits under the base path too. It
++ // is built from the host rather than from base_url, so like the three
++ // data-plane sockets it has to be prefixed explicitly.
++ let base_path = crate::http::normalize_base_path(conn_params.base_path.as_deref());
++ let url = format!("wss://{host}{base_path}/ws");
+
+ // Connect with appropriate TLS verification
+ let mut stream = if let Some(verify_mode) = &conn_params.verify_mode {
+diff --git a/kyclient/src/platform/desktop/mod.rs b/kyclient/src/platform/desktop/mod.rs
+index fadb052..44681a0 100644
+--- a/kysdk/kyctl/kyclient/src/platform/desktop/mod.rs
++++ b/kysdk/kyctl/kyclient/src/platform/desktop/mod.rs
+@@ -46,6 +46,10 @@ pub use tls::{TofuPrompt, TofuVerifier, VerifyMode};
+ pub struct ConnectConfig {
+ pub host: String,
+ pub port: u16,
++ /// Path the controller is reached under, when it sits behind a reverse
++ /// proxy that cannot give it an origin of its own. Every HTTP and
++ /// websocket URL is built beneath it. Default: the origin root.
++ pub base_path: Option<String>,
+ pub tls_host: Option<String>,
+ /// TLS verification mode.
+ pub verify_mode: Option<VerifyMode>,
+@@ -126,6 +130,7 @@ impl VideoPlayerConfig {
+ pub(crate) struct ConnectionParams {
+ pub(crate) host: String,
+ pub(crate) port: u16,
++ pub(crate) base_path: Option<String>,
+ pub(crate) tls_host: Option<String>,
+ pub(crate) verify_mode: Option<VerifyMode>,
+ pub(crate) credentials: AuthCredentials,
+@@ -136,6 +141,7 @@ impl ConnectionParams {
+ Ok(Self {
+ host: connect_config.host,
+ port: connect_config.port,
++ base_path: connect_config.base_path.clone(),
+ tls_host: connect_config.tls_host.clone(),
+ verify_mode: connect_config.verify_mode,
+ credentials: connect_config.credentials,
+diff --git a/kyclient/src/platform/web/host_events.rs b/kyclient/src/platform/web/host_events.rs
+index 425e76c..a6f2415 100644
+--- a/kysdk/kyctl/kyclient/src/platform/web/host_events.rs
++++ b/kysdk/kyctl/kyclient/src/platform/web/host_events.rs
+@@ -315,7 +315,11 @@ impl WsClient {
+ auth_token: &str,
+ ) -> Result<Self> {
+ let host = platform::http::create_host(conn_params);
+- let url = format!("wss://{host}/ws");
++ // The control plane's own websocket sits under the base path too. It
++ // is built from the host rather than from base_url, so like the three
++ // data-plane sockets it has to be prefixed explicitly.
++ let base_path = crate::http::normalize_base_path(conn_params.base_path.as_deref());
++ let url = format!("wss://{host}{base_path}/ws");
+
+ let inner = InnerRef(Inner::new(url, capabilities, event_sink, auth_token).await?);
+
+diff --git a/kyclient/src/platform/web/mod.rs b/kyclient/src/platform/web/mod.rs
+index bec764b..3cfbb9d 100644
+--- a/kysdk/kyctl/kyclient/src/platform/web/mod.rs
++++ b/kysdk/kyctl/kyclient/src/platform/web/mod.rs
+@@ -41,6 +41,10 @@ pub(crate) use websocket::{WebSocket, WebSocketHandler};
+ pub struct ConnectConfig {
+ pub host: String,
+ pub port: u16,
++ /// Path the controller is reached under, when it sits behind a reverse
++ /// proxy that cannot give it an origin of its own. Every HTTP and
++ /// websocket URL is built beneath it. Default: the origin root.
++ pub base_path: Option<String>,
+ pub credentials: AuthCredentials,
+ /// Enable automatic reconnection on connection loss. Default: false.
+ /// When enabled, the client will emit `Reconnecting`, `Reconnected`, and
+@@ -95,6 +99,7 @@ impl From<VideoCodec> for player::VideoCodec {
+ pub(crate) struct ConnectionParams {
+ pub(crate) host: String,
+ pub(crate) port: u16,
++ pub(crate) base_path: Option<String>,
+ pub(crate) credentials: AuthCredentials,
+ }
+
+@@ -103,6 +108,7 @@ impl ConnectionParams {
+ Ok(Self {
+ host: connect_config.host.clone(),
+ port: connect_config.port,
++ base_path: connect_config.base_path.clone(),
+ credentials: connect_config.credentials,
+ })
+ }
+diff --git a/kyclient/src/ws_backend/mod.rs b/kyclient/src/ws_backend/mod.rs
+index c775c98..009612c 100644
+--- a/kysdk/kyctl/kyclient/src/ws_backend/mod.rs
++++ b/kysdk/kyctl/kyclient/src/ws_backend/mod.rs
+@@ -163,8 +163,9 @@ impl WsBackend {
+ VideoPlayer::create(listener, &player_config, metrics).await?;
+
+ let url = format!(
+- "wss://{host}/websocket/video",
+- host = backend_config.http_client.host()
++ "wss://{host}{base_path}/websocket/video",
++ host = backend_config.http_client.host(),
++ base_path = backend_config.http_client.base_path()
+ );
+ let ws = VideoWsSocketHandler::new(&url, player)?;
+
+@@ -194,8 +195,9 @@ impl WsBackend {
+ };
+
+ let url = format!(
+- "wss://{host}/websocket/audio",
+- host = backend_config.http_client.host()
++ "wss://{host}{base_path}/websocket/audio",
++ host = backend_config.http_client.host(),
++ base_path = backend_config.http_client.base_path()
+ );
+ let ws = AudioWsSocketHandler::new(&url, player)?;
+
+@@ -220,8 +222,9 @@ impl WsBackend {
+ #[allow(clippy::arc_with_non_send_sync)]
+ let msg_sink = Arc::new(InputMsgSink::new(backend_config.msg_sender.clone()));
+ let inputs_url = format!(
+- "wss://{host}/websocket/inputs",
+- host = backend_config.http_client.host()
++ "wss://{host}{base_path}/websocket/inputs",
++ host = backend_config.http_client.host(),
++ base_path = backend_config.http_client.base_path()
+ );
+ let input_websocket =
+ InputWebSocketHandler::new(&inputs_url, kynput_tx, kynput_rx, msg_sink)?;
+diff --git a/src/lib.rs b/src/lib.rs
+index e692168..90b5d7c 100644
+--- a/src/lib.rs
++++ b/src/lib.rs
+@@ -496,6 +496,9 @@ impl Client {
+ /// `"decoded"`, `"skipped"`, `"prepared"`, `"displayed"`.
+ /// All timestamps are in microseconds (µs). The server timestamps are already compensated
+ /// to match the client clock.
++ /// `base_path` is the path the controller is reached under when it sits
++ /// behind a reverse proxy that cannot give it an origin of its own; pass
++ /// `null` when it has the origin to itself.
+ pub async fn new(
+ host: &str,
+ port: u16,
+@@ -505,10 +508,12 @@ impl Client {
+ metrics_mode_str: String,
+ live_metrics_callback: Option<js_sys::Function>,
+ auto_reconnection: bool,
++ base_path: Option<String>,
+ ) -> JsResult<Client> {
+ let connect_config = kyclient::ConnectConfig {
+ host: host.into(),
+ port,
++ base_path,
+ credentials: credentials.0,
+ auto_reconnection,
+ };
diff --git a/patches/0002-kywebplayer-resolve-the-renderer-worker-against-the-base-url.patch b/patches/0002-kywebplayer-resolve-the-renderer-worker-against-the-base-url.patch
new file mode 100644
index 0000000..f906736
--- /dev/null
+++ b/patches/0002-kywebplayer-resolve-the-renderer-worker-against-the-base-url.patch
@@ -0,0 +1,72 @@
+From: Kyber/QEMU integration
+Subject: [PATCH] kywebplayer: resolve the renderer worker against the base URL
+
+RendererWorker::create built the URL of the wasm glue from
+window.location.pathname, so the glue had to sit in the same directory as the
+HTML that loaded it. That holds for the demo client, whose page and SDK ship
+side by side, and does not hold for an application that says otherwise with a
+<base href> - Proxmox serves its console page at / and the SDK under /kyber/.
+
+The failure is expensive to find. The worker is created, so the canvas is
+transferred to it and the decoder runs happily; only the module fetch 404s,
+inside a worker, where nothing surfaces it. Frames are then decoded and
+dropped - the browser reports them garbage collected without being closed - and
+the console shows a black canvas with no error in the page, no exception, and
+no failed request that a page-level capture would see. Found by attaching to
+the worker target and watching its network.
+
+baseURI is the document URL wherever no <base> tag exists, so this changes
+nothing for the demo client and every other existing caller.
+---
+diff --git a/kywebplayer/src/video/renderer_worker.rs b/kywebplayer/src/video/renderer_worker.rs
+index 8aa7fb2..8224d25 100644
+--- a/kysdk/kyctl/kywebplayer/src/video/renderer_worker.rs
++++ b/kysdk/kyctl/kywebplayer/src/video/renderer_worker.rs
+@@ -264,20 +264,34 @@ impl RendererWorker {
+ // This is a library, but the path of the wasm-bindings JavaScript file
+ // depends on the top-level crate name, so the worker code must be
+ // generated dynamically.
+- let location = web_sys::window()
+- .ok_or_else(|| JsValue::from(js_sys::Error::new("No window")))?
+- .location();
+- let origin = location.origin()?;
+- let pathname = location.pathname()?;
+- // pathname can be a directory (".../") or include a filename
+- // (".../index.html"); strip back to the last "/" so we resolve the wasm
+- // glue next to the HTML rather than concatenating onto the filename.
+- let dir = match pathname.rfind('/') {
+- Some(i) => &pathname[..=i],
+- None => "/",
+- };
++ let window = web_sys::window()
++ .ok_or_else(|| JsValue::from(js_sys::Error::new("No window")))?;
+ let sanitized_crate_name = app_crate_name.replace("-", "_");
+- let wasm_js_url = format!("{origin}{dir}{sanitized_crate_name}.js");
++
++ // Resolved against the document's base URL, not its path. The two are
++ // the same until a page carries a <base href>, and then they are not:
++ // an application whose HTML is served from one place and whose wasm
++ // glue lives in another says so with that tag, and resolving against
++ // the path instead sends this worker somewhere the script is not.
++ //
++ // It fails in a way that takes a long time to find. The worker is
++ // created, so the canvas is transferred to it and the decoder runs;
++ // only the module fetch 404s, inside a worker, where nothing surfaces
++ // it. Frames are then decoded and dropped, and the console shows a
++ // black canvas with no error anywhere.
++ //
++ // baseURI is the document URL when there is no <base>, so this is the
++ // old behaviour wherever the tag is absent.
++ let base = window
++ .document()
++ .ok_or_else(|| JsValue::from(js_sys::Error::new("No document")))?
++ .base_uri()?
++ .ok_or_else(|| JsValue::from(js_sys::Error::new("No base URI")))?;
++ let wasm_js_url = web_sys::Url::new_with_base(
++ &format!("{sanitized_crate_name}.js"),
++ &base,
++ )?
++ .href();
+ let worker_code = format!(
+ r#"import init, {{ VideoRendererWorkerCtx }} from "{wasm_js_url}";
+ let ctx;
diff --git a/patches/0003-kyclient-put-the-kymux-token-in-the-webtransport-path.patch b/patches/0003-kyclient-put-the-kymux-token-in-the-webtransport-path.patch
new file mode 100644
index 0000000..5008623
--- /dev/null
+++ b/patches/0003-kyclient-put-the-kymux-token-in-the-webtransport-path.patch
@@ -0,0 +1,37 @@
+From: Kyber/QEMU integration
+Subject: [PATCH] kyclient: put the kymux token in the WebTransport path
+
+The client opened WebTransport at the origin root, so the only thing
+distinguishing one console from another was the port it connected to. That is
+fine against a controller, which serves one VM, and it forces a proxy in front
+of several to spend a UDP port per session: nothing in an encrypted datagram
+says which VM it belongs to, so the port has to be the routing key.
+
+Putting the token in the path gives such a proxy something to route on, and it
+costs nothing to either end. The token already exists, both sides already agree
+on it, and it is already carried in this connection - it authenticates to the
+controller a moment later. It does not travel in the clear either: a path is
+inside the encrypted session, unlike a port.
+
+A controller reached directly ignores the path, so this changes nothing for a
+client that is not behind a proxy.
+---
+diff --git a/kyclient/src/kymux_backend/mod.rs b/kyclient/src/kymux_backend/mod.rs
+index db7b13a..b56e888 100644
+--- a/kysdk/kyctl/kyclient/src/kymux_backend/mod.rs
++++ b/kysdk/kyctl/kyclient/src/kymux_backend/mod.rs
+@@ -265,7 +265,13 @@ async fn start_kymux(backend_config: &KymuxBackendConfig<'_>) -> Result<kyproto:
+ &backend_config.conn_params.host,
+ start_response.port,
+ );
+- let url = format!("https://{host_str}");
++ // The token goes in the path as well as into the authentication
++ // below. A proxy that terminates WebTransport to put every console
++ // on one UDP port has nothing else to route on - a datagram says
++ // nothing about which host it is for - and this is the one field
++ // both ends already agree on. Reaching a controller directly, the
++ // path is ignored.
++ let url = format!("https://{host_str}/{token}", token = start_response.token);
+
+ connect_webtransport_js(&url, start_response.certificate_hash).await
+ }
--
2.55.0
next prev 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 ` [RFC pve-manager 06/13] ui: add kyber console Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-manager 07/13] ui: add rdp console Alexandre Derumier
2026-08-25 11:34 ` Alexandre Derumier [this message]
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-11-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