From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id EABB71FF0B7 for ; Tue, 25 Aug 2026 13:36:45 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 4568021644; Tue, 25 Aug 2026 13:36:08 +0200 (CEST) ARC-Seal: i=1; a=rsa-sha256; s=arcselector10001; d=microsoft.com; cv=none; b=cZCMu61+KcPHfxjx2uZ+NWvFlD+NC090mo6Wglgq+29uPL5dMhzg+04S6stbIJY6sFKSaSbyTGwjjq+ANzAJ4xt/vEiSEH0sGy/XdNz5o+k7jzcJmaqDJWS8Mln5n+5K5nq7Y99hvEfppNrETBv+sjX3cBsK9u96pOA83zFMOvWq5EkYsXrbV4AlO4PeOeDaBgPTd5IHikptGuQg3uz4IEo98jkvziLWEu5jdPLM6BpuCXRzCBnnZJAiRCf3ClykRGWyG6fK92vDXI1xK7wkB/NJxbGcpwtGCPzm/7eXPgLLtPVCLQt9ZXq/jLSeFnxZsV/T7A25iYwrzTCaLKdAqw== ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=microsoft.com; s=arcselector10001; h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-AntiSpam-MessageData-ChunkCount:X-MS-Exchange-AntiSpam-MessageData-0:X-MS-Exchange-AntiSpam-MessageData-1; bh=AgA+oZwH0A/o5kxGWRXo8aj+k1lhTtHcN7Ku1SCkK9k=; b=nFm84EtKc+i62Eiqxoc5uDdrow8pPDiv6eQquhmgdfRuyhd6LZ+9m40/nByMgrvIvYjW+ev9WMs1FKzoAPhPPqsUDuQNjyn0PiFA2Re0cGcOtUuRgIJ7pHONaiEhafW+v1hMvC+sDti4I8ZYnNdjuXLPpNM3Vc6Qf+FZ5NejNKOv/bqBK0bGtpXasmqM+6yN9PkgHhHsB3zHvioQ3qDvBnYZuLoT5iYNtkvUH4fKCDjyp+JIRNvg6rmF1p+GX2FZEgZtQW9PkSrNhkoS2iN/Az34EH4ysJY//42admuz5jULue6zekqhA05i+DSLEnHJXDER7dv4ZYJsF6yDHbPMpA== ARC-Authentication-Results: i=1; mx.microsoft.com 1; spf=pass smtp.mailfrom=groupe-cyllene.com; dmarc=pass action=none header.from=groupe-cyllene.com; dkim=pass header.d=groupe-cyllene.com; arc=none DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=groupecyllene.onmicrosoft.com; s=selector2-groupecyllene-onmicrosoft-com; h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-SenderADCheck; bh=AgA+oZwH0A/o5kxGWRXo8aj+k1lhTtHcN7Ku1SCkK9k=; b=NJOdcyEMmOFB85yhOy9CZJ0X+eFqLwhdMDruM4E0oTVOXljE0Zqx3nses0y79heeAEhGEl0OJWbDQPEQOagrg84elIwg1xmW6CZUKOtoP6bQXPL3Zzd8ymxzgJrY5AwRmaKg/J4eXNWyAYCHiQR19W+gO9JmNo5mZc5vVKw6f4A= Authentication-Results: dkim=none (message not signed) header.d=none;dmarc=none action=none header.from=groupe-cyllene.com; From: Alexandre Derumier 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 Message-ID: <20260825113442.947620-11-alexandre.derumier@groupe-cyllene.com> X-Mailer: git-send-email 2.55.0 In-Reply-To: <20260825113442.947620-1-alexandre.derumier@groupe-cyllene.com> References: <20260825113442.947620-1-alexandre.derumier@groupe-cyllene.com> Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X-ClientProxiedBy: PR3P192CA0021.EURP192.PROD.OUTLOOK.COM (2603:10a6:102:56::26) To PR0P264MB3691.FRAP264.PROD.OUTLOOK.COM (2603:10a6:102:14b::12) MIME-Version: 1.0 X-MS-PublicTrafficType: Email X-MS-TrafficTypeDiagnostic: PR0P264MB3691:EE_|PATP264MB7783:EE_ X-MS-Office365-Filtering-Correlation-Id: 404b9d35-6c95-4635-1cde-08df029ce6e4 X-MS-Exchange-SenderADCheck: 1 X-MS-Exchange-AntiSpam-Relay: 0 X-Microsoft-Antispam: BCL:0;ARA:13230040|52116014|376014|1800799024|23010399003|366016|38350700014|6133799003|3023799007|29003799003|56012099006|10067099003|5023799004|12006099003|25016099003|18002099003|18092099006|22082099003; X-Microsoft-Antispam-Message-Info: GPiG3i94KXJcxWBvSfiQehzHRM6KKdMewHg4ANgddUzRrnB/YK6Z+uBTqj8BUJwUyzT/umYsuFU1osMPthnj0OZVM5i852LbLtNNJT+SPIk3XrE8zWU5ltBelWR3EFAVLHbEOpjfDxrM3q5J2jFlRM+jGjMxwXOX1GAlmSj0Q1i0n9S2LHA8rCrJPZhTqCrcMEzis3IkBqavE803ptuQluQBLsZo0WXvt3oPpYyLq6jbIiGhhJs/aaPqbBnPUoWXiRTY8hRINqlcFGrY49/AXmfO0KmymEpOVnp3muv3lhqVSPusF9eUtOcPurrvQZv0O9O0kBwQ86Ht09zkvQYFsqYymJx42VV0oy5h0ZfWW3Zjomyt/WFI1gucYcC+IxTMr96suJgoHOAhq1NyZiE3wgYVCxt6pwYGBdEBiT+f/YHRkE9Prw81bVGIktAjv1HlUt2zKigeKbeWQJqfoDhXrS5L9ZjIXAAbPlrQLLKyR1W8853Q0jb38G5laDK1wn4ltBpS/7EL4DaIFvY6hWv7PfJmJXL3yM0i//lNz+jgb8byD3RVCQtULJx2UvtXsutSx/VGut92PGLLA5WwudwugfG8LR4VPfsjanQw5HOwzJNGrSBWG2oR1lxuNX6KOBxWED1sC8AFqGpopkBES28h09AZ2+ALJ7ZWLW+6N/k0KtNfYWNx5JVZZOhCq42DhFfOd0B1H41Am+K9NefHeDIa3c6IlUrMnAF9vnJDxrAUqfE= X-Forefront-Antispam-Report: CIP:255.255.255.255;CTRY:;LANG:en;SCL:1;SRV:;IPV:NLI;SFV:NSPM;H:PR0P264MB3691.FRAP264.PROD.OUTLOOK.COM;PTR:;CAT:NONE;SFS:(13230040)(52116014)(376014)(1800799024)(23010399003)(366016)(38350700014)(6133799003)(3023799007)(29003799003)(56012099006)(10067099003)(5023799004)(12006099003)(25016099003)(18002099003)(18092099006)(22082099003);DIR:OUT;SFP:1102; X-MS-Exchange-AntiSpam-MessageData-ChunkCount: 1 X-MS-Exchange-AntiSpam-MessageData-0: =?utf-8?B?UHB0TUpWemxGL0hNbFQ3NVoraGIxNTdhLzR3TFpvOUVyNDM0WEQvWi9UQlBt?= =?utf-8?B?NUJlakptYk52K2VjTlpEbkM0RnZmZThValprWkc3MjBiZ2lhRzZwdlpXcWsx?= =?utf-8?B?OVd1OW5hZnJ1MW5HNFBDSlZYeFJocVlYSmRsQTN0amxicXFNZXQ0dzA5NFR5?= =?utf-8?B?eTRLQlcvTVJwVFVNb1dFbGd2SEFRcFZTa0llN1VHT2lSRmEvaFpKQnBxOW13?= =?utf-8?B?V2IvQ3VpZ3lIYUM0SmgyTk1zZlZYVXhhNGxjVHBEQWppdDV1elEwTllweHc5?= =?utf-8?B?QmlaN1NkUEQ2ZXVNNEEzUFN4bkhkeUlPKy9jeVcycGJreFVBcERzbGRvV1Zh?= =?utf-8?B?OHpGT1V6djRmbG9LbGpTdWNXOFAxeGxLdGo5bEpwK2Izbk9kdWxIYTREcDdv?= =?utf-8?B?WG1iQXhCRmJpams4VDY4Qjk2NzVJQlJKVWxKUEtOKzh1QnNhRHg3SytwUnNz?= =?utf-8?B?NHdGZ3ZITGloSm5UZ1NRQVRROCtCQXhvaGN1NlJNM0g2VVhGWHhBRFFWSGpu?= =?utf-8?B?aUhwL3pzSENMdE1jcUZqMWhQaFdSRzAyd0wwUkZTa2M3TUo2NmQ0d3JSYUpi?= =?utf-8?B?RnZNdlJXUzcydjg0b2t6eW9mRXB5SHBxcERhNmVtdjIzN1UyL2V3Z0EvRmZH?= =?utf-8?B?TXhWMnYvaUxmcUlLa1hXeXFWTXUvbzhhdzRER3pyclU3WDZ5aXQ5RGRUNlNF?= =?utf-8?B?Z29taGVXMmgzZWhNVTVHOEt6UXU2aHJsREh5ZFp4S3JpUjVmTlhHcTRZcTNY?= =?utf-8?B?aWVLa3E1VGpCN0VFYjNSS2RPZG9TM3Jya3RlbkRJcDZwdkhJMkoxUFg0NmVF?= =?utf-8?B?Z2JKcnVCejhlR0NnOFh0K05JS213MzRGOUxncGdBWFhBT3ZpdERpWENRemFv?= =?utf-8?B?U05ITGN2T3hpcjN2Z0pzWDBMN01NdVhwZ2NLbjIxcnllZzNUTnFzaVJNTWR2?= =?utf-8?B?dXRtOGNHOUFnZnkzL0pQandtTDJScXEwY1FITUJ3anJkR2ZmTC9zTHdWamk4?= =?utf-8?B?cCs0clE1Z0Mxd1dyQzZubkFUWGxoM2wvcnFsdXBYNHdXN2ZhUHBnUGNZVzR1?= =?utf-8?B?SzkxK2tHNXVTMCtISWMyZTY5c0VIU3dlT044NWNyb2dDL1haNnBBcmpiK0Qx?= =?utf-8?B?NXo0MVJrMnFBeWdqckxoTXV0WVhmYjQwN2lzZzM0bU94dEV4amVIQ2FxUGx0?= =?utf-8?B?d2FWVTlEV3pCY2o1Y05FdXFiejZjL01BWkgwU2JjOStseGJlYnBDdDZPK1Ew?= =?utf-8?B?UDRJbUMvOVVSaDRTR3g0bmEzSkNBM1RBR3UwdlZBU05xZEg3bWcxODZjeHcy?= =?utf-8?B?S2xpTUxySjVqWDFNZFBSWXB4NEpTRHBqKzhTRHRPc294WkhZaGd1QjJlbG0z?= =?utf-8?B?dXBmVituaitCMEVZNHJUbkM4NVhDNG1oY1pKK2dXeU5LQk4yeUhkaktQVy9n?= =?utf-8?B?L1h5UUpLM2NwbG5vR2VNQnVqenYvZFJzK1NNWkk2SkUxZWlhY2hTb2hKeDFM?= =?utf-8?B?cy9DcjA3Q0dhL1lCdHNDUExyRUtQVG5EZFZkcjZCQ3c4Vm5zMFNDdXZHTHNS?= =?utf-8?B?SnV6NnBDK3g1bzhTMWhzQTNEUjg2WG9LWVkvMDJ3eS8vNHdlTFZ5TjJiTEZp?= =?utf-8?B?blV0ZXJZK240TmF4eDBpU3hLYkQxVTlpS0dUb0p2N3cvbkxBUnhqRUxVdnM4?= =?utf-8?B?RXUvY0MvbitjenBLWW5ST2VBbnZJc0NjSWx3U1dNQTRjMkM4M0xJL28vaFF1?= =?utf-8?B?RjNXZktHU2lra0JzbitVbnVwS2ZRNHRTYllCNjJtVHQ2K1l5ajN1M3Q0dWt1?= =?utf-8?B?UllPTFpYYW5OUzB4UC93MnFsa053NFRTVGZwS1lHSk9lWjZHZ0h4VFN1WWk3?= =?utf-8?B?emhMSlhraDlLQVBNY0twbk10ZThrY2RLQzhTd0RGQWpzbUVsQVFpZ01QQXU3?= =?utf-8?B?VGRpWmYwa2tWay8wSTkvQXU2TUJZZEdYdXV6a2wydUZHWXBoV2NSam9tMzZD?= =?utf-8?B?T1g2SFp6MDJNR3hENDFyaFhOUGFHaWppTkU1Z1V4NFFEQXlhdVk0ME5VSklR?= =?utf-8?B?UGhWVGIvQ09EQ1BKNnFoa0RKSzE0YXIyeWg4VEM1UnA4U3JyQUwrbkJFK01W?= =?utf-8?B?Tk5qaFhJYWp3MGFQc1lvaHBlakwxa3Nia0NFTG9sTHR3ZXNsbDloSjNhWGVS?= =?utf-8?B?enBMS3ZrenFPMzFSQjZwUE42K1ZvU0xBVXBpS0RkWHJ6Q0FaYlBVSjlZWnZu?= =?utf-8?B?RXRtRjBYbUNzaGpEVDA3bjIyOGY2M3J6YzRhSHkvMFcyVTl1WTZURnI0YmFr?= =?utf-8?B?SG1hb0lSQVlCZGErZ2RZVXV4ZVRnRFJIaWc3NTBOMmt2WmlFQXFRcGtBQnZD?= =?utf-8?Q?kCZvhf+xS80oz9NA5DW4Pr5N1dH8bzTgqVhPO?= X-OriginatorOrg: groupe-cyllene.com X-MS-Exchange-CrossTenant-Network-Message-Id: 404b9d35-6c95-4635-1cde-08df029ce6e4 X-MS-Exchange-CrossTenant-AuthSource: PR0P264MB3691.FRAP264.PROD.OUTLOOK.COM X-MS-Exchange-CrossTenant-AuthAs: Internal X-MS-Exchange-CrossTenant-OriginalArrivalTime: 25 Aug 2026 11:35:02.5616 (UTC) X-MS-Exchange-CrossTenant-FromEntityHeader: Hosted X-MS-Exchange-CrossTenant-Id: ee11ccf7-112c-4284-848b-f229745e715b X-MS-Exchange-CrossTenant-MailboxType: HOSTED X-MS-Exchange-CrossTenant-UserPrincipalName: LjJLuR09d6ywc3QZGvty7HJnZgq7riZXsPfwI0xKwqQ9k00emEP4xu6244BeIfXwfhRAh+nF18PKghfEdZAt3tD2IehmDAQWxdzrvOvTjnkco7lDRxw70fglqZtMs0WQ X-MS-Exchange-Transport-CrossTenantHeadersStamped: PATP264MB7783 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.560 Adjusted score from AWL reputation of From: address DKIM_SIGNED 0.1 Message has a DKIM or DK signature, not necessarily valid DKIM_VALID -0.1 Message has at least one valid DKIM or DK signature DMARC_PASS -0.1 DMARC pass policy KAM_ASCII_DIVIDERS 0.8 Email that uses ascii formatting dividers and possible spam tricks KAM_SHORT 0.001 Use of a URL Shortener for very short URL POISEN_SPAM_PILL 0.1 Meta: its spam POISEN_SPAM_PILL_1 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_3 0.1 random spam to be learned in bayes PROLO_LEO1 0.1 Meta Catches all Leo drug variations so far RCVD_IN_DNSWL_NONE -0.0001 Sender listed at https://www.dnswl.org/, no trust SPF_HELO_PASS -0.001 SPF: HELO matches SPF record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: YL4HWGLHPEHPHK4TWTKXJM2T3IQHB6OF X-Message-ID-Hash: YL4HWGLHPEHPHK4TWTKXJM2T3IQHB6OF X-MailFrom: Alexandre.DERUMIER@groupe-cyllene.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: 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//qemu// 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 --- .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 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 +Uploaders: Alexandre Derumier +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 +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 . 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/ 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//qemu//, 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 { + 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 { + 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, + pub tls_host: Option, + /// TLS verification mode. + pub verify_mode: Option, +@@ -126,6 +130,7 @@ impl VideoPlayerConfig { + pub(crate) struct ConnectionParams { + pub(crate) host: String, + pub(crate) port: u16, ++ pub(crate) base_path: Option, + pub(crate) tls_host: Option, + pub(crate) verify_mode: Option, + 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 { + 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, + 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 for player::VideoCodec { + pub(crate) struct ConnectionParams { + pub(crate) host: String, + pub(crate) port: u16, ++ pub(crate) base_path: Option, + 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, + auto_reconnection: bool, ++ base_path: Option, + ) -> JsResult { + 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 + - 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 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 , 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 , 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