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-rdpproxy 12/13] Add pve-rdpproxy
Date: Tue, 25 Aug 2026 13:34:37 +0200	[thread overview]
Message-ID: <20260825113442.947620-13-alexandre.derumier@groupe-cyllene.com> (raw)
In-Reply-To: <20260825113442.947620-1-alexandre.derumier@groupe-cyllene.com>

A browser cannot drive a TLS handshake over a websocket, so the IronRDP web
client delegates it through RDCleanPath protocol.

RDCleanPath is no part of RDP: it is Ironrdp rdp protocol extension gateway
by the ironrdp-rdcleanpath crate.

                             browser
                                |
                                | one websocket, and only this one:
                                | HTTPS :8006, no second port to open
                                | /api2/json/nodes/<node>/qemu/<vmid>/rdp/<token>
                                v
                     +---------------------+
                     |      pveproxy       |
                     |  checks VM.Console  |
                     +---------------------+
                                |
                                | unix /run/pverdpproxy.sock
                                | path rewritten to /<vmid>/<token>
                                v
                     +----------------------------------+     reads the
                     |           pverdpproxy            |     token from
                     |  RDCleanPath: X.224 request, TLS |---> <vmid>.rdp.env
                     |  handshake, certificate chain    |     (root only)
                     |  back to the client, then bytes  |
                     +----------------------------------+
                                |
                                | unix /run/qemu-server/<vmid>.rdp.sock
                                | TLS, terminated here - CredSSP binds to
                                | the server's key, so it cannot be dropped
                                v
                     +----------------------------------+     credentials
                     |       qemu-rdp   (pve-rdp@)      |<--- over D-Bus,
                     +----------------------------------+     from the API
                                |
                                | D-Bus unix, org.qemu on the private bus
                                v /run/qemu-server/<vmid>.dbusdisplay
                     QEMU -display dbus,addr=unix:path=...

Signed-off-by: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
---
 .gitignore                              |    6 +
 Cargo.lock                              | 1075 +++++++++++++++++++++++
 Cargo.toml                              |   31 +
 Makefile                                |   54 ++
 debian/changelog                        |    5 +
 debian/control                          |   24 +
 debian/copyright                        |   20 +
 debian/install                          |    1 +
 debian/pve-rdpproxy.pverdpproxy.service |   14 +
 debian/rules                            |   19 +
 debian/source/format                    |    1 +
 src/main.rs                             |  249 ++++++
 src/session.rs                          |  303 +++++++
 13 files changed, 1802 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-rdpproxy.pverdpproxy.service
 create mode 100755 debian/rules
 create mode 100644 debian/source/format
 create mode 100644 src/main.rs
 create mode 100644 src/session.rs

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9f765b7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+/target/
+/staging/
+/pve-rdpproxy-[0-9]*/
+*.deb
+*.changes
+*.buildinfo
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..aa31ee4
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,1075 @@
+# 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 = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[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 = "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 = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[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",
+ "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 2.0.20",
+]
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "der_derive",
+ "zeroize",
+]
+
+[[package]]
+name = "der_derive"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[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 = "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 = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[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 = "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",
+]
+
+[[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 = "ironrdp-rdcleanpath"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c2ce7c76797b5eeca9f5cca4410e2748f9458c5a11cc41e1e6d5ba475da947"
+dependencies = [
+ "der",
+]
+
+[[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 = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[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 = "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 = "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.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
+[[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-rdpproxy"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "env_logger",
+ "futures-util",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "ironrdp-rdcleanpath",
+ "libc",
+ "log",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tokio-tungstenite",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rand"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom",
+]
+
+[[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",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[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-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "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 = "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 = "sha1"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
+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 = "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 = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl 2.0.20",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[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 = "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 = "tokio-tungstenite"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
+dependencies = [
+ "futures-util",
+ "log",
+ "tokio",
+ "tungstenite",
+]
+
+[[package]]
+name = "tungstenite"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
+dependencies = [
+ "byteorder",
+ "bytes",
+ "data-encoding",
+ "http",
+ "httparse",
+ "log",
+ "rand",
+ "sha1",
+ "thiserror 1.0.69",
+ "utf-8",
+]
+
+[[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 = "utf-8"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[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 = "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 = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..896b883
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,31 @@
+[package]
+name = "pve-rdpproxy"
+version = "0.1.0"
+edition = "2021"
+license = "AGPL-3.0-or-later"
+description = "RDCleanPath gateway for Proxmox VE RDP consoles"
+
+[[bin]]
+name = "pverdpproxy"
+path = "src/main.rs"
+
+[dependencies]
+anyhow = "1"
+clap = { version = "4", features = ["derive", "env"] }
+env_logger = "0.11"
+futures-util = { version = "0.3", default-features = false, features = ["sink"] }
+http-body-util = "0.1"
+hyper = { version = "1", features = ["server", "http1"] }
+hyper-util = { version = "0.1", features = ["tokio"] }
+# The RDCleanPath PDU, DER-encoded. Hand-rolling the ASN.1 would be the one
+# part of this gateway with no reason to be ours.
+ironrdp-rdcleanpath = "0.2"
+# getgrnam, to hand the listening socket to pveproxy's group by name.
+libc = "0.2"
+log = "0.4"
+# The websocket is terminated here rather than spliced: the client sends
+# RDCleanPath and then RDP inside binary frames, so the frames have to be read.
+tokio-tungstenite = "0.24"
+rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
+tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "signal", "time"] }
+tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "logging", "tls12"] }
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..1d879cc
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,54 @@
+include /usr/share/dpkg/architecture.mk
+include /usr/share/dpkg/pkg-info.mk
+
+PACKAGE=pve-rdpproxy
+DEB=$(PACKAGE)_$(DEB_VERSION)_$(DEB_HOST_ARCH).deb
+DSC=$(PACKAGE)_$(DEB_VERSION).dsc
+BUILDDIR=$(PACKAGE)-$(DEB_VERSION_UPSTREAM)
+
+all: $(DEB)
+
+# The source tree, copied so dpkg-buildpackage builds in a directory it owns
+# and this one keeps no build output. debian/rules runs cargo from here; there
+# is no staging step, so what is packaged is what the build just produced.
+.PHONY: builddir
+builddir:
+	rm -rf $(BUILDDIR)
+	$(MAKE) $(BUILDDIR)
+
+$(BUILDDIR):
+	rm -rf $@ $@.tmp
+	mkdir $@.tmp
+	cp -a src Cargo.toml Cargo.lock debian Makefile $@.tmp/
+	mv $@.tmp $@
+
+deb: $(DEB)
+$(DEB): $(BUILDDIR)
+	cd $(BUILDDIR); dpkg-buildpackage -b -us -uc
+	lintian $(DEB) || true
+
+# A source package, for sbuild and for review: Proxmox builds every package
+# this way, so it has to work even when the binary path is what gets used.
+.PHONY: dsc
+dsc:
+	rm -rf $(BUILDDIR) $(DSC)
+	$(MAKE) $(DSC)
+	lintian $(DSC)
+
+$(DSC): $(BUILDDIR)
+	cd $(BUILDDIR); dpkg-buildpackage -S -us -uc -d
+
+sbuild: $(DSC)
+	sbuild $<
+
+.PHONY: dinstall
+dinstall: deb
+	dpkg -i $(DEB)
+
+.PHONY: clean
+clean:
+	rm -rf *.deb *.changes *.dsc *.buildinfo *.build $(PACKAGE)-[0-9]*/
+
+.PHONY: distclean
+distclean: clean
+	cargo clean
diff --git a/debian/changelog b/debian/changelog
new file mode 100644
index 0000000..0f5c4d7
--- /dev/null
+++ b/debian/changelog
@@ -0,0 +1,5 @@
+pve-rdpproxy (0.1.0) trixie; urgency=medium
+
+  * initial package
+
+ -- Alexandre Derumier <aderumier@groupe-cyllene.com>  Wed, 19 Aug 2026 12:00:00 +0200
diff --git a/debian/control b/debian/control
new file mode 100644
index 0000000..d3ad9ae
--- /dev/null
+++ b/debian/control
@@ -0,0 +1,24 @@
+Source: pve-rdpproxy
+Section: admin
+Priority: optional
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Uploaders: Alexandre Derumier <aderumier@groupe-cyllene.com>
+Build-Depends: debhelper-compat (= 13),
+                cargo,
+                libssl-dev,
+                pkgconf,
+Standards-Version: 4.7.0.0
+
+Package: pve-rdpproxy
+Architecture: any
+Depends: ${misc:Depends},
+         ${shlibs:Depends},
+Recommends: pve-qemu-rdp,
+Description: RDCleanPath gateway for Proxmox VE RDP consoles
+ The front door for the Kyber-style RDP console: one daemon per node, listening
+ on a unix socket that only pveproxy can open.
+ .
+ A browser cannot drive a TLS handshake over a websocket, so the IronRDP web
+ client delegates it through RDCleanPath. This gateway plays the client's X.224
+ connection request against the VM's own RDP server, performs the TLS handshake
+ on its behalf, returns the certificate chain, and then relays bytes.
diff --git a/debian/copyright b/debian/copyright
new file mode 100644
index 0000000..3f02644
--- /dev/null
+++ b/debian/copyright
@@ -0,0 +1,20 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+Upstream-Name: pve-rdpproxy
+
+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..ce6b6fd
--- /dev/null
+++ b/debian/install
@@ -0,0 +1 @@
+target/release/pverdpproxy usr/sbin/
diff --git a/debian/pve-rdpproxy.pverdpproxy.service b/debian/pve-rdpproxy.pverdpproxy.service
new file mode 100644
index 0000000..488756e
--- /dev/null
+++ b/debian/pve-rdpproxy.pverdpproxy.service
@@ -0,0 +1,14 @@
+[Unit]
+Description=PVE RDP Console Gateway
+After=network.target
+
+[Service]
+Type=simple
+# www-data is pveproxy's group: the socket is 0660, so the only thing that can
+# open it is the thing that has already authenticated the user.
+ExecStart=/usr/sbin/pverdpproxy --socket-group www-data
+Restart=on-failure
+RestartSec=2
+
+[Install]
+WantedBy=multi-user.target
diff --git a/debian/rules b/debian/rules
new file mode 100755
index 0000000..de23a72
--- /dev/null
+++ b/debian/rules
@@ -0,0 +1,19 @@
+#!/usr/bin/make -f
+
+%:
+	dh $@
+
+# Built here rather than before dpkg-buildpackage, so the package is produced
+# from the source in this directory and nothing is staged in beside it.
+# --locked because Cargo.lock is part of the source: a build that silently
+# resolved something else would not be the package that was reviewed.
+override_dh_auto_build:
+	cargo build --release --locked
+
+override_dh_auto_test:
+
+override_dh_auto_clean:
+	cargo clean
+
+override_dh_installsystemd:
+	dh_installsystemd --name=pverdpproxy
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/main.rs b/src/main.rs
new file mode 100644
index 0000000..1cc1e04
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,249 @@
+// pverdpproxy: the front door for Proxmox VE RDP consoles.
+//
+// One daemon per node, listening on a unix socket that only pveproxy can open.
+// pveproxy authenticates the request - a Proxmox session and VM.Console on the
+// VM - and then hands the raw upgraded connection here, so nothing on the
+// network reaches this directly.
+//
+// It terminates the websocket rather than splicing it, which is the difference
+// from pvekyberproxy: the IronRDP web client wraps RDCleanPath and then RDP
+// itself in binary frames, so the frames have to be read to find the handshake.
+
+use std::os::unix::fs::PermissionsExt;
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use anyhow::{bail, Context, Result};
+use clap::Parser;
+use hyper::body::Incoming;
+use hyper::header::{CONNECTION, UPGRADE};
+use hyper::service::service_fn;
+use hyper::{Request, Response, StatusCode};
+use hyper_util::rt::TokioIo;
+use log::{debug, error, info, warn};
+use tokio::net::UnixListener;
+
+mod session;
+
+#[derive(Parser, Debug)]
+#[command(version, about)]
+struct Args {
+    /// Where pveproxy hands over connections.
+    #[arg(long, default_value = "/run/pverdpproxy.sock")]
+    listen: PathBuf,
+
+    /// Where qemu-server puts each VM's RDP socket and credentials.
+    #[arg(long, default_value = "/run/qemu-server")]
+    run_dir: PathBuf,
+
+    /// Group given access to the listening socket, for pveproxy's user.
+    #[arg(long, value_name = "GROUP")]
+    socket_group: Option<String>,
+}
+
+#[tokio::main]
+async fn main() -> Result<()> {
+    env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
+
+    // Installed once here rather than left to whichever code path runs first,
+    // which would otherwise depend on the order connections arrive in.
+    rustls::crypto::ring::default_provider()
+        .install_default()
+        .map_err(|_| anyhow::anyhow!("a rustls crypto provider was already installed"))?;
+
+    let args = Args::parse();
+
+    // A socket left by an unclean stop would fail the bind.
+    match std::fs::remove_file(&args.listen) {
+        Ok(()) => {}
+        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
+        Err(err) => return Err(err).context("clearing the old listening socket"),
+    }
+
+    let listener =
+        UnixListener::bind(&args.listen).with_context(|| format!("binding {:?}", args.listen))?;
+    restrict_socket(&args.listen, args.socket_group.as_deref())?;
+
+    info!(
+        "listening on {:?}, VMs under {:?}",
+        args.listen, args.run_dir
+    );
+
+    let run_dir = Arc::new(args.run_dir);
+
+    loop {
+        let (stream, _addr) = match listener.accept().await {
+            Ok(accepted) => accepted,
+            Err(err) => {
+                error!("accept failed: {err}");
+                continue;
+            }
+        };
+
+        let run_dir = run_dir.clone();
+        tokio::spawn(async move {
+            let service = service_fn(move |req| {
+                let run_dir = run_dir.clone();
+                async move { Ok::<_, std::convert::Infallible>(handle(req, run_dir).await) }
+            });
+
+            if let Err(err) = hyper::server::conn::http1::Builder::new()
+                .serve_connection(TokioIo::new(stream), service)
+                .with_upgrades()
+                .await
+            {
+                debug!("connection ended: {err}");
+            }
+        });
+    }
+}
+
+/// 0660 and pveproxy's group, so the only thing that can open it is the thing
+/// that has already authenticated the user.
+fn restrict_socket(path: &std::path::Path, group: Option<&str>) -> Result<()> {
+    if let Some(group) = group {
+        let name = std::ffi::CString::new(group).context("group name")?;
+        // SAFETY: name outlives the call; the returned pointer is only read.
+        let entry = unsafe { libc::getgrnam(name.as_ptr()) };
+        if entry.is_null() {
+            bail!("no such group: {group}");
+        }
+        let gid = unsafe { (*entry).gr_gid };
+        let c_path =
+            std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).context("socket path")?;
+        if unsafe { libc::chown(c_path.as_ptr(), u32::MAX, gid) } != 0 {
+            return Err(std::io::Error::last_os_error())
+                .with_context(|| format!("giving {path:?} to group {group}"));
+        }
+    }
+
+    // After the chown: chmod does not survive a change of owner on every
+    // filesystem, and the narrower mode is the one worth keeping.
+    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o660))
+        .with_context(|| format!("setting the mode on {path:?}"))?;
+
+    Ok(())
+}
+
+fn bad(status: StatusCode, why: &str) -> Response<String> {
+    warn!("refused: {why}");
+    Response::builder()
+        .status(status)
+        .body(format!("{why}\n"))
+        .expect("a literal response")
+}
+
+async fn handle(mut req: Request<Incoming>, run_dir: Arc<PathBuf>) -> Response<String> {
+    // /<vmid>/<token>. The token says which console this is; the vmid says
+    // which VM, and is the only thing that decides what gets connected to.
+    let path = req.uri().path().trim_matches('/').to_owned();
+    let mut parts = path.split('/');
+    let (Some(vmid), Some(token), None) = (parts.next(), parts.next(), parts.next()) else {
+        return bad(StatusCode::NOT_FOUND, "expected /<vmid>/<token>");
+    };
+
+    let Ok(vmid) = vmid.parse::<u32>() else {
+        return bad(StatusCode::NOT_FOUND, "the VM id is not a number");
+    };
+
+    if let Err(err) = verify_token(&run_dir, vmid, token) {
+        return bad(StatusCode::FORBIDDEN, &format!("VM {vmid}: {err:#}"));
+    }
+
+    if !wants_websocket(&req) {
+        return bad(StatusCode::BAD_REQUEST, "not a websocket upgrade");
+    }
+
+    let Some(key) = req
+        .headers()
+        .get("sec-websocket-key")
+        .and_then(|value| value.to_str().ok())
+        .map(|key| tokio_tungstenite::tungstenite::handshake::derive_accept_key(key.as_bytes()))
+    else {
+        return bad(StatusCode::BAD_REQUEST, "no Sec-WebSocket-Key");
+    };
+
+    let upgrade = hyper::upgrade::on(&mut req);
+    let run_dir = run_dir.clone();
+
+    tokio::spawn(async move {
+        let upgraded = match upgrade.await {
+            Ok(upgraded) => upgraded,
+            Err(err) => {
+                debug!("VM {vmid}: the upgrade never completed: {err}");
+                return;
+            }
+        };
+
+        let ws = tokio_tungstenite::WebSocketStream::from_raw_socket(
+            TokioIo::new(upgraded),
+            tokio_tungstenite::tungstenite::protocol::Role::Server,
+            None,
+        )
+        .await;
+
+        if let Err(err) = session::run(ws, &run_dir, vmid).await {
+            warn!("VM {vmid}: session failed: {err:#}");
+        }
+    });
+
+    Response::builder()
+        .status(StatusCode::SWITCHING_PROTOCOLS)
+        .header(CONNECTION, "Upgrade")
+        .header(UPGRADE, "websocket")
+        .header("sec-websocket-accept", key)
+        .body(String::new())
+        .expect("a literal response")
+}
+
+fn wants_websocket(req: &Request<Incoming>) -> bool {
+    let upgrading = req
+        .headers()
+        .get(CONNECTION)
+        .and_then(|value| value.to_str().ok())
+        .is_some_and(|value| {
+            value
+                .split(',')
+                .any(|token| token.trim().eq_ignore_ascii_case("upgrade"))
+        });
+
+    let websocket = req
+        .headers()
+        .get(UPGRADE)
+        .and_then(|value| value.to_str().ok())
+        .is_some_and(|value| value.eq_ignore_ascii_case("websocket"));
+
+    upgrading && websocket
+}
+
+/// The token qemu-server wrote for this VM, and only this VM.
+///
+/// pveproxy has already established that the user may open a console, so this
+/// is not the authentication - it binds a console to the VM it was opened for,
+/// so a token minted for one cannot be replayed against another.
+fn verify_token(run_dir: &std::path::Path, vmid: u32, presented: &str) -> Result<()> {
+    let path = run_dir.join(format!("{vmid}.rdp.env"));
+    let env = std::fs::read_to_string(&path)
+        .with_context(|| format!("no running RDP server ({path:?})"))?;
+
+    let expected = env
+        .lines()
+        .find_map(|line| line.strip_prefix("RDP_TOKEN="))
+        .map(str::trim)
+        .context("the RDP server has no token")?;
+
+    if !constant_time_eq(expected.as_bytes(), presented.as_bytes()) {
+        bail!("the token does not match");
+    }
+
+    Ok(())
+}
+
+/// Compared in constant time: a token is a secret, and an early return on the
+/// first wrong byte is enough to recover one a byte at a time.
+fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
+    if a.len() != b.len() {
+        return false;
+    }
+    a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
+}
diff --git a/src/session.rs b/src/session.rs
new file mode 100644
index 0000000..9644e01
--- /dev/null
+++ b/src/session.rs
@@ -0,0 +1,303 @@
+// The RDCleanPath handshake, and the relay that follows it.
+//
+// RDCleanPath is Devolutions Gateway's protocol, and it is what the IronRDP
+// web client speaks: TLS terminates here rather than in the browser, because a
+// browser cannot drive a TLS handshake over a websocket. The client sends its
+// X.224 Connection Request inside a DER blob, the gateway plays that against
+// the real server, does the TLS handshake on its behalf and hands back the
+// Connection Confirm together with the server's certificate chain. Everything
+// after that is opaque bytes in both directions.
+
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+use anyhow::{bail, Context, Result};
+use futures_util::{SinkExt, StreamExt};
+use ironrdp_rdcleanpath::{DetectionResult, RDCleanPath, RDCleanPathPdu};
+use log::{debug, info, warn};
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::UnixStream;
+use tokio_tungstenite::tungstenite::Message;
+
+/// The per-VM RDP server's socket. Derived from the VM id, never from anything
+/// the client sent - see `run`.
+fn rdp_socket(run_dir: &Path, vmid: u32) -> PathBuf {
+    run_dir.join(format!("{vmid}.rdp.sock"))
+}
+
+/// TPKT says how long its payload is, so the Connection Confirm can be read
+/// exactly rather than guessed at with a timeout.
+async fn read_tpkt<S>(stream: &mut S) -> Result<Vec<u8>>
+where
+    S: AsyncReadExt + Unpin,
+{
+    let mut header = [0u8; 4];
+    stream
+        .read_exact(&mut header)
+        .await
+        .context("reading the TPKT header")?;
+
+    if header[0] != 3 {
+        bail!(
+            "not a TPKT packet: first byte is {:#x}, expected 0x03",
+            header[0]
+        );
+    }
+
+    let length = u16::from_be_bytes([header[2], header[3]]) as usize;
+    if length < 4 {
+        bail!("TPKT length {length} is shorter than its own header");
+    }
+
+    let mut packet = Vec::with_capacity(length);
+    packet.extend_from_slice(&header);
+    packet.resize(length, 0);
+    stream
+        .read_exact(&mut packet[4..])
+        .await
+        .context("reading the TPKT payload")?;
+
+    Ok(packet)
+}
+
+/// Accepts the per-VM certificate without checking it.
+///
+/// Not a weakening: the certificate is generated by qemu-server on every start
+/// and signs nothing anyone could verify. What authenticates this hop is the
+/// socket - mode 0600, owned by root, on this node - and the handshake exists
+/// only because CredSSP binds to the server's public key. The chain is still
+/// read out and handed to the client, which is the party that gets to decide.
+#[derive(Debug)]
+struct AcceptAnyServer(Arc<rustls::crypto::CryptoProvider>);
+
+impl rustls::client::danger::ServerCertVerifier for AcceptAnyServer {
+    fn verify_server_cert(
+        &self,
+        _end_entity: &rustls::pki_types::CertificateDer<'_>,
+        _intermediates: &[rustls::pki_types::CertificateDer<'_>],
+        _server_name: &rustls::pki_types::ServerName<'_>,
+        _ocsp_response: &[u8],
+        _now: rustls::pki_types::UnixTime,
+    ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
+        Ok(rustls::client::danger::ServerCertVerified::assertion())
+    }
+
+    fn verify_tls12_signature(
+        &self,
+        message: &[u8],
+        cert: &rustls::pki_types::CertificateDer<'_>,
+        dss: &rustls::DigitallySignedStruct,
+    ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
+        rustls::crypto::verify_tls12_signature(
+            message,
+            cert,
+            dss,
+            &self.0.signature_verification_algorithms,
+        )
+    }
+
+    fn verify_tls13_signature(
+        &self,
+        message: &[u8],
+        cert: &rustls::pki_types::CertificateDer<'_>,
+        dss: &rustls::DigitallySignedStruct,
+    ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
+        rustls::crypto::verify_tls13_signature(
+            message,
+            cert,
+            dss,
+            &self.0.signature_verification_algorithms,
+        )
+    }
+
+    fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
+        self.0.signature_verification_algorithms.supported_schemes()
+    }
+}
+
+fn tls_config() -> Arc<rustls::ClientConfig> {
+    let provider = Arc::new(rustls::crypto::ring::default_provider());
+    let config = rustls::ClientConfig::builder_with_provider(provider.clone())
+        .with_safe_default_protocol_versions()
+        .expect("ring provides both protocol versions")
+        .dangerous()
+        .with_custom_certificate_verifier(Arc::new(AcceptAnyServer(provider)))
+        .with_no_client_auth();
+    Arc::new(config)
+}
+
+/// The whole session: handshake, then relay until either side stops.
+///
+/// `vmid` comes from the URL that pveproxy authenticated, never from the PDU.
+/// The client names a destination in its request and this ignores it - honouring
+/// it would turn an authenticated console into a request forgery against
+/// anything this node can reach.
+pub async fn run<S>(
+    mut ws: tokio_tungstenite::WebSocketStream<S>,
+    run_dir: &Path,
+    vmid: u32,
+) -> Result<()>
+where
+    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
+{
+    let request = next_binary(&mut ws)
+        .await
+        .context("waiting for the RDCleanPath request")?;
+
+    match RDCleanPathPdu::detect(&request) {
+        DetectionResult::Detected { .. } => {}
+        DetectionResult::NotEnoughBytes => {
+            bail!("the RDCleanPath request arrived truncated across frames")
+        }
+        DetectionResult::Failed => bail!("the first frame was not an RDCleanPath PDU"),
+    }
+
+    let pdu = RDCleanPathPdu::from_der(&request)
+        .map_err(|err| anyhow::anyhow!("decoding the RDCleanPath request: {err}"))?;
+    let message = pdu
+        .into_enum()
+        .map_err(|err| anyhow::anyhow!("reading the RDCleanPath request: {err}"))?;
+
+    let x224_request = match message {
+        RDCleanPath::Request {
+            destination,
+            x224_connection_request,
+            ..
+        } => {
+            // Logged, not used. Worth seeing when a client is pointed somewhere
+            // unexpected; never worth obeying.
+            debug!("VM {vmid}: client asked for '{destination}', routing by vmid instead");
+            x224_connection_request.into_bytes()
+        }
+        _ => bail!("expected an RDCleanPath request, got a response or an error"),
+    };
+
+    let socket = rdp_socket(run_dir, vmid);
+    let mut upstream = UnixStream::connect(&socket)
+        .await
+        .with_context(|| format!("connecting to the RDP server for VM {vmid} on {socket:?}"))?;
+
+    upstream
+        .write_all(&x224_request)
+        .await
+        .context("forwarding the X.224 connection request")?;
+    let x224_response = read_tpkt(&mut upstream)
+        .await
+        .context("reading the X.224 connection confirm")?;
+
+    // The server switches to TLS immediately after the confirm, so this has to
+    // follow it with nothing in between.
+    let connector = tokio_rustls::TlsConnector::from(tls_config());
+    // A name is required and never checked; the socket already said which
+    // server this is.
+    let name =
+        rustls::pki_types::ServerName::try_from("pve-rdp").expect("a literal, valid DNS name");
+    let tls = connector
+        .connect(name, upstream)
+        .await
+        .context("the TLS handshake with the RDP server failed")?;
+
+    let chain: Vec<Vec<u8>> = tls
+        .get_ref()
+        .1
+        .peer_certificates()
+        .unwrap_or(&[])
+        .iter()
+        .map(|cert| cert.as_ref().to_vec())
+        .collect();
+
+    if chain.is_empty() {
+        bail!("the RDP server presented no certificate");
+    }
+
+    let response = RDCleanPathPdu::new_response(
+        // Reported back for the client's logs. The real address is a unix
+        // socket, which RDCleanPath has no way to express.
+        format!("vm-{vmid}"),
+        x224_response,
+        chain,
+    )
+    .and_then(|pdu| pdu.to_der())
+    .map_err(|err| anyhow::anyhow!("encoding the RDCleanPath response: {err}"))?;
+
+    ws.send(Message::Binary(response))
+        .await
+        .context("sending the RDCleanPath response")?;
+
+    info!("VM {vmid}: RDP session established");
+    relay(ws, tls, vmid).await
+}
+
+async fn next_binary<S>(ws: &mut tokio_tungstenite::WebSocketStream<S>) -> Result<Vec<u8>>
+where
+    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
+{
+    while let Some(message) = ws.next().await {
+        match message.context("reading from the websocket")? {
+            Message::Binary(data) => return Ok(data),
+            Message::Close(_) => bail!("the client closed before sending anything"),
+            // Text is not part of this protocol; ping/pong are handled by the
+            // library and are not worth mentioning.
+            Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => {}
+            Message::Text(_) => bail!("the client sent a text frame"),
+        }
+    }
+    bail!("the websocket ended before the RDCleanPath request")
+}
+
+/// Bytes both ways until one side stops. Nothing here understands RDP.
+async fn relay<S, U>(
+    ws: tokio_tungstenite::WebSocketStream<S>,
+    tls: tokio_rustls::client::TlsStream<U>,
+    vmid: u32,
+) -> Result<()>
+where
+    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
+    U: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
+{
+    let (mut ws_tx, mut ws_rx) = ws.split();
+    let (mut server_rx, mut server_tx) = tokio::io::split(tls);
+
+    let to_server = async {
+        let mut sent: u64 = 0;
+        while let Some(message) = ws_rx.next().await {
+            match message? {
+                Message::Binary(data) => {
+                    server_tx.write_all(&data).await?;
+                    sent += data.len() as u64;
+                }
+                Message::Close(_) => break,
+                _ => {}
+            }
+        }
+        Ok::<u64, anyhow::Error>(sent)
+    };
+
+    let to_client = async {
+        let mut buf = vec![0u8; 32 * 1024];
+        let mut sent: u64 = 0;
+        loop {
+            let read = server_rx.read(&mut buf).await?;
+            if read == 0 {
+                break;
+            }
+            ws_tx.send(Message::Binary(buf[..read].to_vec())).await?;
+            sent += read as u64;
+        }
+        Ok::<u64, anyhow::Error>(sent)
+    };
+
+    tokio::select! {
+        result = to_server => match result {
+            Ok(bytes) => debug!("VM {vmid}: client closed after {bytes} bytes up"),
+            Err(err) => warn!("VM {vmid}: client side ended: {err:#}"),
+        },
+        result = to_client => match result {
+            Ok(bytes) => debug!("VM {vmid}: server closed after {bytes} bytes down"),
+            Err(err) => warn!("VM {vmid}: server side ended: {err:#}"),
+        },
+    }
+
+    info!("VM {vmid}: RDP session closed");
+    Ok(())
+}
-- 
2.55.0




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

Thread overview: 11+ 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-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 ` Alexandre Derumier [this message]

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-13-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