From: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
To: pve-devel@lists.proxmox.com
Cc: Alexandre Derumier <aderumier@groupe-cyllene.com>
Subject: SPAM: [RFC qemu-server 04/13] add rdp display
Date: Tue, 25 Aug 2026 13:08:36 +0200 [thread overview]
Message-ID: <20260825110849.2967694-5-alexandre.derumier@groupe-cyllene.com> (raw)
In-Reply-To: <20260825110849.2967694-1-alexandre.derumier@groupe-cyllene.com>
From: Alexandre Derumier <aderumier@groupe-cyllene.com>
rdp maps to virtio-vga and start a per-VM qemu-rdp on a unix socket
Signed-off-by: Alexandre Derumier <aderumier@groupe-cyllene.com>
---
src/PVE/API2/Qemu.pm | 96 ++++++++++++++++++
src/PVE/QemuServer.pm | 15 ++-
src/PVE/QemuServer/Makefile | 1 +
src/PVE/QemuServer/RDP.pm | 177 ++++++++++++++++++++++++++++++++++
src/test/cfg2cmd/rdp.conf | 3 +
src/test/cfg2cmd/rdp.conf.cmd | 27 ++++++
src/usr/Makefile | 1 +
src/usr/pve-qemu-rdp@.service | 22 +++++
8 files changed, 338 insertions(+), 4 deletions(-)
create mode 100644 src/PVE/QemuServer/RDP.pm
create mode 100644 src/test/cfg2cmd/rdp.conf
create mode 100644 src/test/cfg2cmd/rdp.conf.cmd
create mode 100644 src/usr/pve-qemu-rdp@.service
diff --git a/src/PVE/API2/Qemu.pm b/src/PVE/API2/Qemu.pm
index 55befc1..bae345b 100644
--- a/src/PVE/API2/Qemu.pm
+++ b/src/PVE/API2/Qemu.pm
@@ -37,6 +37,7 @@ use PVE::QemuServer::CPUConfig;
use PVE::QemuServer::Drive qw(checked_volume_format checked_parse_volname);
use PVE::QemuServer::Helpers;
use PVE::QemuServer::Kyber;
+use PVE::QemuServer::RDP;
use PVE::QemuServer::ImportDisk;
use PVE::QemuServer::Monitor qw(mon_cmd vm_qmp_peer);
use PVE::QemuServer::Machine;
@@ -3417,6 +3418,95 @@ __PACKAGE__->register_method({
},
});
+__PACKAGE__->register_method({
+ name => 'rdpproxy',
+ path => '{vmid}/rdpproxy',
+ method => 'POST',
+ protected => 1,
+ proxyto => 'node',
+ permissions => {
+ check => ['perm', '/vms/{vmid}', ['VM.Console']],
+ },
+ description => "Start an RDP server for the VM and return how to reach it.",
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ node => get_standard_option('pve-node'),
+ vmid => get_standard_option('pve-vmid'),
+ },
+ },
+ returns => {
+ additionalProperties => 0,
+ properties => {
+ user => {
+ type => 'string',
+ description => "User name to log in to the RDP server with.",
+ },
+ password => {
+ type => 'string',
+ description => "Password for that user, good for this server only.",
+ },
+ token => {
+ type => 'string',
+ description => "Names this VM's console to pve-rdpproxy.",
+ },
+ },
+ },
+ code => sub {
+ my ($param) = @_;
+
+ my $vmid = $param->{vmid};
+ my $node = $param->{node};
+
+ my $conf = PVE::QemuConfig->load_config($vmid, $node);
+
+ my $vga = PVE::QemuServer::parse_vga($conf->{vga} // '');
+ die "VM $vmid is not configured for the RDP console"
+ . " - set its display to 'rdp' and restart it\n"
+ if ($vga->{type} // '') ne 'rdp';
+
+ die "VM $vmid is not running\n" if !PVE::QemuServer::Helpers::vm_running_locally($vmid);
+
+ my $dbus = PVE::QemuServer::Helpers::dbus_socket($vmid);
+ die "VM $vmid has no D-Bus display socket at $dbus"
+ . " - it was started before its display was set to 'rdp',"
+ . " so it needs a restart\n"
+ if !-S $dbus;
+
+ # Join a server that is already running rather than restarting it and cutting the
+ # first console off; qemu-rdp serves one session at a time, so it displaces.
+ my ($user, $password, $token) = PVE::QemuServer::RDP::running_credentials($vmid);
+
+ if (!$user) {
+ # Fixed: it names nothing, and CredSSP needs some user to bind to.
+ $user = 'pve';
+ $password = PVE::QemuServer::RDP::generate_secret();
+ $token = PVE::QemuServer::RDP::generate_secret();
+
+ PVE::QemuServer::RDP::generate_cert($vmid);
+ PVE::QemuServer::RDP::write_env($vmid, $user, $password, $token);
+ PVE::QemuServer::RDP::restart_server($vmid);
+
+ # Handed over D-Bus once the server has claimed its name, never on a command line.
+ PVE::QemuServer::RDP::set_credentials($vmid, $user, $password);
+ }
+
+ # The socket appears a moment after the credentials, and the client would retry.
+ my $socket = PVE::QemuServer::RDP::socket_file($vmid);
+ for (my $waited = 0; $waited < 5; $waited += 0.05) {
+ last if -S $socket;
+ usleep(50_000);
+ }
+ die "the RDP server for VM $vmid did not start\n" if !-S $socket;
+
+ return {
+ user => $user,
+ password => $password,
+ token => $token,
+ };
+ },
+});
+
__PACKAGE__->register_method({
name => 'spiceproxy',
path => '{vmid}/spiceproxy',
@@ -3545,6 +3635,11 @@ __PACKAGE__->register_method({
type => 'boolean',
optional => 1,
},
+ rdp => {
+ description => "QEMU VGA configuration supports the RDP console.",
+ type => 'boolean',
+ optional => 1,
+ },
agent => {
description => "QEMU Guest Agent is enabled in config.",
type => 'boolean',
@@ -3576,6 +3671,7 @@ __PACKAGE__->register_method({
$spice ||= PVE::QemuServer::vga_conf_has_spice($conf->{vga});
$status->{spice} = 1 if $spice;
$status->{kyber} = 1 if ($vga->{type} // '') eq 'kyber';
+ $status->{rdp} = 1 if ($vga->{type} // '') eq 'rdp';
$status->{clipboard} = $vga->{clipboard};
}
$status->{agent} = 1 if PVE::QemuServer::Agent::get_qga_key($conf, 'enabled');
diff --git a/src/PVE/QemuServer.pm b/src/PVE/QemuServer.pm
index 8c7f023..33a5e40 100644
--- a/src/PVE/QemuServer.pm
+++ b/src/PVE/QemuServer.pm
@@ -100,6 +100,7 @@ use PVE::QemuServer::USB;
use PVE::QemuServer::Virtiofs qw(max_virtiofs start_all_virtiofsd);
use PVE::QemuServer::VolumeChain;
use PVE::QemuServer::DBusDisplay;
+use PVE::QemuServer::RDP;
use PVE::QemuServer::DBusVMState;
my $have_ha_config;
@@ -170,7 +171,7 @@ my $vga_fmt = {
optional => 1,
default_key => 1,
enum => [
- qw(cirrus kyber qxl qxl2 qxl3 qxl4 none serial0 serial1 serial2 serial3 std virtio virtio-gl vmware)
+ qw(cirrus kyber qxl qxl2 qxl3 qxl4 none rdp serial0 serial1 serial2 serial3 std virtio virtio-gl vmware)
],
},
memory => {
@@ -1171,7 +1172,9 @@ sub pve_verify_hotplug_features {
sub assert_clipboard_config {
my ($vga) = @_;
- my $clipboard_regex = qr/^(std|cirrus|vmware|virtio|qxl)/;
+ # The D-Bus displays take it too: QEMU's clipboard needs a guest agent on the
+ # vdagent chardev, which is what this option adds whichever front end reads it.
+ my $clipboard_regex = qr/^(std|cirrus|vmware|virtio|qxl|kyber|rdp)/;
if (
$vga->{'clipboard'}
@@ -1488,6 +1491,8 @@ my $vga_map = {
# A display transport, not a card, so it picks one: virtio-vga rather than a GL
# variant, both of which currently break QEMU.
'kyber' => 'virtio-vga',
+ # Same reasoning for the RDP console: it reads the same D-Bus display.
+ 'rdp' => 'virtio-vga',
};
# QEMU builds only the non-VGA variants of the virtio GPU for aarch64
@@ -1496,6 +1501,7 @@ my $vga_map_aarch64 = {
'virtio' => 'virtio-gpu',
'virtio-gl' => 'virtio-gpu-gl',
'kyber' => 'virtio-gpu',
+ 'rdp' => 'virtio-gpu',
};
my sub map_vga_model {
@@ -3423,7 +3429,7 @@ sub config_to_command {
push @$cmd, '-display', 'egl-headless,gl=core' if $vga->{type} eq 'virtio-gl'; # VIRGL
- if ($vga->{type} eq 'kyber') {
+ if ($vga->{type} =~ /^(?:kyber|rdp)$/) {
my $dbus = PVE::QemuServer::Helpers::dbus_socket($vmid);
my $display = "dbus,addr=unix:path=$dbus";
@@ -5843,7 +5849,7 @@ sub vm_start_nolock {
# QEMU connects to the D-Bus address, so the bus has to be listening first.
my $dbus_vga = parse_vga($conf->{vga} // '');
PVE::QemuServer::DBusDisplay::start($vmid)
- if ($dbus_vga->{type} // '') eq 'kyber';
+ if ($dbus_vga->{type} // '') =~ /^(?:kyber|rdp)$/;
my $tpmpid;
if ((my $tpm = $conf->{tpmstate0}) && !PVE::QemuConfig->is_template($conf)) {
@@ -6234,6 +6240,7 @@ sub vm_stop_cleanup {
# start fails with "timeout waiting on systemd".
eval {
PVE::QemuServer::Kyber::stop_controller($vmid);
+ PVE::QemuServer::RDP::stop_server($vmid);
PVE::QemuServer::DBusDisplay::stop($vmid);
};
warn $@ if $@;
diff --git a/src/PVE/QemuServer/Makefile b/src/PVE/QemuServer/Makefile
index 061d61f..38e5aa6 100644
--- a/src/PVE/QemuServer/Makefile
+++ b/src/PVE/QemuServer/Makefile
@@ -27,6 +27,7 @@ SOURCES=Agent.pm \
QemuImage.pm \
QMPHelpers.pm \
QSD.pm \
+ RDP.pm \
RNG.pm \
RunState.pm \
StateFile.pm \
diff --git a/src/PVE/QemuServer/RDP.pm b/src/PVE/QemuServer/RDP.pm
new file mode 100644
index 0000000..45fc921
--- /dev/null
+++ b/src/PVE/QemuServer/RDP.pm
@@ -0,0 +1,177 @@
+package PVE::QemuServer::RDP;
+
+# Per-VM RDP server, for VMs with 'vga: rdp'. One qemu-rdp per VM on a unix
+# socket, spoken to only by pve-rdpproxy, which pveproxy hands the console's
+# websocket to. It reads the same D-Bus display the Kyber console does, and
+# registers its own control interface on that bus, which isolates it per VM.
+
+use strict;
+use warnings;
+
+use Crypt::OpenSSL::Random;
+use Time::HiRes qw(usleep);
+
+use PVE::Tools qw(file_set_contents);
+use PVE::QemuServer::DBusDisplay;
+use PVE::QemuServer::Helpers;
+
+# RDP itself, on a unix socket: only pve-rdpproxy on this node speaks to it, and a
+# socket carries its own permissions. Needs the --bind-socket patch.
+sub socket_file {
+ my ($vmid) = @_;
+ return "$PVE::QemuServer::Helpers::var_run_tmpdir/$vmid.rdp.sock";
+}
+
+# Carries the RDP credentials, and only those: /proc/<pid>/cmdline is
+# world-readable, and only root reads this.
+sub env_file {
+ my ($vmid) = @_;
+ return "$PVE::QemuServer::Helpers::var_run_tmpdir/$vmid.rdp.env";
+}
+
+# A self-signed certificate per VM, regenerated on every start. qemu-rdp requires
+# TLS - CredSSP binds to the server's public key - but it authenticates nothing:
+# the only peer is pve-rdpproxy, one hop away on the same node.
+sub cert_file {
+ my ($vmid) = @_;
+ return "$PVE::QemuServer::Helpers::var_run_tmpdir/$vmid.rdp.crt";
+}
+
+sub key_file {
+ my ($vmid) = @_;
+ return "$PVE::QemuServer::Helpers::var_run_tmpdir/$vmid.rdp.key";
+}
+
+sub generate_secret {
+ my ($bytes) = @_;
+ $bytes //= 24;
+
+ my $data = Crypt::OpenSSL::Random::random_bytes($bytes)
+ or die "unable to generate a random secret\n";
+
+ return unpack('H*', $data);
+}
+
+# EC rather than RSA: an RSA keygen on every console start would be felt.
+sub generate_cert {
+ my ($vmid) = @_;
+
+ my $cert = cert_file($vmid);
+ my $key = key_file($vmid);
+
+ PVE::Tools::run_command(
+ [
+ 'openssl', 'req', '-x509', '-nodes', '-days', '3650',
+ '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:prime256v1',
+ '-subj', "/CN=pve-rdp-$vmid",
+ '-keyout', $key, '-out', $cert,
+ ],
+ errmsg => "failed to generate an RDP certificate for VM $vmid",
+ outfunc => sub { },
+ errfunc => sub { },
+ );
+
+ chmod 0600, $key;
+ chmod 0644, $cert;
+
+ return ($cert, $key);
+}
+
+# The credentials a running server is accepting, or undef when there is none, so a
+# second console joins instead of restarting and cutting the first off.
+sub running_credentials {
+ my ($vmid) = @_;
+
+ my $env = eval { PVE::Tools::file_get_contents(env_file($vmid)) };
+ return undef if !defined($env);
+
+ my ($user) = $env =~ m/^RDP_USERNAME=(\S+)$/m;
+ my ($pass) = $env =~ m/^RDP_PASSWORD=(\S+)$/m;
+ my ($token) = $env =~ m/^RDP_TOKEN=(\S+)$/m;
+ return undef if !$user || !$pass || !$token;
+
+ # The socket is the only proof: qemu-rdp quits on its own and leaves the file.
+ return undef if !-S socket_file($vmid);
+
+ return ($user, $pass, $token);
+}
+
+# Binds a console to the VM it was opened for - pveproxy has already decided who
+# may open one - so a token for one VM cannot be replayed against another.
+sub write_env {
+ my ($vmid, $username, $password, $token) = @_;
+
+ my $env = <<"EOF";
+RDP_USERNAME=$username
+RDP_PASSWORD=$password
+RDP_TOKEN=$token
+EOF
+
+ my $path = env_file($vmid);
+ file_set_contents($path, $env, 0600);
+
+ return $path;
+}
+
+# Credentials go over D-Bus for the same reason the env file exists, and after the
+# unit is up because the interface exists only once the name is claimed.
+sub set_credentials {
+ my ($vmid, $username, $password) = @_;
+
+ my $addr = 'unix:path=' . PVE::QemuServer::Helpers::dbus_socket($vmid);
+
+ my $err;
+ for (my $waited = 0; $waited < 10; $waited += 0.1) {
+ $err = undef;
+ eval {
+ PVE::Tools::run_command(
+ [
+ 'busctl', '--address', $addr, 'call',
+ 'org.QemuDisplay.RDP', '/org/qemu_display/rdp',
+ 'org.QemuDisplay.RDP', 'SetCredentials', 'sss',
+ $username, $password, '',
+ ],
+ outfunc => sub { },
+ errfunc => sub { },
+ );
+ };
+ $err = $@;
+ last if !$err;
+ usleep(100_000);
+ }
+ die "failed to set RDP credentials for VM $vmid - $err" if $err;
+
+ return;
+}
+
+# A systemd template unit rather than an API worker, as PVE::QemuServer::Kyber
+# explains. PartOf the VM's scope, so it cannot outlive the D-Bus socket; qemu-rdp
+# also quits when org.qemu disappears.
+sub restart_server {
+ my ($vmid) = @_;
+
+ PVE::Tools::run_command(
+ ['systemctl', 'restart', "pve-qemu-rdp\@$vmid"],
+ errmsg => "failed to start the RDP server for VM $vmid",
+ );
+
+ return;
+}
+
+sub stop_server {
+ my ($vmid) = @_;
+
+ eval {
+ PVE::Tools::run_command(['systemctl', 'stop', "pve-qemu-rdp\@$vmid"]);
+ };
+ warn $@ if $@;
+
+ unlink env_file($vmid);
+ unlink cert_file($vmid);
+ unlink key_file($vmid);
+ unlink socket_file($vmid);
+
+ return;
+}
+
+1;
diff --git a/src/test/cfg2cmd/rdp.conf b/src/test/cfg2cmd/rdp.conf
new file mode 100644
index 0000000..71bae1b
--- /dev/null
+++ b/src/test/cfg2cmd/rdp.conf
@@ -0,0 +1,3 @@
+# TEST: RDP console display
+memory: 2048
+vga: rdp
diff --git a/src/test/cfg2cmd/rdp.conf.cmd b/src/test/cfg2cmd/rdp.conf.cmd
new file mode 100644
index 0000000..dfb2e99
--- /dev/null
+++ b/src/test/cfg2cmd/rdp.conf.cmd
@@ -0,0 +1,27 @@
+/usr/bin/kvm
+-id 8006
+-name vm8006
+-no-shutdown
+-chardev 'socket,id=qmp,path=/var/run/qemu-server/8006.qmp,server=on,wait=off'
+-mon 'chardev=qmp,mode=control'
+-chardev 'socket,id=qmp-event,path=/var/run/qmeventd.sock,reconnect-ms=5000'
+-mon 'chardev=qmp-event,mode=control'
+-pidfile /var/run/qemu-server/8006.pid
+-daemonize
+-smp '1,sockets=1,cores=1,maxcpus=1'
+-nodefaults
+-boot 'menu=on,strict=on,reboot-timeout=1000,splash=/usr/share/qemu-server/bootsplash.jpg'
+-display 'dbus,addr=unix:path=/var/run/qemu-server/8006.dbusdisplay'
+-vnc 'unix:/var/run/qemu-server/8006.vnc,password=on'
+-cpu kvm64,enforce,+kvm_pv_eoi,+kvm_pv_unhalt,+lahf_lm,+sep
+-m 2048
+-global 'PIIX4_PM.disable_s3=1'
+-global 'PIIX4_PM.disable_s4=1'
+-device 'pci-bridge,id=pci.1,chassis_nr=1,bus=pci.0,addr=0x1e'
+-device 'pci-bridge,id=pci.2,chassis_nr=2,bus=pci.0,addr=0x1f'
+-device 'piix3-usb-uhci,id=uhci,bus=pci.0,addr=0x1.0x2'
+-device 'usb-tablet,id=tablet,bus=uhci.0,port=1'
+-device 'virtio-vga,id=vga,bus=pci.0,addr=0x2'
+-device 'virtio-balloon-pci,id=balloon0,bus=pci.0,addr=0x3,free-page-reporting=on'
+-iscsi 'initiator-name=iqn.1993-08.org.debian:01:aabbccddeeff'
+-machine 'type=pc+pve0'
\ No newline at end of file
diff --git a/src/usr/Makefile b/src/usr/Makefile
index 58dbb1d..1992dea 100644
--- a/src/usr/Makefile
+++ b/src/usr/Makefile
@@ -23,6 +23,7 @@ install: pve-usb.cfg pve-q35.cfg pve-q35-4.0.cfg bootsplash.jpg modules-load.con
install -d $(LIBSYSTEMDDIR)
install -D -m 0644 pve-dbus-vmstate@.service $(LIBSYSTEMDDIR)/system/pve-dbus-vmstate@.service
install -D -m 0644 pve-qemu-kyber@.service $(LIBSYSTEMDDIR)/system/pve-qemu-kyber@.service
+ install -D -m 0644 pve-qemu-rdp@.service $(LIBSYSTEMDDIR)/system/pve-qemu-rdp@.service
install -d $(DBUSDIR)
install -D -m 0644 org.qemu.VMState1.conf $(DBUSDIR)/system.d/org.qemu.VMState1.conf
diff --git a/src/usr/pve-qemu-rdp@.service b/src/usr/pve-qemu-rdp@.service
new file mode 100644
index 0000000..317e8b0
--- /dev/null
+++ b/src/usr/pve-qemu-rdp@.service
@@ -0,0 +1,22 @@
+[Unit]
+Description=PVE RDP Console Server (VM %i)
+# Tie it to the VM's scope: it goes away with the VM. qemu-rdp also quits when
+# org.qemu disappears.
+PartOf=%i.scope
+After=%i.scope
+
+[Service]
+Slice=qemu.slice
+Type=simple
+# So the listening socket is created 0600 rather than narrowed after bind.
+UMask=0077
+# One address for both directions: qemu-rdp finds org.qemu here and registers its
+# own control interface on the same connection, isolated per VM. A unix socket,
+# not a port; credentials arrive over D-Bus; TLS is required by CredSSP.
+ExecStart=/usr/bin/qemu-rdp \
+ --dbus-address unix:path=/var/run/qemu-server/%i.dbusdisplay \
+ serve \
+ --bind-socket /var/run/qemu-server/%i.rdp.sock \
+ --cert /var/run/qemu-server/%i.rdp.crt \
+ --key /var/run/qemu-server/%i.rdp.key
+Restart=no
--
2.55.0
next prev parent reply other threads:[~2026-08-25 11:09 UTC|newest]
Thread overview: 11+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-08-25 11:08 SPAM: [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:08 ` SPAM: [RFC pve-http-server 01/13] anyevent : proxy a path prefix to a local http proxy Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC qemu-server 02/13] add D-Bus display support Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC qemu-server 03/13] add kyber display Alexandre Derumier
2026-08-25 11:08 ` Alexandre Derumier [this message]
2026-08-25 11:08 ` SPAM: [RFC qemu-server 05/13] add experimental kyber-gl display Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC pve-manager 06/13] ui: add kyber console Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC pve-manager 07/13] ui: add rdp console Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC pve-kyber-web 10/13] add pve-kyber-web: console's webassembly client Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC pve-qemu-rdp 11/13] Add pve-qemu-rdp: an RDP server for the console Alexandre Derumier
2026-08-25 11:08 ` SPAM: [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=20260825110849.2967694-5-alexandre.derumier@groupe-cyllene.com \
--to=alexandre.derumier@groupe-cyllene.com \
--cc=aderumier@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