all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
To: pve-devel@lists.proxmox.com
Subject: [RFC pve-kyberproxy 09/13] Add pve-kyberproxy
Date: Tue, 25 Aug 2026 13:34:34 +0200	[thread overview]
Message-ID: <20260825113442.947620-10-alexandre.derumier@groupe-cyllene.com> (raw)
In-Reply-To: <20260825113442.947620-1-alexandre.derumier@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 <alexandre.derumier@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




  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:34 [RFC pve-http-server/qemu-server/pve-manager/pve-{qemu-kyber,kyberproxy, kyber-web,qemu-rdp,rdpproxy,rdp-web} 00/13] add rdp && kyber consoles for qemu over D-Bus display Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-http-server 01/13] anyevent : proxy a path prefix to a local http proxy Alexandre Derumier
2026-08-25 11:34 ` [RFC qemu-server 02/13] add D-Bus display support Alexandre Derumier
2026-08-25 11:34 ` [RFC qemu-server 03/13] add kyber display Alexandre Derumier
2026-08-25 11:34 ` [RFC qemu-server 04/13] add rdp display Alexandre Derumier
2026-08-25 11:34 ` [RFC qemu-server 05/13] add experimental kyber-gl display Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-manager 06/13] ui: add kyber console Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-manager 07/13] ui: add rdp console Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-qemu-kyber 08/13] Add pve-qemu-kyber: an kyber controller for the qemu console Alexandre Derumier
2026-08-25 11:34 ` Alexandre Derumier [this message]
2026-08-25 11:34 ` [RFC pve-kyber-web 10/13] add pve-kyber-web: console's webassembly client Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-qemu-rdp 11/13] Add pve-qemu-rdp: an RDP server for the console Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-rdpproxy 12/13] Add pve-rdpproxy Alexandre Derumier
2026-08-25 11:34 ` [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=20260825113442.947620-10-alexandre.derumier@groupe-cyllene.com \
    --to=alexandre.derumier@groupe-cyllene.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal