From: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
To: pve-devel@lists.proxmox.com
Subject: [RFC pve-manager 07/13] ui: add rdp console
Date: Tue, 25 Aug 2026 13:34:32 +0200 [thread overview]
Message-ID: <20260825113442.947620-8-alexandre.derumier@groupe-cyllene.com> (raw)
In-Reply-To: <20260825113442.947620-1-alexandre.derumier@groupe-cyllene.com>
Signed-off-by: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
---
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 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>RDP console</title>
+ <style>
+ html, body {
+ margin: 0;
+ height: 100%;
+ background: #000;
+ color: #ddd;
+ font: 13px/1.5 system-ui, sans-serif;
+ overflow: hidden;
+ }
+ #container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ }
+ #canvas {
+ display: block;
+ outline: none;
+ }
+ #status {
+ position: fixed;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 2em;
+ text-align: center;
+ background: #000;
+ white-space: pre-wrap;
+ }
+ #status[hidden] { display: none; }
+ #status.failed { color: #ff8080; }
+ /* The control bar is noVNC's, down to its images: a console should not
+ look like a different product depending on which one it is. Only the
+ rules this page needs are copied - loading noVNC's stylesheet whole
+ would bring its layout with it. */
+ #rdp_control_bar_anchor {
+ position: fixed;
+ top: 0;
+ left: 0;
+ height: 100%;
+ z-index: 10;
+ transition: 0.5s ease-in-out;
+ }
+ #rdp_control_bar {
+ position: relative;
+ left: -100%;
+ height: 100%;
+ padding: 5px;
+ background-color: #1c2331;
+ border-radius: 0 12px 12px 0;
+ transition: 0.5s ease-in-out;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+ #rdp_control_bar.rdp_open { left: 0; }
+ #rdp_control_bar_handle {
+ position: absolute;
+ left: -15px;
+ top: 0;
+ transform: translateY(35px);
+ width: calc(100% + 30px);
+ height: 50px;
+ z-index: -1;
+ cursor: pointer;
+ border-radius: 6px;
+ background-color: #1c2331;
+ background-image: url("/novnc/app/images/handle_bg.svg");
+ background-repeat: no-repeat;
+ background-position: right;
+ box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.5);
+ }
+ #rdp_control_bar_handle:after {
+ content: "";
+ transition: transform 0.1s ease-in-out;
+ background: url("/novnc/app/images/handle.svg");
+ position: absolute;
+ top: 22px;
+ right: 5px;
+ width: 5px;
+ height: 6px;
+ }
+ #rdp_control_bar.rdp_open #rdp_control_bar_handle:after {
+ transform: translateX(1px) rotate(180deg);
+ }
+ .rdp_button {
+ min-width: 36px;
+ padding: 4px;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 6px;
+ background-color: transparent;
+ color: #fff;
+ cursor: pointer;
+ font-size: 11px;
+ line-height: 1.2;
+ }
+ .rdp_button:hover { background-color: rgba(255, 255, 255, 0.1); }
+ .rdp_button img { width: 24px; height: 24px; display: block; margin: auto; }
+ #rdp_power_menu {
+ display: none;
+ position: absolute;
+ left: 100%;
+ margin-left: 8px;
+ background-color: #1c2331;
+ border-radius: 6px;
+ padding: 5px;
+ box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.5);
+ }
+ #rdp_power_menu.rdp_open { display: flex; flex-direction: column; gap: 4px; }
+ #rdp_power_menu .rdp_button { white-space: nowrap; text-align: left; }
+ #container:fullscreen,
+ :fullscreen #container {
+ width: 100vw;
+ height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: #000;
+ }
+ </style>
+ </head>
+ <body>
+ <div id="rdp_control_bar_anchor">
+ <div id="rdp_control_bar">
+ <div id="rdp_control_bar_handle"></div>
+ <button class="rdp_button" id="rdp_ctrl_alt_del" title="Send Ctrl-Alt-Del">
+ <img src="/novnc/app/images/esc.svg" alt=""><span>C-A-D</span>
+ </button>
+ <button class="rdp_button" id="rdp_audio" title="Guest audio">
+ <span id="rdp_audio_label">Audio<br>off</span>
+ </button>
+ <button class="rdp_button" id="rdp_fullscreen" title="Fullscreen">
+ <img src="/novnc/app/images/fullscreen.svg" alt="">
+ </button>
+ <button class="rdp_button" id="rdp_power" title="Power">
+ <img src="/novnc/app/images/power.svg" alt="">
+ </button>
+ <div id="rdp_power_menu">
+ <button class="rdp_button" data-power="start">Start</button>
+ <button class="rdp_button" data-power="shutdown">Shutdown</button>
+ <button class="rdp_button" data-power="reboot">Reboot</button>
+ <button class="rdp_button" data-power="reset">Reset</button>
+ <button class="rdp_button" data-power="stop">Stop</button>
+ </div>
+ </div>
+ </div>
+ <div id="container"><canvas id="canvas" tabindex="0"></canvas></div>
+ <div id="status">Connecting…</div>
+
+ <!-- Installs globalThis.pveRdpAudio, which the client looks up when the
+ session starts. A classic script rather than a module: those are
+ deferred, and this has to be in place before the module runs. Shipped
+ by pve-rdp-web beside the client itself. -->
+ <script src="/rdp/rdp-audio.js"></script>
+
+ <script type="module">
+ import init, { ClipboardData, DesktopSize, DeviceEvent, InputTransaction, SessionBuilder, setup }
+ from '/rdp/rdp_client.js';
+
+ const params = new URLSearchParams(window.location.search);
+ const node = params.get('node');
+ const vmid = params.get('vmid');
+ const vmname = params.get('vmname');
+
+ document.title = vmname ? `${vmname} (${vmid}) - RDP console`
+ : `VM ${vmid} - RDP console`;
+
+ const statusEl = document.getElementById('status');
+ const canvas = document.getElementById('canvas');
+
+ const setStatus = (text, failed) => {
+ statusEl.textContent = text;
+ statusEl.classList.toggle('failed', !!failed);
+ statusEl.hidden = false;
+ };
+
+ let session = null;
+
+ // Windows scancodes, which is what RDP carries, keyed by the browser's
+ // KeyboardEvent.code. Taken from IronRDP's own web client; the high byte
+ // is the extended-key prefix.
+ const SCANCODES = {
+ AltLeft: 0x0038, AltRight: 0xE038, ArrowDown: 0xE050, ArrowLeft: 0xE04B,
+ ArrowRight: 0xE04D, ArrowUp: 0xE048, AudioVolumeDown: 0xE02E,
+ AudioVolumeMute: 0xE020, AudioVolumeUp: 0xE030, Backquote: 0x0029, Backslash: 0x002B,
+ Backspace: 0x000E, BracketLeft: 0x001A, BracketRight: 0x001B, BrowserBack: 0xE06A,
+ BrowserFavorites: 0xE066, BrowserForward: 0xE069, BrowserHome: 0xE032,
+ BrowserRefresh: 0xE067, BrowserSearch: 0xE065, BrowserStop: 0xE068, CapsLock: 0x003A,
+ Comma: 0x0033, ContextMenu: 0xE05D, ControlLeft: 0x001D, ControlRight: 0xE01D,
+ Convert: 0x0079, Copy: 0xE018, Cut: 0xE017, Delete: 0xE053, Digit0: 0x000B,
+ Digit1: 0x0002, Digit2: 0x0003, Digit3: 0x0004, Digit4: 0x0005, Digit5: 0x0006,
+ Digit6: 0x0007, Digit7: 0x0008, Digit8: 0x0009, Digit9: 0x000A, Eject: 0xE02C,
+ End: 0xE04F, Enter: 0x001C, Equal: 0x000D, Escape: 0x0001, F1: 0x003B, F10: 0x0044,
+ F11: 0x0057, F12: 0x0058, F13: 0x0064, F14: 0x0065, F15: 0x0066, F16: 0x0067,
+ F17: 0x0068, F18: 0x0069, F19: 0x006A, F2: 0x003C, F20: 0x006B, F21: 0x006C,
+ F22: 0x006D, F23: 0x006E, F24: 0x0076, F3: 0x003D, F4: 0x003E, F5: 0x003F,
+ F6: 0x0040, F7: 0x0041, F8: 0x0042, F9: 0x0043, Help: 0xE03B, Home: 0xE047,
+ Insert: 0xE052, IntlBackslash: 0x0056, IntlRo: 0x0073, IntlYen: 0x007D,
+ KanaMode: 0x0070, KeyA: 0x001E, KeyB: 0x0030, KeyC: 0x002E, KeyD: 0x0020,
+ KeyE: 0x0012, KeyF: 0x0021, KeyG: 0x0022, KeyH: 0x0023, KeyI: 0x0017, KeyJ: 0x0024,
+ KeyK: 0x0025, KeyL: 0x0026, KeyM: 0x0032, KeyN: 0x0031, KeyO: 0x0018, KeyP: 0x0019,
+ KeyQ: 0x0010, KeyR: 0x0013, KeyS: 0x001F, KeyT: 0x0014, KeyU: 0x0016, KeyV: 0x002F,
+ KeyW: 0x0011, KeyX: 0x002D, KeyY: 0x0015, KeyZ: 0x002C, Lang1: 0x0072, Lang2: 0x0071,
+ Lang3: 0x0078, Lang4: 0x0077, LaunchApp1: 0xE06B, LaunchApp2: 0xE021,
+ LaunchMail: 0xE06C, MediaPlayPause: 0xE022, MediaSelect: 0xE06D, MediaStop: 0xE024,
+ MediaTrackNext: 0xE019, MediaTrackPrevious: 0xE010, MetaLeft: 0xE05B,
+ MetaRight: 0xE05C, Minus: 0x000C, NonConvert: 0x007B, NumLock: 0xE045,
+ Numpad0: 0x0052, Numpad1: 0x004F, Numpad2: 0x0050, Numpad3: 0x0051, Numpad4: 0x004B,
+ Numpad5: 0x004C, Numpad6: 0x004D, Numpad7: 0x0047, Numpad8: 0x0048, Numpad9: 0x0049,
+ NumpadAdd: 0x004E, NumpadComma: 0x007E, NumpadDecimal: 0x0053, NumpadDivide: 0xE035,
+ NumpadEnter: 0xE01C, NumpadEqual: 0x0059, NumpadMultiply: 0x0037,
+ NumpadSubtract: 0x004A, OSLeft: 0xE05B, OSRight: 0xE05C, PageDown: 0xE051,
+ PageUp: 0xE049, Paste: 0xE00A, Pause: 0xE046, Period: 0x0034, Power: 0xE05E,
+ PrintScreen: 0xE037, Quote: 0x0028, ScrollLock: 0x0046, Semicolon: 0x0027,
+ ShiftLeft: 0x002A, ShiftRight: 0x0036, Slash: 0x0035, Sleep: 0xE05F, Space: 0x0039,
+ Tab: 0x000F, Undo: 0xE008, VolumeDown: 0xE02E, VolumeMute: 0xE020, VolumeUp: 0xE030,
+ WakeUp: 0xE063
+ };
+
+ // What to ask the server for. It is only a request: qemu-rdp answers with
+ // the guest's own framebuffer size and resizes only if the guest agrees,
+ // so nothing below may assume this is what arrives.
+ const requestedWidth = Math.max(640, Math.floor(window.innerWidth / 4) * 4);
+ const requestedHeight = Math.max(480, Math.floor(window.innerHeight / 4) * 4);
+
+ // Sized from the canvas itself, never from what was requested: the client
+ // sets the backing store to the size the server negotiated, and giving it
+ // a CSS box of a different size is what scales the picture.
+ function fitCanvas() {
+ const width = canvas.width;
+ const height = canvas.height;
+ if (!width || !height) {
+ return;
+ }
+
+ let scale = Math.min(window.innerWidth / width, window.innerHeight / height);
+ // Native size in a window, shrunk only when the guest is bigger than
+ // the window; fullscreen scales up too, which is the point of it.
+ // One factor for both axes, so a 4:3 guest letterboxes rather than
+ // distorts.
+ if (!document.fullscreenElement) {
+ scale = Math.min(scale, 1);
+ }
+
+ canvas.style.width = `${Math.floor(width * scale)}px`;
+ canvas.style.height = `${Math.floor(height * scale)}px`;
+ }
+
+ // A guest that resizes changes the backing store with no event to go
+ // with it - the SDK's canvas_resized callback is a no-op for RDP, which
+ // has no server-side resize. width and height are reflected attributes,
+ // so the change is still observable.
+ new MutationObserver(fitCanvas).observe(canvas, {
+ attributes: true,
+ attributeFilter: ['width', 'height'],
+ });
+
+ // Pointer coordinates are the guest's, not the page's: the canvas is
+ // scaled by CSS whenever the guest does not fit the window as it is.
+ function guestPosition(event) {
+ const rect = canvas.getBoundingClientRect();
+ const x = (event.clientX - rect.left) * (canvas.width / rect.width);
+ const y = (event.clientY - rect.top) * (canvas.height / rect.height);
+ return [
+ Math.max(0, Math.min(canvas.width - 1, Math.round(x))),
+ Math.max(0, Math.min(canvas.height - 1, Math.round(y))),
+ ];
+ }
+
+ function apply(...events) {
+ if (!session) {
+ return;
+ }
+ const transaction = new InputTransaction();
+ for (const event of events) {
+ transaction.addEvent(event);
+ }
+ session.applyInputs(transaction);
+ }
+
+ function bindInput() {
+ canvas.addEventListener('contextmenu', (e) => e.preventDefault());
+
+ canvas.addEventListener('mousemove', (e) => {
+ apply(DeviceEvent.mouseMove(...guestPosition(e)));
+ });
+
+ canvas.addEventListener('mousedown', (e) => {
+ e.preventDefault();
+ canvas.focus();
+ apply(
+ DeviceEvent.mouseMove(...guestPosition(e)),
+ DeviceEvent.mouseButtonPressed(e.button),
+ );
+ });
+
+ // On the window, not the canvas: a button released outside it would
+ // otherwise stay down in the guest.
+ window.addEventListener('mouseup', (e) => {
+ apply(DeviceEvent.mouseButtonReleased(e.button));
+ });
+
+ canvas.addEventListener('wheel', (e) => {
+ e.preventDefault();
+ const vertical = e.deltaY !== 0;
+ // Negated: the browser counts down as positive, RDP counts up.
+ // deltaMode's values are RotationUnit's, so it goes as it is.
+ apply(DeviceEvent.wheelRotations(
+ vertical,
+ -Math.round(vertical ? e.deltaY : e.deltaX),
+ e.deltaMode,
+ ));
+ }, { passive: false });
+
+ canvas.addEventListener('keydown', (e) => {
+ e.preventDefault();
+ const scancode = SCANCODES[e.code];
+ if (scancode !== undefined) {
+ apply(DeviceEvent.keyPressed(scancode));
+ } else if (e.key.length === 1) {
+ // A layout this table does not cover; the character itself
+ // still gets through.
+ apply(DeviceEvent.unicodePressed(e.key));
+ }
+ });
+
+ canvas.addEventListener('keyup', (e) => {
+ e.preventDefault();
+ const scancode = SCANCODES[e.code];
+ if (scancode !== undefined) {
+ apply(DeviceEvent.keyReleased(scancode));
+ } else if (e.key.length === 1) {
+ apply(DeviceEvent.unicodeReleased(e.key));
+ }
+ });
+
+ // Every key held when focus leaves would stay held in the guest.
+ canvas.addEventListener('blur', () => session?.releaseAllInputs());
+ }
+
+ // The cursor is drawn by the desktop, not into the framebuffer, so the
+ // guest's shape arrives separately and is put on the canvas as a CSS
+ // cursor.
+ function setCursorStyle(kind, data, hotspotX, hotspotY) {
+ if (kind === 'url') {
+ canvas.style.cursor = `url(${data}) ${hotspotX} ${hotspotY}, default`;
+ } else if (kind === 'none') {
+ canvas.style.cursor = 'none';
+ } else {
+ canvas.style.cursor = 'default';
+ }
+ }
+
+ // Clipboard, both ways, through the browser's own - which hands it over
+ // only to a focused document the user has granted permission to, so a
+ // refusal here is normal and not worth failing the console over.
+ //
+ // The guest's clipboard, on its way here.
+ async function onRemoteClipboardChanged(data) {
+ for (const item of data.items()) {
+ if (item.mimeType() === 'text/plain') {
+ const text = item.value();
+ try {
+ await navigator.clipboard.writeText(text);
+ // Remembered, or the poll below would read it back and
+ // announce the guest's own copy straight back at it.
+ lastLocalText = text;
+ } catch (err) {
+ console.warn('could not take the guest clipboard:', err);
+ }
+ return;
+ }
+ }
+ }
+
+ // Ours, on its way to the guest. RDP is announce-then-request: until the
+ // client says it holds a format, the guest's paste has nothing to ask
+ // for. Nothing tells a page that another application copied something,
+ // so the only way to notice is to look.
+ let lastLocalText = null;
+
+ async function announceLocalClipboard() {
+ if (!session || !document.hasFocus()) {
+ return;
+ }
+
+ let text;
+ try {
+ text = await navigator.clipboard.readText();
+ } catch (err) {
+ // Refused or unavailable - Firefox gives web pages no unprompted
+ // read at all. Not an error worth repeating every second.
+ return;
+ }
+
+ if (text === lastLocalText) {
+ return;
+ }
+ lastLocalText = text;
+
+ const data = new ClipboardData();
+ if (text.length) {
+ data.addText('text/plain', text);
+ }
+ await session.onClipboardPaste(data);
+ }
+
+ const CLIPBOARD_POLL_MS = 1000;
+ setInterval(() => {
+ announceLocalClipboard().catch((err) => {
+ console.warn('could not announce the clipboard:', err);
+ });
+ }, CLIPBOARD_POLL_MS);
+ window.addEventListener('focus', () => {
+ announceLocalClipboard().catch(() => {});
+ });
+
+ // Asked for when the guest wants what we last announced - a replay, not a
+ // fresh read: this fires while the guest has focus, and a read then is
+ // refused anyway.
+ async function onForceClipboardUpdate() {
+ const data = new ClipboardData();
+ if (lastLocalText) {
+ data.addText('text/plain', lastLocalText);
+ }
+ await session?.onClipboardPaste(data);
+ }
+
+ async function boot() {
+ if (!node || !vmid) {
+ throw new Error('missing node or vmid');
+ }
+
+ await init();
+ setup('info');
+
+ // Proxmox checks its ACL here, starts the VM's RDP server and mints
+ // the credentials. Nothing is in the URL, so a copied link grants
+ // nothing.
+ const base = `/api2/json/nodes/${node}/qemu/${vmid}`;
+ const res = await fetch(`${base}/rdpproxy`, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { CSRFPreventionToken: '[% token %]' },
+ });
+ if (!res.ok) {
+ const detail = await res.text().catch(() => '');
+ throw new Error(`could not start the console (${res.status}) ${detail}`);
+ }
+ const { data } = await res.json();
+
+ // pveproxy forwards this to pve-rdpproxy, which does the RDCleanPath
+ // handshake against the VM's server: the browser cannot drive TLS
+ // over a websocket, so the gateway does it and hands back the chain.
+ const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
+ const proxy = `${scheme}://${window.location.host}${base}/rdp/${data.token}`;
+
+ session = await new SessionBuilder()
+ .username(data.user)
+ .password(data.password)
+ // Both are required and neither is used: the VM is named by the
+ // path, and pve-rdpproxy routes on that alone.
+ .destination(`vm-${vmid}`)
+ .authToken(data.token)
+ .proxyAddress(proxy)
+ .desktopSize(new DesktopSize(requestedWidth, requestedHeight))
+ .renderCanvas(canvas)
+ .setCursorStyleCallback(setCursorStyle)
+ .setCursorStyleCallbackContext(window)
+ .remoteClipboardChangedCallback(onRemoteClipboardChanged)
+ .forceClipboardUpdateCallback(onForceClipboardUpdate)
+ .connect();
+
+ statusEl.hidden = true;
+ fitCanvas();
+ bindInput();
+ canvas.focus();
+
+ // Resolves when the session ends, however it ends.
+ const info = await session.run();
+ session = null;
+ setStatus(`Console stopped: ${info.reason()}`, true);
+ }
+
+ // The bar retracts like noVNC's: the handle is always reachable, the bar
+ // itself only when asked for.
+ const controlBar = document.getElementById('rdp_control_bar');
+ const powerMenu = document.getElementById('rdp_power_menu');
+ document.getElementById('rdp_control_bar_handle').addEventListener('click', () => {
+ controlBar.classList.toggle('rdp_open');
+ if (!controlBar.classList.contains('rdp_open')) {
+ powerMenu.classList.remove('rdp_open');
+ }
+ });
+
+ // Ctrl+Alt+Del cannot be captured by any web page - the OS takes it
+ // before a browser sees it - so it is injected as scancodes instead.
+ document.getElementById('rdp_ctrl_alt_del').addEventListener('click', () => {
+ apply(
+ DeviceEvent.keyPressed(SCANCODES.ControlLeft),
+ DeviceEvent.keyPressed(SCANCODES.AltLeft),
+ DeviceEvent.keyPressed(SCANCODES.Delete),
+ DeviceEvent.keyReleased(SCANCODES.Delete),
+ DeviceEvent.keyReleased(SCANCODES.AltLeft),
+ DeviceEvent.keyReleased(SCANCODES.ControlLeft),
+ );
+ canvas.focus();
+ });
+
+ // Browsers refuse to start an AudioContext without a user gesture, so
+ // audio cannot simply follow the session. Two ways in: the button, and
+ // the first click into the guest - which is what a user does anyway, and
+ // saves the button being the only way to discover the feature exists.
+ const audioLabel = document.getElementById('rdp_audio_label');
+
+ function showAudioState(state) {
+ const on = state === 'running';
+ audioLabel.innerHTML = on ? 'Audio<br>on' : 'Audio<br>off';
+ }
+
+ async function startAudio() {
+ if (!globalThis.pveRdpAudio || globalThis.pveRdpAudio.state === 'running') {
+ return;
+ }
+ try {
+ showAudioState(await globalThis.pveRdpAudio.resume());
+ } catch (err) {
+ console.warn('could not start guest audio:', err);
+ }
+ }
+
+ document.getElementById('rdp_audio').addEventListener('click', async () => {
+ await startAudio();
+ canvas.focus();
+ });
+
+ canvas.addEventListener('mousedown', startAudio, { once: true });
+
+ // Fullscreen takes the keyboard with it. Without the lock, combinations
+ // the browser and desktop claim - Ctrl+W, Alt+Tab, F11 - never reach the
+ // guest. It is granted only to a fullscreen document, and has to be
+ // released again or it outlives the console.
+ async function enterFullscreen() {
+ await document.documentElement.requestFullscreen();
+ try {
+ await navigator.keyboard?.lock?.();
+ } catch (err) {
+ console.warn('keyboard lock refused:', err);
+ }
+ canvas.focus();
+ }
+
+ document.getElementById('rdp_fullscreen').addEventListener('click', async () => {
+ try {
+ if (document.fullscreenElement) {
+ await document.exitFullscreen();
+ } else {
+ await enterFullscreen();
+ }
+ } catch (err) {
+ setStatus(`Fullscreen failed: ${err.message ?? err}`, true);
+ }
+ });
+
+ document.addEventListener('fullscreenchange', () => {
+ if (!document.fullscreenElement) {
+ navigator.keyboard?.unlock?.();
+ }
+ fitCanvas();
+ });
+ window.addEventListener('resize', fitCanvas);
+
+ document.getElementById('rdp_power').addEventListener('click', () => {
+ powerMenu.classList.toggle('rdp_open');
+ });
+
+ // Power goes through the API rather than the guest, so it works when the
+ // guest does not answer - which is the case the buttons exist for.
+ for (const button of powerMenu.querySelectorAll('[data-power]')) {
+ button.addEventListener('click', async () => {
+ const action = button.dataset.power;
+ powerMenu.classList.remove('rdp_open');
+ setStatus(`${button.textContent}…`);
+ try {
+ const res = await fetch(
+ `/api2/json/nodes/${node}/qemu/${vmid}/status/${action}`,
+ {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { CSRFPreventionToken: '[% token %]' },
+ },
+ );
+ if (!res.ok) {
+ throw new Error(`${res.status} ${await res.text().catch(() => '')}`);
+ }
+ statusEl.hidden = true;
+ } catch (err) {
+ setStatus(`${button.textContent} failed: ${err.message ?? err}`, true);
+ }
+ });
+ }
+
+ boot().catch((err) => {
+ console.error(err);
+ setStatus(`RDP console failed: ${err.message ?? err}`, true);
+ });
+ </script>
+ </body>
+</html>
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
next prev parent reply other threads:[~2026-08-25 11:36 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 ` [RFC qemu-server 04/13] add rdp display Alexandre Derumier
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 ` Alexandre Derumier [this message]
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-8-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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox