From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id 3AA191FF0C1 for ; Wed, 26 Aug 2026 09:58:46 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id C5061212FF; Wed, 26 Aug 2026 09:58:45 +0200 (CEST) ARC-Seal: i=1; a=rsa-sha256; s=arcselector10001; d=microsoft.com; cv=none; b=S/4h/xVp2Qtsc1uDNzVpSfO0MJP9dG4WslN2tpBSVwgCuV01M/brvjGUtVLtpwHKi3zSWEmMvZYdhulJdH0ejGcXQK8viR5nlVgYOP+d6FfwM8qqh7dsVGQASNLWnpVERrR7CZLnGc0KwqngkaCt1FjS9sjmZyARgiFeLpSt6z8sAuqGtRaIq1Vzuc7FYZgRd83lZRUDzsjKPNCixnL+1QMcjoX+UDbx9mXh6KJyDLQS/IgMrVHw04T4ei3yBxt9DuoTO42RMP5D8t9BT+JM31kJFGqSb/MQOzlhkVvVFWw4l7vLWIUN5t4jq0f0bExISbYfcUhGQVw+lw1QfafIUw== ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=microsoft.com; s=arcselector10001; h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-AntiSpam-MessageData-ChunkCount:X-MS-Exchange-AntiSpam-MessageData-0:X-MS-Exchange-AntiSpam-MessageData-1; bh=a4dA3BkrAHZzj1cu15IWh4kVFz9FjBoJIT/ezMG1DuU=; b=MpRAvIHLYePA6Q8fuXTGzoFB74FQ2raJ0grYuKt4rVF+7y9C2WVnirNJFtOCao/PjbvvdR+/bO6DnKbhtjXtvZgEx9NiZIoE0gjMBUoUpQ6Nm+71T/TgT2CBfsvN9/qzI28PL0NOnfveZB1eLMQq6QHAU2k6DPu/aG6Zvzv81lacWNMoNMkunHAnrY+S1PMTF8bAZV7i/R5CPvem2niWlbhQLPhuNLjQe2RVRW9td0qb4vWAPlVM5OpB3raLZ79kbymkw9r+a6d3dUapgEA1hsfb7RABmuvO3Za3wNHHrhZ82bKXkJfMysseKsAV6zVg3tO9oDKpr6eIJkYPS+gtow== ARC-Authentication-Results: i=1; mx.microsoft.com 1; spf=pass smtp.mailfrom=groupe-cyllene.com; dmarc=pass action=none header.from=groupe-cyllene.com; dkim=pass header.d=groupe-cyllene.com; arc=none DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=groupecyllene.onmicrosoft.com; s=selector2-groupecyllene-onmicrosoft-com; h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-SenderADCheck; bh=a4dA3BkrAHZzj1cu15IWh4kVFz9FjBoJIT/ezMG1DuU=; b=AQjq4dvPwtXpjkys2+eG+2x0IdGeDw1VKDrKYXAz+RJv2GCN8PG3DdaeHjkbk//s1KxURsGgJGW1M+dJfoF9/bWyX+qSZaCRZlV5bu77LoGwjRkrNFsq0by3ZSxtm11rS2PyMC4vNlGcjdOSaF/h/SaoELMrtKvNwsze3dDEQIQ= Authentication-Results: dkim=none (message not signed) header.d=none;dmarc=none action=none header.from=groupe-cyllene.com; From: Alexandre Derumier To: pve-devel@lists.proxmox.com Subject: [RFC v2 qemu-server 04/13] add rdp display Date: Wed, 26 Aug 2026 09:43:36 +0200 Message-ID: <20260826074347.1256659-5-alexandre.derumier@groupe-cyllene.com> X-Mailer: git-send-email 2.55.0 In-Reply-To: <20260826074347.1256659-1-alexandre.derumier@groupe-cyllene.com> References: <20260826074347.1256659-1-alexandre.derumier@groupe-cyllene.com> Content-Transfer-Encoding: 8bit Content-Type: text/plain X-ClientProxiedBy: PA7P264CA0229.FRAP264.PROD.OUTLOOK.COM (2603:10a6:102:372::8) To PR1P264MB3696.FRAP264.PROD.OUTLOOK.COM (2603:10a6:102:143::11) MIME-Version: 1.0 X-MS-PublicTrafficType: Email X-MS-TrafficTypeDiagnostic: PR1P264MB3696:EE_|PR0P264MB2257:EE_ X-MS-Office365-Filtering-Correlation-Id: fcb64514-ab7a-4f99-84f4-08df0345d476 X-MS-Exchange-SenderADCheck: 1 X-MS-Exchange-AntiSpam-Relay: 0 X-Microsoft-Antispam: BCL:0;ARA:13230040|376014|23010399003|52116014|1800799024|366016|38350700014|6133799003|3023799007|56012099006|10067099003|5023799004|20052099010|22082099003|18002099003; X-Microsoft-Antispam-Message-Info: rYgsjxWBBkh6nvXaMb/pIz1YDK3jkm3PHvW2+j7Yc1i9kQ9b+IDSWzSg2A+HvFREAjdSrDFpVrtm5zB7O1/Q7AHJqrgWoAr6Q6650asqxTJ87rM8MGkYs8NZWTc4g5qid3nu05YTCijqLWXD23DysIpXbRjurIrxwwOZEI1OAlccPVHnCeT5TJ2bo+Xdd0mho8yi26Zyt1u0FirLIX7tSmuW7qtwJFuq8m+0eBFimb/Hv8ZZsb7fPCxgImsVIpQT2TcWATnRh3Ef4aYeMM0FIV+wbZfELCZsvnNYhUCHFaQUwB8anl1mwkNYD1ISl/RduaDhVgcEH6l1p6OYdV9nWfq2T5K6iuX0D1oUl65/D3JGi0Z/gojPybYyRBsI66z+TAGzCNYJ95FeKI5b9kJpdFjqQfFRfmWbYPLqoBJCgAaSMlds58IFPuaMYEDJIn1LkvgCLoD9ar+gJQZ+xD3h8dG/Xiu0ZNRAYYoCWY/obnv/dMzEPAgaokuoVApAbvHahvLkERqIDvEbwqXMov+IQ8bRGZlgs8cd4PmxpyAvLH8WK38ZtjYzGqRp7aQeFsJ7exEYgYl51gL945eTsVOyNPaduH35e4xT80jHWChfCSYs8ND7oY6TkY1wgoeMahDFVEtbZe2+rqUZElsrYLOCJWV5dhuhrvjhBCrLENEFbkAWWgdYR1/CfMkHY5HXJhs0jQGqDd3X5WClro+yCQyWnEih+2NMyYQwtqsxHyktGmw= X-Forefront-Antispam-Report: CIP:255.255.255.255;CTRY:;LANG:en;SCL:1;SRV:;IPV:NLI;SFV:NSPM;H:PR1P264MB3696.FRAP264.PROD.OUTLOOK.COM;PTR:;CAT:NONE;SFS:(13230040)(376014)(23010399003)(52116014)(1800799024)(366016)(38350700014)(6133799003)(3023799007)(56012099006)(10067099003)(5023799004)(20052099010)(22082099003)(18002099003);DIR:OUT;SFP:1102; X-MS-Exchange-AntiSpam-MessageData-ChunkCount: 1 X-MS-Exchange-AntiSpam-MessageData-0: =?us-ascii?Q?bwfiR7sqqWPBpqpNvzLbGwG3pPvU7/gFF0WnOxlKzEnIua1ZUOvCwFzgKhHq?= =?us-ascii?Q?RNXFXI4uwypF5dZrgxYMkXDWEnqDRMWkVVqL8YzMmZmandEa6A56XxMQqF/e?= =?us-ascii?Q?CBB4kzuILdXR2bV56TtF8t3dG05zPeRTG11SfmxkJ3VxxGGEY8yG7fEYRdIp?= =?us-ascii?Q?4SPI/jTBnNuVq6uC00+Fe79T/+zMcmR9cwFfoKMAxCtoZutvxd9FGutVYSrm?= =?us-ascii?Q?sPX6TE7PgTvBipufNd7SdFFpO0SrqvBPD3FYtTFvBsaiN+3ZkDetM8TgoXFy?= =?us-ascii?Q?2zN6KhKhuCqlkD+EPJSiXoQIq84UqgQOD1wor6v0VFjrBgd0hJ2gSJ6piXTB?= =?us-ascii?Q?R8vI0av+pTK2owc2NIgTJwBE5uf2uxceKDCVjxI2uvjK96t1zVWWPuq0Z3a6?= =?us-ascii?Q?1ftxLcRrOo4fYxoufKvB+bPHF4G0VHo5AJCWne3cz8Cdq4XYaDjJqPP8aWtj?= =?us-ascii?Q?3YewPykPkpuYyHTOKUohdEOfyUHD9vnclLwT7cd+Y7z9DCoeghcdW/EBwWg8?= =?us-ascii?Q?dhEZ1aOWp8V6EpDxv4WgKU320NLbFWEtHOpTsnUb+V3uA/tOp/U6eceuOxtu?= =?us-ascii?Q?FQMOkzvybJ0cTDEwAkHZhqhuuiEEFbFmOSHR0EVUVkX/h2pj7KOE8d2CcVGU?= =?us-ascii?Q?16F3XaK7wTAfBVlrMOuxzwUZUcLH66IIDmHy1PjZt6yPd5t61/gPpjFmVAg4?= =?us-ascii?Q?oWnToNloAeHIIfjrdHzxOc1wWNatU35xjwrlvZnwA8ztSzxh3SA6FBXVuO1u?= =?us-ascii?Q?+iVxUO6K20eDMSyRmPjEOzy/0IvpzCTcUibKPl5uzfw2LGD8hoJOBhVkMjDL?= =?us-ascii?Q?5LnQvkVQU1SuTsENLYS5Nt/HQHB8wVXGixNeJYTAVt/lXVC0zdNQ695dRVNa?= =?us-ascii?Q?RBSYI2DlNE7N0eYqcmPQ+xQHHezZvojVpdAwMIHn7wzSaxPM+J+e5d4K3++X?= =?us-ascii?Q?oxA0OA4AG8ZZVg1pKUa1wXZonAYR3Q3aw+TY1GRW/q5lFPVBrUt5d4TheJ8r?= =?us-ascii?Q?E0nO8Pfflz6p31p+WzgbO57xCYtF2by5gGroW4/17f6HzWvw40torfNH0W3A?= =?us-ascii?Q?/odP08XvbiNZOcAtz3+GQxH3xOcvwUl46uglM3Uox+GGpAlLHNT+Qj+cEj0r?= =?us-ascii?Q?OXynnow8YqIYwk5JSjw6qCUhFgRoNWV0NMYDhnvAJF7lfBKtKJTa8pBaP0F3?= =?us-ascii?Q?SjshBCLDQ+q+7o0vrfi57qjpZmf6x8a7BeBvYIAAAGd3WNtX9EMAcstmm6uF?= =?us-ascii?Q?h4dFUfiymqKKHV9RDa3LmTWJOYK11SmPnwUSkjPwuNUPCgYmMyLIg0f3bMHd?= =?us-ascii?Q?9SOsxDRdcd99qeWjb/rwgng9KmZZ1pMGoGTDica5D9aowi6+35Sq7zGs8uB3?= =?us-ascii?Q?UiYTNK1AY9ogaC1j9NnK/d37Y11YjR0ZFESbhbOKlqHS4Vh1oy5KFYBxSsnk?= =?us-ascii?Q?iXam+TRvPGj2zPeBRdy2ouqdu1InkMceEXgMCHlfBDMhzq/UsdFkUrh2ktyg?= =?us-ascii?Q?i7ZrTJwfiPVNxtldc79NnWaoVRGjcODyEGe/zXOlznWeXVC98ETXYBgi2ZZF?= =?us-ascii?Q?0gTPA9aM0ypqUOKrbHgeurmct3ybTV/p7ujkNZmOd6FDZWwCJ5x5XiMAMN8y?= =?us-ascii?Q?8NxIWExz3K/p8B7y6XbaOCzMiTc0Z+aerce1OOSHXB3txJn5c7/u/tAzJRR9?= =?us-ascii?Q?QlE2Spj9DTIMywmBussW/V1sSfeSzq0tSIaC+4map6BWdw1shZs3cD3Vr/I4?= =?us-ascii?Q?KVA/vn/EcI/k3AeLI+d6Zbg0qBlxhI/szZ2DleWDt2Qspx9VaQu2?= X-OriginatorOrg: groupe-cyllene.com X-MS-Exchange-CrossTenant-Network-Message-Id: fcb64514-ab7a-4f99-84f4-08df0345d476 X-MS-Exchange-CrossTenant-AuthSource: PR1P264MB3696.FRAP264.PROD.OUTLOOK.COM X-MS-Exchange-CrossTenant-AuthAs: Internal X-MS-Exchange-CrossTenant-OriginalArrivalTime: 26 Aug 2026 07:44:16.3341 (UTC) X-MS-Exchange-CrossTenant-FromEntityHeader: Hosted X-MS-Exchange-CrossTenant-Id: ee11ccf7-112c-4284-848b-f229745e715b X-MS-Exchange-CrossTenant-MailboxType: HOSTED X-MS-Exchange-CrossTenant-UserPrincipalName: 5gIUe6h2frcFxnkMKb4Fx4w80VWjoc/ydf0ptadxXTVrb0fQHayQyIS8FPY5MvJzVjvziNgHLJ5dH0KTeRs8LKFg0fw25pZk8Z243vUADWLoI9sowPuITImS+zcXeX1N X-MS-Exchange-Transport-CrossTenantHeadersStamped: PR0P264MB2257 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.244 Adjusted score from AWL reputation of From: address DKIM_SIGNED 0.1 Message has a DKIM or DK signature, not necessarily valid DKIM_VALID -0.1 Message has at least one valid DKIM or DK signature DMARC_PASS -0.1 DMARC pass policy KAM_SHORT 0.001 Use of a URL Shortener for very short URL RCVD_IN_DNSWL_NONE -0.0001 Sender listed at https://www.dnswl.org/, no trust SPF_HELO_PASS -0.001 SPF: HELO matches SPF record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: M7T7TJ2LFLDES5QQBCR6DQVYVSHTLB4A X-Message-ID-Hash: M7T7TJ2LFLDES5QQBCR6DQVYVSHTLB4A X-MailFrom: Alexandre.DERUMIER@groupe-cyllene.com 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 X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: 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 | 194 ++++++++++++++++++++++++++++++++++ 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, 355 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 378e103..79aab86 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; @@ -3420,6 +3421,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', @@ -3548,6 +3638,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', @@ -3579,6 +3674,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 cadc8fe..1e012c8 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 kyber-gl qxl qxl2 qxl3 qxl4 none serial0 serial1 serial2 serial3 std virtio virtio-gl vmware) + qw(cirrus kyber kyber-gl 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'} @@ -1489,6 +1492,8 @@ my $vga_map = { # variant, both of which currently break QEMU. 'kyber' => 'virtio-vga', 'kyber-gl' => 'virtio-vga-gl', + # 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 @@ -1498,6 +1503,7 @@ my $vga_map_aarch64 = { 'virtio-gl' => 'virtio-gpu-gl', 'kyber' => 'virtio-gpu', 'kyber-gl' => 'virtio-gpu-gl', + 'rdp' => 'virtio-gpu', }; my sub map_vga_model { @@ -3425,7 +3431,7 @@ sub config_to_command { push @$cmd, '-display', 'egl-headless,gl=core' if $vga->{type} eq 'virtio-gl'; # VIRGL - if ($vga->{type} =~ /^(?:kyber|kyber-gl)$/) { + if ($vga->{type} =~ /^(?:kyber|kyber-gl|rdp)$/) { my $dbus = PVE::QemuServer::Helpers::dbus_socket($vmid); my $display = "dbus,addr=unix:path=$dbus"; $display .= ",gl=on" if $vga->{type} eq 'kyber-gl'; @@ -5846,7 +5852,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} // '') =~ /^(?:kyber|kyber-gl)$/; + if ($dbus_vga->{type} // '') =~ /^(?:kyber|kyber-gl|rdp)$/; my $tpmpid; if ((my $tpm = $conf->{tpmstate0}) && !PVE::QemuConfig->is_template($conf)) { @@ -6237,6 +6243,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..36dca6d --- /dev/null +++ b/src/PVE/QemuServer/RDP.pm @@ -0,0 +1,194 @@ +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 IO::Socket::UNIX; +use Socket qw(SOCK_STREAM); +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. +# Whether anything is listening, rather than whether a file is in the way. +# Both servers quit on their own when the VM's D-Bus display goes, and leave +# their socket behind when they do, so a plain -S reports a server that is not +# there - and the console then fails with ECONNREFUSED one hop further on. +my sub socket_answers { + my ($path) = @_; + + return 0 if !-S $path; + + my $sock = IO::Socket::UNIX->new(Type => SOCK_STREAM, Peer => $path); + return 0 if !$sock; + + close($sock); + return 1; +} + +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; + + return undef if !socket_answers(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