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 167951FF0B7 for ; Tue, 25 Aug 2026 13:10:15 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id C27B721700; Tue, 25 Aug 2026 13:09:16 +0200 (CEST) From: Alexandre Derumier To: pve-devel@lists.proxmox.com subject: SPAM: [RFC pve-manager 07/13] ui: add rdp console Date: Tue, 25 Aug 2026 13:08:39 +0200 Message-ID: <20260825110849.2967694-8-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-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 6 AWL 0.057 Adjusted score from AWL reputation of From: address CONTENT_AFTER_HTML 0.001 More content after HTML close tag + other spam signs 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 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: WIC6HBXZPECRF4I4L7A4QHOAGD57KPDX X-Message-ID-Hash: WIC6HBXZPECRF4I4L7A4QHOAGD57KPDX 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 Signed-off-by: Alexandre Derumier --- Makefile | 2 +- PVE/Service/pveproxy.pm | 14 + rdp-web/Makefile | 18 + rdp-web/index.html.tpl | 615 +++++++++++++++++++++++++++ www/manager6/Utils.js | 31 +- www/manager6/button/ConsoleButton.js | 21 + www/manager6/qemu/Config.js | 6 +- www/manager6/qemu/DisplayEdit.js | 4 +- 8 files changed, 705 insertions(+), 6 deletions(-) create mode 100644 rdp-web/Makefile create mode 100644 rdp-web/index.html.tpl diff --git a/Makefile b/Makefile index b7dfc7f..18de847 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ DSC=$(PACKAGE)_$(DEB_VERSION).dsc DEB=$(PACKAGE)_$(DEB_VERSION)_all.deb DESTDIR= -SUBDIRS = aplinfo PVE bin www services configs kyber-web network-hooks test templates +SUBDIRS = aplinfo PVE bin www services configs kyber-web rdp-web network-hooks test templates all: $(SUBDIRS) set -e && for i in $(SUBDIRS); do $(MAKE) -C $$i; done diff --git a/PVE/Service/pveproxy.pm b/PVE/Service/pveproxy.pm index bcc1353..ddc04ff 100755 --- a/PVE/Service/pveproxy.pm +++ b/PVE/Service/pveproxy.pm @@ -58,6 +58,7 @@ my $basedirs = { i18n => '/usr/share/pve-i18n', manager => '/usr/share/pve-manager', kyber => '/usr/share/pve-kyber-web', + rdp => '/usr/share/pve-rdp-web', novnc => '/usr/share/novnc-pve', yew_mobile => '/usr/share/pve-yew-mobile-gui', i18n_yew => '/usr/share/pve-yew-mobile-i18n', @@ -68,6 +69,9 @@ my $basedirs = { my $kyber_console_prefix = qr!^/api2/json/nodes/([^/]+)/qemu/(\d+)/kyber(/.*)$!; my $kyber_proxy_socket = '/run/pvekyberproxy.sock'; +my $rdp_console_prefix = qr!^/api2/json/nodes/([^/]+)/qemu/(\d+)/rdp/([^/]+)$!; +my $rdp_proxy_socket = '/run/pverdpproxy.sock'; + my sub check_console_access { my ($auth, $console_type, $node, $vmid) = @_; @@ -97,6 +101,12 @@ sub console_proxy { return { socket => $kyber_proxy_socket, path => $target, tls => 0 }; } + if (my ($node, $vmid, $token) = $path =~ $rdp_console_prefix) { + check_console_access($auth, 'RDP', $node, $vmid); + + return { socket => $rdp_proxy_socket, path => "/$vmid/$token", tls => 0 }; + } + return undef; } @@ -118,6 +128,7 @@ sub init { my $dirs = {}; add_dirs($dirs, '/kyber/' => "$basedirs->{kyber}/"); + add_dirs($dirs, '/rdp/' => "$basedirs->{rdp}/"); add_dirs($dirs, '/novnc/' => "$basedirs->{novnc}/"); add_dirs($dirs, '/pve-docs/' => "$basedirs->{docs}/"); add_dirs($dirs, '/pve-docs/api-viewer/extjs/' => "$basedirs->{extjs}/"); @@ -290,6 +301,7 @@ sub get_index { || $args->{mobile}; my $kyber = defined($args->{console}) && $args->{kyber}; + my $rdp = defined($args->{console}) && $args->{rdp}; my $novnc = defined($args->{console}) && $args->{novnc}; my $xtermjs = defined($args->{console}) && $args->{xtermjs}; @@ -336,6 +348,8 @@ sub get_index { if ($kyber) { $dir = $basedirs->{kyber}; + } elsif ($rdp) { + $dir = $basedirs->{rdp}; } elsif ($novnc) { $dir = $basedirs->{novnc}; } elsif ($xtermjs) { diff --git a/rdp-web/Makefile b/rdp-web/Makefile new file mode 100644 index 0000000..f51cfc2 --- /dev/null +++ b/rdp-web/Makefile @@ -0,0 +1,18 @@ +include ../defines.mk + +RDPDIR = $(DESTDIR)/usr/share/pve-rdp-web + +all: + +.PHONY: install +install: index.html.tpl + install -d $(RDPDIR) + install -m 0644 index.html.tpl $(RDPDIR)/index.html.tpl +# The client itself - rdp_client.js and rdp_client_bg.wasm beside it - is +# IronRDP built for the browser, shipped by pve-rdp-web rather than vendored +# here: a separate upstream with a wasm toolchain that has no business in this +# build. The page drives it directly and needs nothing else from it. + +.PHONY: clean distclean +distclean: clean +clean: diff --git a/rdp-web/index.html.tpl b/rdp-web/index.html.tpl new file mode 100644 index 0000000..eda7006 --- /dev/null +++ b/rdp-web/index.html.tpl @@ -0,0 +1,615 @@ + + + + + RDP console + + + +
+
+
+ + + + +
+ + + + + +
+
+
+
+
Connecting…
+ + + + + + + diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js index 44ffdfe..d707ac0 100644 --- a/www/manager6/Utils.js +++ b/www/manager6/Utils.js @@ -657,6 +657,7 @@ Ext.define('PVE.Utils', { virtio: 'VirtIO-GPU', 'virtio-gl': 'VirGL GPU', kyber: 'Kyber', + rdp: 'RDP', none: Proxmox.Utils.noneText, }, @@ -1455,6 +1456,8 @@ Ext.define('PVE.Utils', { Proxmox.Utils.openXtermJsViewer(consoleType, vmid, nodename, vmname, cmd); } else if (viewer === 'kyber') { PVE.Utils.openKyberViewer(consoleType, vmid, nodename, vmname); + } else if (viewer === 'rdp') { + PVE.Utils.openRdpViewer(consoleType, vmid, nodename, vmname); } else if (viewer === 'vv') { let url = '/nodes/' + nodename + '/spiceshell'; let params = { @@ -1478,22 +1481,27 @@ Ext.define('PVE.Utils', { }, defaultViewer: function (consoles, type) { - var allowSpice, allowXtermjs, allowKyber; + var allowSpice, allowXtermjs, allowKyber, allowRdp; if (consoles === true) { allowSpice = true; allowXtermjs = true; allowKyber = true; + allowRdp = true; } else if (typeof consoles === 'object') { allowSpice = consoles.spice; allowXtermjs = !!consoles.xtermjs; allowKyber = !!consoles.kyber; + allowRdp = !!consoles.rdp; } let dv = PVE.UIOptions.options.console || (type === 'kvm' ? 'vv' : 'xtermjs'); - // A Kyber display serves no VNC, so nothing else can show it. + // Neither display serves VNC, so nothing else can show them. if (allowKyber) { return 'kyber'; } + if (allowRdp) { + return 'rdp'; + } if (dv === 'vv' && !allowSpice) { dv = allowXtermjs ? 'xtermjs' : 'html5'; } else if (dv === 'xtermjs' && !allowXtermjs) { @@ -1542,6 +1550,24 @@ Ext.define('PVE.Utils', { } }, + // The RDP console talks to the VM's own RDP server through + // pve-rdpproxy rather than through noVNC, so it gets a window of its + // own. Its credentials are fetched by that page from rdpproxy; nothing + // is passed in the URL, so a copied link grants nothing on its own. + openRdpViewer: function (vmtype, vmid, nodename, vmname) { + let url = Ext.Object.toQueryString({ + console: vmtype, + rdp: 1, + vmid: vmid, + vmname: vmname, + node: nodename, + }); + let nw = window.open('?' + url, '_blank', 'innerWidth=1280,innerheight=800'); + if (nw) { + nw.focus(); + } + }, + openSpiceViewer: function (url, params) { var downloadWithName = function (uri, name) { var link = Ext.DomHelper.append(document.body, { @@ -1618,6 +1644,7 @@ Ext.define('PVE.Utils', { spice: !!conf.spice, xtermjs: !!conf.serial, kyber: !!conf.kyber, + rdp: !!conf.rdp, }; PVE.Utils.openDefaultConsoleWindow(consoles, 'kvm', vmid, nodename, vmname); }, diff --git a/www/manager6/button/ConsoleButton.js b/www/manager6/button/ConsoleButton.js index 63377e7..3c4406b 100644 --- a/www/manager6/button/ConsoleButton.js +++ b/www/manager6/button/ConsoleButton.js @@ -14,6 +14,7 @@ Ext.define('PVE.button.ConsoleButton', { enableXtermjs: true, // Off unless a VM says otherwise, so other guests show it greyed out. enableKyber: false, + enableRdp: false, nodename: undefined, @@ -42,6 +43,13 @@ Ext.define('PVE.button.ConsoleButton', { me.down('#kybermenu').setDisabled(!enable); }, + setEnableRdp: function (enable) { + var me = this; + + me.enableRdp = enable; + me.down('#rdpmenu').setDisabled(!enable); + }, + handler: function () { // main, general, handler let me = this; @@ -50,6 +58,7 @@ Ext.define('PVE.button.ConsoleButton', { spice: me.enableSpice, xtermjs: me.enableXtermjs, kyber: me.enableKyber, + rdp: me.enableRdp, }, me.consoleType, me.vmid, @@ -106,6 +115,18 @@ Ext.define('PVE.button.ConsoleButton', { view.openConsole(button.type); }, }, + { + xtype: 'menuitem', + itemId: 'rdpmenu', + text: 'RDP', + type: 'rdp', + iconCls: 'fa fa-fw fa-desktop', + disabled: true, + handler: function (button) { + let view = this.up('button'); + view.openConsole(button.type); + }, + }, { text: 'xterm.js', itemId: 'xtermjs', diff --git a/www/manager6/qemu/Config.js b/www/manager6/qemu/Config.js index bcf54d1..79ab012 100644 --- a/www/manager6/qemu/Config.js +++ b/www/manager6/qemu/Config.js @@ -232,10 +232,11 @@ Ext.define('PVE.qemu.Config', { disabled: !caps.vms['VM.Console'], hidden: template, consoleType: 'kvm', - // disable spice/xterm/kyber for default action until status api call succeeded + // disable spice/xterm/kyber/rdp for default action until status api call succeeded enableSpice: false, enableXtermjs: false, enableKyber: false, + enableRdp: false, consoleName: vm.name, nodename: nodename, vmid: vmid, @@ -460,6 +461,7 @@ Ext.define('PVE.qemu.Config', { var spice = false; var xtermjs = false; var kyber = false; + var rdp = false; var lock; var rec; @@ -481,6 +483,7 @@ Ext.define('PVE.qemu.Config', { // 'kyber', which is also the only case with a controller // behind it. kyber = !!s.data.get('kyber'); + rdp = !!s.data.get('rdp'); } rec = s.data.get('tags'); @@ -503,6 +506,7 @@ Ext.define('PVE.qemu.Config', { consoleBtn.setEnableSpice(spice); consoleBtn.setEnableXtermJS(xtermjs); consoleBtn.setEnableKyber(kyber); + consoleBtn.setEnableRdp(rdp); statusTxt.update({ lock: lock }); diff --git a/www/manager6/qemu/DisplayEdit.js b/www/manager6/qemu/DisplayEdit.js index 79e1ea2..decc016 100644 --- a/www/manager6/qemu/DisplayEdit.js +++ b/www/manager6/qemu/DisplayEdit.js @@ -26,8 +26,8 @@ Ext.define('PVE.qemu.DisplayInputPanel', { return '4'; } else if (val === 'std' || val.match(/^qxl\d?$/) || val === 'vmware') { return '16'; - } else if (val.match(/^virtio/) || val === 'kyber') { - // kyber is a virtio-vga underneath, so it takes the same + } else if (val.match(/^virtio/) || val === 'kyber' || val === 'rdp') { + // Both are a virtio-vga underneath, so they take the same // memory as one. return '256'; } else if (get('matchNonGUIOption')) { -- 2.55.0