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
Cc: Alexandre Derumier <aderumier@groupe-cyllene.com>
Subject: SPAM: [RFC pve-kyber-web 10/13] add pve-kyber-web: console's webassembly client
Date: Tue, 25 Aug 2026 13:08:42 +0200	[thread overview]
Message-ID: <20260825110849.2967694-11-alexandre.derumier@groupe-cyllene.com> (raw)
In-Reply-To: <20260825110849.2967694-1-alexandre.derumier@groupe-cyllene.com>

From: Alexandre Derumier <aderumier@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 <aderumier@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




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

Thread overview: 11+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-08-25 11:08 SPAM: [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:08 ` SPAM: [RFC pve-http-server 01/13] anyevent : proxy a path prefix to a local http proxy Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC qemu-server 02/13] add D-Bus display support Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC qemu-server 03/13] add kyber display Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC qemu-server 04/13] add rdp display Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC qemu-server 05/13] add experimental kyber-gl display Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC pve-manager 06/13] ui: add kyber console Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC pve-manager 07/13] ui: add rdp console Alexandre Derumier
2026-08-25 11:08 ` Alexandre Derumier [this message]
2026-08-25 11:08 ` SPAM: [RFC pve-qemu-rdp 11/13] Add pve-qemu-rdp: an RDP server for the console Alexandre Derumier
2026-08-25 11:08 ` SPAM: [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=20260825110849.2967694-11-alexandre.derumier@groupe-cyllene.com \
    --to=alexandre.derumier@groupe-cyllene.com \
    --cc=aderumier@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