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 988611FF0B7 for ; Tue, 25 Aug 2026 13:14:57 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 2D251215B2; Tue, 25 Aug 2026 13:14:57 +0200 (CEST) From: Alexandre Derumier To: pve-devel@lists.proxmox.com subject: SPAM: [RFC pve-qemu-rdp 11/13] Add pve-qemu-rdp: an RDP server for the console Date: Tue, 25 Aug 2026 13:08:43 +0200 Message-ID: <20260825110849.2967694-12-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-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 6 AWL 0.223 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_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 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: 2AKGVVGQTFMIDOPXM3YVVGSYJKV5QN5T X-Message-ID-Hash: 2AKGVVGQTFMIDOPXM3YVVGSYJKV5QN5T 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 qemu-rdp reads a VM's display over org.qemu.Display1. It's also provide clipboard && audio. Signed-off-by: Alexandre Derumier --- .gitignore | 5 + .gitmodules | 3 + Makefile | 76 +++++++++ debian/changelog | 5 + debian/control | 24 +++ debian/copyright | 37 ++++ debian/install | 1 + debian/rules | 16 ++ debian/source/format | 1 + ...rdp-allow-listening-on-a-unix-socket.patch | 159 ++++++++++++++++++ qemu-display | 1 + 11 files changed, 328 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 Makefile create mode 100644 debian/changelog create mode 100644 debian/control create mode 100644 debian/copyright create mode 100644 debian/install create mode 100755 debian/rules create mode 100644 debian/source/format create mode 100644 patches/0001-qemu-rdp-allow-listening-on-a-unix-socket.patch create mode 160000 qemu-display diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43c1faa --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/staging/ +/pve-qemu-rdp-[0-9]*/ +*.deb +*.changes +*.buildinfo diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..ee0ae27 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "qemu-display"] + path = qemu-display + url = https://gitlab.com/marcandre.lureau/qemu-display.git diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5f457c6 --- /dev/null +++ b/Makefile @@ -0,0 +1,76 @@ +include /usr/share/dpkg/architecture.mk +include /usr/share/dpkg/pkg-info.mk + +PACKAGE=pve-qemu-rdp + +# qemu-display: MIT where this package is, but a separate upstream, so it is a +# submodule rather than vendored. Pinned by commit and not by tag - the unix +# socket support this needs is only in master, see patches/. +SRCDIR=qemu-display + +BUILDDIR=$(PACKAGE)-$(DEB_VERSION_UPSTREAM) +ORIG_SRC_TAR=$(PACKAGE)_$(DEB_VERSION_UPSTREAM).orig.tar.gz + +DSC=$(PACKAGE)_$(DEB_VERSION).dsc +DEB=$(PACKAGE)_$(DEB_VERSION)_$(DEB_HOST_ARCH).deb +DEB_DBG=$(PACKAGE)-dbgsym_$(DEB_VERSION)_$(DEB_HOST_ARCH).deb +DEBS=$(DEB) $(DEB_DBG) + +all: $(DEBS) + +# Fetched once and left alone afterwards: nothing below writes to it, so a +# checkout someone has been working in stays theirs. +.PHONY: submodule +submodule: + test -f "$(SRCDIR)/Cargo.toml" || git submodule update --init --recursive $(SRCDIR) + +# The patches are applied to the copy, never to the submodule. That is what +# lets the fetch above be a one-off, and what keeps a rebuild from finding a +# tree that is already patched. +# +# .git goes first: it is a gitlink pointing back at the submodule, so `git +# apply` would otherwise find that work tree instead of this copy. target/ goes +# with it because a stray build tree is half a gigabyte. +$(BUILDDIR): submodule debian/changelog + rm -rf $@ $@.tmp + cp -a $(SRCDIR) $@.tmp + find $@.tmp -name .git -prune -exec rm -rf {} + + rm -rf $@.tmp/target + set -e; for p in $(CURDIR)/patches/*.patch; do \ + git -C $@.tmp apply "$$p"; \ + done + cp -a debian $@.tmp/debian + mv $@.tmp $@ + +$(ORIG_SRC_TAR): $(BUILDDIR) + tar czf $(ORIG_SRC_TAR) --exclude="$(BUILDDIR)/debian" $(BUILDDIR) + +.PHONY: deb +deb: $(DEBS) +$(DEBS) &: $(BUILDDIR) + cd $(BUILDDIR); dpkg-buildpackage -b -us -uc + lintian $(DEBS) + +.PHONY: dsc +dsc: + rm -rf $(BUILDDIR) $(ORIG_SRC_TAR) $(DSC) + $(MAKE) $(DSC) + lintian $(DSC) + +$(DSC): $(BUILDDIR) $(ORIG_SRC_TAR) + cd $(BUILDDIR); dpkg-buildpackage -S -us -uc -d + +sbuild: $(DSC) + sbuild $< + +.PHONY: dinstall +dinstall: deb + dpkg -i $(DEBS) + +.PHONY: distclean +distclean: clean + +.PHONY: clean +clean: + rm -rf $(PACKAGE)-[0-9]*/ + rm -rf $(PACKAGE)*.tar* *.deb *.dsc *.changes *.buildinfo *.build diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..9a5412d --- /dev/null +++ b/debian/changelog @@ -0,0 +1,5 @@ +pve-qemu-rdp (0.1.1-1) 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..7b292b6 --- /dev/null +++ b/debian/control @@ -0,0 +1,24 @@ +Source: pve-qemu-rdp +Section: admin +Priority: optional +Maintainer: Proxmox Support Team +Uploaders: Alexandre Derumier +Build-Depends: debhelper-compat (= 13), + cargo, + git, + libssl-dev, + pkgconf, +Standards-Version: 4.7.0.0 + +Package: pve-qemu-rdp +Architecture: any +Depends: ${misc:Depends}, + ${shlibs:Depends}, +Description: RDP server for the Proxmox VE console + An RDP server that reads a VM's display over the org.qemu.Display1 D-Bus + interface exposed by "qemu -display dbus", so a guest is streamed without a + guest agent and without a second display device. + . + Started per VM by qemu-server for VMs configured with "vga: rdp", on a unix + socket, and reached from a browser only through pveproxy and pve-rdpproxy. + RDP brings clipboard, audio and monitor resize with it. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..92edec8 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,37 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: qemu-rdp +Source: https://gitlab.com/marcandre.lureau/qemu-display + +Files: * +Copyright: 2023-2026 Marc-André Lureau + 2023 Mihnea Buzatu +License: MIT + +Files: debian/* +Copyright: 2026 Proxmox Server Solutions GmbH +License: MIT + +License: MIT + Permission is hereby granted, free of charge, to any + person obtaining a copy of this software and associated + documentation files (the "Software"), to deal in the + Software without restriction, including without + limitation the rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software + is furnished to do so, subject to the following + conditions: + . + The above copyright notice and this permission notice + shall be included in all copies or substantial portions + of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT + SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR + IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. diff --git a/debian/install b/debian/install new file mode 100644 index 0000000..b9dc42d --- /dev/null +++ b/debian/install @@ -0,0 +1 @@ +target/release/qemu-rdp usr/bin/ diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..9dad7b9 --- /dev/null +++ b/debian/rules @@ -0,0 +1,16 @@ +#!/usr/bin/make -f +%: + dh $@ + +# The build directory is the upstream tree with debian/ overlaid, as in frr and +# corosync-pve, so cargo runs at its root and the package is built from source +# rather than from anything staged in. +# --locked because Cargo.lock is part of what the outer Makefile checked out at +# a pinned commit: resolving something else would undo that pin. +override_dh_auto_build: + cargo build --release --locked -p qemu-rdp + +override_dh_auto_test: + +override_dh_auto_clean: + cargo clean diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..163aaf8 --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/patches/0001-qemu-rdp-allow-listening-on-a-unix-socket.patch b/patches/0001-qemu-rdp-allow-listening-on-a-unix-socket.patch new file mode 100644 index 0000000..4ec08d0 --- /dev/null +++ b/patches/0001-qemu-rdp-allow-listening-on-a-unix-socket.patch @@ -0,0 +1,159 @@ +From a0e530f3f05cd7da9a824fa18b227af83aaec72a Mon Sep 17 00:00:00 2001 +From: Alexandre Derumier +Date: Wed, 19 Aug 2026 13:51:18 +0200 +Subject: [PATCH] qemu-rdp: allow listening on a unix socket + +RdpServer::run binds a TcpListener itself, so serving a unix socket means +repeating the accept loop against a UnixListener; run_connection is already +generic over the stream. + +A loopback port is reachable by every local user on the host. A socket +created with mode 0600 is not, which matters when the server is reached +through a gateway on the same host rather than from the network. + +run also drains the server's event queue while waiting to accept, which is +where SetCredentials takes effect. That receiver is private, so the +credentials are kept on the D-Bus object and applied before each connection +instead - otherwise CredSSP runs without them. +--- +diff --git a/qemu-rdp/src/args.rs b/qemu-rdp/src/args.rs +index 37c6f9a..2f6e261 100644 +--- a/qemu-rdp/src/args.rs ++++ b/qemu-rdp/src/args.rs +@@ -38,6 +38,14 @@ pub struct ServerArgs { + #[clap(short, long, default_value = "0.0.0.0:3389")] + pub bind_address: std::net::SocketAddr, + ++ /// Listen on a unix socket instead of a TCP port. ++ /// ++ /// The socket is created with mode 0600, so access is controlled by the ++ /// filesystem rather than being open to every local user as a loopback ++ /// port is. Intended for a gateway on the same host. ++ #[clap(long, value_parser, conflicts_with = "bind_address")] ++ pub bind_socket: Option, ++ + /// Path to tls certificate + #[clap(short, long, value_parser)] + pub cert: Option, +diff --git a/qemu-rdp/src/server/mod.rs b/qemu-rdp/src/server/mod.rs +index 40b191b..48befb3 100644 +--- a/qemu-rdp/src/server/mod.rs ++++ b/qemu-rdp/src/server/mod.rs +@@ -1,7 +1,10 @@ + use anyhow::{bail, Error}; + use enumflags2::BitFlags; + use ironrdp::server::{Credentials, ServerEvent, TlsIdentityCtx}; +-use std::path::PathBuf; ++use std::os::unix::fs::PermissionsExt; ++use std::path::{Path, PathBuf}; ++use std::sync::{Arc, Mutex}; ++use tokio::net::UnixListener; + use tokio::sync::{mpsc::UnboundedSender, oneshot}; + use tracing::{debug, error}; + use zbus::object_server::SignalEmitter; +@@ -27,6 +30,11 @@ pub struct Server { + + struct DBusCtrl { + ev: UnboundedSender, ++ /// The last credentials SetCredentials was given, for --bind-socket. ++ /// ++ /// See `run_on_socket`: that loop cannot drain the server's event queue, ++ /// so it reads them from here instead. ++ pending_credentials: Arc>>, + } + + impl Server { +@@ -80,7 +88,11 @@ impl Server { + .build(); + + let ev = server.event_sender().clone(); +- let dbus_ctrl = DBusCtrl { ev }; ++ let pending_credentials = Arc::new(Mutex::new(None)); ++ let dbus_ctrl = DBusCtrl { ++ ev, ++ pending_credentials: Arc::clone(&pending_credentials), ++ }; + let dbus_path = "/org/qemu_display/rdp"; + self.dbus.object_server().at(dbus_path, dbus_ctrl).await?; + +@@ -109,16 +121,63 @@ impl Server { + + println!("Starting RDP server, args: {:?}", self.args); + println!("Cert: {cert:?}, Key: {key:?}"); +- server.run().await?; ++ match self.args.bind_socket.clone() { ++ Some(path) => Self::run_on_socket(&mut server, &path, &pending_credentials).await?, ++ None => server.run().await?, ++ } + println!("RDP server ended"); + Ok(()) + } ++ ++ /// Accept loop for --bind-socket. ++ /// ++ /// `RdpServer::run` binds a `TcpListener` of its own, so serving a unix ++ /// socket means repeating the accept loop here; `run_connection` is ++ /// generic over the stream and takes a `UnixStream` unchanged. ++ /// ++ /// `run` also drains the server's event queue while waiting to accept, and ++ /// that is where SetCredentials is applied. The queue's receiver is ++ /// private, so this cannot do the same: credentials sent while no client ++ /// was connected would sit there until one arrived, and would then be ++ /// applied only after CredSSP had already failed for want of them. They ++ /// are taken from DBusCtrl directly instead, before each connection. ++ async fn run_on_socket( ++ server: &mut RdpServer, ++ path: &Path, ++ pending_credentials: &Mutex>, ++ ) -> Result<(), Error> { ++ // A socket left behind by an unclean exit would fail the bind. ++ match std::fs::remove_file(path) { ++ Ok(()) => {} ++ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} ++ Err(e) => return Err(e.into()), ++ } ++ ++ let listener = UnixListener::bind(path)?; ++ // Narrowed after the fact, so a caller wanting no window at all should ++ // set a umask too. This permission is the point of the socket. ++ std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; ++ ++ loop { ++ let (stream, _addr) = listener.accept().await?; ++ let credentials = pending_credentials ++ .lock() ++ .expect("SetCredentials does not panic while holding this") ++ .clone(); ++ if credentials.is_some() { ++ server.set_credentials(credentials); ++ } ++ if let Err(error) = server.run_connection(stream).await { ++ error!(?error, "RDP connection ended with an error"); ++ } ++ } ++ } + } + + #[zbus::interface(name = "org.QemuDisplay.RDP")] + impl DBusCtrl { + async fn set_credentials(&self, username: &str, password: &str, domain: &str) { +- if let Err(error) = self.ev.send(ServerEvent::SetCredentials(Credentials { ++ let credentials = Credentials { + username: username.into(), + password: password.into(), + domain: if domain.is_empty() { +@@ -126,7 +185,13 @@ impl DBusCtrl { + } else { + Some(domain.into()) + }, +- })) { ++ }; ++ // Kept as well as sent, for the --bind-socket accept loop. ++ *self ++ .pending_credentials ++ .lock() ++ .expect("nothing panics while holding this") = Some(credentials.clone()); ++ if let Err(error) = self.ev.send(ServerEvent::SetCredentials(credentials)) { + error!(?error, "Failed to send SetCredentials") + } + } diff --git a/qemu-display b/qemu-display new file mode 160000 index 0000000..8ac3da9 --- /dev/null +++ b/qemu-display @@ -0,0 +1 @@ +Subproject commit 8ac3da95abeca92e5bb0aee2c58adf54e86f4482 -- 2.55.0