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 0163C1FF0B7 for ; Tue, 25 Aug 2026 13:09:33 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 19E642165E; Tue, 25 Aug 2026 13:09:15 +0200 (CEST) From: Alexandre Derumier To: pve-devel@lists.proxmox.com subject: SPAM: [RFC qemu-server 04/13] add rdp display Date: Tue, 25 Aug 2026 13:08:36 +0200 Message-ID: <20260825110849.2967694-5-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: 6 AWL 0.133 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 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: SSJM57NY3YDAJOBYUOQJALLTFHZU2EKI X-Message-ID-Hash: SSJM57NY3YDAJOBYUOQJALLTFHZU2EKI 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 rdp maps to virtio-vga and start a per-VM qemu-rdp on a unix socket Signed-off-by: Alexandre Derumier --- 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//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