From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id CE74A1FF0B7 for ; Tue, 25 Aug 2026 13:10:44 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id ECFF42174B; Tue, 25 Aug 2026 13:09:17 +0200 (CEST) From: Alexandre Derumier To: pve-devel@lists.proxmox.com subject: SPAM: [RFC pve-rdpproxy 12/13] Add pve-rdpproxy Date: Tue, 25 Aug 2026 13:08:44 +0200 Message-ID: <20260825110849.2967694-13-alexandre.derumier@groupe-cyllene.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260825110849.2967694-1-alexandre.derumier@groupe-cyllene.com> References: <20260825110849.2967694-1-alexandre.derumier@groupe-cyllene.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 7 AWL -0.330 Adjusted score from AWL reputation of From: address DMARC_QUAR 0.1 DMARC quarantine policy HEADER_FROM_DIFFERENT_DOMAINS 0.25 From and EnvelopeFrom 2nd level mail domains are different KAM_ASCII_DIVIDERS 0.8 Email that uses ascii formatting dividers and possible spam tricks KAM_DMARC_QUARANTINE 4 DKIM has Failed or SPF has failed on the message and the domain has a DMARC quarantine policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) KAM_LAZY_DOMAIN_SECURITY 1 Sending domain does not have any anti-forgery methods KAM_LOTSOFHASH 0.25 Emails with lots of hash-like gibberish KAM_SHORT 0.001 Use of a URL Shortener for very short URL RDNS_NONE 1.274 Delivered to internal network by a host with no rDNS SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_NONE 0.001 SPF: sender does not publish an SPF Record Message-ID-Hash: 677IRB2VCDQUM4P6PZPY5TKMOHZN2CE5 X-Message-ID-Hash: 677IRB2VCDQUM4P6PZPY5TKMOHZN2CE5 X-MailFrom: root@formationkvm1.odiso.net X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header CC: Alexandre Derumier X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: From: Alexandre Derumier 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//qemu//rdp/ v +---------------------+ | pveproxy | | checks VM.Console | +---------------------+ | | unix /run/pverdpproxy.sock | path rewritten to // v +----------------------------------+ reads the | pverdpproxy | token from | RDCleanPath: X.224 request, TLS |---> .rdp.env | handshake, certificate chain | (root only) | back to the client, then bytes | +----------------------------------+ | | unix /run/qemu-server/.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/.dbusdisplay QEMU -display dbus,addr=unix:path=... Signed-off-by: Alexandre Derumier --- .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 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 +Uploaders: Alexandre Derumier +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 +License: AGPL-3.0-or-later + +License: AGPL-3.0-or-later + This program is free software: you can redistribute it and/or modify it under + the terms of the GNU Affero General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) any + later version. + . + This program is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + details. + . + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . diff --git a/debian/install b/debian/install new file mode 100644 index 0000000..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, +} + +#[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 { + warn!("refused: {why}"); + Response::builder() + .status(status) + .body(format!("{why}\n")) + .expect("a literal response") +} + +async fn handle(mut req: Request, run_dir: Arc) -> Response { + // //. 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 //"); + }; + + let Ok(vmid) = vmid.parse::() 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) -> 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(stream: &mut S) -> Result> +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); + +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 { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + 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::crypto::verify_tls13_signature( + message, + cert, + dss, + &self.0.signature_verification_algorithms, + ) + } + + fn supported_verify_schemes(&self) -> Vec { + self.0.signature_verification_algorithms.supported_schemes() + } +} + +fn tls_config() -> Arc { + 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( + mut ws: tokio_tungstenite::WebSocketStream, + 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> = 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(ws: &mut tokio_tungstenite::WebSocketStream) -> Result> +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( + ws: tokio_tungstenite::WebSocketStream, + tls: tokio_rustls::client::TlsStream, + 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::(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::(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