all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
To: pve-devel@lists.proxmox.com
Subject: [RFC qemu-server 04/13] add rdp display
Date: Tue, 25 Aug 2026 13:34:29 +0200	[thread overview]
Message-ID: <20260825113442.947620-5-alexandre.derumier@groupe-cyllene.com> (raw)
In-Reply-To: <20260825113442.947620-1-alexandre.derumier@groupe-cyllene.com>

rdp maps to virtio-vga and start a per-VM qemu-rdp on a unix socket

Signed-off-by: Alexandre Derumier <alexandre.derumier@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




  parent reply	other threads:[~2026-08-25 11:35 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 ` Alexandre Derumier [this message]
2026-08-25 11:34 ` [RFC qemu-server 05/13] add experimental kyber-gl display Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-manager 06/13] ui: add kyber console Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-manager 07/13] ui: add rdp console Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-kyber-web 10/13] add pve-kyber-web: console's webassembly client Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-qemu-rdp 11/13] Add pve-qemu-rdp: an RDP server for the console Alexandre Derumier
2026-08-25 11:34 ` [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-5-alexandre.derumier@groupe-cyllene.com \
    --to=alexandre.derumier@groupe-cyllene.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal