From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id 83E741FF0C1 for ; Wed, 26 Aug 2026 09:45:04 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 65A88213F4; Wed, 26 Aug 2026 09:45:02 +0200 (CEST) ARC-Seal: i=1; a=rsa-sha256; s=arcselector10001; d=microsoft.com; cv=none; b=ykxYcU524Lz+HFCDda0yrdyuNmqsXalKVjLPIR2hdr3I1gxKYSOWi4GZJDyrUEO9ZFqfxNsVgalj1GWUe0LYY+WE2K62Xjz8IEWBAUhHlA1N9FzvgNHUkp6MIq5sj1Ns6XMd54Sa1v0sM/k73ocqmY7Jy7Vq0huUecT6cAcGRIAyIUydEDYcGqS1+ew1f79VRcqOxm4PhiEvRQ0W0aM5bquAt2Pa42BXI56cwLsek8VZxBIxwbfL1wZ/LgvtYL5fc66kOjKGlWTQuMLCDGgjrk2MMsPRmLfQKYNYlTHmSvYaVPVTUixRaqucEr3KeRiedg0IKmmnRZVKdNf8pE5s4w== 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=JjORafKyaduD+Alptm8N146XzXpRdjYCJ7EiDT0im1Rz9HOv697zmgNhrlSF0AHtzA4PP2D/CWPOL1xzq38Kg90GF634pNM/17htVGjiEjYJpp6EbBWUpI1MYpu8oqWzFIOSLqEXtQl+NW1v8FT7YvAATYgeCg3RXbKxH1OqIxmq61mzVWJ+4CoXAwPVqYWNNJhaKpvBhIYdG0R8nNk/vvaDK2yr1m4o+YSjWcF9rzyPrxWk9+MHLKsh/dgQ+56UggTfGGIN7uPb66vTFiJ8UOHo9uI683D6aJQg2F0waE3h1zCjCxQ22TmPHTqkReSGvROD0YUQT2+ZdE2fYfXVIQ== 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=EKOIIj6U06ONSs1NKUtsHWHXi/ixFWgyC3Ymll9NOH8OfuDhQTYNHfCQ6jcustZrYBfCetXuQsFwfxO3MDxzhL1PpCXPRGuRww7dnPBlcbFkgreutpDr6xOw2OQLWNRpMBLTROF4VaeAuWs5DPm+c8oOOpfBUpj8Dt1XHBncBhg= 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 v2 pve-kyber-web 10/13] add pve-kyber-web: console's webassembly client Date: Wed, 26 Aug 2026 09:43:42 +0200 Message-ID: <20260826074347.1256659-11-alexandre.derumier@groupe-cyllene.com> X-Mailer: git-send-email 2.55.0 In-Reply-To: <20260826074347.1256659-1-alexandre.derumier@groupe-cyllene.com> References: <20260826074347.1256659-1-alexandre.derumier@groupe-cyllene.com> Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X-ClientProxiedBy: PA7P264CA0229.FRAP264.PROD.OUTLOOK.COM (2603:10a6:102:372::8) To PR1P264MB3696.FRAP264.PROD.OUTLOOK.COM (2603:10a6:102:143::11) MIME-Version: 1.0 X-MS-PublicTrafficType: Email X-MS-TrafficTypeDiagnostic: PR1P264MB3696:EE_|PR0P264MB2257:EE_ X-MS-Office365-Filtering-Correlation-Id: eefa2ee5-2eab-4e97-9f3e-08df0345d68f X-MS-Exchange-SenderADCheck: 1 X-MS-Exchange-AntiSpam-Relay: 0 X-Microsoft-Antispam: BCL:0;ARA:13230040|376014|23010399003|52116014|1800799024|366016|38350700014|6133799003|3023799007|29003799003|12006099003|56012099006|10067099003|5023799004|25016099003|22082099003|18092099006|18002099003; X-Microsoft-Antispam-Message-Info: gShCvYNXXL033anFZP7B2ikKMFZEiL5bVDpMhsSdWm5Iv/vfbXyVXXUDp6+vZosdZgix294jdV/LnF4X1ZCpSUKy8yfiSLsWqITWhoYvoU4T9F+qBDf8af7vj8H7oiR4/WDUyZqUSilnXmLcArykXxPj+Dpq2HwsdgejL9OSWYbwQpfvmEOW2ugk/upH7an5D1uevb5KNfsP0vI1ho8JMbXP/IueUr2tNUq4yuXlEIkdvOA61Ix7ZM3am1pTrA/1VrHvnNotefWivSWEhjz2Rg0bbqpQwCLJYNwmKkuU9/dafDBKfmK40rY3HbebEVtA+xwwbLGvJmBnAbJt1rlb9/jPHV2IhpbEHipDWaBlRnTElRx5gb99uO/rCiyhpifAsLVZsa0LtdOt8wSH7nl6kHDFWTzLa/i0WJUgiLx91Ow0E5WIs2ZV6bLaN+kT+VsLiY/C35eIkAXjAtc7aRw42JxgxdOIkSRAXEmY3FbcJiccanjNea8QAa65ZsspuArxFP23DPp7TeIyUdkEC2EclECQ5cnyT9X67fR2Sozvpj/QnrsdTtpaZdRVr+PNu9iQuErjGEIP4j9WDdsFkTnur3FvpeYis52eURzbsT3NCTqX32X4Kt+iK6iHnHV+Cy/perPhvTFH+fetaST8Uf/rAN5YJWxRZV6vfCuPw92GqmXTOXJ+jxoVArpSE66w9c+pZ6panhkUNwV4bD2JPLvmhd7i8lvkRbbb8foK74EEX1w= X-Forefront-Antispam-Report: CIP:255.255.255.255;CTRY:;LANG:en;SCL:1;SRV:;IPV:NLI;SFV:NSPM;H:PR1P264MB3696.FRAP264.PROD.OUTLOOK.COM;PTR:;CAT:NONE;SFS:(13230040)(376014)(23010399003)(52116014)(1800799024)(366016)(38350700014)(6133799003)(3023799007)(29003799003)(12006099003)(56012099006)(10067099003)(5023799004)(25016099003)(22082099003)(18092099006)(18002099003);DIR:OUT;SFP:1102; X-MS-Exchange-AntiSpam-MessageData-ChunkCount: 1 X-MS-Exchange-AntiSpam-MessageData-0: =?utf-8?B?UVYrSERLTDhLWEJPWWVmckFkRFNxcVRiRGxkdTB4dDlST3Z3d3A4KzlqVFg1?= =?utf-8?B?MThSa1Q1ZzE1UnJkdmNhaEs3Y0lxYTQxV1A2RlNHbHQ5R2p1U0xrU1JoYVV6?= =?utf-8?B?Q1NCNEZLUnh5V1pxalljMEMxZUo5SWtkN0xJNEVmcGVtUDVRVitid216TzlQ?= =?utf-8?B?Q1ZRSklubFFHNXpxbGlMV0Y3SWdpMlhSNnB2ejJYd0JBTW5WTFRyMVZmMGkz?= =?utf-8?B?UzUvZG02d0FSWVpYeW55R1RPMnZHTlh3eGJtSC9PZ2Y3ajNFVU1zUjR3QzNH?= =?utf-8?B?c2l3WEs5Ym16Z1FJN01WZ2ViMmo5a1VnTkdnKzRDSXBHWXNrVkg1dGVmQ1hj?= =?utf-8?B?bnM0RjIyR3pTVk1IRVhzWUdNQVplNlhJK0N1UUZROGxLczJmQ0JrY2Nyc29Y?= =?utf-8?B?bmJKQzVUQjZPcVdwUkl2ejF3RXlpMFNmckV0VXp3cHllQ3VIRk1MRWlCYnc5?= =?utf-8?B?UEg2T2d4MWxmWVVEa0tXdW9abGowVWk5V250SDlpSldlRVNNN1pwTExxbHNH?= =?utf-8?B?L1NiOVplWlBEVkcrSVpLNXphSGVHWDF5WXBZQ0kwS3ZVZzVVWFBRTUQzaDNY?= =?utf-8?B?WnNhbytzMUFWelpkc0E0eHo3bmhPdmt2UkF6KzB2bzQxeTVFN2lSVWZ4Z0tC?= =?utf-8?B?TzFMRFJhMzdIZ0ErbFgxaUd6amVKQlg2eEdoQWg2MkZ4Q0RwYnJCcGZUOFdw?= =?utf-8?B?VDNoR0FiajZxbGxhanAyZHFHSXR4V1hGUkJIVXBidTAwbGVmZUNvVEttanll?= =?utf-8?B?eGtmNXQ4Z05YREdmTmZxdkJOb0lnbUdsUkY3MGZWSHJqMlVpOGp0RnVLYVdi?= =?utf-8?B?azBEeFk3RGtnVjhnMnRkeEd3WUE2cC9wazB1U0dlaXgxS28vWDExK3lqUkVN?= =?utf-8?B?b0k3aVhyWnV4dDdPQkZFMHNLOTlLRVFISWNuM2pmZEFDK3p0VS9jK2hPWXcw?= =?utf-8?B?WjRpQmJGNzA2d3NJUVJIWGJQbG9JSTlDSUd6Vlg1TEU5MGE1V0YwZzNTc1Nn?= =?utf-8?B?RDhlM3RvWmpnY2h6eFJrR3RXbWMrUXAxV0RlSTdrZ1BUQmlnbThnTGhHcTJO?= =?utf-8?B?QzZvSXYwdkk0dUpJbE5lVERnT1VOdXltVUNwb2pWK1IrbzcwY2FmVW5nMmty?= =?utf-8?B?Tys3WDhGQ2NjeTVBRllHVE83c3N4UmRjR3pmbTE1NVk1cXVIUzNESmdUTUVo?= =?utf-8?B?K0tMSnl3VGFPeUFxdEFQay9LMlYvSDlQcFRyN2tOQmtNYjV5LzN4bk5SbVZt?= =?utf-8?B?T29iUzhZMnRsNTRiMWt6dTdCVFlRVHlzQi9QclZtWERES25yOTZmWUpuMktl?= =?utf-8?B?OFdNNXF3RzdOcGtUcGFiZ0d3WldKL2p5cmw1WkxWejlSWEhqTitVcWQ0UDNj?= =?utf-8?B?dVhkcUcyTEZ1dSs4R2huS0p4V1hSWTExa3NVWktKWTNETXcwb3pZVVdYU3lj?= =?utf-8?B?RXpaL2ZJeWtvS0hrNndVRnBESXJlVnBrRUorUG9zMndmVElaK0RwMXlSV1Ir?= =?utf-8?B?VlVBcC91WU0vNVJoSXJNZGlxVkFNRWQyZHp6OVM1c3FBWDJ3MEtNRmNMN1JM?= =?utf-8?B?V25VNmdsb2xoUnFvMkNwOGl0TVlIR1plSFlvU2Z1WU9BRWVWK1JXb2RrTndT?= =?utf-8?B?THY3N3V2UktQVE5KZmQ2RDR3VEp1MlNXZWFrVnNNTmlVRjNaUkxHUHI4S0dn?= =?utf-8?B?VGhQSkVVOXF3bG95b0QwbzlPeVBXeEY2aUcycVRvamI5cXJQRnU3NnhuOFhq?= =?utf-8?B?NFQ0OUpLaGhqTWk2VXB6Y1RhS0dGRmZxVFI1RWNVSmdURWdadCtaZFR5Skh3?= =?utf-8?B?bjIwYVVIMXM4ZWlrQlMzNnlVblB2QWI0cWhQTVFvdXJrUEhCS1VQbi9kT0lR?= =?utf-8?B?V3hpY3FMcEVwNXVuUDRvdVQ5TThaOVlIMmdxbEhqV0hwT2NiUkIrcnk0YnBB?= =?utf-8?B?RjQ4M25ZS2pQSE9pUnhYQUlOK0Z6NFRtYkJDYzZOYUlJYmR2a3pLWGtlSlMw?= =?utf-8?B?aXA4NjVRM1hJdnpqeDlSK1hGWmx2ekJFcUhTWjA2VmI1MnpaMVRLeTRKUW9E?= =?utf-8?B?ME9yanhqMGdVVVVYUmRCdm9BbCtQYjIvSnJvZFNwdFFDcm43RmRnUERCaVQv?= =?utf-8?B?OGhzMnVucitKUU5aMG5SVHNoVzIwdCtMUlR0MGd2VWd1a3RYblNuNDd6T0hX?= =?utf-8?B?NkFFM2hnRUlPczZKMDRrR0FvMEdxb1dRaC83ajh6cXFZaTV5UUxGdXZYalFl?= =?utf-8?B?ZThyL2hvVnRuVFJHWFVocVp0dnNuZ3BzbTF2SGs3eXc2U08rVG5lSkgzYzRV?= =?utf-8?B?bFRHY2ZxUHFQUFoxeDhuemxnRk5hTDYzdlBWZmJjd0llQ2J0b0I4TUh4cERq?= =?utf-8?Q?jr2PlFJ9iykfr08uBv27SHdQPYuWRtum15UsR?= X-OriginatorOrg: groupe-cyllene.com X-MS-Exchange-CrossTenant-Network-Message-Id: eefa2ee5-2eab-4e97-9f3e-08df0345d68f X-MS-Exchange-CrossTenant-AuthSource: PR1P264MB3696.FRAP264.PROD.OUTLOOK.COM X-MS-Exchange-CrossTenant-AuthAs: Internal X-MS-Exchange-CrossTenant-OriginalArrivalTime: 26 Aug 2026 07:44:19.8669 (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: 44osx0qWiwTC5cQ6Ll8w4syfijWFRxdqT00SUW8YVvWwso/93uMmo+/fPb5TNzYtwEQXrLmg4QsJ6m0Y+vPcFlSfxgWBv8/RYnEdzze8xD5H0VT+lYQRqwf43nFRyT+9 X-MS-Exchange-Transport-CrossTenantHeadersStamped: PR0P264MB2257 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.387 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: B27AXLBSM37HDB2H2I7GRK3C5CTR6BXU X-Message-ID-Hash: B27AXLBSM37HDB2H2I7GRK3C5CTR6BXU 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