From: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
To: pve-devel@lists.proxmox.com
Subject: [RFC pve-qemu-rdp 11/13] Add pve-qemu-rdp: an RDP server for the console
Date: Tue, 25 Aug 2026 13:34:36 +0200 [thread overview]
Message-ID: <20260825113442.947620-12-alexandre.derumier@groupe-cyllene.com> (raw)
In-Reply-To: <20260825113442.947620-1-alexandre.derumier@groupe-cyllene.com>
qemu-rdp reads a VM's display over org.qemu.Display1.
It's also provide clipboard && audio.
Signed-off-by: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
---
.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 <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..7b292b6
--- /dev/null
+++ b/debian/control
@@ -0,0 +1,24 @@
+Source: pve-qemu-rdp
+Section: admin
+Priority: optional
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Uploaders: Alexandre Derumier <aderumier@groupe-cyllene.com>
+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 <marcandre.lureau@redhat.com>
+ 2023 Mihnea Buzatu <mihneabuzatu88@gmail.com>
+License: MIT
+
+Files: debian/*
+Copyright: 2026 Proxmox Server Solutions GmbH <support@proxmox.com>
+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 <aderumier@groupe-cyllene.com>
+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<PathBuf>,
++
+ /// Path to tls certificate
+ #[clap(short, long, value_parser)]
+ pub cert: Option<PathBuf>,
+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<ServerEvent>,
++ /// 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<Mutex<Option<Credentials>>>,
+ }
+
+ 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<Option<Credentials>>,
++ ) -> 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
next prev 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 ` Alexandre Derumier [this message]
2026-08-25 11:34 ` [RFC pve-rdpproxy 12/13] Add pve-rdpproxy Alexandre Derumier
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260825113442.947620-12-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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox