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-kyberproxy 09/13] Add pve-kyberproxy
Date: Tue, 25 Aug 2026 13:08:41 +0200 [thread overview]
Message-ID: <20260825110849.2967694-10-alexandre.derumier@groupe-cyllene.com> (raw)
In-Reply-To: <20260825110849.2967694-1-alexandre.derumier@groupe-cyllene.com>
From: Alexandre Derumier <aderumier@groupe-cyllene.com>
Control plane: forwards the console's control plane from pveproxy to
the VM's controller.
Dataplane : terminates WebTransport on one UDP port, routing sessions by
token;
browser
|
+-------------------------+-------------------------+
| |
control plane data plane
HTTPS :8006 WebTransport (QUIC)
/api2/json/nodes/<node>/qemu/<vmid>/kyber/ UDP :63100, per node
| routed by the token
v start_mux handed out
+---------------------+ |
| pveproxy | |
| checks VM.Console | |
+---------------------+ |
| |
| unix /run/pvekyberproxy.sock |
v v
+---------------------------------------------------------------+
| pvekyberproxy |
| start_mux is answered with this daemon's port and its |
| certificate hash, which is what sends the client here |
+---------------------------------------------------------------+
| |
| unix | QUIC
| /run/qemu-server/<vmid>.kyber.sock | 127.0.0.1:63000
v v
+---------------------------------------------------------------+
| kycontroller (pve-kyber) |
+---------------------------------------------------------------+
| spawns
v
kyavserver / kynputserver
| spawn
v
kyber-qemu-server ---> kymux tcp 127.0.0.1:9091/0 video, /1 audio
|
| D-Bus unix /run/qemu-server/<vmid>.dbusdisplay, org.qemu on
v the private bus qemu-server starts for that VM
QEMU -display dbus,addr=unix:path=...
Signed-off-by: Alexandre Derumier <aderumier@groupe-cyllene.com>
---
.gitignore | 6 +
Cargo.lock | 1870 +++++++++++++++++++
Cargo.toml | 53 +
Makefile | 74 +
debian/changelog | 5 +
debian/control | 24 +
debian/copyright | 20 +
debian/install | 1 +
debian/pve-kyberproxy.pvekyberproxy.service | 32 +
debian/pvekyberproxy.default | 12 +
debian/rules | 43 +
debian/source/format | 1 +
src/control.rs | 506 +++++
src/main.rs | 139 ++
src/relay.rs | 234 +++
src/webtransport.rs | 225 +++
16 files changed, 3245 insertions(+)
create mode 100644 .gitignore
create mode 100644 Cargo.lock
create mode 100644 Cargo.toml
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 100644 debian/pve-kyberproxy.pvekyberproxy.service
create mode 100644 debian/pvekyberproxy.default
create mode 100755 debian/rules
create mode 100644 debian/source/format
create mode 100644 src/control.rs
create mode 100644 src/main.rs
create mode 100644 src/relay.rs
create mode 100644 src/webtransport.rs
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f1d47f0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+/target/
+/vendor/
+/pve-kyberproxy-[0-9]*/
+*.deb
+*.buildinfo
+*.changes
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..b1ad2be
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,1870 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "asn1-rs"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
+dependencies = [
+ "asn1-rs-derive",
+ "asn1-rs-impl",
+ "displaydoc",
+ "nom",
+ "num-traits",
+ "rusticata-macros",
+ "thiserror",
+ "time",
+]
+
+[[package]]
+name = "asn1-rs-derive"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "asn1-rs-impl"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bit-vec"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "block-buffer"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cc"
+version = "1.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "clap"
+version = "4.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "const-oid"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
+
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "data-encoding"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
+
+[[package]]
+name = "defmt"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
+dependencies = [
+ "bitflags 1.3.2",
+ "defmt-macros",
+]
+
+[[package]]
+name = "defmt-macros"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
+dependencies = [
+ "defmt-parser",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "defmt-parser"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
+dependencies = [
+ "thiserror",
+]
+
+[[package]]
+name = "der-parser"
+version = "10.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
+dependencies = [
+ "asn1-rs",
+ "displaydoc",
+ "nom",
+ "num-bigint",
+ "num-traits",
+ "rusticata-macros",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+
+[[package]]
+name = "digest"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
+dependencies = [
+ "block-buffer",
+ "const-oid",
+ "crypto-common",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "env_filter"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217"
+dependencies = [
+ "log",
+ "regex",
+]
+
+[[package]]
+name = "env_logger"
+version = "0.11.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "env_filter",
+ "jiff",
+ "log",
+]
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi",
+ "wasip2",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "httlib-huffman"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a9fcbcc408c5526c3ab80d534e5c86e7967c1fb7aa0a8c76abd1edc27deb877"
+
+[[package]]
+name = "http"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "hybrid-array"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
+dependencies = [
+ "typenum",
+]
+
+[[package]]
+name = "hyper"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "bytes",
+ "http",
+ "http-body",
+ "hyper",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+
+[[package]]
+name = "icu_properties"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
+
+[[package]]
+name = "icu_provider"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jiff"
+version = "0.2.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
+dependencies = [
+ "defmt",
+ "jiff-core",
+ "jiff-static",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde_core",
+]
+
+[[package]]
+name = "jiff-core"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
+dependencies = [
+ "defmt",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
+dependencies = [
+ "jiff-core",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.85"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "litemap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "mio"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-integer"
+version = "0.1.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "octets"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "866cb5af6f3aa3c1b44c3c2d79d22165fbb1b102e1b3fb499864bfe34736ec4b"
+
+[[package]]
+name = "oid-registry"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
+dependencies = [
+ "asn1-rs",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "openssl-probe"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
+[[package]]
+name = "pem"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
+dependencies = [
+ "base64",
+ "serde_core",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "pve-kyberproxy"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "env_logger",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "libc",
+ "log",
+ "rcgen",
+ "serde",
+ "serde_json",
+ "tokio",
+ "tokio-rustls",
+ "wtransport",
+]
+
+[[package]]
+name = "quinn"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
+dependencies = [
+ "bytes",
+ "getrandom 0.3.4",
+ "lru-slab",
+ "rand",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "rand"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
+dependencies = [
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "rcgen"
+version = "0.14.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4"
+dependencies = [
+ "pem",
+ "ring",
+ "rustls-pki-types",
+ "time",
+ "x509-parser",
+ "yasna",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rusticata-macros"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
+dependencies = [
+ "nom",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.43"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
+dependencies = [
+ "log",
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-native-certs"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
+dependencies = [
+ "openssl-probe",
+ "rustls-pki-types",
+ "schannel",
+ "security-framework",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "schannel"
+version = "0.1.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "security-framework"
+version = "3.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
+dependencies = [
+ "bitflags 2.13.1",
+ "core-foundation",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "sha2"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "time"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.2+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.108"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.108"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.108"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.108"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+
+[[package]]
+name = "writeable"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+
+[[package]]
+name = "wtransport"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4273ce3157a3262a68665f8d3f20a0ac0c5b8a69ffd67f05ae986832ebec036"
+dependencies = [
+ "bytes",
+ "pem",
+ "quinn",
+ "rcgen",
+ "rustls",
+ "rustls-native-certs",
+ "rustls-pki-types",
+ "sha2",
+ "socket2",
+ "thiserror",
+ "time",
+ "tokio",
+ "tracing",
+ "url",
+ "wtransport-proto",
+ "x509-parser",
+]
+
+[[package]]
+name = "wtransport-proto"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aad9059572c7dbd6901ccef37f3b7321678cd708dcf58a64b1921dbeab7bfede"
+dependencies = [
+ "httlib-huffman",
+ "octets",
+ "thiserror",
+ "url",
+]
+
+[[package]]
+name = "x509-parser"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
+dependencies = [
+ "asn1-rs",
+ "data-encoding",
+ "der-parser",
+ "lazy_static",
+ "nom",
+ "oid-registry",
+ "ring",
+ "rusticata-macros",
+ "thiserror",
+ "time",
+]
+
+[[package]]
+name = "yasna"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
+dependencies = [
+ "bit-vec",
+ "time",
+]
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..0f3807a
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,53 @@
+[package]
+name = "pve-kyberproxy"
+version = "0.1.0"
+edition = "2021"
+description = "QUIC front door for per-VM Kyber consoles on a Proxmox node"
+license = "AGPL-3.0-or-later"
+
+[[bin]]
+name = "pvekyberproxy"
+path = "src/main.rs"
+
+[features]
+# The unauthenticated TCP control plane, for working on this without a Proxmox
+# around it. On by default so a local checkout builds what the run script
+# expects; a package for a node is built with --no-default-features, and then
+# the flag does not exist rather than merely being unused. Its dependencies -
+# a TLS stack and a certificate generator - go with it.
+default = ["dev-tcp", "webtransport"]
+dev-tcp = ["dep:rcgen", "dep:tokio-rustls"]
+# The single-port data plane. Off leaves the per-session UDP relay in place.
+webtransport = ["dep:wtransport"]
+
+[dependencies]
+# Nothing from Kyber. The relay moves datagrams and the control plane moves
+# JSON, so neither half needs the protocol crates - which is the clearest sign
+# that not terminating was the right call.
+
+anyhow = "1"
+clap = { version = "4", features = ["derive", "env"] }
+env_logger = "0.11"
+http-body-util = "0.1"
+hyper = { version = "1", features = ["server", "client", "http1"] }
+hyper-util = { version = "0.1", features = ["tokio"] }
+# getgrnam, to hand the control socket to pveproxy's group by name. Already in
+# the tree as a transitive dependency; named here because we call it.
+libc = "0.2"
+log = "0.4"
+# Self-signed TLS for --insecure-listen-tcp only. The client builds every
+# control-plane URL as https:// with no way to say otherwise, so a plain
+# listener there is not a lesser version of the real thing - it is one no
+# client can talk to at all. ring rather than the default aws-lc-rs: it is a
+# throwaway certificate on loopback, and ring needs no C toolchain to build.
+rcgen = { version = "0.14", default-features = false, features = ["crypto", "pem", "ring"], optional = true }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "signal", "sync", "time"] }
+# A WebTransport server and client on quinn. Terminating is the price of one
+# UDP port for the node: nothing in an encrypted datagram says which VM it is
+# for, so the only way to share a port is to read the session's path, and the
+# only way to read it is to decrypt. Default features are self-signed + ring,
+# so no C toolchain and no CA.
+wtransport = { version = "0.7", optional = true }
+tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "logging", "tls12"], optional = true }
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..d86d080
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,74 @@
+include /usr/share/dpkg/architecture.mk
+include /usr/share/dpkg/pkg-info.mk
+
+PACKAGE=pve-kyberproxy
+ARCH:=$(DEB_HOST_ARCH)
+
+DEB=$(PACKAGE)_$(DEB_VERSION_UPSTREAM_REVISION)_$(ARCH).deb
+DEB_DBG=$(PACKAGE)-dbgsym_$(DEB_VERSION_UPSTREAM_REVISION)_$(ARCH).deb
+
+DSC=$(PACKAGE)_$(DEB_VERSION).dsc
+BUILDDIR=$(PACKAGE)-$(DEB_VERSION_UPSTREAM)
+
+all: $(DEB)
+
+# Dependencies are vendored rather than taken from librust-*-dev: the hyper 1.x
+# stack this needs is not packaged in Debian, and a console proxy is not a good
+# reason to ask for it to be. Same arrangement proxmox-biome uses.
+.PHONY: vendor
+vendor:
+ rm -rf vendor
+ cargo vendor --locked vendor
+
+.PHONY: builddir
+builddir:
+ rm -rf $(BUILDDIR)
+ $(MAKE) $(BUILDDIR)
+
+$(BUILDDIR): vendor
+ rm -rf $@ $@.tmp
+ mkdir $@.tmp
+ cp -a src/ Cargo.toml Cargo.lock $@.tmp/
+ cp -a debian/ $@.tmp/debian
+ cp -a vendor/ $@.tmp/vendor
+ mkdir -p $@.tmp/.cargo
+ printf '[source.crates-io]\nreplace-with = "vendored-sources"\n\n[source.vendored-sources]\ndirectory = "vendor"\n' \
+ > $@.tmp/.cargo/config.toml
+ mv $@.tmp $@
+
+.PHONY: deb
+deb: $(DEB)
+$(DEB) $(DEB_DBG) &: $(BUILDDIR)
+ cd $(BUILDDIR); dpkg-buildpackage -b -us -uc
+ lintian $(DEB)
+
+# 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: upload
+upload: UPLOAD_DIST ?= $(DEB_DISTRIBUTION)
+upload: $(DEB) $(DEB_DBG)
+ tar cf - $(DEB) $(DEB_DBG) | ssh repoman@repo.proxmox.com -- upload --product pve --dist $(UPLOAD_DIST) --arch $(ARCH)
+
+.PHONY: clean
+clean:
+ rm -rf *~ debian/*~ *.deb *.changes *.buildinfo $(PACKAGE)-[0-9]*/ vendor/
+ cargo clean
+
+.PHONY: distclean
+distclean: clean
diff --git a/debian/changelog b/debian/changelog
new file mode 100644
index 0000000..30572a2
--- /dev/null
+++ b/debian/changelog
@@ -0,0 +1,5 @@
+pve-kyberproxy (0.1.0) trixie; urgency=medium
+
+ * Initial release: QUIC front door for per-VM Kyber consoles.
+
+ -- Proxmox Support Team <support@proxmox.com> Tue, 18 Aug 2026 15:00:00 +0200
diff --git a/debian/control b/debian/control
new file mode 100644
index 0000000..77404be
--- /dev/null
+++ b/debian/control
@@ -0,0 +1,24 @@
+Source: pve-kyberproxy
+Section: admin
+Priority: optional
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Uploaders: Alexandre Derumier <aderumier@groupe-cyllene.com>
+Build-Depends: cargo,
+ debhelper-compat (= 13),
+Standards-Version: 4.7.0.0
+
+Package: pve-kyberproxy
+Architecture: any
+Depends: ${misc:Depends},
+ ${shlibs:Depends},
+Description: front door for Proxmox VE Kyber consoles
+ One daemon per node, in front of the per-VM Kyber console controllers.
+ .
+ It forwards the console's control plane, which arrives from pveproxy on a
+ unix socket, to the controller belonging to the VM named in the path, and
+ rewrites the one response that tells a client where its data plane lives.
+ .
+ The data plane itself is relayed rather than terminated: the daemon moves
+ UDP datagrams between the client and the controller, holding no key and
+ reassembling no stream, so the client's QUIC connection runs end to end and
+ stays one congestion-controlled path.
diff --git a/debian/copyright b/debian/copyright
new file mode 100644
index 0000000..8b5623f
--- /dev/null
+++ b/debian/copyright
@@ -0,0 +1,20 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+Upstream-Name: pve-kyberproxy
+
+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..9c75aa2
--- /dev/null
+++ b/debian/install
@@ -0,0 +1 @@
+target/release/pvekyberproxy usr/bin/
diff --git a/debian/pve-kyberproxy.pvekyberproxy.service b/debian/pve-kyberproxy.pvekyberproxy.service
new file mode 100644
index 0000000..d9b1e86
--- /dev/null
+++ b/debian/pve-kyberproxy.pvekyberproxy.service
@@ -0,0 +1,32 @@
+[Unit]
+Description=PVE Kyber Console Proxy
+# One per node. It routes by path and by port, so nothing restarts it when a
+# VM does.
+Documentation=file:///usr/share/doc/pve-kyberproxy/kyber-proxy.md
+After=network.target
+
+[Service]
+Type=simple
+# pveproxy has already authenticated the caller, so this socket's permissions
+# are what stand between an unprivileged process and every console on the node.
+# --insecure-listen-tcp must never appear here.
+# Node-local rather than datacenter.cfg: what decides it is the node's
+# firewall. Same arrangement as /etc/default/pveproxy.
+EnvironmentFile=-/etc/default/pvekyberproxy
+
+ExecStart=/usr/bin/pvekyberproxy \
+ --listen-socket /run/pvekyberproxy.sock \
+ --run-dir /var/run/qemu-server \
+ --socket-group www-data \
+ $DATAPLANE_ARGS
+
+# pveproxy runs as www-data; the controllers' sockets are root's.
+User=root
+Group=root
+RuntimeDirectory=pvekyberproxy
+
+Restart=on-failure
+RestartSec=1
+
+[Install]
+WantedBy=multi-user.target
diff --git a/debian/pvekyberproxy.default b/debian/pvekyberproxy.default
new file mode 100644
index 0000000..2907be7
--- /dev/null
+++ b/debian/pvekyberproxy.default
@@ -0,0 +1,12 @@
+# Configuration for pvekyberproxy, the Kyber console's front door.
+#
+# DATAPLANE_ARGS chooses how console video reaches a browser. Uncomment one.
+# Changing it restarts the daemon, which drops every open console.
+
+# One UDP port for the node: WebTransport is terminated here and each session
+# routed by the token in its path. Needs the client from pve-kyber-web.
+DATAPLANE_ARGS="--webtransport-port 63100"
+
+# Or relay datagrams unread, at one UDP port per open console - nothing in an
+# encrypted datagram says which VM it is for, so the port is the routing key.
+#DATAPLANE_ARGS="--relay-ports 63100-63150"
diff --git a/debian/rules b/debian/rules
new file mode 100755
index 0000000..8deefff
--- /dev/null
+++ b/debian/rules
@@ -0,0 +1,43 @@
+#!/usr/bin/make -f
+
+include /usr/share/dpkg/architecture.mk
+include /usr/share/dpkg/pkg-info.mk
+
+#export DH_VERBOSE=1
+
+%:
+ dh $@
+
+override_dh_update_autotools_config:
+
+# The vendored crates carry Cargo.toml.orig, which cargo checksums and dh_clean
+# would otherwise delete as build residue - taking the build with it.
+override_dh_clean:
+ dh_clean -X Cargo.toml.orig
+
+# --no-default-features drops the dev-tcp feature, and with it
+# --insecure-listen-tcp: an unauthenticated control plane has no business
+# existing in a packaged binary, rather than merely being left out of the
+# unit file.
+override_dh_auto_build:
+ cargo build --release --no-default-features --features webtransport
+
+override_dh_auto_test:
+ cargo test --release --no-default-features --features webtransport
+
+override_dh_auto_clean:
+ cargo clean
+
+# The unit is pvekyberproxy.service, not pve-kyberproxy.service - PVE's
+# services do not carry the dash its package name does (pveproxy, spiceproxy,
+# pvedaemon). debhelper only finds a unit named after the package on its own,
+# so the name has to be spelled out or the unit is silently left out of the
+# .deb entirely.
+override_dh_installsystemd:
+ dh_installsystemd --name=pvekyberproxy
+
+# A conffile, so a node's choice of data plane survives an upgrade.
+override_dh_install:
+ dh_install
+ install -D -m 0644 debian/pvekyberproxy.default \
+ debian/pve-kyberproxy/etc/default/pvekyberproxy
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/src/control.rs b/src/control.rs
new file mode 100644
index 0000000..cd457fa
--- /dev/null
+++ b/src/control.rs
@@ -0,0 +1,506 @@
+// The control plane, forwarded to the VM's controller.
+//
+// pveproxy hands this everything under /nodes/{node}/qemu/{vmid}/kyber/ over a
+// unix socket, having already authenticated the caller and checked its ACL;
+// nothing here re-checks that.
+//
+// Requests pass through untouched except start_mux, which would otherwise name
+// a controller no browser can reach. /ws is a websocket rather than a
+// request/response, so its upgrade is spliced instead of forwarded.
+
+use std::net::{IpAddr, Ipv4Addr, SocketAddr};
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+use anyhow::{anyhow, Context, Result};
+use http_body_util::{BodyExt, Full};
+use hyper::body::{Bytes, Incoming};
+use hyper::header::{CONNECTION, UPGRADE};
+use hyper::server::conn::http1;
+use hyper::service::service_fn;
+use hyper::{Request, Response, StatusCode};
+use hyper_util::rt::TokioIo;
+use log::{debug, info, warn};
+use tokio::net::{UnixListener, UnixStream};
+#[cfg(feature = "dev-tcp")]
+use tokio::net::TcpListener;
+#[cfg(feature = "dev-tcp")]
+use tokio_rustls::TlsAcceptor;
+
+use crate::relay;
+
+pub struct Control {
+ /// Where the per-VM controller sockets live.
+ pub run_dir: PathBuf,
+ /// UDP ports the relay may use, when a firewall needs them knowable.
+ pub relay_ports: Option<relay::PortRange>,
+ /// The single-port data plane, when it is serving instead of the relay.
+ #[cfg(feature = "webtransport")]
+ pub gateway: Option<std::sync::Arc<crate::webtransport::Gateway>>,
+}
+
+impl Control {
+ fn controller_socket(&self, vmid: u32) -> PathBuf {
+ self.run_dir.join(format!("{vmid}.kyber.sock"))
+ }
+}
+
+/// Split a proxied path into the VM it names and the controller path under it.
+/// A path that does not match is a bug on our side, so it is refused.
+fn split_path(path: &str) -> Option<(u32, String)> {
+ let rest = path.strip_prefix("/api2/json").unwrap_or(path);
+ let rest = rest.strip_prefix("/nodes/")?;
+ let (_node, rest) = rest.split_once('/')?;
+ let rest = rest.strip_prefix("qemu/")?;
+ let (vmid, rest) = rest.split_once('/')?;
+ let vmid: u32 = vmid.parse().ok()?;
+ let rest = rest.strip_prefix("kyber")?;
+
+ if rest.is_empty() {
+ return Some((vmid, "/".to_string()));
+ }
+ if !rest.starts_with('/') {
+ return None;
+ }
+
+ Some((vmid, rest.to_string()))
+}
+
+/// Give the socket to one group and take it from everyone else. chown before
+/// chmod, or it would be world-connectable for as long as the chown took.
+fn restrict_socket(socket: &Path, group: &str) -> Result<()> {
+ use std::ffi::CString;
+ use std::os::unix::ffi::OsStrExt;
+ use std::os::unix::fs::PermissionsExt;
+
+ let name = CString::new(group).context("group name")?;
+ // getgrnam rather than /etc/group: the answer can come from anywhere NSS
+ // is configured to ask, and on a node that is not always a file.
+ let entry = unsafe { libc::getgrnam(name.as_ptr()) };
+ if entry.is_null() {
+ return Err(anyhow!("no group named '{group}' on this system"));
+ }
+ let gid = unsafe { (*entry).gr_gid };
+
+ let path = CString::new(socket.as_os_str().as_bytes()).context("socket path")?;
+ // -1 leaves the owner alone; only the group is being changed.
+ if unsafe { libc::chown(path.as_ptr(), libc::uid_t::MAX, gid) } != 0 {
+ return Err(std::io::Error::last_os_error()).context("chown");
+ }
+
+ std::fs::set_permissions(socket, std::fs::Permissions::from_mode(0o660)).context("chmod")?;
+
+ Ok(())
+}
+
+pub async fn serve(control: Arc<Control>, socket: &Path, group: Option<&str>) -> Result<()> {
+ // A socket left by an unclean exit would make every start fail.
+ match std::fs::remove_file(socket) {
+ Ok(()) => {}
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
+ Err(err) => return Err(err).context("removing a stale control socket"),
+ }
+
+ let listener = UnixListener::bind(socket)
+ .with_context(|| format!("binding {}", socket.display()))?;
+
+ // The whole of this socket's access control, and not optional: at the
+ // umask's default pveproxy cannot connect to it at all.
+ if let Some(group) = group {
+ restrict_socket(socket, group)
+ .with_context(|| format!("handing {} to group {group}", socket.display()))?;
+ }
+
+ info!("control plane on {}", socket.display());
+
+ loop {
+ let (stream, _) = listener.accept().await.context("accepting")?;
+ let control = control.clone();
+
+ tokio::spawn(async move {
+ let service = service_fn(move |req| handle(control.clone(), req));
+ // /ws is a websocket; without this the 101 is written and the
+ // connection dropped underneath it.
+ if let Err(err) = http1::Builder::new()
+ .serve_connection(TokioIo::new(stream), service)
+ .with_upgrades()
+ .await
+ {
+ debug!("control connection ended: {err}");
+ }
+ });
+ }
+}
+
+/// The same service on a TCP address, for working without a Proxmox around it.
+/// What it skips is authentication, not TLS: the client builds every
+/// control-plane URL as https:// with no way to say otherwise.
+#[cfg(feature = "dev-tcp")]
+pub async fn serve_tcp(
+ control: Arc<Control>,
+ addr: std::net::SocketAddr,
+ tls: TlsAcceptor,
+) -> Result<()> {
+ let listener = TcpListener::bind(addr)
+ .await
+ .with_context(|| format!("binding {addr}"))?;
+ info!("control plane on {addr}, unauthenticated");
+
+ loop {
+ let (stream, peer) = listener.accept().await.context("accepting")?;
+ let control = control.clone();
+ let tls = tls.clone();
+
+ tokio::spawn(async move {
+ // Per connection: a failed handshake must not take the listener
+ // down, and one fails whenever a certificate is not yet accepted.
+ let stream = match tls.accept(stream).await {
+ Ok(stream) => stream,
+ Err(err) => {
+ debug!("TLS handshake with {peer} failed: {err}");
+ return;
+ }
+ };
+
+ let service = service_fn(move |req| handle(control.clone(), req));
+ if let Err(err) = http1::Builder::new()
+ .serve_connection(TokioIo::new(stream), service)
+ .with_upgrades()
+ .await
+ {
+ debug!("control connection ended: {err}");
+ }
+ });
+ }
+}
+
+/// A throwaway certificate for the address above, generated rather than stored:
+/// a key on disk that nothing needs is only something to leak.
+#[cfg(feature = "dev-tcp")]
+pub fn self_signed_tls(addr: std::net::SocketAddr) -> Result<TlsAcceptor> {
+ use tokio_rustls::rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer};
+ use tokio_rustls::rustls::ServerConfig;
+
+ let names = vec!["localhost".to_string(), addr.ip().to_string()];
+ let certified = rcgen::generate_simple_self_signed(names)
+ .context("generating a certificate for the TCP control plane")?;
+
+ let cert = certified.cert.der().clone();
+ let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(certified.signing_key.serialize_der()));
+
+ // Passed in rather than installed process-wide, which would be a global
+ // fact set by a development flag.
+ let provider = Arc::new(tokio_rustls::rustls::crypto::ring::default_provider());
+ let mut config = ServerConfig::builder_with_provider(provider)
+ .with_safe_default_protocol_versions()
+ .context("selecting TLS versions")?
+ .with_no_client_auth()
+ .with_single_cert(vec![cert], key)
+ .context("building the TLS configuration")?;
+
+ // hyper serves HTTP/1.1; say so or a browser may negotiate h2.
+ config.alpn_protocols = vec![b"http/1.1".to_vec()];
+
+ Ok(TlsAcceptor::from(Arc::new(config)))
+}
+
+fn bad_request(msg: &str) -> Response<Full<Bytes>> {
+ let mut response = Response::new(Full::new(Bytes::from(msg.to_string())));
+ *response.status_mut() = StatusCode::BAD_REQUEST;
+ response
+}
+
+fn bad_gateway(msg: String) -> Response<Full<Bytes>> {
+ let mut response = Response::new(Full::new(Bytes::from(msg)));
+ *response.status_mut() = StatusCode::BAD_GATEWAY;
+ response
+}
+
+/// Whether this request asks to stop speaking HTTP. Connection is a list and
+/// its token is case-insensitive, so neither header compares whole.
+fn wants_upgrade(req: &Request<Incoming>) -> bool {
+ let connection = req
+ .headers()
+ .get(CONNECTION)
+ .and_then(|value| value.to_str().ok())
+ .map(|value| {
+ value
+ .split(',')
+ .any(|token| token.trim().eq_ignore_ascii_case("upgrade"))
+ })
+ .unwrap_or(false);
+
+ connection && req.headers().contains_key(UPGRADE)
+}
+
+async fn handle(
+ control: Arc<Control>,
+ req: Request<Incoming>,
+) -> Result<Response<Full<Bytes>>, std::convert::Infallible> {
+ let path = req.uri().path().to_string();
+
+ let Some((vmid, rest)) = split_path(&path) else {
+ warn!("control plane asked for a path it does not serve: {path}");
+ return Ok(bad_request("not a Kyber console path\n"));
+ };
+
+ match forward(&control, vmid, &rest, req).await {
+ Ok(response) => Ok(response),
+ Err(err) => {
+ warn!("VM {vmid}: {rest} failed: {err:#}");
+ Ok(bad_gateway(format!("{err:#}\n")))
+ }
+ }
+}
+
+async fn forward(
+ control: &Control,
+ vmid: u32,
+ rest: &str,
+ mut req: Request<Incoming>,
+) -> Result<Response<Full<Bytes>>> {
+ let socket = control.controller_socket(vmid);
+ let stream = UnixStream::connect(&socket)
+ .await
+ .with_context(|| format!("no controller for VM {vmid} at {}", socket.display()))?;
+
+ let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(stream))
+ .await
+ .context("HTTP handshake with the controller")?;
+
+ // Claimed before the request is taken apart.
+ let upgrading = wants_upgrade(&req);
+ let client_upgrade = upgrading.then(|| hyper::upgrade::on(&mut req));
+
+ if upgrading {
+ tokio::spawn(async move {
+ if let Err(err) = conn.with_upgrades().await {
+ debug!("controller connection ended: {err}");
+ }
+ });
+ } else {
+ tokio::spawn(async move {
+ if let Err(err) = conn.await {
+ debug!("controller connection ended: {err}");
+ }
+ });
+ }
+
+ // Rebuild against the controller's path, query string included.
+ let target = match req.uri().query() {
+ Some(query) => format!("{rest}?{query}"),
+ None => rest.to_string(),
+ };
+
+ let (parts, body) = req.into_parts();
+ let body = body
+ .collect()
+ .await
+ .context("reading the request body")?
+ .to_bytes();
+
+ let mut upstream = Request::builder()
+ .method(parts.method.clone())
+ .uri(target)
+ .body(Full::new(body))
+ .context("building the upstream request")?;
+
+ // Everything the client sent, minus Host.
+ for (name, value) in parts.headers.iter() {
+ if name == hyper::header::HOST {
+ continue;
+ }
+ upstream.headers_mut().insert(name, value.clone());
+ }
+ upstream
+ .headers_mut()
+ .insert(hyper::header::HOST, "localhost".parse().unwrap());
+
+ let mut response = sender
+ .send_request(upstream)
+ .await
+ .context("forwarding to the controller")?;
+
+ // Once the controller agrees, this connection stops being HTTP: splice the
+ // two halves and copy until one stops.
+ if response.status() == StatusCode::SWITCHING_PROTOCOLS {
+ let Some(client_upgrade) = client_upgrade else {
+ return Err(anyhow!(
+ "the controller upgraded a request that did not ask to be upgraded"
+ ));
+ };
+ let upstream_upgrade = hyper::upgrade::on(&mut response);
+
+ tokio::spawn(async move {
+ let (client, controller) = match tokio::try_join!(client_upgrade, upstream_upgrade) {
+ Ok(pair) => pair,
+ Err(err) => {
+ debug!("VM {vmid}: websocket upgrade never completed: {err}");
+ return;
+ }
+ };
+
+ let mut client = TokioIo::new(client);
+ let mut controller = TokioIo::new(controller);
+ match tokio::io::copy_bidirectional(&mut client, &mut controller).await {
+ Ok((to_controller, to_client)) => debug!(
+ "VM {vmid}: websocket closed after {to_controller} up, {to_client} down"
+ ),
+ Err(err) => debug!("VM {vmid}: websocket ended: {err}"),
+ }
+ });
+
+ let (parts, _) = response.into_parts();
+ let mut out = Response::new(Full::new(Bytes::new()));
+ *out.status_mut() = parts.status;
+ // Every header: Sec-WebSocket-Accept is computed from a key we never
+ // saw, so the controller's own answer is what the client needs.
+ for (name, value) in parts.headers.iter() {
+ out.headers_mut().insert(name, value.clone());
+ }
+ return Ok(out);
+ }
+
+ let (parts, body) = response.into_parts();
+ let body = body
+ .collect()
+ .await
+ .context("reading the controller's response")?
+ .to_bytes();
+
+ let body = if rest == "/kymux/start_mux" && parts.status.is_success() {
+ rewrite_start_mux(
+ vmid,
+ body,
+ control.relay_ports.as_ref(),
+ #[cfg(feature = "webtransport")]
+ control.gateway.as_ref(),
+ )
+ .await?
+ } else {
+ body
+ };
+
+ let mut out = Response::new(Full::new(body));
+ *out.status_mut() = parts.status;
+ for (name, value) in parts.headers.iter() {
+ // Length changes under the rewrite; hyper sets it again.
+ if name == hyper::header::CONTENT_LENGTH {
+ continue;
+ }
+ out.headers_mut().insert(name, value.clone());
+ }
+
+ Ok(out)
+}
+
+/// Point the client at this daemon rather than at the controller. Relaying
+/// replaces only the port, since TLS still runs to the controller; terminating
+/// replaces the certificate hash too, because the server is then this daemon.
+/// The controller reports its certificate hash as plain hex.
+#[cfg(feature = "webtransport")]
+fn parse_hash(text: &str) -> Option<[u8; 32]> {
+ let text = text.trim();
+ if text.len() != 64 {
+ return None;
+ }
+ let mut out = [0u8; 32];
+ for (i, byte) in out.iter_mut().enumerate() {
+ *byte = u8::from_str_radix(text.get(i * 2..i * 2 + 2)?, 16).ok()?;
+ }
+ Some(out)
+}
+
+async fn rewrite_start_mux(
+ vmid: u32,
+ body: Bytes,
+ ports: Option<&relay::PortRange>,
+ #[cfg(feature = "webtransport")] gateway: Option<&std::sync::Arc<crate::webtransport::Gateway>>,
+) -> Result<Bytes> {
+ let mut json: serde_json::Value =
+ serde_json::from_slice(&body).context("start_mux answered with something that is not JSON")?;
+
+ let object = json
+ .as_object_mut()
+ .ok_or_else(|| anyhow!("start_mux answered with a JSON value that is not an object"))?;
+
+ let upstream_port = object
+ .get("port")
+ .and_then(|port| port.as_u64())
+ .ok_or_else(|| anyhow!("start_mux answered without a port"))?;
+ let upstream_port =
+ u16::try_from(upstream_port).context("start_mux answered with a port that is not one")?;
+
+ let upstream = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), upstream_port);
+
+ #[cfg(feature = "webtransport")]
+ if let Some(gateway) = gateway {
+ // One port for the node: the client is told this daemon's port and
+ // certificate, and routes itself by the token it already has.
+ let token = object
+ .get("token")
+ .and_then(|token| token.as_str())
+ .ok_or_else(|| anyhow!("start_mux answered without a token"))?
+ .to_string();
+
+ let cert_hash = object
+ .get("certificate_hash")
+ .and_then(|hash| hash.get("hash"))
+ .and_then(|hash| hash.as_str())
+ .and_then(parse_hash);
+
+ gateway
+ .register(
+ token,
+ crate::webtransport::Upstream {
+ vmid,
+ addr: upstream,
+ cert_hash,
+ },
+ )
+ .await;
+
+ object.insert("port".to_string(), serde_json::Value::from(gateway.port));
+ if let Some(hash) = object
+ .get_mut("certificate_hash")
+ .and_then(|hash| hash.get_mut("hash"))
+ {
+ *hash = serde_json::Value::from(gateway.certificate_hash());
+ }
+
+ return Ok(Bytes::from(serde_json::to_vec(&json)?));
+ }
+
+ let session = relay::start(vmid, upstream, ports)
+ .await
+ .context("opening a relay for this session")?;
+
+ object.insert("port".to_string(), serde_json::Value::from(session.port));
+
+ Ok(Bytes::from(serde_json::to_vec(&json)?))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::split_path;
+
+ #[test]
+ fn splits_a_console_path() {
+ assert_eq!(
+ split_path("/api2/json/nodes/pve1/qemu/100/kyber/capabilities"),
+ Some((100, "/capabilities".to_string()))
+ );
+ assert_eq!(
+ split_path("/nodes/pve1/qemu/100/kyber/kymux/start_mux"),
+ Some((100, "/kymux/start_mux".to_string()))
+ );
+ }
+
+ #[test]
+ fn refuses_anything_else() {
+ assert_eq!(split_path("/nodes/pve1/qemu/100/vncwebsocket"), None);
+ assert_eq!(split_path("/nodes/pve1/qemu/abc/kyber/x"), None);
+ // A prefix that only looks like ours.
+ assert_eq!(split_path("/nodes/pve1/qemu/100/kyberproxy"), None);
+ }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..0f8638c
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,139 @@
+// pvekyberproxy - the front door for Kyber consoles on a Proxmox node.
+//
+// One per node. The control plane arrives from pveproxy on a unix socket, the
+// data plane over QUIC; both are forwarded to the VM's controller, which
+// listens only on this machine.
+//
+// See docs/kyber-proxy.md for why it is shaped this way.
+
+mod control;
+mod relay;
+#[cfg(feature = "webtransport")]
+mod webtransport;
+
+#[cfg(feature = "dev-tcp")]
+use std::net::SocketAddr;
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use anyhow::{anyhow, Context, Result};
+use clap::Parser;
+use log::info;
+#[cfg(feature = "dev-tcp")]
+use log::warn;
+
+#[derive(Debug, Parser)]
+#[command(version, about = "QUIC front door for Kyber consoles")]
+struct Cli {
+ /// Unix socket pveproxy forwards the control plane to.
+ #[arg(long, default_value = "/run/pvekyberproxy.sock")]
+ listen_socket: PathBuf,
+
+ /// Where the per-VM controller sockets live.
+ #[arg(long, default_value = "/run/qemu-server")]
+ run_dir: PathBuf,
+
+ /// Serve the data plane on one UDP port, by terminating WebTransport.
+ /// The alternative, --relay-ports, reads nothing but costs a port per
+ /// console.
+ #[cfg(feature = "webtransport")]
+ #[arg(long, value_name = "PORT", conflicts_with = "relay_ports")]
+ webtransport_port: Option<u16>,
+
+ /// UDP ports the relay may use, as LOW-HIGH. One per open console, chosen
+ /// by the kernel when unset - which no firewall rule can express.
+ #[arg(long, value_name = "LOW-HIGH")]
+ relay_ports: Option<relay::PortRange>,
+
+ /// Group given access to the control socket. pveproxy runs as www-data and
+ /// cannot connect to the mode bind leaves.
+ #[arg(long, value_name = "GROUP")]
+ socket_group: Option<String>,
+
+ /// Also serve the control plane on a TCP address, with no authentication.
+ ///
+ /// For working without a Proxmox around it: anyone who can reach this can
+ /// open any console on the node. It still serves HTTPS, because the client
+ /// has no way to ask for anything else.
+ #[cfg(feature = "dev-tcp")]
+ #[arg(long, value_name = "ADDR")]
+ insecure_listen_tcp: Option<SocketAddr>,
+}
+
+#[tokio::main]
+async fn main() -> Result<()> {
+ env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
+
+ let cli = Cli::parse();
+
+ if let Some(range) = &cli.relay_ports {
+ info!("data-plane relay confined to UDP {range}");
+ }
+
+ #[cfg(feature = "webtransport")]
+ let (gateway, gateway_config) = match cli.webtransport_port {
+ Some(port) => {
+ let (gateway, config) = webtransport::Gateway::new(port)?;
+ (Some(gateway), Some(config))
+ }
+ None => (None, None),
+ };
+ #[cfg(not(feature = "webtransport"))]
+ let gateway: Option<std::convert::Infallible> = None;
+
+ let control = Arc::new(control::Control {
+ run_dir: cli.run_dir,
+ relay_ports: cli.relay_ports,
+ #[cfg(feature = "webtransport")]
+ gateway: gateway.clone(),
+ });
+
+ #[cfg(feature = "webtransport")]
+ let data_plane = gateway
+ .clone()
+ .zip(gateway_config)
+ .map(|(gateway, config)| tokio::spawn(webtransport::serve(gateway, config)));
+
+ #[cfg(not(feature = "dev-tcp"))]
+ let tcp: Option<tokio::task::JoinHandle<Result<()>>> = None;
+
+ #[cfg(feature = "dev-tcp")]
+ let tcp = match cli.insecure_listen_tcp {
+ None => None,
+ Some(addr) => {
+ warn!("serving the control plane on {addr} with NO authentication");
+ // Before the listener, so a bad certificate stops the daemon here.
+ let tls = control::self_signed_tls(addr)?;
+ Some(tokio::spawn({
+ let control = control.clone();
+ async move { control::serve_tcp(control, addr, tls).await }
+ }))
+ }
+ };
+
+ tokio::select! {
+ result = async {
+ #[cfg(feature = "webtransport")]
+ match data_plane {
+ Some(task) => task.await.unwrap_or_else(|err| Err(anyhow!("{err}"))),
+ None => std::future::pending().await,
+ }
+ #[cfg(not(feature = "webtransport"))]
+ std::future::pending::<Result<()>>().await
+ } => result.context("data plane"),
+ result = control::serve(control.clone(), &cli.listen_socket, cli.socket_group.as_deref()) => {
+ result.context("control plane")
+ }
+ result = async {
+ match tcp {
+ Some(task) => task.await.unwrap_or_else(|err| Err(anyhow!("{err}"))),
+ // Nothing to wait for; leave the decision to the others.
+ None => std::future::pending().await,
+ }
+ } => result.context("TCP control plane"),
+ _ = tokio::signal::ctrl_c() => {
+ info!("stopping");
+ Ok(())
+ }
+ }
+}
diff --git a/src/relay.rs b/src/relay.rs
new file mode 100644
index 0000000..286c9b1
--- /dev/null
+++ b/src/relay.rs
@@ -0,0 +1,234 @@
+// The data plane, relayed rather than terminated: datagrams in, datagrams out,
+// no key held and no stream reassembled.
+//
+// Nothing in an encrypted QUIC packet says which VM it is for, so each session
+// gets a port of its own and the port is the routing key. See webtransport.rs
+// for the alternative, which spends a session's privacy for one port.
+
+use std::net::SocketAddr;
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use anyhow::{Context, Result};
+use log::{debug, info, warn};
+use tokio::net::UdpSocket;
+use tokio::sync::Mutex;
+
+/// A console silent this long has gone; the client keeps alive well inside it.
+const IDLE_TIMEOUT: Duration = Duration::from_secs(120);
+
+/// The UDP maximum, not the expected MTU: `recvfrom` truncates an oversized
+/// datagram silently, and a QUIC packet with its tail cut off is discarded by
+/// the peer with nothing logged anywhere.
+const MAX_DATAGRAM: usize = 65535;
+
+pub struct Session {
+ /// The port the client was told to use.
+ pub port: u16,
+}
+
+/// Open a relay from a fresh port to a VM's controller.
+///
+/// Dual-stack: the browser picks the address family from DNS, so an IPv4-only
+/// relay answers some clients and silently fails others. `range` exists for
+/// firewalls, which cannot express an ephemeral port.
+pub async fn start(vmid: u32, upstream: SocketAddr, range: Option<&PortRange>) -> Result<Session> {
+ let socket = bind_relay(range).await?;
+ let port = socket.local_addr().context("reading the relay port")?.port();
+
+ info!("VM {vmid}: relaying UDP {port} to {upstream}");
+
+ tokio::spawn(async move {
+ if let Err(err) = run(vmid, socket, upstream).await {
+ debug!("VM {vmid}: relay on {port} ended: {err:#}");
+ }
+ info!("VM {vmid}: relay on {port} closed");
+ });
+
+ Ok(Session { port })
+}
+
+/// A closed range of UDP ports the relay may use.
+#[derive(Debug, Clone, Copy)]
+pub struct PortRange {
+ pub low: u16,
+ pub high: u16,
+}
+
+impl std::str::FromStr for PortRange {
+ type Err = anyhow::Error;
+
+ fn from_str(text: &str) -> Result<Self> {
+ let (low, high) = text
+ .split_once('-')
+ .ok_or_else(|| anyhow::anyhow!("expected LOW-HIGH, got '{text}'"))?;
+ let low: u16 = low.trim().parse().context("low port")?;
+ let high: u16 = high.trim().parse().context("high port")?;
+ if low > high {
+ return Err(anyhow::anyhow!("port range {low}-{high} is backwards"));
+ }
+ Ok(Self { low, high })
+ }
+}
+
+impl std::fmt::Display for PortRange {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}-{}", self.low, self.high)
+ }
+}
+
+/// Bind the client-facing socket, dual-stack where the host allows it.
+async fn bind_relay(range: Option<&PortRange>) -> Result<UdpSocket> {
+ let mut last: Option<std::io::Error> = None;
+
+ match range {
+ None => {
+ // The kernel picks; try IPv6 first so the socket is dual-stack.
+ match UdpSocket::bind(("::", 0)).await {
+ Ok(socket) => return Ok(socket),
+ Err(err) => last = Some(err),
+ }
+ match UdpSocket::bind(("0.0.0.0", 0)).await {
+ Ok(socket) => {
+ warn!("relay bound IPv4-only: {}", last.as_ref().unwrap());
+ return Ok(socket);
+ }
+ Err(err) => last = Some(err),
+ }
+ }
+ Some(range) => {
+ for port in range.low..=range.high {
+ match UdpSocket::bind(("::", port)).await {
+ Ok(socket) => return Ok(socket),
+ Err(err) => last = Some(err),
+ }
+ match UdpSocket::bind(("0.0.0.0", port)).await {
+ Ok(socket) => {
+ warn!("relay bound IPv4-only on {port}");
+ return Ok(socket);
+ }
+ Err(err) => last = Some(err),
+ }
+ }
+ return Err(anyhow::anyhow!(
+ "no free port in {range}: {}",
+ last.map(|e| e.to_string()).unwrap_or_default()
+ ));
+ }
+ }
+
+ Err(last.unwrap()).context("binding a relay port")
+}
+
+async fn run(vmid: u32, client_side: UdpSocket, upstream: SocketAddr) -> Result<()> {
+ // A second socket, so replies are told apart by where they arrived.
+ // Matched to the controller's family; a v4-mapped socket cannot send to a
+ // v4 address it did not bind for.
+ let bind_addr = if upstream.is_ipv4() { "127.0.0.1" } else { "::1" };
+ let upstream_side = UdpSocket::bind((bind_addr, 0))
+ .await
+ .context("binding the controller side")?;
+ upstream_side
+ .connect(upstream)
+ .await
+ .context("pointing the controller side at the controller")?;
+
+ let client_side = Arc::new(client_side);
+ let upstream_side = Arc::new(upstream_side);
+
+ // Pinned at the first datagram. Following whoever spoke last would let
+ // anyone who can reach the port take the stream, and nothing here can
+ // authenticate a migration: path validation runs inside the encryption.
+ // A client that really does move reconnects instead.
+ let client_addr: Arc<Mutex<Option<SocketAddr>>> = Arc::new(Mutex::new(None));
+ let last = Arc::new(Mutex::new(Instant::now()));
+
+ let to_upstream = {
+ let client_side = client_side.clone();
+ let upstream_side = upstream_side.clone();
+ let client_addr = client_addr.clone();
+ let last = last.clone();
+
+ async move {
+ let mut buf = vec![0u8; MAX_DATAGRAM];
+ let mut turned_away: u64 = 0;
+ loop {
+ let (read, from) = client_side.recv_from(&mut buf).await?;
+
+ {
+ let mut pinned = client_addr.lock().await;
+ match *pinned {
+ None => {
+ info!("VM {vmid}: relay bound to {from}");
+ *pinned = Some(from);
+ }
+ Some(client) if client != from => {
+ // Not forwarded: the controller should not spend
+ // cycles decrypting a stranger's datagram.
+ turned_away += 1;
+ if turned_away.is_power_of_two() {
+ warn!(
+ "VM {vmid}: {turned_away} datagram(s) from {from} \
+ ignored, this relay belongs to {client}"
+ );
+ }
+ continue;
+ }
+ Some(_) => {}
+ }
+ }
+
+ *last.lock().await = Instant::now();
+ upstream_side.send(&buf[..read]).await?;
+ }
+ #[allow(unreachable_code)]
+ Ok::<(), std::io::Error>(())
+ }
+ };
+
+ let to_client = {
+ let client_side = client_side.clone();
+ let upstream_side = upstream_side.clone();
+ let client_addr = client_addr.clone();
+ let last = last.clone();
+
+ async move {
+ let mut buf = vec![0u8; MAX_DATAGRAM];
+ loop {
+ let read = upstream_side.recv(&mut buf).await?;
+ *last.lock().await = Instant::now();
+ let Some(addr) = *client_addr.lock().await else {
+ // The controller spoke first, which it cannot do: it has
+ // nothing to say until a client has said something.
+ continue;
+ };
+ client_side.send_to(&buf[..read], addr).await?;
+ }
+ #[allow(unreachable_code)]
+ Ok::<(), std::io::Error>(())
+ }
+ };
+
+ // No session state, so silence is the only end-of-console signal.
+ tokio::select! {
+ result = to_upstream => result.context("client side")?,
+ result = to_client => result.context("controller side")?,
+ _ = idle(last.clone()) => {
+ debug!("VM {vmid}: relay idle");
+ }
+ }
+
+ Ok(())
+}
+
+/// Resolves once nothing has crossed either way for IDLE_TIMEOUT. A timestamp
+/// rather than a peek at the socket, which two tasks already hold.
+async fn idle(last: Arc<Mutex<Instant>>) {
+ loop {
+ let elapsed = last.lock().await.elapsed();
+ if elapsed >= IDLE_TIMEOUT {
+ return;
+ }
+ tokio::time::sleep(IDLE_TIMEOUT - elapsed).await;
+ }
+}
diff --git a/src/webtransport.rs b/src/webtransport.rs
new file mode 100644
index 0000000..a284362
--- /dev/null
+++ b/src/webtransport.rs
@@ -0,0 +1,225 @@
+// The data plane on one UDP port, by terminating WebTransport instead of
+// relaying datagrams.
+//
+// Sharing a port means reading the session's path, which means decrypting.
+// That splits one congestion-controlled path into two, which matters in front
+// of a distant backend and not in front of loopback.
+//
+// Sessions are routed by the token the controller already minted: the proxy
+// sees it when it rewrites start_mux, and the client sends it in the path.
+
+use std::collections::HashMap;
+use std::time::Duration;
+use std::net::{IpAddr, Ipv4Addr, SocketAddr};
+use std::sync::Arc;
+
+use anyhow::{anyhow, Context, Result};
+use log::{debug, info, warn};
+use tokio::sync::Mutex;
+use wtransport::endpoint::IncomingSession;
+use wtransport::tls::{Sha256Digest, Sha256DigestFmt};
+use wtransport::{ClientConfig, Endpoint, Identity, ServerConfig};
+
+/// Keep-alive defaults to off in quinn, and a console encodes almost nothing
+/// while its screen is still - so without this a connection carrying a static
+/// desktop idles out and the console drops for no visible reason.
+const KEEP_ALIVE: Duration = Duration::from_secs(5);
+
+/// Long enough to ride out a lost keep-alive or two, short enough that a dead
+/// peer is noticed.
+const IDLE_TIMEOUT: Duration = Duration::from_secs(30);
+
+/// Where a session's traffic is going, learned from the controller's own
+/// answer to start_mux.
+#[derive(Clone)]
+pub struct Upstream {
+ pub vmid: u32,
+ pub addr: SocketAddr,
+ /// The controller's certificate hash, as it reported it.
+ pub cert_hash: Option<[u8; 32]>,
+}
+
+pub struct Gateway {
+ /// The one port every console arrives on.
+ pub port: u16,
+ cert_hash: String,
+ sessions: Mutex<HashMap<String, Upstream>>,
+}
+
+impl Gateway {
+ /// A certificate of this daemon's own, and its hash for start_mux. The
+ /// client pins by hash, so no CA is involved.
+ pub fn new(port: u16) -> Result<(Arc<Self>, ServerConfig)> {
+ let identity =
+ Identity::self_signed(["localhost", "127.0.0.1", "::1"]).context("certificate")?;
+
+ // The client wants plain hex; wtransport formats it dotted.
+ let cert_hash = identity
+ .certificate_chain()
+ .as_slice()
+ .first()
+ .ok_or_else(|| anyhow!("a certificate with no certificate in it"))?
+ .hash()
+ .fmt(Sha256DigestFmt::DottedHex)
+ .replace(':', "");
+
+ let config = ServerConfig::builder()
+ .with_bind_default(port)
+ .with_identity(identity)
+ .keep_alive_interval(Some(KEEP_ALIVE))
+ .max_idle_timeout(Some(IDLE_TIMEOUT))
+ .context("idle timeout")?
+ .build();
+
+ Ok((
+ Arc::new(Self {
+ port,
+ cert_hash,
+ sessions: Mutex::new(HashMap::new()),
+ }),
+ config,
+ ))
+ }
+
+ pub fn certificate_hash(&self) -> &str {
+ &self.cert_hash
+ }
+
+ /// Remember where a token leads, so its session can be routed.
+ pub async fn register(&self, token: String, upstream: Upstream) {
+ let mut sessions = self.sessions.lock().await;
+ info!(
+ "VM {}: session registered for {} on the shared port",
+ upstream.vmid, upstream.addr
+ );
+ sessions.insert(token, upstream);
+ }
+
+ async fn lookup(&self, token: &str) -> Option<Upstream> {
+ self.sessions.lock().await.get(token).cloned()
+ }
+}
+
+pub async fn serve(gateway: Arc<Gateway>, config: ServerConfig) -> Result<()> {
+ let endpoint = Endpoint::server(config).context("binding the WebTransport endpoint")?;
+ info!(
+ "data plane on UDP {} (WebTransport), certificate {}",
+ gateway.port,
+ &gateway.cert_hash[..16]
+ );
+
+ loop {
+ let incoming = endpoint.accept().await;
+ let gateway = gateway.clone();
+ tokio::spawn(async move {
+ if let Err(err) = accept(gateway, incoming).await {
+ info!("session ended: {err:#}");
+ }
+ });
+ }
+}
+
+async fn accept(gateway: Arc<Gateway>, incoming: IncomingSession) -> Result<()> {
+ let request = incoming.await.context("awaiting the session request")?;
+
+ // A token this daemon did not issue is refused: start_mux registers every
+ // console before the client is told where to go.
+ let token = request.path().trim_start_matches('/').to_string();
+ let Some(upstream) = gateway.lookup(&token).await else {
+ warn!("a session arrived with a token this proxy did not issue");
+ request.not_found().await;
+ return Ok(());
+ };
+
+ let vmid = upstream.vmid;
+ let client = request.accept().await.context("accepting the session")?;
+
+ let controller = connect_upstream(&upstream)
+ .await
+ .with_context(|| format!("VM {vmid}: connecting to the controller"))?;
+
+ info!("VM {vmid}: session open");
+ let result = pipe(client, controller).await;
+ info!("VM {vmid}: session closed");
+ result
+}
+
+async fn connect_upstream(upstream: &Upstream) -> Result<wtransport::Connection> {
+ // Pinned by the hash the controller reported - the one the client would
+ // have pinned directly. No hash means nothing to pin, so refuse.
+ let hash = upstream
+ .cert_hash
+ .ok_or_else(|| anyhow!("the controller reported no certificate to pin"))?;
+ // Loopback, not the wildcard with_bind_default picks: exactly one port
+ // should be reachable from the network.
+ let config = ClientConfig::builder()
+ .with_bind_address(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0))
+ .with_server_certificate_hashes([Sha256Digest::new(hash)])
+ .keep_alive_interval(Some(KEEP_ALIVE))
+ .max_idle_timeout(Some(IDLE_TIMEOUT))
+ .context("idle timeout")?
+ .build();
+
+ let endpoint = Endpoint::client(config).context("client endpoint")?;
+ let url = format!("https://{}/", upstream.addr);
+ endpoint.connect(url).await.context("connect").map_err(Into::into)
+}
+
+/// Copy streams and datagrams both ways without reading them: kyproto stays a
+/// conversation between the client and the controller.
+async fn pipe(client: wtransport::Connection, controller: wtransport::Connection) -> Result<()> {
+ let client = Arc::new(client);
+ let controller = Arc::new(controller);
+
+ let datagrams_up = forward_datagrams(client.clone(), controller.clone());
+ let datagrams_down = forward_datagrams(controller.clone(), client.clone());
+ let bidi_up = forward_bidi(client.clone(), controller.clone());
+ let bidi_down = forward_bidi(controller.clone(), client.clone());
+ let uni_up = forward_uni(client.clone(), controller.clone());
+ let uni_down = forward_uni(controller.clone(), client.clone());
+
+ // Either side finishing ends the session.
+ tokio::select! {
+ result = datagrams_up => result.context("datagrams to the controller"),
+ result = datagrams_down => result.context("datagrams to the client"),
+ result = bidi_up => result.context("streams to the controller"),
+ result = bidi_down => result.context("streams to the client"),
+ result = uni_up => result.context("one-way streams to the controller"),
+ result = uni_down => result.context("one-way streams to the client"),
+ }
+}
+
+async fn forward_datagrams(from: Arc<wtransport::Connection>, to: Arc<wtransport::Connection>) -> Result<()> {
+ loop {
+ let datagram = from.receive_datagram().await?;
+ to.send_datagram(datagram.payload())?;
+ }
+}
+
+async fn forward_bidi(from: Arc<wtransport::Connection>, to: Arc<wtransport::Connection>) -> Result<()> {
+ loop {
+ let (mut from_send, mut from_recv) = from.accept_bi().await?;
+ let (mut to_send, mut to_recv) = to.open_bi().await?.await?;
+
+ tokio::spawn(async move {
+ let up = tokio::io::copy(&mut from_recv, &mut to_send);
+ let down = tokio::io::copy(&mut to_recv, &mut from_send);
+ if let Err(err) = tokio::try_join!(up, down) {
+ debug!("stream ended: {err}");
+ }
+ });
+ }
+}
+
+async fn forward_uni(from: Arc<wtransport::Connection>, to: Arc<wtransport::Connection>) -> Result<()> {
+ loop {
+ let mut from_recv = from.accept_uni().await?;
+ let mut to_send = to.open_uni().await?.await?;
+
+ tokio::spawn(async move {
+ if let Err(err) = tokio::io::copy(&mut from_recv, &mut to_send).await {
+ debug!("one-way stream ended: {err}");
+ }
+ });
+ }
+}
--
2.55.0
next prev parent reply other threads:[~2026-08-26 9:29 UTC|newest]
Thread overview: 14+ 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 ` SPAM: [RFC pve-qemu-kyber 08/13] Add pve-qemu-kyber: an kyber controller for the qemu console Alexandre Derumier
2026-08-25 11:08 ` Alexandre Derumier [this message]
2026-08-25 11:08 ` SPAM: [RFC pve-kyber-web 10/13] add pve-kyber-web: console's webassembly client Alexandre Derumier
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
2026-08-25 11:08 ` SPAM: [RFC pve-rdp-web 13/13] add pve-rdp-web: console's webassembly client 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-10-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