* [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
@ 2026-08-25 11:34 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
` (9 more replies)
0 siblings, 10 replies; 11+ messages in thread
From: Alexandre Derumier @ 2026-08-25 11:34 UTC (permalink / raw)
To: pve-devel
Hi,
This patch series add support for 2 new remote console protocols as alternative
to VNC && spice.
They use the QEMU's D-Bus display feature to encode and stream the video to
through an external daemon.
1) Kyber web console
This use the new streaming protocol developped by the VLC developpers
https://gitlab.com/kyber/kyber
https://www.youtube.com/watch?v=nqVzOMebNx8
The target is a replacement of spice protocol for cloud gaming, video
acceleration, 3d support.
The kyber server has been patched to support qemu video output && inputs.
Kyber use a controlplane through websockets && dataplane for video through
quick/webtransport.
browser
|
+-------------------------+-------------------------+
| |
control plane data plane
HTTPS :8006 WebTransport (QUIC)
/api2/json/nodes/<node>/qemu/<vmid>/kyber/ UDP :63100, per node
| routed by the token
v start_mux handed out
+---------------------+ |
| pveproxy | |
| checks VM.Console | |
+---------------------+ |
| |
| unix /run/pvekyberproxy.sock |
v v
+---------------------------------------------------------------+
| pvekyberproxy |
| start_mux is answered with this daemon's port and its |
| certificate hash, which is what sends the client here |
+---------------------------------------------------------------+
| |
| unix | QUIC
| /run/qemu-server/<vmid>.kyber.sock | 127.0.0.1:63000
v v
+---------------------------------------------------------------+
| kycontroller (pve-kyber) |
+---------------------------------------------------------------+
| spawns
v
kyavserver / kynputserver
| spawn
v
kyber-qemu-server ---> kymux tcp 127.0.0.1:9091/0 video, /1 audio
|
| D-Bus unix /run/qemu-server/<vmid>.dbusdisplay, org.qemu on
v the private bus qemu-server starts for that VM
QEMU -display dbus,addr=unix:path=...
2) RDP web console
It's use the IronRDP server && gateway implementation
https://github.com/Devolutions/IronRDP
https://github.com/Devolutions/devolutions-gateway
Target is VNC replacement (with audio && clipboard support),
cpu usage is pretty low && display is a lot better than VNC when playing videos
for example.
browser
|
| one websocket, and only this one:
| HTTPS :8006, no second port to open
| /api2/json/nodes/<node>/qemu/<vmid>/rdp/<token>
v
+---------------------+
| pveproxy |
| checks VM.Console |
+---------------------+
|
| unix /run/pverdpproxy.sock
| path rewritten to /<vmid>/<token>
v
+----------------------------------+ reads the
| pverdpproxy | token from
| RDCleanPath: X.224 request, TLS |---> <vmid>.rdp.env
| handshake, certificate chain | (root only)
| back to the client, then bytes |
+----------------------------------+
|
| unix /run/qemu-server/<vmid>.rdp.sock
| TLS, terminated here - CredSSP binds to
| the server's key, so it cannot be dropped
v
+----------------------------------+ credentials
| qemu-rdp (pve-rdp@) |<--- over D-Bus,
+----------------------------------+ from the API
|
| D-Bus unix, org.qemu on the private bus
v /run/qemu-server/<vmid>.dbusdisplay
QEMU -display dbus,addr=unix:path=...
the kyber/rdp servers && proxy are written in Rust, as I'm a pretty poor rust
developper, I have use claude for most of their implementation, and review all
the code multiple times, but please review carefully to be sure.
For kyber, I have added an experimental patch for qemu-server to use gpu
acceleration through DMABUF, but I had some bugs with some resolutions display,
tested with an amd gpu, I didn't have nvidia hardware to compare.
pve-http-server (1):
anyevent : proxy a path prefix to a local http proxy
src/PVE/APIServer/AnyEvent.pm | 263 ++++++++++++++++++++++++++++++++++
1 file changed, 263 insertions(+)
qemu-server (4):
add D-Bus display support
add kyber display
add rdp display
add experimental kyber-gl display
17 files changed, 929 insertions(+), 7 deletions(-)
pve-manager (2):
ui: add kyber console
ui: add rdp console
12 files changed, 1280 insertions(+), 6 deletions(-)
new packages (6):
pve-qemu-kyber 28 files changed, 7987 insertions(+)
pve-kyberproxy 16 files changed, 3245 insertions(+)
pve-kyber-web 13 files changed, 542 insertions(+)
pve-qemu-rdp 11 files changed, 328 insertions(+)
pve-rdpproxy 13 files changed, 1802 insertions(+)
pve-rdp-web 15 files changed, 5055 insertions(+)
--
2.55.0
^ permalink raw reply [flat|nested] 11+ messages in thread
* [RFC pve-http-server 01/13] anyevent : proxy a path prefix to a local http proxy
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 ` Alexandre Derumier
2026-08-25 11:34 ` [RFC qemu-server 02/13] add D-Bus display support Alexandre Derumier
` (8 subsequent siblings)
9 siblings, 0 replies; 11+ messages in thread
From: Alexandre Derumier @ 2026-08-25 11:34 UTC (permalink / raw)
To: pve-devel
A handler says which prefixes go to a local backend and who may reach them,
so a service can use this server's TLS and authentication without its own port.
Requests go verbatim, not re-encoded like proxy_request; an upgrade becomes a
pipe after 101, and the relay stops reading while the far side is behind.
Signed-off-by: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
---
src/PVE/APIServer/AnyEvent.pm | 263 ++++++++++++++++++++++++++++++++++
1 file changed, 263 insertions(+)
diff --git a/src/PVE/APIServer/AnyEvent.pm b/src/PVE/APIServer/AnyEvent.pm
index 915d678..dc95c12 100644
--- a/src/PVE/APIServer/AnyEvent.pm
+++ b/src/PVE/APIServer/AnyEvent.pm
@@ -731,6 +731,250 @@ sub websocket_proxy {
}
}
+# Queued for one side before the other stops being read. Smaller than
+# response_stream's 4MB: consoles are not downloads, and a backlog is only
+# latency the far end could have coalesced away.
+my $relay_buf_size = 1024 * 1024;
+
+# What a handle still owes its socket; TLS keeps a second buffer.
+sub relay_queued {
+ my ($hdl) = @_;
+ return length($hdl->{wbuf}) + length($hdl->{_tls_wbuf} // '');
+}
+
+# One direction of an upgraded connection: copy to the other side, and stop
+# reading while that side is behind, so back pressure reaches the far end
+# instead of queueing here. Same shape as response_stream, and named rather
+# than a closure over itself, which would be a cycle. The handles come from
+# callbacks because either may be gone by the time this runs.
+sub relay_reader {
+ my ($from, $to) = @_;
+
+ return sub {
+ my ($hdl) = @_;
+
+ my $writer = $to->();
+ return if !$writer;
+
+ my $data = $hdl->{rbuf};
+ $hdl->{rbuf} = '';
+ $writer->push_write($data) if length($data);
+
+ return if relay_queued($writer) < $relay_buf_size;
+
+ my $prev_on_drain = $writer->{on_drain};
+ $writer->on_drain(sub {
+ my ($wrhdl) = @_;
+ # Restored first: setting on_drain runs it on an empty buffer.
+ $wrhdl->on_drain($prev_on_drain);
+ if (my $reader = $from->()) {
+ $reader->on_read(relay_reader($from, $to));
+ }
+ });
+
+ $hdl->on_read();
+ };
+}
+
+# Hand an upgrade to the backend: the request goes out as it arrived and the
+# answer comes back untouched, so the two ends compute the accept key. After
+# 101 this is a pipe, which knows nothing of websockets.
+sub local_http_proxy_upgrade {
+ my ($self, $reqstate, $method, $target) = @_;
+
+ my $r = $reqstate->{request};
+
+ my ($remhost, $remport);
+ if ($target->{port}) {
+ $remhost = 'localhost';
+ $remport = $target->{port};
+ } else {
+ $remhost = 'unix/';
+ $remport = $target->{socket};
+ }
+ my $path = $target->{path} // '/';
+
+ # Only Host is rewritten: this is the hop being upgraded, so Connection
+ # and Upgrade stay.
+ my $headers = '';
+ $r->headers->scan(sub {
+ my ($key, $value) = @_;
+ return if lc($key) eq 'host';
+ $headers .= "$key: $value\015\012";
+ });
+ my $request = "$method $path HTTP/1.1\015\012Host: localhost\015\012$headers\015\012";
+
+ tcp_connect $remhost, $remport, sub {
+ my ($fh) = @_
+ or do {
+ $self->error($reqstate, HTTP_BAD_GATEWAY, "connect to backend failed: $!");
+ return;
+ };
+
+ $reqstate->{proxyhdl} = AnyEvent::Handle->new(
+ fh => $fh,
+ rbuf_max => 64 * 1024,
+ wbuf_max => 4 * $relay_buf_size,
+ timeout => 30,
+ on_eof => sub {
+ eval {
+ $self->log_aborted_request($reqstate);
+ $self->client_do_disconnect($reqstate);
+ };
+ warn $@ if $@;
+ },
+ on_error => sub {
+ my ($hdl, $fatal, $message) = @_;
+ eval {
+ $self->log_aborted_request($reqstate, $message);
+ $self->client_do_disconnect($reqstate);
+ };
+ warn $@ if $@;
+ },
+ );
+
+ $reqstate->{proxyhdl}->push_write($request);
+
+ $reqstate->{proxyhdl}->push_read(
+ line => "\015\012\015\012",
+ sub {
+ my ($hdl, $response) = @_;
+
+ # Only 101 means the backend stopped speaking HTTP.
+ if ($response !~ m|^HTTP/1\.1 101|) {
+ my ($status) = $response =~ m|^(\S+ \d+[^\015]*)|;
+ $self->log_aborted_request($reqstate,
+ "backend refused upgrade: " . ($status // 'unparseable response'));
+ $self->client_do_disconnect($reqstate);
+ return;
+ }
+
+ # Verbatim: it carries the accept key for the client's key.
+ $reqstate->{hdl}->push_write($response . "\015\012\015\012");
+
+ $reqstate->{proxyhdl}->timeout(0);
+ $reqstate->{hdl}->timeout(0);
+
+ my $client = sub { $reqstate->{hdl} };
+ my $backend = sub { $reqstate->{proxyhdl} };
+
+ $reqstate->{proxyhdl}->on_read(relay_reader($backend, $client));
+ $reqstate->{hdl}->on_read(relay_reader($client, $backend));
+
+ $reqstate->{log}->{code} = 101;
+ $self->log_request($reqstate);
+ },
+ );
+ };
+
+ return;
+}
+
+# Forward a request verbatim to a service on loopback, unlike proxy_request,
+# which re-encodes parsed parameters for another PVE node. The backend is a
+# foreign HTTP server, kept behind this server's TLS and authentication.
+sub local_http_proxy_request {
+ my ($self, $reqstate, $method, $target) = @_;
+
+ my $r = $reqstate->{request};
+
+ my $port = $target->{port};
+ my $socket = $target->{socket};
+ die "local_http_proxy_request: missing port or socket\n" if !$port && !$socket;
+ my $path = $target->{path} // '/';
+ my $scheme = $target->{tls} ? 'https' : 'http';
+
+ if ($r->header('upgrade')) {
+ $self->local_http_proxy_upgrade($reqstate, $method, $target);
+ return;
+ }
+
+ # Hop-by-hop headers describe the connection they arrived on, and
+ # Accept-Encoding goes too, so this server can compress the body itself.
+ my $skip = {
+ map { $_ => 1 } qw(
+ connection keep-alive host content-length transfer-encoding
+ upgrade te trailer proxy-authorization accept-encoding
+ )
+ };
+
+ # A unix socket has no authority to name, and nothing behind here routes on
+ # Host anyway.
+ my $headers = { Host => $port ? "127.0.0.1:$port" : 'localhost' };
+ $r->headers->scan(sub {
+ my ($key, $value) = @_;
+ $headers->{$key} = $value if !$skip->{ lc($key) };
+ });
+
+ my $content = $r->content;
+ $headers->{'Content-Length'} = length($content) if length($content);
+
+ my $tls_ctx;
+ if ($target->{tls}) {
+ # Loopback, with a certificate no browser sees and no CA signed: there
+ # is nothing verification could check.
+ $tls_ctx = AnyEvent::TLS->new(method => 'any', sslv2 => 0, sslv3 => 0, verify => 0);
+ }
+
+ # AnyEvent::HTTP needs a URL to parse, so a unix backend gets a nominal
+ # authority and a tcp_connect that ignores it.
+ my $url = $port ? "$scheme://127.0.0.1:$port$path" : "$scheme://localhost$path";
+ my $tcp_connect;
+ if ($socket) {
+ $tcp_connect = sub {
+ my (undef, undef, $connect_cb, $prepare_cb) = @_;
+ return AnyEvent::Socket::tcp_connect('unix/', $socket, $connect_cb, $prepare_cb);
+ };
+ }
+
+ my $w;
+ $w = http_request(
+ $method => $url,
+ headers => $headers,
+ $tcp_connect ? (tcp_connect => $tcp_connect) : (),
+ timeout => 30,
+ proxy => undef, # avoid use of $ENV{HTTP_PROXY}
+ persistent => 0,
+ keepalive => 0,
+ body => length($content) ? $content : undef,
+ $tls_ctx ? (tls_ctx => $tls_ctx) : (),
+ sub {
+ my ($body, $hdr) = @_;
+
+ undef $w;
+
+ if (!$reqstate->{hdl}) {
+ warn "local http proxy detected vanished client connection\n";
+ return;
+ }
+
+ eval {
+ my $code = delete $hdr->{Status};
+ my $msg = delete $hdr->{Reason};
+ delete $hdr->{URL};
+ delete $hdr->{HTTPVersion};
+
+ # AnyEvent::HTTP reports its own failures in the 59x range.
+ if ($code >= 590) {
+ $self->error($reqstate, HTTP_BAD_GATEWAY, "$msg");
+ return;
+ }
+
+ # Set by this server for the connection it answers on.
+ delete $hdr->{$_} for qw(connection transfer-encoding content-length);
+
+ my $header = HTTP::Headers->new(%$hdr);
+ my $resp = HTTP::Response->new($code, $msg, $header, $body);
+ # Note: disable compression, the backend decides its own encoding
+ $self->response($reqstate, $resp, undef, 1);
+ };
+ warn $@ if $@;
+ },
+ );
+
+ return;
+}
+
sub proxy_request {
my ($self, $reqstate, $clientip, $host, $node, $method, $uri, $auth, $params) = @_;
@@ -1222,6 +1466,25 @@ sub handle_request {
# we re-enable timeout in response()
$reqstate->{hdl}->timeout(0);
+ # The handler says where to send it, or nothing for the usual dispatch.
+ if (my $handler = $self->{local_http_proxy_handler}) {
+ my $target = eval { $handler->($self, $reqstate, $auth, $method, $path) };
+ if (my $err = $@) {
+ # The handler's refusals are answers: a denial must stay 403.
+ my $code = HTTP_INTERNAL_SERVER_ERROR;
+ if (ref($err) && eval { $err->{code} }) {
+ my $carried = $err->{code};
+ $code = $carried if $carried =~ m/^\d+$/ && $carried >= 400 && $carried <= 599;
+ }
+ $self->error($reqstate, $code, "$err");
+ return;
+ }
+ if ($target) {
+ $self->local_http_proxy_request($reqstate, $method, $target);
+ return;
+ }
+ }
+
if ($path =~ m/^\Q$base_uri\E/) {
$self->handle_api2_request($reqstate, $auth, $method, $path);
return;
--
2.55.0
^ permalink raw reply related [flat|nested] 11+ messages in thread
* [RFC qemu-server 02/13] add D-Bus display support
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 ` Alexandre Derumier
2026-08-25 11:34 ` [RFC qemu-server 03/13] add kyber display Alexandre Derumier
` (7 subsequent siblings)
9 siblings, 0 replies; 11+ messages in thread
From: Alexandre Derumier @ 2026-08-25 11:34 UTC (permalink / raw)
To: pve-devel
Add a private D-Bus daemon per VM for QEMU's -display dbus.
Signed-off-by: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
---
src/PVE/QemuServer/DBusDisplay.pm | 100 ++++++++++++++++++++++++++++++
src/PVE/QemuServer/Helpers.pm | 6 ++
2 files changed, 106 insertions(+)
create mode 100644 src/PVE/QemuServer/DBusDisplay.pm
diff --git a/src/PVE/QemuServer/DBusDisplay.pm b/src/PVE/QemuServer/DBusDisplay.pm
new file mode 100644
index 0000000..b8684be
--- /dev/null
+++ b/src/PVE/QemuServer/DBusDisplay.pm
@@ -0,0 +1,100 @@
+package PVE::QemuServer::DBusDisplay;
+
+# A private D-Bus bus per VM for QEMU's -display dbus. QEMU connects to the
+# address rather than creating it, and every QEMU wants to own org.qemu, so the
+# session bus would cap a node at one VM. libvirt does the same.
+
+use strict;
+use warnings;
+
+use Time::HiRes qw(usleep);
+
+use PVE::ProcFSTools;
+use PVE::Tools qw(file_set_contents);
+use PVE::QemuServer::Helpers;
+
+sub config_file {
+ my ($vmid) = @_;
+ return "$PVE::QemuServer::Helpers::var_run_tmpdir/$vmid.dbusdisplay.conf";
+}
+
+sub pidfile {
+ my ($vmid) = @_;
+ return "$PVE::QemuServer::Helpers::var_run_tmpdir/$vmid.dbusdisplay.pid";
+}
+
+sub write_config {
+ my ($vmid) = @_;
+
+ my $socket = PVE::QemuServer::Helpers::dbus_socket($vmid);
+ my $pidfile = pidfile($vmid);
+
+ my $conf = <<"EOF";
+<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN"
+ "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
+<busconfig>
+ <type>org.qemu</type>
+ <listen>unix:path=$socket</listen>
+ <pidfile>$pidfile</pidfile>
+ <auth>EXTERNAL</auth>
+ <policy context="default">
+ <allow send_destination="*" eavesdrop="true"/>
+ <allow eavesdrop="true"/>
+ <allow own="*"/>
+ </policy>
+</busconfig>
+EOF
+
+ my $path = config_file($vmid);
+ file_set_contents($path, $conf, 0600);
+
+ return $path;
+}
+
+# Called inside the VM's systemd scope, so it dies with the VM, as swtpm does.
+sub start {
+ my ($vmid) = @_;
+
+ stop($vmid); # a survivor from an unclean stop still holds the socket
+
+ my $config = write_config($vmid);
+ unlink PVE::QemuServer::Helpers::dbus_socket($vmid);
+
+ PVE::Tools::run_command(
+ ['dbus-daemon', "--config-file=$config", '--fork'],
+ errmsg => "failed to start D-Bus daemon for VM $vmid",
+ );
+
+ return;
+}
+
+sub stop {
+ my ($vmid) = @_;
+
+ my $pidfile = pidfile($vmid);
+ if (my $pid = eval { PVE::Tools::file_read_firstline($pidfile) }) {
+ if ($pid =~ m/^(\d+)$/) {
+ $pid = $1;
+ kill('TERM', $pid);
+
+ # Waited for: a dying daemon unlinks the new socket and holds the VM's scope
+ # cgroup open, which blocks the next start.
+ for (my $waited = 0; $waited < 5; $waited += 0.05) {
+ last if !PVE::ProcFSTools::check_process_running($pid);
+ usleep(50_000);
+ }
+ if (PVE::ProcFSTools::check_process_running($pid)) {
+ warn "D-Bus daemon for VM $vmid did not exit, killing it\n";
+ kill('KILL', $pid);
+ }
+ }
+ }
+
+ unlink $pidfile;
+ unlink config_file($vmid);
+ unlink PVE::QemuServer::Helpers::dbus_socket($vmid);
+
+ return;
+}
+
+1;
diff --git a/src/PVE/QemuServer/Helpers.pm b/src/PVE/QemuServer/Helpers.pm
index dd17eef..816f7aa 100644
--- a/src/PVE/QemuServer/Helpers.pm
+++ b/src/PVE/QemuServer/Helpers.pm
@@ -140,6 +140,12 @@ sub vnc_socket {
return "${var_run_tmpdir}/$vmid.vnc";
}
+sub dbus_socket {
+ my ($vmid) = @_;
+ # dbusdisplay, not dbus: keep it apart from the dbus-vmstate helper's files.
+ return "${var_run_tmpdir}/$vmid.dbusdisplay";
+}
+
# Parse the cmdline of a running kvm/qemu-* process and return arguments as hash
sub parse_cmdline {
my ($pid) = @_;
--
2.55.0
^ permalink raw reply related [flat|nested] 11+ messages in thread
* [RFC qemu-server 03/13] add kyber display
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 ` Alexandre Derumier
2026-08-25 11:34 ` [RFC qemu-server 04/13] add rdp display Alexandre Derumier
` (6 subsequent siblings)
9 siblings, 0 replies; 11+ messages in thread
From: Alexandre Derumier @ 2026-08-25 11:34 UTC (permalink / raw)
To: pve-devel
kyber maps to virtio-vga, with a controller started per VM on demand by
the kyberproxy API call.
Signed-off-by: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
---
src/PVE/API2/Qemu.pm | 94 +++++++++++++++++++++++
src/PVE/QemuServer.pm | 50 ++++++++++++-
src/PVE/QemuServer/Kyber.pm | 128 ++++++++++++++++++++++++++++++++
src/PVE/QemuServer/Makefile | 2 +
src/test/cfg2cmd/kyber.conf | 3 +
src/test/cfg2cmd/kyber.conf.cmd | 27 +++++++
src/usr/Makefile | 1 +
src/usr/pve-qemu-kyber@.service | 25 +++++++
8 files changed, 326 insertions(+), 4 deletions(-)
create mode 100644 src/PVE/QemuServer/Kyber.pm
create mode 100644 src/test/cfg2cmd/kyber.conf
create mode 100644 src/test/cfg2cmd/kyber.conf.cmd
create mode 100644 src/usr/pve-qemu-kyber@.service
diff --git a/src/PVE/API2/Qemu.pm b/src/PVE/API2/Qemu.pm
index 71247ee..55befc1 100644
--- a/src/PVE/API2/Qemu.pm
+++ b/src/PVE/API2/Qemu.pm
@@ -12,6 +12,7 @@ use IPC::Open3;
use JSON;
use URI::Escape;
use Socket qw(SOCK_STREAM);
+use Time::HiRes qw(usleep);
use PVE::APIClient::LWP;
use PVE::CGroup;
@@ -35,6 +36,7 @@ use PVE::QemuServer::Cloudinit;
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::ImportDisk;
use PVE::QemuServer::Monitor qw(mon_cmd vm_qmp_peer);
use PVE::QemuServer::Machine;
@@ -3329,6 +3331,92 @@ __PACKAGE__->register_method({
},
});
+__PACKAGE__->register_method({
+ name => 'kyberproxy',
+ path => '{vmid}/kyberproxy',
+ method => 'POST',
+ protected => 1,
+ proxyto => 'node',
+ permissions => {
+ check => ['perm', '/vms/{vmid}', ['VM.Console']],
+ },
+ description => "Start a Kyber console controller 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' },
+ ticket => {
+ type => 'string',
+ description => "Short-lived token authenticating this user to the controller.",
+ },
+ },
+ },
+ code => sub {
+ my ($param) = @_;
+
+ my $rpcenv = PVE::RPCEnvironment::get();
+ my $authuser = $rpcenv->get_user();
+
+ 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 Kyber console"
+ . " - set its display to 'kyber' and restart it\n"
+ if ($vga->{type} // '') ne 'kyber';
+
+ die "VM $vmid is not running\n" if !PVE::QemuServer::Helpers::vm_running_locally($vmid);
+
+ my $socket = PVE::QemuServer::Helpers::dbus_socket($vmid);
+ die "VM $vmid has no D-Bus display socket at $socket"
+ . " - it was started before its display was set to 'kyber',"
+ . " so it needs a restart\n"
+ if !-S $socket;
+
+ # Join a controller that is already streaming: it shares one capture between
+ # clients, and restarting to install a new secret would cut the first off.
+ my ($secret, $port) = PVE::QemuServer::Kyber::running_secret($vmid);
+
+ if (!$secret) {
+ my $family = PVE::Tools::get_host_address_family($node);
+ $port = PVE::QemuServer::Kyber::next_port($family);
+
+ # Fresh per controller: with none running there is nothing to cut off.
+ $secret = PVE::QemuServer::Kyber::generate_secret();
+
+ # Only 'vnc' adds the vdagent chardev the guest needs to share a clipboard.
+ my $clipboard = ($vga->{clipboard} // '') eq 'vnc';
+
+ PVE::QemuServer::Kyber::write_env($vmid, $secret, $port, $clipboard);
+ PVE::QemuServer::Kyber::restart_controller($vmid);
+ }
+
+ my $ticket = PVE::QemuServer::Kyber::assemble_ticket($secret, $authuser);
+
+ # Listening within ~30ms, so waiting here saves the client a retry loop.
+ my $kybersocket = PVE::QemuServer::Kyber::socket_file($vmid);
+ for (my $waited = 0; $waited < 5; $waited += 0.05) {
+ last if -S $kybersocket;
+ usleep(50_000);
+ }
+ die "Kyber console controller for VM $vmid did not start\n" if !-S $kybersocket;
+
+ return {
+ user => $authuser,
+ ticket => $ticket,
+ };
+ },
+});
+
__PACKAGE__->register_method({
name => 'spiceproxy',
path => '{vmid}/spiceproxy',
@@ -3452,6 +3540,11 @@ __PACKAGE__->register_method({
type => 'boolean',
optional => 1,
},
+ kyber => {
+ description => "QEMU VGA configuration supports the Kyber console.",
+ type => 'boolean',
+ optional => 1,
+ },
agent => {
description => "QEMU Guest Agent is enabled in config.",
type => 'boolean',
@@ -3482,6 +3575,7 @@ __PACKAGE__->register_method({
my $spice = defined($vga->{type}) && $vga->{type} =~ /^virtio/;
$spice ||= PVE::QemuServer::vga_conf_has_spice($conf->{vga});
$status->{spice} = 1 if $spice;
+ $status->{kyber} = 1 if ($vga->{type} // '') eq 'kyber';
$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 2f43faa..8c7f023 100644
--- a/src/PVE/QemuServer.pm
+++ b/src/PVE/QemuServer.pm
@@ -80,6 +80,7 @@ use PVE::QemuServer::Drive qw(
storage_allows_io_uring_default
);
use PVE::QemuServer::DriveDevice qw(print_drivedevice_full scsihw_infos);
+use PVE::QemuServer::Kyber;
use PVE::QemuServer::Machine;
use PVE::QemuServer::Memory qw(get_current_memory);
use PVE::QemuServer::MetaInfo;
@@ -98,6 +99,7 @@ use PVE::QemuServer::StateFile;
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::DBusVMState;
my $have_ha_config;
@@ -168,7 +170,7 @@ my $vga_fmt = {
optional => 1,
default_key => 1,
enum => [
- qw(cirrus qxl qxl2 qxl3 qxl4 none serial0 serial1 serial2 serial3 std virtio virtio-gl vmware)
+ qw(cirrus kyber qxl qxl2 qxl3 qxl4 none serial0 serial1 serial2 serial3 std virtio virtio-gl vmware)
],
},
memory => {
@@ -213,10 +215,12 @@ my $audio_fmt = {
},
driver => {
type => 'string',
- enum => ['spice', 'none'],
+ enum => ['spice', 'dbus', 'none'],
default => 'spice',
optional => 1,
- description => "Driver backend for the audio device.",
+ description => "Driver backend for the audio device."
+ . " 'dbus' exposes it on the VM's D-Bus display, which is what the"
+ . " Kyber and RDP consoles read.",
},
};
@@ -1481,6 +1485,9 @@ my $vga_map = {
'vmware' => 'vmware-svga',
'virtio' => 'virtio-vga',
'virtio-gl' => 'virtio-vga-gl',
+ # 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',
};
# QEMU builds only the non-VGA variants of the virtio GPU for aarch64
@@ -1488,6 +1495,7 @@ my $vga_map_aarch64 = {
$vga_map->%*,
'virtio' => 'virtio-gpu',
'virtio-gl' => 'virtio-gpu-gl',
+ 'kyber' => 'virtio-gpu',
};
my sub map_vga_model {
@@ -2839,7 +2847,14 @@ sub audio_devs {
die "unknown audio device '$audio->{dev}', implement me!";
}
- push @$devs, '-audiodev', "$audio->{backend},id=$audio->{backend_id}";
+ my $backend = "$audio->{backend},id=$audio->{backend_id}";
+
+ # Pinned for the D-Bus backend: what reads it is an Opus encoder and libopus takes
+ # 48kHz only. QEMU already resamples, so this costs nothing new.
+ $backend .= ',out.frequency=48000,out.channels=2,out.format=s16'
+ if $audio->{backend} eq 'dbus';
+
+ push @$devs, '-audiodev', $backend;
return $devs;
}
@@ -3408,6 +3423,19 @@ sub config_to_command {
push @$cmd, '-display', 'egl-headless,gl=core' if $vga->{type} eq 'virtio-gl'; # VIRGL
+ if ($vga->{type} eq 'kyber') {
+ my $dbus = PVE::QemuServer::Helpers::dbus_socket($vmid);
+ my $display = "dbus,addr=unix:path=$dbus";
+
+ # The display exports org.qemu.Display1.Audio only when told which audiodev to
+ # read, and nothing else can consume a dbus audiodev.
+ my $audio = conf_has_audio($conf);
+ $display .= ",audiodev=$audio->{backend_id}"
+ if $audio && $audio->{backend} eq 'dbus';
+
+ push @$cmd, '-display', $display;
+ }
+
my $socket = PVE::QemuServer::Helpers::vnc_socket($vmid);
push @$cmd, '-vnc', "unix:$socket,password=on";
} else {
@@ -5812,6 +5840,11 @@ sub vm_start_nolock {
my $virtiofs_sockets = start_all_virtiofsd($conf, $vmid);
+ # 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';
+
my $tpmpid;
if ((my $tpm = $conf->{tpmstate0}) && !PVE::QemuConfig->is_template($conf)) {
# start the TPM emulator so QEMU can connect on start
@@ -6196,6 +6229,15 @@ sub vm_stop_cleanup {
my ($storecfg, $vmid, $conf, $keepActive, $apply_pending_changes, $noerr, $skip_hookscript) =
@_;
+ # Before the cleanup flag is consulted, deliberately: the bus is forked into the
+ # VM's systemd scope, and a survivor keeps that cgroup from emptying, so the next
+ # start fails with "timeout waiting on systemd".
+ eval {
+ PVE::QemuServer::Kyber::stop_controller($vmid);
+ PVE::QemuServer::DBusDisplay::stop($vmid);
+ };
+ warn $@ if $@;
+
my $can_use_cleanup_flag = PVE::QemuServer::RunState::can_use_cleanup_flag();
if ($can_use_cleanup_flag) {
return if !PVE::QemuServer::RunState::cleanup_flag_exists($vmid);
diff --git a/src/PVE/QemuServer/Kyber.pm b/src/PVE/QemuServer/Kyber.pm
new file mode 100644
index 0000000..3115f82
--- /dev/null
+++ b/src/PVE/QemuServer/Kyber.pm
@@ -0,0 +1,128 @@
+package PVE::QemuServer::Kyber;
+
+# Per-VM Kyber console controller: one kycontroller per VM, spawned on demand and
+# reaped when it exits.
+
+use strict;
+use warnings;
+
+use Digest::SHA qw(hmac_sha256);
+use JSON;
+use Crypt::OpenSSL::Random;
+use MIME::Base64 qw(encode_base64url);
+
+use PVE::Tools qw(file_set_contents);
+use PVE::QemuServer::DBusDisplay;
+use PVE::QemuServer::Helpers;
+
+sub socket_file {
+ my ($vmid) = @_;
+ return "$PVE::QemuServer::Helpers::var_run_tmpdir/$vmid.kyber.sock";
+}
+
+# Carries the signing key, and only that: /proc/<pid>/cmdline is world-readable,
+# so it cannot go on the command line. systemd passes it as KYBER_JWT_KEY.
+sub env_file {
+ my ($vmid) = @_;
+ return "$PVE::QemuServer::Helpers::var_run_tmpdir/$vmid.kyber.env";
+}
+
+sub next_port {
+ my ($family) = @_;
+ return PVE::Tools::next_unused_port(63000, 63099, $family, '127.0.0.1');
+}
+
+sub generate_secret {
+ my $bytes = Crypt::OpenSSL::Random::random_bytes(32)
+ or die "unable to generate a random secret\n";
+ return unpack('H*', $bytes);
+}
+
+# An HS256 token the controller will accept. The secret is regenerated whenever a
+# controller starts and never leaves the node, so it is useless against other VMs.
+sub assemble_ticket {
+ my ($secret, $username, $ttl) = @_;
+
+ # As long as a VNC ticket, and for the same reason: the client re-presents it to
+ # renew its session. It is worth little alone - the controller is on loopback.
+ $ttl //= 3600;
+ my $now = time();
+
+ my $header = encode_base64url(encode_json({ alg => 'HS256', typ => 'JWT' }));
+ # The controller requires aud=kyber (auth/jwt.rs) and rejects a token without it
+ # as malformed, which reads like a signing failure.
+ my $claims = encode_base64url(
+ encode_json({
+ aud => 'kyber',
+ sub => $username,
+ iat => $now,
+ exp => $now + $ttl,
+ }),
+ );
+
+ my $signature = encode_base64url(hmac_sha256("$header.$claims", $secret));
+
+ return "$header.$claims.$signature";
+}
+
+# The secret a running controller is verifying against, or undef when there is
+# none. Kept in a root-only env file so a second console can join instead of
+# restarting the controller and cutting the first viewer off.
+sub running_secret {
+ my ($vmid) = @_;
+
+ return undef if !-S socket_file($vmid);
+
+ my $env = eval { PVE::Tools::file_get_contents(env_file($vmid)) };
+ return undef if !defined($env);
+
+ my ($secret) = $env =~ m/^KYBER_JWT_KEY=(\S+)$/m;
+ my ($port) = $env =~ m/^KYBER_DATAPLANE_PORT=(\d+)$/m;
+ return undef if !$secret || !$port;
+
+ return ($secret, $port);
+}
+
+sub write_env {
+ my ($vmid, $secret, $dataplane_port, $clipboard) = @_;
+
+ my $clipboard_env = $clipboard ? 1 : 0;
+
+ my $env = <<"EOF";
+KYBER_JWT_KEY=$secret
+KYBER_DATAPLANE_PORT=$dataplane_port
+KQS_CLIPBOARD=$clipboard_env
+EOF
+
+ my $path = env_file($vmid);
+ file_set_contents($path, $env, 0600);
+
+ return $path;
+}
+
+sub restart_controller {
+ my ($vmid) = @_;
+
+ PVE::Tools::run_command(
+ ['systemctl', 'restart', "pve-qemu-kyber\@$vmid"],
+ errmsg => "failed to start Kyber console controller for VM $vmid",
+ );
+
+ return;
+}
+
+sub stop_controller {
+ my ($vmid) = @_;
+
+ eval {
+ PVE::Tools::run_command(['systemctl', 'stop', "pve-qemu-kyber\@$vmid"]);
+ };
+ warn $@ if $@;
+
+ unlink env_file($vmid);
+ unlink socket_file($vmid);
+
+ return;
+}
+
+1;
diff --git a/src/PVE/QemuServer/Makefile b/src/PVE/QemuServer/Makefile
index 060fac2..061d61f 100644
--- a/src/PVE/QemuServer/Makefile
+++ b/src/PVE/QemuServer/Makefile
@@ -10,11 +10,13 @@ SOURCES=Agent.pm \
Cloudinit.pm \
CPUConfig.pm \
CPUFlags.pm \
+ DBusDisplay.pm \
DBusVMState.pm \
Drive.pm \
DriveDevice.pm \
Helpers.pm \
ImportDisk.pm \
+ Kyber.pm \
Machine.pm \
Memory.pm \
MetaInfo.pm \
diff --git a/src/test/cfg2cmd/kyber.conf b/src/test/cfg2cmd/kyber.conf
new file mode 100644
index 0000000..31000dd
--- /dev/null
+++ b/src/test/cfg2cmd/kyber.conf
@@ -0,0 +1,3 @@
+# TEST: Kyber console display
+memory: 2048
+vga: kyber
diff --git a/src/test/cfg2cmd/kyber.conf.cmd b/src/test/cfg2cmd/kyber.conf.cmd
new file mode 100644
index 0000000..dfb2e99
--- /dev/null
+++ b/src/test/cfg2cmd/kyber.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 1365544..58dbb1d 100644
--- a/src/usr/Makefile
+++ b/src/usr/Makefile
@@ -22,6 +22,7 @@ install: pve-usb.cfg pve-q35.cfg pve-q35-4.0.cfg bootsplash.jpg modules-load.con
install -D -m 0755 dbus-vmstate $(LIBEXECDIR)/dbus-vmstate
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 $(DBUSDIR)
install -D -m 0644 org.qemu.VMState1.conf $(DBUSDIR)/system.d/org.qemu.VMState1.conf
diff --git a/src/usr/pve-qemu-kyber@.service b/src/usr/pve-qemu-kyber@.service
new file mode 100644
index 0000000..46e394e
--- /dev/null
+++ b/src/usr/pve-qemu-kyber@.service
@@ -0,0 +1,25 @@
+[Unit]
+Description=PVE Kyber Console Controller (VM %i)
+# Tie it to the VM's scope: it goes away with the VM.
+PartOf=%i.scope
+After=%i.scope
+
+[Service]
+Slice=qemu.slice
+Type=simple
+# The adapters find their VM's QEMU here rather than on a session bus.
+Environment=KQS_DBUS_ADDR=unix:path=/var/run/qemu-server/%i.dbusdisplay
+# Carries KYBER_JWT_KEY: /proc/<pid>/cmdline is readable by every user.
+EnvironmentFile=/var/run/qemu-server/%i.kyber.env
+# No configuration file: one line is all this needs. The control plane is a unix
+# socket; the data plane needs a UDP port, and --dataplane-addr keeps it off
+# every other interface - upstream binds the wildcard.
+ExecStart=/usr/bin/kycontroller \
+ --listen-socket /var/run/qemu-server/%i.kyber.sock \
+ --dataplane-addr 127.0.0.1 \
+ --tls-cert /etc/pve/local/pve-ssl.pem \
+ --tls-key /etc/pve/local/pve-ssl.key \
+ --no-basic-auth \
+ --no-oidc-auth \
+ --no-tray
+Restart=no
--
2.55.0
^ permalink raw reply related [flat|nested] 11+ messages in thread
* [RFC qemu-server 04/13] add rdp display
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
` (2 preceding siblings ...)
2026-08-25 11:34 ` [RFC qemu-server 03/13] add kyber display Alexandre Derumier
@ 2026-08-25 11:34 ` Alexandre Derumier
2026-08-25 11:34 ` [RFC qemu-server 05/13] add experimental kyber-gl display Alexandre Derumier
` (5 subsequent siblings)
9 siblings, 0 replies; 11+ messages in thread
From: Alexandre Derumier @ 2026-08-25 11:34 UTC (permalink / raw)
To: pve-devel
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
^ permalink raw reply related [flat|nested] 11+ messages in thread
* [RFC qemu-server 05/13] add experimental kyber-gl display
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
` (3 preceding siblings ...)
2026-08-25 11:34 ` [RFC qemu-server 04/13] add rdp display Alexandre Derumier
@ 2026-08-25 11:34 ` Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-manager 06/13] ui: add kyber console Alexandre Derumier
` (4 subsequent siblings)
9 siblings, 0 replies; 11+ messages in thread
From: Alexandre Derumier @ 2026-08-25 11:34 UTC (permalink / raw)
To: pve-devel
kyber-gl display is mapping on vhost-user-gpu-pci.
virgl in a helper process rather than inside QEMU: it exports a dmabuf, so QEMU
emits ScanoutDMABUF2 and the encoder imports it instead of reading pixels back.
Currently experimental because of detected bugs:
- tried vhost-user-gpu instead vhost-user-gpu-pci, but deadlock intermittently
on QEMU 11.0.2, blocking the main thread under the BQL,
so the guest freezes rather than just the stream.
- Modes whose stride is not a multiple of 256 - 800x600 and 1360x768 - fail
to import on radeonsi and are never streamed. The rest import once the
retry is made with an explicit DRM_FORMAT_MOD_LINEAR, QEMU labelling its
scanouts DRM_FORMAT_MOD_INVALID; those two need a copy instead.
- The picture is flipped vertically through the kernel and plymouth phase,
while GRUB and the desktop are both correct, so the origin changes twice
within a session and y0_top does not track it.
Signed-off-by: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
---
src/PVE/API2/Qemu.pm | 9 ++-
src/PVE/QemuServer.pm | 29 ++++++--
src/PVE/QemuServer/Kyber.pm | 4 +-
src/PVE/QemuServer/Makefile | 1 +
src/PVE/QemuServer/VhostUserGPU.pm | 103 +++++++++++++++++++++++++++++
src/test/cfg2cmd/kyber-gl.conf | 3 +
src/test/cfg2cmd/kyber-gl.conf.cmd | 29 ++++++++
7 files changed, 169 insertions(+), 9 deletions(-)
create mode 100644 src/PVE/QemuServer/VhostUserGPU.pm
create mode 100644 src/test/cfg2cmd/kyber-gl.conf
create mode 100644 src/test/cfg2cmd/kyber-gl.conf.cmd
diff --git a/src/PVE/API2/Qemu.pm b/src/PVE/API2/Qemu.pm
index bae345b..79aab86 100644
--- a/src/PVE/API2/Qemu.pm
+++ b/src/PVE/API2/Qemu.pm
@@ -3371,9 +3371,10 @@ __PACKAGE__->register_method({
my $conf = PVE::QemuConfig->load_config($vmid, $node);
my $vga = PVE::QemuServer::parse_vga($conf->{vga} // '');
+ my $vga_type = $vga->{type} // '';
die "VM $vmid is not configured for the Kyber console"
- . " - set its display to 'kyber' and restart it\n"
- if ($vga->{type} // '') ne 'kyber';
+ . " - set its display to 'kyber' or 'kyber-gl' and restart it\n"
+ if $vga_type !~ /^kyber(?:-gl)?$/;
die "VM $vmid is not running\n" if !PVE::QemuServer::Helpers::vm_running_locally($vmid);
@@ -3397,7 +3398,9 @@ __PACKAGE__->register_method({
# Only 'vnc' adds the vdagent chardev the guest needs to share a clipboard.
my $clipboard = ($vga->{clipboard} // '') eq 'vnc';
- PVE::QemuServer::Kyber::write_env($vmid, $secret, $port, $clipboard);
+ my $dmabuf = $vga_type eq 'kyber-gl';
+
+ PVE::QemuServer::Kyber::write_env($vmid, $secret, $port, $clipboard, $dmabuf);
PVE::QemuServer::Kyber::restart_controller($vmid);
}
diff --git a/src/PVE/QemuServer.pm b/src/PVE/QemuServer.pm
index 33a5e40..652f3ea 100644
--- a/src/PVE/QemuServer.pm
+++ b/src/PVE/QemuServer.pm
@@ -97,6 +97,7 @@ use PVE::QemuServer::RNG qw(parse_rng print_rng_device_commandline print_rng_obj
use PVE::QemuServer::RunState;
use PVE::QemuServer::StateFile;
use PVE::QemuServer::USB;
+use PVE::QemuServer::VhostUserGPU;
use PVE::QemuServer::Virtiofs qw(max_virtiofs start_all_virtiofsd);
use PVE::QemuServer::VolumeChain;
use PVE::QemuServer::DBusDisplay;
@@ -171,7 +172,7 @@ my $vga_fmt = {
optional => 1,
default_key => 1,
enum => [
- qw(cirrus kyber qxl qxl2 qxl3 qxl4 none rdp 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 => {
@@ -1491,6 +1492,7 @@ 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',
+ 'kyber-gl' => 'vhost-user-vga',
# Same reasoning for the RDP console: it reads the same D-Bus display.
'rdp' => 'virtio-vga',
};
@@ -1501,6 +1503,7 @@ my $vga_map_aarch64 = {
'virtio' => 'virtio-gpu',
'virtio-gl' => 'virtio-gpu-gl',
'kyber' => 'virtio-gpu',
+ 'kyber-gl' => 'vhost-user-gpu-pci',
'rdp' => 'virtio-gpu',
};
@@ -1548,6 +1551,12 @@ sub print_vga_device {
$memory = ",ram_size=67108864,vram_size=33554432";
}
+ my $chardev = "";
+ if ($vga->{type} eq 'kyber-gl') {
+ $memory = "";
+ $chardev = ",chardev=" . PVE::QemuServer::VhostUserGPU::chardev_id();
+ }
+
my $edidoff = "";
if ($type eq 'VGA' && windows_version($conf->{ostype})) {
$edidoff = ",edid=off" if (!defined($conf->{bios}) || $conf->{bios} ne 'ovmf');
@@ -1576,7 +1585,7 @@ sub print_vga_device {
if !PVE::Tools::dir_glob_regex('/dev/dri/', "renderD.*");
}
- return "$type,id=${vgaid}${memory}${max_outputs}${pciaddr}${edidoff}";
+ return "$type,id=${vgaid}${memory}${chardev}${max_outputs}${pciaddr}${edidoff}";
}
sub vm_is_volid_owner {
@@ -3429,9 +3438,15 @@ sub config_to_command {
push @$cmd, '-display', 'egl-headless,gl=core' if $vga->{type} eq 'virtio-gl'; # VIRGL
- if ($vga->{type} =~ /^(?:kyber|rdp)$/) {
+ if ($vga->{type} =~ /^(?:kyber|kyber-gl|rdp)$/) {
my $dbus = PVE::QemuServer::Helpers::dbus_socket($vmid);
my $display = "dbus,addr=unix:path=$dbus";
+ if ($vga->{type} eq 'kyber-gl') {
+ $display .= ",gl=on";
+ my $socket = PVE::QemuServer::VhostUserGPU::socket_file($vmid);
+ my $id = PVE::QemuServer::VhostUserGPU::chardev_id();
+ push @$cmd, '-chardev', "socket,id=$id,path=$socket";
+ }
# The display exports org.qemu.Display1.Audio only when told which audiodev to
# read, and nothing else can consume a dbus audiodev.
@@ -3474,6 +3489,7 @@ sub config_to_command {
}
my $virtiofs_enabled = PVE::QemuServer::Virtiofs::virtiofs_enabled($conf);
+ my $shared_memory = $virtiofs_enabled || ($vga->{type} // '') eq 'kyber-gl';
PVE::QemuServer::Memory::config(
$conf,
@@ -3481,7 +3497,7 @@ sub config_to_command {
$sockets,
$cores,
$hotplug_features->{memory},
- $virtiofs_enabled,
+ $shared_memory,
$cmd,
$machineFlags,
);
@@ -5849,7 +5865,9 @@ 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|rdp)$/;
+ if ($dbus_vga->{type} // '') =~ /^(?:kyber|kyber-gl|rdp)$/;
+ PVE::QemuServer::VhostUserGPU::start($vmid)
+ if ($dbus_vga->{type} // '') eq 'kyber-gl';
my $tpmpid;
if ((my $tpm = $conf->{tpmstate0}) && !PVE::QemuConfig->is_template($conf)) {
@@ -6242,6 +6260,7 @@ sub vm_stop_cleanup {
PVE::QemuServer::Kyber::stop_controller($vmid);
PVE::QemuServer::RDP::stop_server($vmid);
PVE::QemuServer::DBusDisplay::stop($vmid);
+ PVE::QemuServer::VhostUserGPU::stop($vmid);
};
warn $@ if $@;
diff --git a/src/PVE/QemuServer/Kyber.pm b/src/PVE/QemuServer/Kyber.pm
index 3115f82..953413b 100644
--- a/src/PVE/QemuServer/Kyber.pm
+++ b/src/PVE/QemuServer/Kyber.pm
@@ -84,14 +84,16 @@ sub running_secret {
}
sub write_env {
- my ($vmid, $secret, $dataplane_port, $clipboard) = @_;
+ my ($vmid, $secret, $dataplane_port, $clipboard, $dmabuf) = @_;
my $clipboard_env = $clipboard ? 1 : 0;
+ my $dmabuf_env = $dmabuf ? 1 : 0;
my $env = <<"EOF";
KYBER_JWT_KEY=$secret
KYBER_DATAPLANE_PORT=$dataplane_port
KQS_CLIPBOARD=$clipboard_env
+KQS_DMABUF=$dmabuf_env
EOF
my $path = env_file($vmid);
diff --git a/src/PVE/QemuServer/Makefile b/src/PVE/QemuServer/Makefile
index 38e5aa6..1bd7a2f 100644
--- a/src/PVE/QemuServer/Makefile
+++ b/src/PVE/QemuServer/Makefile
@@ -32,6 +32,7 @@ SOURCES=Agent.pm \
RunState.pm \
StateFile.pm \
USB.pm \
+ VhostUserGPU.pm \
Virtiofs.pm \
VolumeChain.pm
diff --git a/src/PVE/QemuServer/VhostUserGPU.pm b/src/PVE/QemuServer/VhostUserGPU.pm
new file mode 100644
index 0000000..63d571b
--- /dev/null
+++ b/src/PVE/QemuServer/VhostUserGPU.pm
@@ -0,0 +1,103 @@
+package PVE::QemuServer::VhostUserGPU;
+
+# virgl in a helper process rather than inside QEMU, for the 'kyber-gl' display.
+# The helper renders and exports a dmabuf, so QEMU emits ScanoutDMABUF2 and the
+# console encoder imports it instead of reading back pixels.
+
+use strict;
+use warnings;
+
+use POSIX;
+use Time::HiRes qw(usleep);
+
+use PVE::ProcFSTools;
+use PVE::Tools qw(file_set_contents file_read_firstline);
+use PVE::QemuServer::Helpers;
+
+my $BINARY = '/usr/lib/kvm/vhost-user-gpu';
+
+sub socket_file {
+ my ($vmid) = @_;
+ return "$PVE::QemuServer::Helpers::var_run_tmpdir/$vmid.vhost-user-gpu.sock";
+}
+
+sub pidfile {
+ my ($vmid) = @_;
+ return "$PVE::QemuServer::Helpers::var_run_tmpdir/$vmid.vhost-user-gpu.pid";
+}
+
+sub chardev_id {
+ return 'vhost-user-gpu';
+}
+
+sub start {
+ my ($vmid) = @_;
+
+ die "vhost-user-gpu is not installed ($BINARY)\n" if !-x $BINARY;
+
+ stop($vmid);
+
+ my $socket = socket_file($vmid);
+ my $pidfile = pidfile($vmid);
+ my $log = "$PVE::QemuServer::Helpers::var_run_tmpdir/$vmid-vhost-user-gpu.log";
+
+ my $pid = fork();
+ die "could not fork to start vhost-user-gpu for VM $vmid\n" if !defined($pid);
+
+ if ($pid == 0) {
+ POSIX::setsid();
+ $0 = "task pve-vm$vmid-vhost-user-gpu";
+
+ my $pid2 = fork();
+ if (!defined($pid2)) {
+ POSIX::_exit(1);
+ } elsif ($pid2 == 0) {
+ open(STDIN, '<', '/dev/null');
+ open(STDOUT, '>>', $log);
+ open(STDERR, '>&', \*STDOUT);
+ exec($BINARY, '--virgl', '--socket-path', $socket);
+ POSIX::_exit(1);
+ }
+
+ eval { file_set_contents($pidfile, "$pid2\n") };
+ POSIX::_exit(0);
+ }
+
+ waitpid($pid, 0);
+
+ for (my $waited = 0; $waited < 5; $waited += 0.05) {
+ last if -S $socket;
+ usleep(50_000);
+ }
+ die "vhost-user-gpu for VM $vmid did not create $socket - see $log\n" if !-S $socket;
+
+ return;
+}
+
+sub stop {
+ my ($vmid) = @_;
+
+ my $pidfile = pidfile($vmid);
+ if (my $pid = eval { file_read_firstline($pidfile) }) {
+ if ($pid =~ m/^(\d+)$/) {
+ $pid = $1;
+ kill('TERM', $pid);
+
+ for (my $waited = 0; $waited < 5; $waited += 0.05) {
+ last if !PVE::ProcFSTools::check_process_running($pid);
+ usleep(50_000);
+ }
+ if (PVE::ProcFSTools::check_process_running($pid)) {
+ warn "vhost-user-gpu for VM $vmid did not exit, killing it\n";
+ kill('KILL', $pid);
+ }
+ }
+ }
+
+ unlink $pidfile;
+ unlink socket_file($vmid);
+
+ return;
+}
+
+1;
diff --git a/src/test/cfg2cmd/kyber-gl.conf b/src/test/cfg2cmd/kyber-gl.conf
new file mode 100644
index 0000000..e127bea
--- /dev/null
+++ b/src/test/cfg2cmd/kyber-gl.conf
@@ -0,0 +1,3 @@
+# TEST: Kyber console on the vhost-user GPU
+memory: 2048
+vga: kyber-gl
diff --git a/src/test/cfg2cmd/kyber-gl.conf.cmd b/src/test/cfg2cmd/kyber-gl.conf.cmd
new file mode 100644
index 0000000..d95b7df
--- /dev/null
+++ b/src/test/cfg2cmd/kyber-gl.conf.cmd
@@ -0,0 +1,29 @@
+/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'
+-chardev 'socket,id=vhost-user-gpu,path=/var/run/qemu-server/8006.vhost-user-gpu.sock'
+-display 'dbus,addr=unix:path=/var/run/qemu-server/8006.dbusdisplay,gl=on'
+-vnc 'unix:/var/run/qemu-server/8006.vnc,password=on'
+-cpu kvm64,enforce,+kvm_pv_eoi,+kvm_pv_unhalt,+lahf_lm,+sep
+-m 2048
+-object 'memory-backend-memfd,id=virtiofs-mem,size=2048M,share=on'
+-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 'vhost-user-vga,id=vga,chardev=vhost-user-gpu,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 'memory-backend=virtiofs-mem,type=pc+pve0'
\ No newline at end of file
--
2.55.0
^ permalink raw reply related [flat|nested] 11+ messages in thread
* [RFC pve-manager 06/13] ui: add kyber console
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
` (4 preceding siblings ...)
2026-08-25 11:34 ` [RFC qemu-server 05/13] add experimental kyber-gl display Alexandre Derumier
@ 2026-08-25 11:34 ` Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-manager 07/13] ui: add rdp console Alexandre Derumier
` (3 subsequent siblings)
9 siblings, 0 replies; 11+ messages in thread
From: Alexandre Derumier @ 2026-08-25 11:34 UTC (permalink / raw)
To: pve-devel
Signed-off-by: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
---
Makefile | 2 +-
PVE/HTTPServer.pm | 4 +-
PVE/Service/pveproxy.pm | 53 +++-
kyber-web/Makefile | 22 ++
kyber-web/index.html.tpl | 438 +++++++++++++++++++++++++++
www/manager6/Utils.js | 30 +-
www/manager6/button/ConsoleButton.js | 22 ++
www/manager6/qemu/AudioEdit.js | 3 +
www/manager6/qemu/Config.js | 9 +-
www/manager6/qemu/DisplayEdit.js | 4 +-
10 files changed, 581 insertions(+), 6 deletions(-)
create mode 100644 kyber-web/Makefile
create mode 100644 kyber-web/index.html.tpl
diff --git a/Makefile b/Makefile
index 6c8b82e..b7dfc7f 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 network-hooks test templates
+SUBDIRS = aplinfo PVE bin www services configs kyber-web network-hooks test templates
all: $(SUBDIRS)
set -e && for i in $(SUBDIRS); do $(MAKE) -C $$i; done
diff --git a/PVE/HTTPServer.pm b/PVE/HTTPServer.pm
index 34403e8..c3fef48 100755
--- a/PVE/HTTPServer.pm
+++ b/PVE/HTTPServer.pm
@@ -120,11 +120,13 @@ sub auth_handler {
$isUpload = 1;
}
+ my $is_kyber_console = $rel_uri =~ m|^/nodes/[^/]+/qemu/\d+/kyber/|;
+
# Skip CSRF check for file upload (difficult to pass CSRF header with native html forms).
# Also skip the check with API tokens, as one of the design goals of API tokens was to
# provide stateless API access without requiring round-trips to get such CSRF tokens.
# CSRF-prevention also does not make much sense outside of the browser context.
- if ($method ne 'GET' && !($api_token || $isUpload)) {
+ if ($method ne 'GET' && !($api_token || $isUpload || $is_kyber_console)) {
my $euid = $>;
PVE::AccessControl::verify_csrf_prevention_token($username, $token) if $euid != 0;
}
diff --git a/PVE/Service/pveproxy.pm b/PVE/Service/pveproxy.pm
index dfdd014..bcc1353 100755
--- a/PVE/Service/pveproxy.pm
+++ b/PVE/Service/pveproxy.pm
@@ -20,6 +20,10 @@ use PVE::Cluster;
use PVE::Daemon;
use PVE::DataCenterConfig;
use PVE::HTTPServer;
+use HTTP::Status qw(HTTP_BAD_REQUEST);
+use PVE::Exception;
+use PVE::INotify;
+use PVE::RPCEnvironment;
use PVE::SafeSyslog;
use PVE::pvecfg;
use PVE::Tools;
@@ -53,6 +57,7 @@ my $basedirs = {
fontlogos => '/usr/share/fonts-font-logos',
i18n => '/usr/share/pve-i18n',
manager => '/usr/share/pve-manager',
+ kyber => '/usr/share/pve-kyber-web',
novnc => '/usr/share/novnc-pve',
yew_mobile => '/usr/share/pve-yew-mobile-gui',
i18n_yew => '/usr/share/pve-yew-mobile-i18n',
@@ -60,6 +65,41 @@ my $basedirs = {
xtermjs => '/usr/share/pve-xtermjs',
};
+my $kyber_console_prefix = qr!^/api2/json/nodes/([^/]+)/qemu/(\d+)/kyber(/.*)$!;
+my $kyber_proxy_socket = '/run/pvekyberproxy.sock';
+
+my sub check_console_access {
+ my ($auth, $console_type, $node, $vmid) = @_;
+
+ PVE::Exception::raise(
+ "the $console_type console for VM $vmid must be opened on node '$node'\n",
+ code => HTTP_BAD_REQUEST,
+ ) if $node ne PVE::INotify::nodename();
+
+ my $rpcenv = PVE::RPCEnvironment::get();
+ $rpcenv->check($auth->{userid}, "/vms/$vmid", ['VM.Console']);
+
+ return;
+}
+
+sub console_proxy {
+ my ($server, $reqstate, $auth, $method, $path) = @_;
+
+ if (my ($node, $vmid, $rest) = $path =~ $kyber_console_prefix) {
+ check_console_access($auth, 'Kyber', $node, $vmid);
+
+ my $target = $path;
+
+ if (my $query = $reqstate->{request}->url->query()) {
+ $target .= "?$query";
+ }
+
+ return { socket => $kyber_proxy_socket, path => $target, tls => 0 };
+ }
+
+ return undef;
+}
+
sub init {
my ($self) = @_;
@@ -77,6 +117,7 @@ sub init {
my $dirs = {};
+ add_dirs($dirs, '/kyber/' => "$basedirs->{kyber}/");
add_dirs($dirs, '/novnc/' => "$basedirs->{novnc}/");
add_dirs($dirs, '/pve-docs/' => "$basedirs->{docs}/");
add_dirs($dirs, '/pve-docs/api-viewer/extjs/' => "$basedirs->{extjs}/");
@@ -134,6 +175,7 @@ sub init {
},
},
dirs => $dirs,
+ local_http_proxy_handler => \&console_proxy,
};
if (defined($proxyconf->{DHPARAMS})) {
@@ -247,6 +289,7 @@ sub get_index {
(is_phone($r->header('User-Agent')) && (!defined($args->{mobile}) || $args->{mobile}))
|| $args->{mobile};
+ my $kyber = defined($args->{console}) && $args->{kyber};
my $novnc = defined($args->{console}) && $args->{novnc};
my $xtermjs = defined($args->{console}) && $args->{xtermjs};
@@ -291,7 +334,9 @@ sub get_index {
# by default, load the normal index
my $dir = $basedirs->{manager};
- if ($novnc) {
+ if ($kyber) {
+ $dir = $basedirs->{kyber};
+ } elsif ($novnc) {
$dir = $basedirs->{novnc};
} elsif ($xtermjs) {
$dir = $basedirs->{xtermjs};
@@ -305,6 +350,12 @@ sub get_index {
$template->process("$dir/index.html.tpl", $vars, \$page) || die $template->error(), "\n";
my $headers = HTTP::Headers->new(Content_Type => "text/html; charset=utf-8");
+
+ if ($kyber) {
+ $headers->header('Cross-Origin-Opener-Policy' => 'same-origin');
+ $headers->header('Cross-Origin-Embedder-Policy' => 'require-corp');
+ }
+
my $resp = HTTP::Response->new(200, "OK", $headers, $page);
return $resp;
diff --git a/kyber-web/Makefile b/kyber-web/Makefile
new file mode 100644
index 0000000..28b07a0
--- /dev/null
+++ b/kyber-web/Makefile
@@ -0,0 +1,22 @@
+include ../defines.mk
+
+KYBERDIR = $(DESTDIR)/usr/share/pve-kyber-web
+
+all:
+
+.PHONY: install
+install: index.html.tpl
+ install -d $(KYBERDIR)
+ install -m 0644 index.html.tpl $(KYBERDIR)/index.html.tpl
+# The client itself - kyclient_wasm.js, kyclient_wasm_bg.wasm and the audio
+# worklet beside them - is built from kyber-web and shipped by its own package
+# rather than vendored here. It is AGPL-3.0-or-later, same as pve-manager, but
+# it is a separate upstream with its own release cadence and a wasm toolchain
+# that has no business in this build.
+#
+# The page drives that SDK directly and needs nothing else from it: no bundle,
+# no stylesheets, and none of the demo client's UI.
+
+.PHONY: clean distclean
+distclean: clean
+clean:
diff --git a/kyber-web/index.html.tpl b/kyber-web/index.html.tpl
new file mode 100644
index 0000000..31f5e76
--- /dev/null
+++ b/kyber-web/index.html.tpl
@@ -0,0 +1,438 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>Kyber console</title>
+ <!--
+ Relative URLs resolve into the client's own directory rather than against
+ this page, which is served from '/'. The SDK loads a few assets by bare
+ name - the audio worklet among them - and without this they would be
+ looked for at the document root and 404 there.
+ -->
+ <base href="/kyber/">
+ <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%;
+ }
+ #container 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. */
+ #kyber_control_bar_anchor {
+ position: fixed;
+ top: 0;
+ left: 0;
+ height: 100%;
+ z-index: 10;
+ transition: 0.5s ease-in-out;
+ }
+ #kyber_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;
+ }
+ #kyber_control_bar.kyber_open { left: 0; }
+ #kyber_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);
+ }
+ #kyber_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;
+ }
+ #kyber_control_bar.kyber_open #kyber_control_bar_handle:after {
+ transform: translateX(1px) rotate(180deg);
+ }
+ .kyber_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;
+ }
+ .kyber_button:hover { background-color: rgba(255, 255, 255, 0.1); }
+ .kyber_button img { width: 24px; height: 24px; display: block; margin: auto; }
+ #kyber_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);
+ }
+ #kyber_power_menu.kyber_open { display: flex; flex-direction: column; gap: 4px; }
+ #kyber_power_menu .kyber_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="kyber_control_bar_anchor">
+ <div id="kyber_control_bar">
+ <div id="kyber_control_bar_handle"></div>
+ <button class="kyber_button" id="kyber_ctrl_alt_del" title="Send Ctrl-Alt-Del">
+ <img src="/novnc/app/images/esc.svg" alt=""><span>C-A-D</span>
+ </button>
+ <button class="kyber_button" id="kyber_fullscreen" title="Fullscreen">
+ <img src="/novnc/app/images/fullscreen.svg" alt="">
+ </button>
+ <button class="kyber_button" id="kyber_power" title="Power">
+ <img src="/novnc/app/images/power.svg" alt="">
+ </button>
+ <div id="kyber_power_menu">
+ <button class="kyber_button" data-power="start">Start</button>
+ <button class="kyber_button" data-power="shutdown">Shutdown</button>
+ <button class="kyber_button" data-power="reboot">Reboot</button>
+ <button class="kyber_button" data-power="reset">Reset</button>
+ <button class="kyber_button" data-power="stop">Stop</button>
+ </div>
+ </div>
+ </div>
+ <div id="container"></div>
+ <div id="status">Connecting…</div>
+
+ <script type="module">
+ import init, { AuthCredentials, Client, StreamingConfigBuilder }
+ from '/kyber/kyclient_wasm.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}) - Kyber console`
+ : `VM ${vmid} - Kyber console`;
+
+ const statusEl = document.getElementById('status');
+ const container = document.getElementById('container');
+
+ const setStatus = (text, failed) => {
+ statusEl.textContent = text;
+ statusEl.classList.toggle('failed', !!failed);
+ statusEl.hidden = false;
+ };
+
+ let client = null;
+ let streamingConfig = null;
+
+ function createCanvas() {
+ const canvas = document.createElement('canvas');
+ canvas.id = 'canvas';
+ canvas.width = 1024;
+ canvas.height = 768;
+ // Without this every keystroke goes to the document, not the guest.
+ canvas.tabIndex = 0;
+ container.appendChild(canvas);
+ canvas.focus();
+ return canvas;
+ }
+
+ function deleteCanvas() {
+ document.getElementById('canvas')?.remove();
+ }
+
+ // The canvas cannot be reused: its control was transferred to an
+ // offscreen canvas in the worker, one way.
+ async function restartStreaming() {
+ deleteCanvas();
+ createCanvas();
+ await client.start_streaming('canvas', streamingConfig.clone());
+ }
+
+ // A guest resize shows up as the host video process stopping; the stream
+ // must be rebuilt, since the decoder cannot change codec configuration
+ // mid-stream. Rate limited against a host stuck in a start/die loop.
+ // The stream's own geometry, which is what the canvas is sized from.
+ let videoSize = null;
+
+ // Native size in a window, scaled up to fill the screen in fullscreen -
+ // aspect ratio kept, so a 4:3 guest letterboxes rather than distorts.
+ function fitCanvas() {
+ const canvas = document.getElementById('canvas');
+ if (!canvas || !videoSize) {
+ return;
+ }
+ let { width, height } = videoSize;
+ if (document.fullscreenElement) {
+ const scale = Math.min(
+ window.innerWidth / width,
+ window.innerHeight / height,
+ );
+ width = Math.floor(width * scale);
+ height = Math.floor(height * scale);
+ }
+ canvas.style.width = `${width}px`;
+ canvas.style.height = `${height}px`;
+ }
+
+ const VIDEO_RESTART_MIN_INTERVAL_MS = 2000;
+ let lastVideoRestart = 0;
+
+ async function onClientEvent(name, event) {
+ switch (name) {
+ case 'streamer_pipeline_event':
+ // Not 'streaming_stopped': the SDK emits that only for a
+ // stop this client asked for. stop_streaming() comes back as
+ // one below, which rebuilds canvas and stream.
+ if (event.type === 'video' && event.stage === 'process'
+ && event.event === 'stopped' && client !== null) {
+ const now = Date.now();
+ if (now - lastVideoRestart < VIDEO_RESTART_MIN_INTERVAL_MS) {
+ console.warn('Host video stopped again too soon; not restarting');
+ break;
+ }
+ lastVideoRestart = now;
+ setStatus('Display changed, restarting…');
+ client.stop_streaming();
+ }
+ break;
+ case 'controlplane_connected':
+ statusEl.hidden = true;
+ await client.start_streaming('canvas', streamingConfig.clone());
+ break;
+ case 'canvas_resized':
+ videoSize = { width: event.width, height: event.height };
+ fitCanvas();
+ break;
+ case 'streaming_stopped':
+ // The session is still up, so pick the new stream up.
+ await restartStreaming();
+ statusEl.hidden = true;
+ break;
+ case 'reconnecting':
+ setStatus(`Reconnecting (${event.attempt}/${event.maxAttempts})…`);
+ break;
+ case 'reconnected':
+ statusEl.hidden = true;
+ if (event.shouldRestartStreaming) {
+ await restartStreaming();
+ }
+ break;
+ case 'reconnection_failed':
+ setStatus('Connection lost. Close this window and open the console again.', true);
+ deleteCanvas();
+ break;
+ case 'stopped':
+ setStatus(`Console stopped: ${event.reason()}`, true);
+ deleteCanvas();
+ break;
+ }
+ }
+
+ async function boot() {
+ if (!node || !vmid) {
+ throw new Error('missing node or vmid');
+ }
+
+ await init();
+
+ // Proxmox checks its ACL here and mints the token. Nothing is in the
+ // URL, so a copied link grants nothing.
+ const base = `/api2/json/nodes/${node}/qemu/${vmid}`;
+ const res = await fetch(`${base}/kyberproxy`, {
+ 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();
+
+ // Control plane only: pveproxy forwards this prefix to
+ // pvekyberproxy and on to the VM's controller. The data plane goes
+ // straight to the UDP port start_mux names - pveproxy speaks no QUIC.
+ const basePath = `${base}/kyber`;
+
+ const builder = new StreamingConfigBuilder();
+ builder.protocol = 'kymux';
+ builder.codec = 'h264';
+ builder.bitrate = 20000000;
+ builder.display_index = 0;
+ builder.inputs = true;
+ builder.clipboard = true;
+ // Carried when the VM has an audio device on a 'dbus' audiodev.
+ // The controller asks the AV service, which says no when there is
+ // none, and the client simply gets no audio stream.
+ builder.audio = true;
+ // Needs nvenc; this encoder is libx264.
+ builder.intra_refresh = false;
+ streamingConfig = builder.build();
+
+ createCanvas();
+
+ client = await Client.new(
+ window.location.hostname,
+ window.location.port || '443',
+ AuthCredentials.jwt(data.ticket),
+ onClientEvent,
+ false, // metrics
+ 'none', // metrics mode
+ null, // live metrics callback
+ true, // reconnect on its own
+ basePath,
+ );
+
+ client.connect();
+ }
+
+ // The bar retracts like noVNC's: the handle is always reachable, the bar
+ // itself only when asked for.
+ const controlBar = document.getElementById('kyber_control_bar');
+ const powerMenu = document.getElementById('kyber_power_menu');
+ document.getElementById('kyber_control_bar_handle').addEventListener('click', () => {
+ controlBar.classList.toggle('kyber_open');
+ if (!controlBar.classList.contains('kyber_open')) {
+ powerMenu.classList.remove('kyber_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('kyber_ctrl_alt_del').addEventListener('click', () => {
+ try {
+ client?.send_ctrl_alt_del();
+ } catch (err) {
+ setStatus(`Could not send Ctrl-Alt-Del: ${err.message ?? err}`, 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() {
+ const el = document.documentElement;
+ await el.requestFullscreen();
+ try {
+ await navigator.keyboard?.lock?.();
+ } catch (err) {
+ console.warn('keyboard lock refused:', err);
+ }
+ document.getElementById('canvas')?.focus();
+ }
+
+ document.getElementById('kyber_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('kyber_power').addEventListener('click', () => {
+ powerMenu.classList.toggle('kyber_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('kyber_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(`Kyber console failed: ${err.message ?? err}`, true);
+ });
+ </script>
+ </body>
+</html>
diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
index c86a00c..44ffdfe 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -656,6 +656,7 @@ Ext.define('PVE.Utils', {
serial3: gettext('Serial terminal') + ' 3',
virtio: 'VirtIO-GPU',
'virtio-gl': 'VirGL GPU',
+ kyber: 'Kyber',
none: Proxmox.Utils.noneText,
},
@@ -1452,6 +1453,8 @@ Ext.define('PVE.Utils', {
PVE.Utils.openVNCViewer(consoleType, vmid, nodename, vmname, cmd);
} else if (viewer === 'xtermjs') {
Proxmox.Utils.openXtermJsViewer(consoleType, vmid, nodename, vmname, cmd);
+ } else if (viewer === 'kyber') {
+ PVE.Utils.openKyberViewer(consoleType, vmid, nodename, vmname);
} else if (viewer === 'vv') {
let url = '/nodes/' + nodename + '/spiceshell';
let params = {
@@ -1475,16 +1478,22 @@ Ext.define('PVE.Utils', {
},
defaultViewer: function (consoles, type) {
- var allowSpice, allowXtermjs;
+ var allowSpice, allowXtermjs, allowKyber;
if (consoles === true) {
allowSpice = true;
allowXtermjs = true;
+ allowKyber = true;
} else if (typeof consoles === 'object') {
allowSpice = consoles.spice;
allowXtermjs = !!consoles.xtermjs;
+ allowKyber = !!consoles.kyber;
}
let dv = PVE.UIOptions.options.console || (type === 'kvm' ? 'vv' : 'xtermjs');
+ // A Kyber display serves no VNC, so nothing else can show it.
+ if (allowKyber) {
+ return 'kyber';
+ }
if (dv === 'vv' && !allowSpice) {
dv = allowXtermjs ? 'xtermjs' : 'html5';
} else if (dv === 'xtermjs' && !allowXtermjs) {
@@ -1515,6 +1524,24 @@ Ext.define('PVE.Utils', {
}
},
+ // The Kyber console streams the display over its own controller rather
+ // than through noVNC, so it gets a window of its own. Everything it
+ // needs is fetched by that page from kyberproxy; nothing is passed in
+ // the URL, so a copied link grants nothing on its own.
+ openKyberViewer: function (vmtype, vmid, nodename, vmname) {
+ let url = Ext.Object.toQueryString({
+ console: vmtype,
+ kyber: 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, {
@@ -1590,6 +1617,7 @@ Ext.define('PVE.Utils', {
let consoles = {
spice: !!conf.spice,
xtermjs: !!conf.serial,
+ kyber: !!conf.kyber,
};
PVE.Utils.openDefaultConsoleWindow(consoles, 'kvm', vmid, nodename, vmname);
},
diff --git a/www/manager6/button/ConsoleButton.js b/www/manager6/button/ConsoleButton.js
index d64f280..63377e7 100644
--- a/www/manager6/button/ConsoleButton.js
+++ b/www/manager6/button/ConsoleButton.js
@@ -12,6 +12,8 @@ Ext.define('PVE.button.ConsoleButton', {
enableSpice: true,
enableXtermjs: true,
+ // Off unless a VM says otherwise, so other guests show it greyed out.
+ enableKyber: false,
nodename: undefined,
@@ -33,6 +35,13 @@ Ext.define('PVE.button.ConsoleButton', {
me.down('#xtermjs').setDisabled(!enable);
},
+ setEnableKyber: function (enable) {
+ var me = this;
+
+ me.enableKyber = enable;
+ me.down('#kybermenu').setDisabled(!enable);
+ },
+
handler: function () {
// main, general, handler
let me = this;
@@ -40,6 +49,7 @@ Ext.define('PVE.button.ConsoleButton', {
{
spice: me.enableSpice,
xtermjs: me.enableXtermjs,
+ kyber: me.enableKyber,
},
me.consoleType,
me.vmid,
@@ -84,6 +94,18 @@ Ext.define('PVE.button.ConsoleButton', {
view.openConsole(button.type);
},
},
+ {
+ xtype: 'menuitem',
+ itemId: 'kybermenu',
+ text: 'Kyber',
+ type: 'kyber',
+ 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/AudioEdit.js b/www/manager6/qemu/AudioEdit.js
index ba588ed..49f03e1 100644
--- a/www/manager6/qemu/AudioEdit.js
+++ b/www/manager6/qemu/AudioEdit.js
@@ -35,6 +35,9 @@ Ext.define('PVE.qemu.AudioInputPanel', {
fieldLabel: gettext('Backend Driver'),
comboItems: [
['spice', 'SPICE'],
+ // Puts the guest's audio on the same D-Bus as its display,
+ // which is where the Kyber and RDP consoles read it.
+ ['dbus', gettext('D-Bus (Kyber/RDP console)')],
['none', `${Proxmox.Utils.NoneText} (${gettext('Dummy Device')})`],
],
},
diff --git a/www/manager6/qemu/Config.js b/www/manager6/qemu/Config.js
index 842d35d..bcf54d1 100644
--- a/www/manager6/qemu/Config.js
+++ b/www/manager6/qemu/Config.js
@@ -232,9 +232,10 @@ Ext.define('PVE.qemu.Config', {
disabled: !caps.vms['VM.Console'],
hidden: template,
consoleType: 'kvm',
- // disable spice/xterm for default action until status api call succeeded
+ // disable spice/xterm/kyber for default action until status api call succeeded
enableSpice: false,
enableXtermjs: false,
+ enableKyber: false,
consoleName: vm.name,
nodename: nodename,
vmid: vmid,
@@ -458,6 +459,7 @@ Ext.define('PVE.qemu.Config', {
var qmpstatus;
var spice = false;
var xtermjs = false;
+ var kyber = false;
var lock;
var rec;
@@ -475,6 +477,10 @@ Ext.define('PVE.qemu.Config', {
spice = !!s.data.get('spice');
xtermjs = !!s.data.get('serial');
+ // Reported by the status API only for a display set to
+ // 'kyber', which is also the only case with a controller
+ // behind it.
+ kyber = !!s.data.get('kyber');
}
rec = s.data.get('tags');
@@ -496,6 +502,7 @@ Ext.define('PVE.qemu.Config', {
consoleBtn.setEnableSpice(spice);
consoleBtn.setEnableXtermJS(xtermjs);
+ consoleBtn.setEnableKyber(kyber);
statusTxt.update({ lock: lock });
diff --git a/www/manager6/qemu/DisplayEdit.js b/www/manager6/qemu/DisplayEdit.js
index 3f583ad..79e1ea2 100644
--- a/www/manager6/qemu/DisplayEdit.js
+++ b/www/manager6/qemu/DisplayEdit.js
@@ -26,7 +26,9 @@ Ext.define('PVE.qemu.DisplayInputPanel', {
return '4';
} else if (val === 'std' || val.match(/^qxl\d?$/) || val === 'vmware') {
return '16';
- } else if (val.match(/^virtio/)) {
+ } else if (val.match(/^virtio/) || val === 'kyber') {
+ // kyber is a virtio-vga underneath, so it takes the same
+ // memory as one.
return '256';
} else if (get('matchNonGUIOption')) {
return 'N/A';
--
2.55.0
^ permalink raw reply related [flat|nested] 11+ messages in thread
* [RFC pve-manager 07/13] ui: add rdp console
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
` (5 preceding siblings ...)
2026-08-25 11:34 ` [RFC pve-manager 06/13] ui: add kyber console Alexandre Derumier
@ 2026-08-25 11:34 ` Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-kyber-web 10/13] add pve-kyber-web: console's webassembly client Alexandre Derumier
` (2 subsequent siblings)
9 siblings, 0 replies; 11+ messages in thread
From: Alexandre Derumier @ 2026-08-25 11:34 UTC (permalink / raw)
To: pve-devel
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
^ permalink raw reply related [flat|nested] 11+ messages in thread
* [RFC pve-kyber-web 10/13] add pve-kyber-web: console's webassembly client
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
` (6 preceding siblings ...)
2026-08-25 11:34 ` [RFC pve-manager 07/13] ui: add rdp console Alexandre Derumier
@ 2026-08-25 11:34 ` 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
9 siblings, 0 replies; 11+ messages in thread
From: Alexandre Derumier @ 2026-08-25 11:34 UTC (permalink / raw)
To: pve-devel
The console page use Kyber's WASM SDK directly, with some modifications
to make it work behind pveproxy:
- 0001 gives kyclient a base path, so the controller can be reached under
/api2/json/nodes/<node>/qemu/<vmid>/ rather than owning an origin.
- 0002 resolves the renderer worker's wasm glue against baseURI instead of
window.location.pathname.
- 0003 puts the kymux token in the WebTransport path, giving a proxy in
front of several VMs something to route on.
Signed-off-by: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
---
.gitignore | 4 +
.gitmodules | 3 +
Makefile | 118 +++++++++
debian/changelog | 7 +
debian/control | 21 ++
debian/copyright | 20 ++
debian/install | 1 +
debian/rules | 9 +
debian/source/format | 1 +
kyber-web | 1 +
.../0001-kyclient-support-a-base-path.patch | 248 ++++++++++++++++++
...renderer-worker-against-the-base-url.patch | 72 +++++
...kymux-token-in-the-webtransport-path.patch | 37 +++
13 files changed, 542 insertions(+)
create mode 100644 .gitignore
create mode 100644 .gitmodules
create mode 100644 Makefile
create mode 100644 debian/changelog
create mode 100644 debian/control
create mode 100644 debian/copyright
create mode 100644 debian/install
create mode 100755 debian/rules
create mode 100644 debian/source/format
create mode 160000 kyber-web
create mode 100644 patches/0001-kyclient-support-a-base-path.patch
create mode 100644 patches/0002-kywebplayer-resolve-the-renderer-worker-against-the-base-url.patch
create mode 100644 patches/0003-kyclient-put-the-kymux-token-in-the-webtransport-path.patch
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..dea6af5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+/sdk/
+*.deb
+*.buildinfo
+*.changes
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..b7a917b
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "kyber-web"]
+ path = kyber-web
+ url = https://gitlab.com/kyber/apps/kyber-web.git
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..ed833c4
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,118 @@
+include /usr/share/dpkg/architecture.mk
+include /usr/share/dpkg/pkg-info.mk
+
+PACKAGE=pve-kyber-web
+DEB=$(PACKAGE)_$(DEB_VERSION_UPSTREAM_REVISION)_all.deb
+DSC=$(PACKAGE)_$(DEB_VERSION).dsc
+BUILDDIR=$(PACKAGE)-$(DEB_VERSION_UPSTREAM)
+
+# Kyber's web client: AGPL-3.0-or-later like this package, but a separate
+# upstream with its own cadence, so it is cloned at build time rather than
+# vendored or carried as a submodule. This repo then holds nothing but
+# packaging, and the revision it builds is one greppable line rather than a
+# gitlink.
+#
+# Pinned by commit, so a moved tag cannot change what this builds.
+KYBER_WEB_DIR = kyber-web
+
+# The four files the console page loads. Not the demo client's bundle, its
+# sidebar or its metrics UI: the page drives the SDK directly, so none of that
+# is built and neither pnpm nor esbuild is needed.
+SDK_FILES = kyclient_wasm.js kyclient_wasm_bg.wasm audio_worklet.js spinlock.js
+
+all: $(DEB)
+
+# --- upstream ---------------------------------------------------------------
+# Fetched once, and left alone after that, following pve-qemu: a build must not
+# depend on re-fetching, and a checkout someone has been working in is theirs.
+#
+# The patch below is what needs the tree pristine, so it resets just the files
+# it touches rather than the whole submodule - which would discard the build
+# output beside them for nothing.
+.PHONY: submodule
+submodule:
+ifeq ($(shell test -f "$(KYBER_WEB_DIR)/Cargo.toml" && echo 1 || echo 0), 0)
+ git submodule update --init --recursive $(KYBER_WEB_DIR)
+endif
+
+# Applied from the kyber-web root: they span it and its nested kysdk submodule.
+.PHONY: patch
+patch: submodule
+ git -C $(KYBER_WEB_DIR) checkout --force -- .
+ git -C $(KYBER_WEB_DIR) submodule foreach --recursive --quiet 'git checkout --force -- .'
+ set -e; for p in $(CURDIR)/patches/000*.patch; do \
+ git -C $(KYBER_WEB_DIR) apply "$$p"; \
+ done
+
+# --- build ------------------------------------------------------------------
+# The version Cargo.lock pins, read at use rather than at parse: the checkout
+# does not exist yet when make reads this file.
+WASM_BINDGEN_VERSION = $(shell sed -n '/^name = "wasm-bindgen"$$/{n;s/^version = "\(.*\)"/\1/p;q}' $(KYBER_WEB_DIR)/Cargo.lock)
+
+# web_sys_unstable_apis is required for WebTransport, which is the whole data
+# plane. A wasm-bindgen CLI older or newer than the crate emits glue the wasm
+# rejects at instantiation, with no error until the console is opened.
+.PHONY: wasm
+wasm: patch
+ have=$$(wasm-bindgen --version | awk '{print $$2}'); \
+ want="$(WASM_BINDGEN_VERSION)"; \
+ if [ "$$have" != "$$want" ]; then \
+ echo "wasm-bindgen $$have found, Cargo.lock pins $$want" >&2; \
+ echo "install it with: cargo install --locked wasm-bindgen-cli@$$want" >&2; \
+ exit 1; \
+ fi
+ cd $(KYBER_WEB_DIR) && RUSTFLAGS=--cfg=web_sys_unstable_apis \
+ cargo build --target wasm32-unknown-unknown -p kyclient-wasm --release
+ cd $(KYBER_WEB_DIR) && wasm-bindgen --target web --no-typescript --out-dir html \
+ target/wasm32-unknown-unknown/release/kyclient_wasm.wasm
+ cd $(KYBER_WEB_DIR) && wasm-opt html/kyclient_wasm_bg.wasm \
+ -o html/kyclient_wasm_bg.wasm -Os -g
+ # The JS glue: worker entry points and the audio worklet.
+ cd $(KYBER_WEB_DIR)/kysdk/kyctl && ./build-wasm.sh -j $(CURDIR)/$(KYBER_WEB_DIR)/html
+
+# --- packaging --------------------------------------------------------------
+.PHONY: sdk
+sdk: wasm
+ rm -rf sdk && mkdir sdk
+ for f in $(SDK_FILES); do install -m 0644 "$(KYBER_WEB_DIR)/html/$$f" sdk/; done
+
+.PHONY: builddir
+builddir:
+ rm -rf $(BUILDDIR)
+ $(MAKE) $(BUILDDIR)
+
+$(BUILDDIR): sdk
+ rm -rf $@ $@.tmp
+ mkdir $@.tmp
+ cp -a sdk debian Makefile $@.tmp/
+ mv $@.tmp $@
+
+deb: $(DEB)
+$(DEB): $(BUILDDIR)
+ cd $(BUILDDIR); dpkg-buildpackage -b -us -uc
+ lintian $(DEB) || true
+
+# A source package, for sbuild and for review: Proxmox builds every package
+# this way, so it has to work even when the binary path is what gets used.
+.PHONY: dsc
+dsc:
+ rm -rf $(BUILDDIR) $(DSC)
+ $(MAKE) $(DSC)
+ lintian $(DSC)
+
+$(DSC): $(BUILDDIR)
+ cd $(BUILDDIR); dpkg-buildpackage -S -us -uc -d
+
+sbuild: $(DSC)
+ sbuild $<
+
+.PHONY: dinstall
+dinstall: deb
+ dpkg -i $(DEB)
+
+.PHONY: clean
+clean:
+ rm -rf *.deb *.changes *.dsc *.buildinfo *.build $(PACKAGE)-[0-9]*/ sdk/
+
+.PHONY: distclean
+distclean: clean
diff --git a/debian/changelog b/debian/changelog
new file mode 100644
index 0000000..ff9047b
--- /dev/null
+++ b/debian/changelog
@@ -0,0 +1,7 @@
+pve-kyber-web (0.27.0) trixie; urgency=medium
+
+ * Initial release: the Kyber WebAssembly client.
+ * Carries the fix for resolving the renderer worker against the document
+ base URL, without which the console renders black behind a path prefix.
+
+ -- Proxmox Support Team <support@proxmox.com> Wed, 19 Aug 2026 07:00:00 +0200
diff --git a/debian/control b/debian/control
new file mode 100644
index 0000000..2be348f
--- /dev/null
+++ b/debian/control
@@ -0,0 +1,21 @@
+Source: pve-kyber-web
+Section: admin
+Priority: optional
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Uploaders: Alexandre Derumier <aderumier@groupe-cyllene.com>
+Build-Depends: debhelper-compat (= 13),
+ binaryen,
+ git,
+Standards-Version: 4.7.0.0
+
+Package: pve-kyber-web
+Architecture: all
+Depends: ${misc:Depends},
+Description: Kyber streaming client for the Proxmox VE console
+ The WebAssembly client the Kyber console page drives: the wasm module, its
+ JavaScript glue and the audio worklet.
+ .
+ Built from kyber-web, which is a separate upstream with its own release
+ cadence and a wasm toolchain that has no business in the pve-manager build,
+ so it is packaged on its own and the console page in pve-manager loads it
+ from here.
diff --git a/debian/copyright b/debian/copyright
new file mode 100644
index 0000000..702a252
--- /dev/null
+++ b/debian/copyright
@@ -0,0 +1,20 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+Upstream-Name: pve-kyber-web
+
+Files: *
+Copyright: 2026 Proxmox Server Solutions GmbH <support@proxmox.com>
+License: AGPL-3.0-or-later
+
+License: AGPL-3.0-or-later
+ This program is free software: you can redistribute it and/or modify it under
+ the terms of the GNU Affero General Public License as published by the Free
+ Software Foundation, either version 3 of the License, or (at your option) any
+ later version.
+ .
+ This program is distributed in the hope that it will be useful, but WITHOUT
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+ details.
+ .
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
diff --git a/debian/install b/debian/install
new file mode 100644
index 0000000..701f265
--- /dev/null
+++ b/debian/install
@@ -0,0 +1 @@
+sdk/* usr/share/pve-kyber-web/
diff --git a/debian/rules b/debian/rules
new file mode 100755
index 0000000..2a8f910
--- /dev/null
+++ b/debian/rules
@@ -0,0 +1,9 @@
+#!/usr/bin/make -f
+%:
+ dh $@
+# The sdk/ directory is staged into the build dir before dpkg-buildpackage
+# runs, and the Makefile carried along with it has a distclean that removes
+# it. Nothing here is built or cleaned by dh.
+override_dh_auto_clean:
+override_dh_auto_build:
+override_dh_auto_test:
diff --git a/debian/source/format b/debian/source/format
new file mode 100644
index 0000000..89ae9db
--- /dev/null
+++ b/debian/source/format
@@ -0,0 +1 @@
+3.0 (native)
diff --git a/kyber-web b/kyber-web
new file mode 160000
index 0000000..ad610dc
--- /dev/null
+++ b/kyber-web
@@ -0,0 +1 @@
+Subproject commit ad610dc64b5dd5d88aecdbf5e56a21b49572307a
diff --git a/patches/0001-kyclient-support-a-base-path.patch b/patches/0001-kyclient-support-a-base-path.patch
new file mode 100644
index 0000000..4b9fb1d
--- /dev/null
+++ b/patches/0001-kyclient-support-a-base-path.patch
@@ -0,0 +1,248 @@
+From: Kyber/QEMU integration
+Subject: [PATCH] kyclient: let the controller be reached under a base path
+
+Client::new took a host and a port and built /session/login,
+/kymux/start_session and /websocket/<plane> from them, so the controller had to
+own the origin it was served on. Behind a reverse proxy it does not: Proxmox
+exposes it under /api2/json/nodes/<node>/qemu/<vmid>/, and there was no way to
+say so.
+
+Add an optional base_path, threaded through ConnectConfig and ConnectionParams.
+Every HTTP URL already derives from HttpClient::base_url, so those come along
+with one change; the websocket URLs are built from the host instead and are
+prefixed explicitly.
+
+That includes /ws, the control plane's own websocket in host_events - which is
+easy to miss, because it lives on the other side of the platform split from the
+three data-plane sockets and is opened later. Missing it does not fail at
+startup: the client logs in, fetches /capabilities and only then tries to open
+a websocket at the origin root, which behind a proxy names no VM. Found by
+running the client through pvekyberproxy.
+
+normalize_base_path accepts "/kyber", "kyber/" and "/kyber/" alike - a doubled
+or missing slash would otherwise show up as a 404 a long way from the setting
+that caused it.
+
+This is not Proxmox-specific: any reverse-proxied deployment needs it, which is
+why it is worth sending upstream rather than carrying here.
+---
+diff --git a/kyclient/src/http.rs b/kyclient/src/http.rs
+index bdf88b6..17d52f2 100644
+--- a/kysdk/kyctl/kyclient/src/http.rs
++++ b/kysdk/kyctl/kyclient/src/http.rs
+@@ -38,8 +38,20 @@ pub(crate) fn format_host_for_url(host: &str, port: u16) -> String {
+ }
+ }
+
++/// Trim a base path to the form the URL builders expect: a leading slash and
++/// no trailing one, or empty for the origin root. Accepting "/kyber",
++/// "kyber/" and "/kyber/" alike avoids a double or missing slash showing up
++/// as a 404 far from the setting that caused it.
++pub(crate) fn normalize_base_path(base_path: Option<&str>) -> String {
++ match base_path.map(str::trim).filter(|p| !p.is_empty() && *p != "/") {
++ None => String::new(),
++ Some(path) => format!("/{}", path.trim_matches('/')),
++ }
++}
++
+ pub(crate) struct HttpClient {
+ base_url: String,
++ base_path: String,
+ client: reqwest::Client,
+ host: String,
+ }
+@@ -47,12 +59,17 @@ pub(crate) struct HttpClient {
+ impl HttpClient {
+ pub(crate) fn new(conn_params: ConnectionParams) -> Result<Self> {
+ let host = platform_http::create_host(&conn_params);
+- let base_url = format!("https://{host}");
++ // Behind a reverse proxy the controller has no origin of its own, so
++ // every URL has to be built under the path it is exposed at rather
++ // than at the root.
++ let base_path = normalize_base_path(conn_params.base_path.as_deref());
++ let base_url = format!("https://{host}{base_path}");
+
+ let client = platform_http::create_reqwest(conn_params)?;
+
+ Ok(Self {
+ base_url,
++ base_path,
+ client,
+ host,
+ })
+@@ -74,6 +91,12 @@ impl HttpClient {
+ &self.base_url
+ }
+
++ /// The path prefix on its own, for the websocket URLs - those are built
++ /// from the host rather than from base_url, so they cannot reuse it.
++ pub(crate) fn base_path(&self) -> &str {
++ &self.base_path
++ }
++
+ pub(crate) fn peer_addr_from_response(
+ &self,
+ response: &reqwest::Response,
+diff --git a/kyclient/src/platform/desktop/host_events.rs b/kyclient/src/platform/desktop/host_events.rs
+index f8fd9e1..5345ff0 100644
+--- a/kysdk/kyctl/kyclient/src/platform/desktop/host_events.rs
++++ b/kysdk/kyctl/kyclient/src/platform/desktop/host_events.rs
+@@ -53,7 +53,11 @@ impl WsClient {
+ auth_token: &str,
+ ) -> Result<Self> {
+ let host = platform::http::create_host(conn_params);
+- let url = format!("wss://{host}/ws");
++ // The control plane's own websocket sits under the base path too. It
++ // is built from the host rather than from base_url, so like the three
++ // data-plane sockets it has to be prefixed explicitly.
++ let base_path = crate::http::normalize_base_path(conn_params.base_path.as_deref());
++ let url = format!("wss://{host}{base_path}/ws");
+
+ // Connect with appropriate TLS verification
+ let mut stream = if let Some(verify_mode) = &conn_params.verify_mode {
+diff --git a/kyclient/src/platform/desktop/mod.rs b/kyclient/src/platform/desktop/mod.rs
+index fadb052..44681a0 100644
+--- a/kysdk/kyctl/kyclient/src/platform/desktop/mod.rs
++++ b/kysdk/kyctl/kyclient/src/platform/desktop/mod.rs
+@@ -46,6 +46,10 @@ pub use tls::{TofuPrompt, TofuVerifier, VerifyMode};
+ pub struct ConnectConfig {
+ pub host: String,
+ pub port: u16,
++ /// Path the controller is reached under, when it sits behind a reverse
++ /// proxy that cannot give it an origin of its own. Every HTTP and
++ /// websocket URL is built beneath it. Default: the origin root.
++ pub base_path: Option<String>,
+ pub tls_host: Option<String>,
+ /// TLS verification mode.
+ pub verify_mode: Option<VerifyMode>,
+@@ -126,6 +130,7 @@ impl VideoPlayerConfig {
+ pub(crate) struct ConnectionParams {
+ pub(crate) host: String,
+ pub(crate) port: u16,
++ pub(crate) base_path: Option<String>,
+ pub(crate) tls_host: Option<String>,
+ pub(crate) verify_mode: Option<VerifyMode>,
+ pub(crate) credentials: AuthCredentials,
+@@ -136,6 +141,7 @@ impl ConnectionParams {
+ Ok(Self {
+ host: connect_config.host,
+ port: connect_config.port,
++ base_path: connect_config.base_path.clone(),
+ tls_host: connect_config.tls_host.clone(),
+ verify_mode: connect_config.verify_mode,
+ credentials: connect_config.credentials,
+diff --git a/kyclient/src/platform/web/host_events.rs b/kyclient/src/platform/web/host_events.rs
+index 425e76c..a6f2415 100644
+--- a/kysdk/kyctl/kyclient/src/platform/web/host_events.rs
++++ b/kysdk/kyctl/kyclient/src/platform/web/host_events.rs
+@@ -315,7 +315,11 @@ impl WsClient {
+ auth_token: &str,
+ ) -> Result<Self> {
+ let host = platform::http::create_host(conn_params);
+- let url = format!("wss://{host}/ws");
++ // The control plane's own websocket sits under the base path too. It
++ // is built from the host rather than from base_url, so like the three
++ // data-plane sockets it has to be prefixed explicitly.
++ let base_path = crate::http::normalize_base_path(conn_params.base_path.as_deref());
++ let url = format!("wss://{host}{base_path}/ws");
+
+ let inner = InnerRef(Inner::new(url, capabilities, event_sink, auth_token).await?);
+
+diff --git a/kyclient/src/platform/web/mod.rs b/kyclient/src/platform/web/mod.rs
+index bec764b..3cfbb9d 100644
+--- a/kysdk/kyctl/kyclient/src/platform/web/mod.rs
++++ b/kysdk/kyctl/kyclient/src/platform/web/mod.rs
+@@ -41,6 +41,10 @@ pub(crate) use websocket::{WebSocket, WebSocketHandler};
+ pub struct ConnectConfig {
+ pub host: String,
+ pub port: u16,
++ /// Path the controller is reached under, when it sits behind a reverse
++ /// proxy that cannot give it an origin of its own. Every HTTP and
++ /// websocket URL is built beneath it. Default: the origin root.
++ pub base_path: Option<String>,
+ pub credentials: AuthCredentials,
+ /// Enable automatic reconnection on connection loss. Default: false.
+ /// When enabled, the client will emit `Reconnecting`, `Reconnected`, and
+@@ -95,6 +99,7 @@ impl From<VideoCodec> for player::VideoCodec {
+ pub(crate) struct ConnectionParams {
+ pub(crate) host: String,
+ pub(crate) port: u16,
++ pub(crate) base_path: Option<String>,
+ pub(crate) credentials: AuthCredentials,
+ }
+
+@@ -103,6 +108,7 @@ impl ConnectionParams {
+ Ok(Self {
+ host: connect_config.host.clone(),
+ port: connect_config.port,
++ base_path: connect_config.base_path.clone(),
+ credentials: connect_config.credentials,
+ })
+ }
+diff --git a/kyclient/src/ws_backend/mod.rs b/kyclient/src/ws_backend/mod.rs
+index c775c98..009612c 100644
+--- a/kysdk/kyctl/kyclient/src/ws_backend/mod.rs
++++ b/kysdk/kyctl/kyclient/src/ws_backend/mod.rs
+@@ -163,8 +163,9 @@ impl WsBackend {
+ VideoPlayer::create(listener, &player_config, metrics).await?;
+
+ let url = format!(
+- "wss://{host}/websocket/video",
+- host = backend_config.http_client.host()
++ "wss://{host}{base_path}/websocket/video",
++ host = backend_config.http_client.host(),
++ base_path = backend_config.http_client.base_path()
+ );
+ let ws = VideoWsSocketHandler::new(&url, player)?;
+
+@@ -194,8 +195,9 @@ impl WsBackend {
+ };
+
+ let url = format!(
+- "wss://{host}/websocket/audio",
+- host = backend_config.http_client.host()
++ "wss://{host}{base_path}/websocket/audio",
++ host = backend_config.http_client.host(),
++ base_path = backend_config.http_client.base_path()
+ );
+ let ws = AudioWsSocketHandler::new(&url, player)?;
+
+@@ -220,8 +222,9 @@ impl WsBackend {
+ #[allow(clippy::arc_with_non_send_sync)]
+ let msg_sink = Arc::new(InputMsgSink::new(backend_config.msg_sender.clone()));
+ let inputs_url = format!(
+- "wss://{host}/websocket/inputs",
+- host = backend_config.http_client.host()
++ "wss://{host}{base_path}/websocket/inputs",
++ host = backend_config.http_client.host(),
++ base_path = backend_config.http_client.base_path()
+ );
+ let input_websocket =
+ InputWebSocketHandler::new(&inputs_url, kynput_tx, kynput_rx, msg_sink)?;
+diff --git a/src/lib.rs b/src/lib.rs
+index e692168..90b5d7c 100644
+--- a/src/lib.rs
++++ b/src/lib.rs
+@@ -496,6 +496,9 @@ impl Client {
+ /// `"decoded"`, `"skipped"`, `"prepared"`, `"displayed"`.
+ /// All timestamps are in microseconds (µs). The server timestamps are already compensated
+ /// to match the client clock.
++ /// `base_path` is the path the controller is reached under when it sits
++ /// behind a reverse proxy that cannot give it an origin of its own; pass
++ /// `null` when it has the origin to itself.
+ pub async fn new(
+ host: &str,
+ port: u16,
+@@ -505,10 +508,12 @@ impl Client {
+ metrics_mode_str: String,
+ live_metrics_callback: Option<js_sys::Function>,
+ auto_reconnection: bool,
++ base_path: Option<String>,
+ ) -> JsResult<Client> {
+ let connect_config = kyclient::ConnectConfig {
+ host: host.into(),
+ port,
++ base_path,
+ credentials: credentials.0,
+ auto_reconnection,
+ };
diff --git a/patches/0002-kywebplayer-resolve-the-renderer-worker-against-the-base-url.patch b/patches/0002-kywebplayer-resolve-the-renderer-worker-against-the-base-url.patch
new file mode 100644
index 0000000..f906736
--- /dev/null
+++ b/patches/0002-kywebplayer-resolve-the-renderer-worker-against-the-base-url.patch
@@ -0,0 +1,72 @@
+From: Kyber/QEMU integration
+Subject: [PATCH] kywebplayer: resolve the renderer worker against the base URL
+
+RendererWorker::create built the URL of the wasm glue from
+window.location.pathname, so the glue had to sit in the same directory as the
+HTML that loaded it. That holds for the demo client, whose page and SDK ship
+side by side, and does not hold for an application that says otherwise with a
+<base href> - Proxmox serves its console page at / and the SDK under /kyber/.
+
+The failure is expensive to find. The worker is created, so the canvas is
+transferred to it and the decoder runs happily; only the module fetch 404s,
+inside a worker, where nothing surfaces it. Frames are then decoded and
+dropped - the browser reports them garbage collected without being closed - and
+the console shows a black canvas with no error in the page, no exception, and
+no failed request that a page-level capture would see. Found by attaching to
+the worker target and watching its network.
+
+baseURI is the document URL wherever no <base> tag exists, so this changes
+nothing for the demo client and every other existing caller.
+---
+diff --git a/kywebplayer/src/video/renderer_worker.rs b/kywebplayer/src/video/renderer_worker.rs
+index 8aa7fb2..8224d25 100644
+--- a/kysdk/kyctl/kywebplayer/src/video/renderer_worker.rs
++++ b/kysdk/kyctl/kywebplayer/src/video/renderer_worker.rs
+@@ -264,20 +264,34 @@ impl RendererWorker {
+ // This is a library, but the path of the wasm-bindings JavaScript file
+ // depends on the top-level crate name, so the worker code must be
+ // generated dynamically.
+- let location = web_sys::window()
+- .ok_or_else(|| JsValue::from(js_sys::Error::new("No window")))?
+- .location();
+- let origin = location.origin()?;
+- let pathname = location.pathname()?;
+- // pathname can be a directory (".../") or include a filename
+- // (".../index.html"); strip back to the last "/" so we resolve the wasm
+- // glue next to the HTML rather than concatenating onto the filename.
+- let dir = match pathname.rfind('/') {
+- Some(i) => &pathname[..=i],
+- None => "/",
+- };
++ let window = web_sys::window()
++ .ok_or_else(|| JsValue::from(js_sys::Error::new("No window")))?;
+ let sanitized_crate_name = app_crate_name.replace("-", "_");
+- let wasm_js_url = format!("{origin}{dir}{sanitized_crate_name}.js");
++
++ // Resolved against the document's base URL, not its path. The two are
++ // the same until a page carries a <base href>, and then they are not:
++ // an application whose HTML is served from one place and whose wasm
++ // glue lives in another says so with that tag, and resolving against
++ // the path instead sends this worker somewhere the script is not.
++ //
++ // It fails in a way that takes a long time to find. The worker is
++ // created, so the canvas is transferred to it and the decoder runs;
++ // only the module fetch 404s, inside a worker, where nothing surfaces
++ // it. Frames are then decoded and dropped, and the console shows a
++ // black canvas with no error anywhere.
++ //
++ // baseURI is the document URL when there is no <base>, so this is the
++ // old behaviour wherever the tag is absent.
++ let base = window
++ .document()
++ .ok_or_else(|| JsValue::from(js_sys::Error::new("No document")))?
++ .base_uri()?
++ .ok_or_else(|| JsValue::from(js_sys::Error::new("No base URI")))?;
++ let wasm_js_url = web_sys::Url::new_with_base(
++ &format!("{sanitized_crate_name}.js"),
++ &base,
++ )?
++ .href();
+ let worker_code = format!(
+ r#"import init, {{ VideoRendererWorkerCtx }} from "{wasm_js_url}";
+ let ctx;
diff --git a/patches/0003-kyclient-put-the-kymux-token-in-the-webtransport-path.patch b/patches/0003-kyclient-put-the-kymux-token-in-the-webtransport-path.patch
new file mode 100644
index 0000000..5008623
--- /dev/null
+++ b/patches/0003-kyclient-put-the-kymux-token-in-the-webtransport-path.patch
@@ -0,0 +1,37 @@
+From: Kyber/QEMU integration
+Subject: [PATCH] kyclient: put the kymux token in the WebTransport path
+
+The client opened WebTransport at the origin root, so the only thing
+distinguishing one console from another was the port it connected to. That is
+fine against a controller, which serves one VM, and it forces a proxy in front
+of several to spend a UDP port per session: nothing in an encrypted datagram
+says which VM it belongs to, so the port has to be the routing key.
+
+Putting the token in the path gives such a proxy something to route on, and it
+costs nothing to either end. The token already exists, both sides already agree
+on it, and it is already carried in this connection - it authenticates to the
+controller a moment later. It does not travel in the clear either: a path is
+inside the encrypted session, unlike a port.
+
+A controller reached directly ignores the path, so this changes nothing for a
+client that is not behind a proxy.
+---
+diff --git a/kyclient/src/kymux_backend/mod.rs b/kyclient/src/kymux_backend/mod.rs
+index db7b13a..b56e888 100644
+--- a/kysdk/kyctl/kyclient/src/kymux_backend/mod.rs
++++ b/kysdk/kyctl/kyclient/src/kymux_backend/mod.rs
+@@ -265,7 +265,13 @@ async fn start_kymux(backend_config: &KymuxBackendConfig<'_>) -> Result<kyproto:
+ &backend_config.conn_params.host,
+ start_response.port,
+ );
+- let url = format!("https://{host_str}");
++ // The token goes in the path as well as into the authentication
++ // below. A proxy that terminates WebTransport to put every console
++ // on one UDP port has nothing else to route on - a datagram says
++ // nothing about which host it is for - and this is the one field
++ // both ends already agree on. Reaching a controller directly, the
++ // path is ignored.
++ let url = format!("https://{host_str}/{token}", token = start_response.token);
+
+ connect_webtransport_js(&url, start_response.certificate_hash).await
+ }
--
2.55.0
^ permalink raw reply related [flat|nested] 11+ messages in thread
* [RFC pve-qemu-rdp 11/13] Add pve-qemu-rdp: an RDP server for the console
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
` (7 preceding siblings ...)
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 ` Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-rdpproxy 12/13] Add pve-rdpproxy Alexandre Derumier
9 siblings, 0 replies; 11+ messages in thread
From: Alexandre Derumier @ 2026-08-25 11:34 UTC (permalink / raw)
To: pve-devel
qemu-rdp reads a VM's display over org.qemu.Display1.
It's also provide clipboard && audio.
Signed-off-by: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
---
.gitignore | 5 +
.gitmodules | 3 +
Makefile | 76 +++++++++
debian/changelog | 5 +
debian/control | 24 +++
debian/copyright | 37 ++++
debian/install | 1 +
debian/rules | 16 ++
debian/source/format | 1 +
...rdp-allow-listening-on-a-unix-socket.patch | 159 ++++++++++++++++++
qemu-display | 1 +
11 files changed, 328 insertions(+)
create mode 100644 .gitignore
create mode 100644 .gitmodules
create mode 100644 Makefile
create mode 100644 debian/changelog
create mode 100644 debian/control
create mode 100644 debian/copyright
create mode 100644 debian/install
create mode 100755 debian/rules
create mode 100644 debian/source/format
create mode 100644 patches/0001-qemu-rdp-allow-listening-on-a-unix-socket.patch
create mode 160000 qemu-display
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..43c1faa
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+/staging/
+/pve-qemu-rdp-[0-9]*/
+*.deb
+*.changes
+*.buildinfo
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..ee0ae27
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "qemu-display"]
+ path = qemu-display
+ url = https://gitlab.com/marcandre.lureau/qemu-display.git
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..5f457c6
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,76 @@
+include /usr/share/dpkg/architecture.mk
+include /usr/share/dpkg/pkg-info.mk
+
+PACKAGE=pve-qemu-rdp
+
+# qemu-display: MIT where this package is, but a separate upstream, so it is a
+# submodule rather than vendored. Pinned by commit and not by tag - the unix
+# socket support this needs is only in master, see patches/.
+SRCDIR=qemu-display
+
+BUILDDIR=$(PACKAGE)-$(DEB_VERSION_UPSTREAM)
+ORIG_SRC_TAR=$(PACKAGE)_$(DEB_VERSION_UPSTREAM).orig.tar.gz
+
+DSC=$(PACKAGE)_$(DEB_VERSION).dsc
+DEB=$(PACKAGE)_$(DEB_VERSION)_$(DEB_HOST_ARCH).deb
+DEB_DBG=$(PACKAGE)-dbgsym_$(DEB_VERSION)_$(DEB_HOST_ARCH).deb
+DEBS=$(DEB) $(DEB_DBG)
+
+all: $(DEBS)
+
+# Fetched once and left alone afterwards: nothing below writes to it, so a
+# checkout someone has been working in stays theirs.
+.PHONY: submodule
+submodule:
+ test -f "$(SRCDIR)/Cargo.toml" || git submodule update --init --recursive $(SRCDIR)
+
+# The patches are applied to the copy, never to the submodule. That is what
+# lets the fetch above be a one-off, and what keeps a rebuild from finding a
+# tree that is already patched.
+#
+# .git goes first: it is a gitlink pointing back at the submodule, so `git
+# apply` would otherwise find that work tree instead of this copy. target/ goes
+# with it because a stray build tree is half a gigabyte.
+$(BUILDDIR): submodule debian/changelog
+ rm -rf $@ $@.tmp
+ cp -a $(SRCDIR) $@.tmp
+ find $@.tmp -name .git -prune -exec rm -rf {} +
+ rm -rf $@.tmp/target
+ set -e; for p in $(CURDIR)/patches/*.patch; do \
+ git -C $@.tmp apply "$$p"; \
+ done
+ cp -a debian $@.tmp/debian
+ mv $@.tmp $@
+
+$(ORIG_SRC_TAR): $(BUILDDIR)
+ tar czf $(ORIG_SRC_TAR) --exclude="$(BUILDDIR)/debian" $(BUILDDIR)
+
+.PHONY: deb
+deb: $(DEBS)
+$(DEBS) &: $(BUILDDIR)
+ cd $(BUILDDIR); dpkg-buildpackage -b -us -uc
+ lintian $(DEBS)
+
+.PHONY: dsc
+dsc:
+ rm -rf $(BUILDDIR) $(ORIG_SRC_TAR) $(DSC)
+ $(MAKE) $(DSC)
+ lintian $(DSC)
+
+$(DSC): $(BUILDDIR) $(ORIG_SRC_TAR)
+ cd $(BUILDDIR); dpkg-buildpackage -S -us -uc -d
+
+sbuild: $(DSC)
+ sbuild $<
+
+.PHONY: dinstall
+dinstall: deb
+ dpkg -i $(DEBS)
+
+.PHONY: distclean
+distclean: clean
+
+.PHONY: clean
+clean:
+ rm -rf $(PACKAGE)-[0-9]*/
+ rm -rf $(PACKAGE)*.tar* *.deb *.dsc *.changes *.buildinfo *.build
diff --git a/debian/changelog b/debian/changelog
new file mode 100644
index 0000000..9a5412d
--- /dev/null
+++ b/debian/changelog
@@ -0,0 +1,5 @@
+pve-qemu-rdp (0.1.1-1) trixie; urgency=medium
+
+ * initial package
+
+ -- Alexandre Derumier <aderumier@groupe-cyllene.com> Wed, 19 Aug 2026 12:00:00 +0200
diff --git a/debian/control b/debian/control
new file mode 100644
index 0000000..7b292b6
--- /dev/null
+++ b/debian/control
@@ -0,0 +1,24 @@
+Source: pve-qemu-rdp
+Section: admin
+Priority: optional
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Uploaders: Alexandre Derumier <aderumier@groupe-cyllene.com>
+Build-Depends: debhelper-compat (= 13),
+ cargo,
+ git,
+ libssl-dev,
+ pkgconf,
+Standards-Version: 4.7.0.0
+
+Package: pve-qemu-rdp
+Architecture: any
+Depends: ${misc:Depends},
+ ${shlibs:Depends},
+Description: RDP server for the Proxmox VE console
+ An RDP server that reads a VM's display over the org.qemu.Display1 D-Bus
+ interface exposed by "qemu -display dbus", so a guest is streamed without a
+ guest agent and without a second display device.
+ .
+ Started per VM by qemu-server for VMs configured with "vga: rdp", on a unix
+ socket, and reached from a browser only through pveproxy and pve-rdpproxy.
+ RDP brings clipboard, audio and monitor resize with it.
diff --git a/debian/copyright b/debian/copyright
new file mode 100644
index 0000000..92edec8
--- /dev/null
+++ b/debian/copyright
@@ -0,0 +1,37 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+Upstream-Name: qemu-rdp
+Source: https://gitlab.com/marcandre.lureau/qemu-display
+
+Files: *
+Copyright: 2023-2026 Marc-André Lureau <marcandre.lureau@redhat.com>
+ 2023 Mihnea Buzatu <mihneabuzatu88@gmail.com>
+License: MIT
+
+Files: debian/*
+Copyright: 2026 Proxmox Server Solutions GmbH <support@proxmox.com>
+License: MIT
+
+License: MIT
+ Permission is hereby granted, free of charge, to any
+ person obtaining a copy of this software and associated
+ documentation files (the "Software"), to deal in the
+ Software without restriction, including without
+ limitation the rights to use, copy, modify, merge,
+ publish, distribute, sublicense, and/or sell copies of
+ the Software, and to permit persons to whom the Software
+ is furnished to do so, subject to the following
+ conditions:
+ .
+ The above copyright notice and this permission notice
+ shall be included in all copies or substantial portions
+ of the Software.
+ .
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+ TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+ PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+ SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ DEALINGS IN THE SOFTWARE.
diff --git a/debian/install b/debian/install
new file mode 100644
index 0000000..b9dc42d
--- /dev/null
+++ b/debian/install
@@ -0,0 +1 @@
+target/release/qemu-rdp usr/bin/
diff --git a/debian/rules b/debian/rules
new file mode 100755
index 0000000..9dad7b9
--- /dev/null
+++ b/debian/rules
@@ -0,0 +1,16 @@
+#!/usr/bin/make -f
+%:
+ dh $@
+
+# The build directory is the upstream tree with debian/ overlaid, as in frr and
+# corosync-pve, so cargo runs at its root and the package is built from source
+# rather than from anything staged in.
+# --locked because Cargo.lock is part of what the outer Makefile checked out at
+# a pinned commit: resolving something else would undo that pin.
+override_dh_auto_build:
+ cargo build --release --locked -p qemu-rdp
+
+override_dh_auto_test:
+
+override_dh_auto_clean:
+ cargo clean
diff --git a/debian/source/format b/debian/source/format
new file mode 100644
index 0000000..163aaf8
--- /dev/null
+++ b/debian/source/format
@@ -0,0 +1 @@
+3.0 (quilt)
diff --git a/patches/0001-qemu-rdp-allow-listening-on-a-unix-socket.patch b/patches/0001-qemu-rdp-allow-listening-on-a-unix-socket.patch
new file mode 100644
index 0000000..4ec08d0
--- /dev/null
+++ b/patches/0001-qemu-rdp-allow-listening-on-a-unix-socket.patch
@@ -0,0 +1,159 @@
+From a0e530f3f05cd7da9a824fa18b227af83aaec72a Mon Sep 17 00:00:00 2001
+From: Alexandre Derumier <aderumier@groupe-cyllene.com>
+Date: Wed, 19 Aug 2026 13:51:18 +0200
+Subject: [PATCH] qemu-rdp: allow listening on a unix socket
+
+RdpServer::run binds a TcpListener itself, so serving a unix socket means
+repeating the accept loop against a UnixListener; run_connection is already
+generic over the stream.
+
+A loopback port is reachable by every local user on the host. A socket
+created with mode 0600 is not, which matters when the server is reached
+through a gateway on the same host rather than from the network.
+
+run also drains the server's event queue while waiting to accept, which is
+where SetCredentials takes effect. That receiver is private, so the
+credentials are kept on the D-Bus object and applied before each connection
+instead - otherwise CredSSP runs without them.
+---
+diff --git a/qemu-rdp/src/args.rs b/qemu-rdp/src/args.rs
+index 37c6f9a..2f6e261 100644
+--- a/qemu-rdp/src/args.rs
++++ b/qemu-rdp/src/args.rs
+@@ -38,6 +38,14 @@ pub struct ServerArgs {
+ #[clap(short, long, default_value = "0.0.0.0:3389")]
+ pub bind_address: std::net::SocketAddr,
+
++ /// Listen on a unix socket instead of a TCP port.
++ ///
++ /// The socket is created with mode 0600, so access is controlled by the
++ /// filesystem rather than being open to every local user as a loopback
++ /// port is. Intended for a gateway on the same host.
++ #[clap(long, value_parser, conflicts_with = "bind_address")]
++ pub bind_socket: Option<PathBuf>,
++
+ /// Path to tls certificate
+ #[clap(short, long, value_parser)]
+ pub cert: Option<PathBuf>,
+diff --git a/qemu-rdp/src/server/mod.rs b/qemu-rdp/src/server/mod.rs
+index 40b191b..48befb3 100644
+--- a/qemu-rdp/src/server/mod.rs
++++ b/qemu-rdp/src/server/mod.rs
+@@ -1,7 +1,10 @@
+ use anyhow::{bail, Error};
+ use enumflags2::BitFlags;
+ use ironrdp::server::{Credentials, ServerEvent, TlsIdentityCtx};
+-use std::path::PathBuf;
++use std::os::unix::fs::PermissionsExt;
++use std::path::{Path, PathBuf};
++use std::sync::{Arc, Mutex};
++use tokio::net::UnixListener;
+ use tokio::sync::{mpsc::UnboundedSender, oneshot};
+ use tracing::{debug, error};
+ use zbus::object_server::SignalEmitter;
+@@ -27,6 +30,11 @@ pub struct Server {
+
+ struct DBusCtrl {
+ ev: UnboundedSender<ServerEvent>,
++ /// The last credentials SetCredentials was given, for --bind-socket.
++ ///
++ /// See `run_on_socket`: that loop cannot drain the server's event queue,
++ /// so it reads them from here instead.
++ pending_credentials: Arc<Mutex<Option<Credentials>>>,
+ }
+
+ impl Server {
+@@ -80,7 +88,11 @@ impl Server {
+ .build();
+
+ let ev = server.event_sender().clone();
+- let dbus_ctrl = DBusCtrl { ev };
++ let pending_credentials = Arc::new(Mutex::new(None));
++ let dbus_ctrl = DBusCtrl {
++ ev,
++ pending_credentials: Arc::clone(&pending_credentials),
++ };
+ let dbus_path = "/org/qemu_display/rdp";
+ self.dbus.object_server().at(dbus_path, dbus_ctrl).await?;
+
+@@ -109,16 +121,63 @@ impl Server {
+
+ println!("Starting RDP server, args: {:?}", self.args);
+ println!("Cert: {cert:?}, Key: {key:?}");
+- server.run().await?;
++ match self.args.bind_socket.clone() {
++ Some(path) => Self::run_on_socket(&mut server, &path, &pending_credentials).await?,
++ None => server.run().await?,
++ }
+ println!("RDP server ended");
+ Ok(())
+ }
++
++ /// Accept loop for --bind-socket.
++ ///
++ /// `RdpServer::run` binds a `TcpListener` of its own, so serving a unix
++ /// socket means repeating the accept loop here; `run_connection` is
++ /// generic over the stream and takes a `UnixStream` unchanged.
++ ///
++ /// `run` also drains the server's event queue while waiting to accept, and
++ /// that is where SetCredentials is applied. The queue's receiver is
++ /// private, so this cannot do the same: credentials sent while no client
++ /// was connected would sit there until one arrived, and would then be
++ /// applied only after CredSSP had already failed for want of them. They
++ /// are taken from DBusCtrl directly instead, before each connection.
++ async fn run_on_socket(
++ server: &mut RdpServer,
++ path: &Path,
++ pending_credentials: &Mutex<Option<Credentials>>,
++ ) -> Result<(), Error> {
++ // A socket left behind by an unclean exit would fail the bind.
++ match std::fs::remove_file(path) {
++ Ok(()) => {}
++ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
++ Err(e) => return Err(e.into()),
++ }
++
++ let listener = UnixListener::bind(path)?;
++ // Narrowed after the fact, so a caller wanting no window at all should
++ // set a umask too. This permission is the point of the socket.
++ std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
++
++ loop {
++ let (stream, _addr) = listener.accept().await?;
++ let credentials = pending_credentials
++ .lock()
++ .expect("SetCredentials does not panic while holding this")
++ .clone();
++ if credentials.is_some() {
++ server.set_credentials(credentials);
++ }
++ if let Err(error) = server.run_connection(stream).await {
++ error!(?error, "RDP connection ended with an error");
++ }
++ }
++ }
+ }
+
+ #[zbus::interface(name = "org.QemuDisplay.RDP")]
+ impl DBusCtrl {
+ async fn set_credentials(&self, username: &str, password: &str, domain: &str) {
+- if let Err(error) = self.ev.send(ServerEvent::SetCredentials(Credentials {
++ let credentials = Credentials {
+ username: username.into(),
+ password: password.into(),
+ domain: if domain.is_empty() {
+@@ -126,7 +185,13 @@ impl DBusCtrl {
+ } else {
+ Some(domain.into())
+ },
+- })) {
++ };
++ // Kept as well as sent, for the --bind-socket accept loop.
++ *self
++ .pending_credentials
++ .lock()
++ .expect("nothing panics while holding this") = Some(credentials.clone());
++ if let Err(error) = self.ev.send(ServerEvent::SetCredentials(credentials)) {
+ error!(?error, "Failed to send SetCredentials")
+ }
+ }
diff --git a/qemu-display b/qemu-display
new file mode 160000
index 0000000..8ac3da9
--- /dev/null
+++ b/qemu-display
@@ -0,0 +1 @@
+Subproject commit 8ac3da95abeca92e5bb0aee2c58adf54e86f4482
--
2.55.0
^ permalink raw reply related [flat|nested] 11+ messages in thread
* [RFC pve-rdpproxy 12/13] Add pve-rdpproxy
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
` (8 preceding siblings ...)
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 ` Alexandre Derumier
9 siblings, 0 replies; 11+ messages in thread
From: Alexandre Derumier @ 2026-08-25 11:34 UTC (permalink / raw)
To: pve-devel
A browser cannot drive a TLS handshake over a websocket, so the IronRDP web
client delegates it through RDCleanPath protocol.
RDCleanPath is no part of RDP: it is Ironrdp rdp protocol extension gateway
by the ironrdp-rdcleanpath crate.
browser
|
| one websocket, and only this one:
| HTTPS :8006, no second port to open
| /api2/json/nodes/<node>/qemu/<vmid>/rdp/<token>
v
+---------------------+
| pveproxy |
| checks VM.Console |
+---------------------+
|
| unix /run/pverdpproxy.sock
| path rewritten to /<vmid>/<token>
v
+----------------------------------+ reads the
| pverdpproxy | token from
| RDCleanPath: X.224 request, TLS |---> <vmid>.rdp.env
| handshake, certificate chain | (root only)
| back to the client, then bytes |
+----------------------------------+
|
| unix /run/qemu-server/<vmid>.rdp.sock
| TLS, terminated here - CredSSP binds to
| the server's key, so it cannot be dropped
v
+----------------------------------+ credentials
| qemu-rdp (pve-rdp@) |<--- over D-Bus,
+----------------------------------+ from the API
|
| D-Bus unix, org.qemu on the private bus
v /run/qemu-server/<vmid>.dbusdisplay
QEMU -display dbus,addr=unix:path=...
Signed-off-by: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
---
.gitignore | 6 +
Cargo.lock | 1075 +++++++++++++++++++++++
Cargo.toml | 31 +
Makefile | 54 ++
debian/changelog | 5 +
debian/control | 24 +
debian/copyright | 20 +
debian/install | 1 +
debian/pve-rdpproxy.pverdpproxy.service | 14 +
debian/rules | 19 +
debian/source/format | 1 +
src/main.rs | 249 ++++++
src/session.rs | 303 +++++++
13 files changed, 1802 insertions(+)
create mode 100644 .gitignore
create mode 100644 Cargo.lock
create mode 100644 Cargo.toml
create mode 100644 Makefile
create mode 100644 debian/changelog
create mode 100644 debian/control
create mode 100644 debian/copyright
create mode 100644 debian/install
create mode 100644 debian/pve-rdpproxy.pverdpproxy.service
create mode 100755 debian/rules
create mode 100644 debian/source/format
create mode 100644 src/main.rs
create mode 100644 src/session.rs
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9f765b7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+/target/
+/staging/
+/pve-rdpproxy-[0-9]*/
+*.deb
+*.changes
+*.buildinfo
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..aa31ee4
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,1075 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cc"
+version = "1.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "clap"
+version = "4.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "data-encoding"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
+
+[[package]]
+name = "defmt"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
+dependencies = [
+ "bitflags",
+ "defmt-macros",
+]
+
+[[package]]
+name = "defmt-macros"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
+dependencies = [
+ "defmt-parser",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "defmt-parser"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
+dependencies = [
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "der_derive",
+ "zeroize",
+]
+
+[[package]]
+name = "der_derive"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "env_filter"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217"
+dependencies = [
+ "log",
+ "regex",
+]
+
+[[package]]
+name = "env_logger"
+version = "0.11.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "env_filter",
+ "jiff",
+ "log",
+]
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
+
+[[package]]
+name = "futures-channel"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "http"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "hyper"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "bytes",
+ "http",
+ "http-body",
+ "hyper",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "ironrdp-rdcleanpath"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c2ce7c76797b5eeca9f5cca4410e2748f9458c5a11cc41e1e6d5ba475da947"
+dependencies = [
+ "der",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jiff"
+version = "0.2.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
+dependencies = [
+ "defmt",
+ "jiff-core",
+ "jiff-static",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde_core",
+]
+
+[[package]]
+name = "jiff-core"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
+dependencies = [
+ "defmt",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
+dependencies = [
+ "jiff-core",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "mio"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "pve-rdpproxy"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "env_logger",
+ "futures-util",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "ironrdp-rdcleanpath",
+ "libc",
+ "log",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tokio-tungstenite",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rand"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.43"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
+dependencies = [
+ "log",
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl 2.0.20",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-tungstenite"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
+dependencies = [
+ "futures-util",
+ "log",
+ "tokio",
+ "tungstenite",
+]
+
+[[package]]
+name = "tungstenite"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
+dependencies = [
+ "byteorder",
+ "bytes",
+ "data-encoding",
+ "http",
+ "httparse",
+ "log",
+ "rand",
+ "sha1",
+ "thiserror 1.0.69",
+ "utf-8",
+]
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "utf-8"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "zerocopy"
+version = "0.8.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..896b883
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,31 @@
+[package]
+name = "pve-rdpproxy"
+version = "0.1.0"
+edition = "2021"
+license = "AGPL-3.0-or-later"
+description = "RDCleanPath gateway for Proxmox VE RDP consoles"
+
+[[bin]]
+name = "pverdpproxy"
+path = "src/main.rs"
+
+[dependencies]
+anyhow = "1"
+clap = { version = "4", features = ["derive", "env"] }
+env_logger = "0.11"
+futures-util = { version = "0.3", default-features = false, features = ["sink"] }
+http-body-util = "0.1"
+hyper = { version = "1", features = ["server", "http1"] }
+hyper-util = { version = "0.1", features = ["tokio"] }
+# The RDCleanPath PDU, DER-encoded. Hand-rolling the ASN.1 would be the one
+# part of this gateway with no reason to be ours.
+ironrdp-rdcleanpath = "0.2"
+# getgrnam, to hand the listening socket to pveproxy's group by name.
+libc = "0.2"
+log = "0.4"
+# The websocket is terminated here rather than spliced: the client sends
+# RDCleanPath and then RDP inside binary frames, so the frames have to be read.
+tokio-tungstenite = "0.24"
+rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
+tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "signal", "time"] }
+tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "logging", "tls12"] }
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..1d879cc
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,54 @@
+include /usr/share/dpkg/architecture.mk
+include /usr/share/dpkg/pkg-info.mk
+
+PACKAGE=pve-rdpproxy
+DEB=$(PACKAGE)_$(DEB_VERSION)_$(DEB_HOST_ARCH).deb
+DSC=$(PACKAGE)_$(DEB_VERSION).dsc
+BUILDDIR=$(PACKAGE)-$(DEB_VERSION_UPSTREAM)
+
+all: $(DEB)
+
+# The source tree, copied so dpkg-buildpackage builds in a directory it owns
+# and this one keeps no build output. debian/rules runs cargo from here; there
+# is no staging step, so what is packaged is what the build just produced.
+.PHONY: builddir
+builddir:
+ rm -rf $(BUILDDIR)
+ $(MAKE) $(BUILDDIR)
+
+$(BUILDDIR):
+ rm -rf $@ $@.tmp
+ mkdir $@.tmp
+ cp -a src Cargo.toml Cargo.lock debian Makefile $@.tmp/
+ mv $@.tmp $@
+
+deb: $(DEB)
+$(DEB): $(BUILDDIR)
+ cd $(BUILDDIR); dpkg-buildpackage -b -us -uc
+ lintian $(DEB) || true
+
+# A source package, for sbuild and for review: Proxmox builds every package
+# this way, so it has to work even when the binary path is what gets used.
+.PHONY: dsc
+dsc:
+ rm -rf $(BUILDDIR) $(DSC)
+ $(MAKE) $(DSC)
+ lintian $(DSC)
+
+$(DSC): $(BUILDDIR)
+ cd $(BUILDDIR); dpkg-buildpackage -S -us -uc -d
+
+sbuild: $(DSC)
+ sbuild $<
+
+.PHONY: dinstall
+dinstall: deb
+ dpkg -i $(DEB)
+
+.PHONY: clean
+clean:
+ rm -rf *.deb *.changes *.dsc *.buildinfo *.build $(PACKAGE)-[0-9]*/
+
+.PHONY: distclean
+distclean: clean
+ cargo clean
diff --git a/debian/changelog b/debian/changelog
new file mode 100644
index 0000000..0f5c4d7
--- /dev/null
+++ b/debian/changelog
@@ -0,0 +1,5 @@
+pve-rdpproxy (0.1.0) trixie; urgency=medium
+
+ * initial package
+
+ -- Alexandre Derumier <aderumier@groupe-cyllene.com> Wed, 19 Aug 2026 12:00:00 +0200
diff --git a/debian/control b/debian/control
new file mode 100644
index 0000000..d3ad9ae
--- /dev/null
+++ b/debian/control
@@ -0,0 +1,24 @@
+Source: pve-rdpproxy
+Section: admin
+Priority: optional
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Uploaders: Alexandre Derumier <aderumier@groupe-cyllene.com>
+Build-Depends: debhelper-compat (= 13),
+ cargo,
+ libssl-dev,
+ pkgconf,
+Standards-Version: 4.7.0.0
+
+Package: pve-rdpproxy
+Architecture: any
+Depends: ${misc:Depends},
+ ${shlibs:Depends},
+Recommends: pve-qemu-rdp,
+Description: RDCleanPath gateway for Proxmox VE RDP consoles
+ The front door for the Kyber-style RDP console: one daemon per node, listening
+ on a unix socket that only pveproxy can open.
+ .
+ A browser cannot drive a TLS handshake over a websocket, so the IronRDP web
+ client delegates it through RDCleanPath. This gateway plays the client's X.224
+ connection request against the VM's own RDP server, performs the TLS handshake
+ on its behalf, returns the certificate chain, and then relays bytes.
diff --git a/debian/copyright b/debian/copyright
new file mode 100644
index 0000000..3f02644
--- /dev/null
+++ b/debian/copyright
@@ -0,0 +1,20 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+Upstream-Name: pve-rdpproxy
+
+Files: *
+Copyright: 2026 Proxmox Server Solutions GmbH <support@proxmox.com>
+License: AGPL-3.0-or-later
+
+License: AGPL-3.0-or-later
+ This program is free software: you can redistribute it and/or modify it under
+ the terms of the GNU Affero General Public License as published by the Free
+ Software Foundation, either version 3 of the License, or (at your option) any
+ later version.
+ .
+ This program is distributed in the hope that it will be useful, but WITHOUT
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+ details.
+ .
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
diff --git a/debian/install b/debian/install
new file mode 100644
index 0000000..ce6b6fd
--- /dev/null
+++ b/debian/install
@@ -0,0 +1 @@
+target/release/pverdpproxy usr/sbin/
diff --git a/debian/pve-rdpproxy.pverdpproxy.service b/debian/pve-rdpproxy.pverdpproxy.service
new file mode 100644
index 0000000..488756e
--- /dev/null
+++ b/debian/pve-rdpproxy.pverdpproxy.service
@@ -0,0 +1,14 @@
+[Unit]
+Description=PVE RDP Console Gateway
+After=network.target
+
+[Service]
+Type=simple
+# www-data is pveproxy's group: the socket is 0660, so the only thing that can
+# open it is the thing that has already authenticated the user.
+ExecStart=/usr/sbin/pverdpproxy --socket-group www-data
+Restart=on-failure
+RestartSec=2
+
+[Install]
+WantedBy=multi-user.target
diff --git a/debian/rules b/debian/rules
new file mode 100755
index 0000000..de23a72
--- /dev/null
+++ b/debian/rules
@@ -0,0 +1,19 @@
+#!/usr/bin/make -f
+
+%:
+ dh $@
+
+# Built here rather than before dpkg-buildpackage, so the package is produced
+# from the source in this directory and nothing is staged in beside it.
+# --locked because Cargo.lock is part of the source: a build that silently
+# resolved something else would not be the package that was reviewed.
+override_dh_auto_build:
+ cargo build --release --locked
+
+override_dh_auto_test:
+
+override_dh_auto_clean:
+ cargo clean
+
+override_dh_installsystemd:
+ dh_installsystemd --name=pverdpproxy
diff --git a/debian/source/format b/debian/source/format
new file mode 100644
index 0000000..89ae9db
--- /dev/null
+++ b/debian/source/format
@@ -0,0 +1 @@
+3.0 (native)
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..1cc1e04
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,249 @@
+// pverdpproxy: the front door for Proxmox VE RDP consoles.
+//
+// One daemon per node, listening on a unix socket that only pveproxy can open.
+// pveproxy authenticates the request - a Proxmox session and VM.Console on the
+// VM - and then hands the raw upgraded connection here, so nothing on the
+// network reaches this directly.
+//
+// It terminates the websocket rather than splicing it, which is the difference
+// from pvekyberproxy: the IronRDP web client wraps RDCleanPath and then RDP
+// itself in binary frames, so the frames have to be read to find the handshake.
+
+use std::os::unix::fs::PermissionsExt;
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use anyhow::{bail, Context, Result};
+use clap::Parser;
+use hyper::body::Incoming;
+use hyper::header::{CONNECTION, UPGRADE};
+use hyper::service::service_fn;
+use hyper::{Request, Response, StatusCode};
+use hyper_util::rt::TokioIo;
+use log::{debug, error, info, warn};
+use tokio::net::UnixListener;
+
+mod session;
+
+#[derive(Parser, Debug)]
+#[command(version, about)]
+struct Args {
+ /// Where pveproxy hands over connections.
+ #[arg(long, default_value = "/run/pverdpproxy.sock")]
+ listen: PathBuf,
+
+ /// Where qemu-server puts each VM's RDP socket and credentials.
+ #[arg(long, default_value = "/run/qemu-server")]
+ run_dir: PathBuf,
+
+ /// Group given access to the listening socket, for pveproxy's user.
+ #[arg(long, value_name = "GROUP")]
+ socket_group: Option<String>,
+}
+
+#[tokio::main]
+async fn main() -> Result<()> {
+ env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
+
+ // Installed once here rather than left to whichever code path runs first,
+ // which would otherwise depend on the order connections arrive in.
+ rustls::crypto::ring::default_provider()
+ .install_default()
+ .map_err(|_| anyhow::anyhow!("a rustls crypto provider was already installed"))?;
+
+ let args = Args::parse();
+
+ // A socket left by an unclean stop would fail the bind.
+ match std::fs::remove_file(&args.listen) {
+ Ok(()) => {}
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
+ Err(err) => return Err(err).context("clearing the old listening socket"),
+ }
+
+ let listener =
+ UnixListener::bind(&args.listen).with_context(|| format!("binding {:?}", args.listen))?;
+ restrict_socket(&args.listen, args.socket_group.as_deref())?;
+
+ info!(
+ "listening on {:?}, VMs under {:?}",
+ args.listen, args.run_dir
+ );
+
+ let run_dir = Arc::new(args.run_dir);
+
+ loop {
+ let (stream, _addr) = match listener.accept().await {
+ Ok(accepted) => accepted,
+ Err(err) => {
+ error!("accept failed: {err}");
+ continue;
+ }
+ };
+
+ let run_dir = run_dir.clone();
+ tokio::spawn(async move {
+ let service = service_fn(move |req| {
+ let run_dir = run_dir.clone();
+ async move { Ok::<_, std::convert::Infallible>(handle(req, run_dir).await) }
+ });
+
+ if let Err(err) = hyper::server::conn::http1::Builder::new()
+ .serve_connection(TokioIo::new(stream), service)
+ .with_upgrades()
+ .await
+ {
+ debug!("connection ended: {err}");
+ }
+ });
+ }
+}
+
+/// 0660 and pveproxy's group, so the only thing that can open it is the thing
+/// that has already authenticated the user.
+fn restrict_socket(path: &std::path::Path, group: Option<&str>) -> Result<()> {
+ if let Some(group) = group {
+ let name = std::ffi::CString::new(group).context("group name")?;
+ // SAFETY: name outlives the call; the returned pointer is only read.
+ let entry = unsafe { libc::getgrnam(name.as_ptr()) };
+ if entry.is_null() {
+ bail!("no such group: {group}");
+ }
+ let gid = unsafe { (*entry).gr_gid };
+ let c_path =
+ std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).context("socket path")?;
+ if unsafe { libc::chown(c_path.as_ptr(), u32::MAX, gid) } != 0 {
+ return Err(std::io::Error::last_os_error())
+ .with_context(|| format!("giving {path:?} to group {group}"));
+ }
+ }
+
+ // After the chown: chmod does not survive a change of owner on every
+ // filesystem, and the narrower mode is the one worth keeping.
+ std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o660))
+ .with_context(|| format!("setting the mode on {path:?}"))?;
+
+ Ok(())
+}
+
+fn bad(status: StatusCode, why: &str) -> Response<String> {
+ warn!("refused: {why}");
+ Response::builder()
+ .status(status)
+ .body(format!("{why}\n"))
+ .expect("a literal response")
+}
+
+async fn handle(mut req: Request<Incoming>, run_dir: Arc<PathBuf>) -> Response<String> {
+ // /<vmid>/<token>. The token says which console this is; the vmid says
+ // which VM, and is the only thing that decides what gets connected to.
+ let path = req.uri().path().trim_matches('/').to_owned();
+ let mut parts = path.split('/');
+ let (Some(vmid), Some(token), None) = (parts.next(), parts.next(), parts.next()) else {
+ return bad(StatusCode::NOT_FOUND, "expected /<vmid>/<token>");
+ };
+
+ let Ok(vmid) = vmid.parse::<u32>() else {
+ return bad(StatusCode::NOT_FOUND, "the VM id is not a number");
+ };
+
+ if let Err(err) = verify_token(&run_dir, vmid, token) {
+ return bad(StatusCode::FORBIDDEN, &format!("VM {vmid}: {err:#}"));
+ }
+
+ if !wants_websocket(&req) {
+ return bad(StatusCode::BAD_REQUEST, "not a websocket upgrade");
+ }
+
+ let Some(key) = req
+ .headers()
+ .get("sec-websocket-key")
+ .and_then(|value| value.to_str().ok())
+ .map(|key| tokio_tungstenite::tungstenite::handshake::derive_accept_key(key.as_bytes()))
+ else {
+ return bad(StatusCode::BAD_REQUEST, "no Sec-WebSocket-Key");
+ };
+
+ let upgrade = hyper::upgrade::on(&mut req);
+ let run_dir = run_dir.clone();
+
+ tokio::spawn(async move {
+ let upgraded = match upgrade.await {
+ Ok(upgraded) => upgraded,
+ Err(err) => {
+ debug!("VM {vmid}: the upgrade never completed: {err}");
+ return;
+ }
+ };
+
+ let ws = tokio_tungstenite::WebSocketStream::from_raw_socket(
+ TokioIo::new(upgraded),
+ tokio_tungstenite::tungstenite::protocol::Role::Server,
+ None,
+ )
+ .await;
+
+ if let Err(err) = session::run(ws, &run_dir, vmid).await {
+ warn!("VM {vmid}: session failed: {err:#}");
+ }
+ });
+
+ Response::builder()
+ .status(StatusCode::SWITCHING_PROTOCOLS)
+ .header(CONNECTION, "Upgrade")
+ .header(UPGRADE, "websocket")
+ .header("sec-websocket-accept", key)
+ .body(String::new())
+ .expect("a literal response")
+}
+
+fn wants_websocket(req: &Request<Incoming>) -> bool {
+ let upgrading = req
+ .headers()
+ .get(CONNECTION)
+ .and_then(|value| value.to_str().ok())
+ .is_some_and(|value| {
+ value
+ .split(',')
+ .any(|token| token.trim().eq_ignore_ascii_case("upgrade"))
+ });
+
+ let websocket = req
+ .headers()
+ .get(UPGRADE)
+ .and_then(|value| value.to_str().ok())
+ .is_some_and(|value| value.eq_ignore_ascii_case("websocket"));
+
+ upgrading && websocket
+}
+
+/// The token qemu-server wrote for this VM, and only this VM.
+///
+/// pveproxy has already established that the user may open a console, so this
+/// is not the authentication - it binds a console to the VM it was opened for,
+/// so a token minted for one cannot be replayed against another.
+fn verify_token(run_dir: &std::path::Path, vmid: u32, presented: &str) -> Result<()> {
+ let path = run_dir.join(format!("{vmid}.rdp.env"));
+ let env = std::fs::read_to_string(&path)
+ .with_context(|| format!("no running RDP server ({path:?})"))?;
+
+ let expected = env
+ .lines()
+ .find_map(|line| line.strip_prefix("RDP_TOKEN="))
+ .map(str::trim)
+ .context("the RDP server has no token")?;
+
+ if !constant_time_eq(expected.as_bytes(), presented.as_bytes()) {
+ bail!("the token does not match");
+ }
+
+ Ok(())
+}
+
+/// Compared in constant time: a token is a secret, and an early return on the
+/// first wrong byte is enough to recover one a byte at a time.
+fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
+ if a.len() != b.len() {
+ return false;
+ }
+ a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
+}
diff --git a/src/session.rs b/src/session.rs
new file mode 100644
index 0000000..9644e01
--- /dev/null
+++ b/src/session.rs
@@ -0,0 +1,303 @@
+// The RDCleanPath handshake, and the relay that follows it.
+//
+// RDCleanPath is Devolutions Gateway's protocol, and it is what the IronRDP
+// web client speaks: TLS terminates here rather than in the browser, because a
+// browser cannot drive a TLS handshake over a websocket. The client sends its
+// X.224 Connection Request inside a DER blob, the gateway plays that against
+// the real server, does the TLS handshake on its behalf and hands back the
+// Connection Confirm together with the server's certificate chain. Everything
+// after that is opaque bytes in both directions.
+
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+use anyhow::{bail, Context, Result};
+use futures_util::{SinkExt, StreamExt};
+use ironrdp_rdcleanpath::{DetectionResult, RDCleanPath, RDCleanPathPdu};
+use log::{debug, info, warn};
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::UnixStream;
+use tokio_tungstenite::tungstenite::Message;
+
+/// The per-VM RDP server's socket. Derived from the VM id, never from anything
+/// the client sent - see `run`.
+fn rdp_socket(run_dir: &Path, vmid: u32) -> PathBuf {
+ run_dir.join(format!("{vmid}.rdp.sock"))
+}
+
+/// TPKT says how long its payload is, so the Connection Confirm can be read
+/// exactly rather than guessed at with a timeout.
+async fn read_tpkt<S>(stream: &mut S) -> Result<Vec<u8>>
+where
+ S: AsyncReadExt + Unpin,
+{
+ let mut header = [0u8; 4];
+ stream
+ .read_exact(&mut header)
+ .await
+ .context("reading the TPKT header")?;
+
+ if header[0] != 3 {
+ bail!(
+ "not a TPKT packet: first byte is {:#x}, expected 0x03",
+ header[0]
+ );
+ }
+
+ let length = u16::from_be_bytes([header[2], header[3]]) as usize;
+ if length < 4 {
+ bail!("TPKT length {length} is shorter than its own header");
+ }
+
+ let mut packet = Vec::with_capacity(length);
+ packet.extend_from_slice(&header);
+ packet.resize(length, 0);
+ stream
+ .read_exact(&mut packet[4..])
+ .await
+ .context("reading the TPKT payload")?;
+
+ Ok(packet)
+}
+
+/// Accepts the per-VM certificate without checking it.
+///
+/// Not a weakening: the certificate is generated by qemu-server on every start
+/// and signs nothing anyone could verify. What authenticates this hop is the
+/// socket - mode 0600, owned by root, on this node - and the handshake exists
+/// only because CredSSP binds to the server's public key. The chain is still
+/// read out and handed to the client, which is the party that gets to decide.
+#[derive(Debug)]
+struct AcceptAnyServer(Arc<rustls::crypto::CryptoProvider>);
+
+impl rustls::client::danger::ServerCertVerifier for AcceptAnyServer {
+ fn verify_server_cert(
+ &self,
+ _end_entity: &rustls::pki_types::CertificateDer<'_>,
+ _intermediates: &[rustls::pki_types::CertificateDer<'_>],
+ _server_name: &rustls::pki_types::ServerName<'_>,
+ _ocsp_response: &[u8],
+ _now: rustls::pki_types::UnixTime,
+ ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
+ Ok(rustls::client::danger::ServerCertVerified::assertion())
+ }
+
+ fn verify_tls12_signature(
+ &self,
+ message: &[u8],
+ cert: &rustls::pki_types::CertificateDer<'_>,
+ dss: &rustls::DigitallySignedStruct,
+ ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
+ rustls::crypto::verify_tls12_signature(
+ message,
+ cert,
+ dss,
+ &self.0.signature_verification_algorithms,
+ )
+ }
+
+ fn verify_tls13_signature(
+ &self,
+ message: &[u8],
+ cert: &rustls::pki_types::CertificateDer<'_>,
+ dss: &rustls::DigitallySignedStruct,
+ ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
+ rustls::crypto::verify_tls13_signature(
+ message,
+ cert,
+ dss,
+ &self.0.signature_verification_algorithms,
+ )
+ }
+
+ fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
+ self.0.signature_verification_algorithms.supported_schemes()
+ }
+}
+
+fn tls_config() -> Arc<rustls::ClientConfig> {
+ let provider = Arc::new(rustls::crypto::ring::default_provider());
+ let config = rustls::ClientConfig::builder_with_provider(provider.clone())
+ .with_safe_default_protocol_versions()
+ .expect("ring provides both protocol versions")
+ .dangerous()
+ .with_custom_certificate_verifier(Arc::new(AcceptAnyServer(provider)))
+ .with_no_client_auth();
+ Arc::new(config)
+}
+
+/// The whole session: handshake, then relay until either side stops.
+///
+/// `vmid` comes from the URL that pveproxy authenticated, never from the PDU.
+/// The client names a destination in its request and this ignores it - honouring
+/// it would turn an authenticated console into a request forgery against
+/// anything this node can reach.
+pub async fn run<S>(
+ mut ws: tokio_tungstenite::WebSocketStream<S>,
+ run_dir: &Path,
+ vmid: u32,
+) -> Result<()>
+where
+ S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
+{
+ let request = next_binary(&mut ws)
+ .await
+ .context("waiting for the RDCleanPath request")?;
+
+ match RDCleanPathPdu::detect(&request) {
+ DetectionResult::Detected { .. } => {}
+ DetectionResult::NotEnoughBytes => {
+ bail!("the RDCleanPath request arrived truncated across frames")
+ }
+ DetectionResult::Failed => bail!("the first frame was not an RDCleanPath PDU"),
+ }
+
+ let pdu = RDCleanPathPdu::from_der(&request)
+ .map_err(|err| anyhow::anyhow!("decoding the RDCleanPath request: {err}"))?;
+ let message = pdu
+ .into_enum()
+ .map_err(|err| anyhow::anyhow!("reading the RDCleanPath request: {err}"))?;
+
+ let x224_request = match message {
+ RDCleanPath::Request {
+ destination,
+ x224_connection_request,
+ ..
+ } => {
+ // Logged, not used. Worth seeing when a client is pointed somewhere
+ // unexpected; never worth obeying.
+ debug!("VM {vmid}: client asked for '{destination}', routing by vmid instead");
+ x224_connection_request.into_bytes()
+ }
+ _ => bail!("expected an RDCleanPath request, got a response or an error"),
+ };
+
+ let socket = rdp_socket(run_dir, vmid);
+ let mut upstream = UnixStream::connect(&socket)
+ .await
+ .with_context(|| format!("connecting to the RDP server for VM {vmid} on {socket:?}"))?;
+
+ upstream
+ .write_all(&x224_request)
+ .await
+ .context("forwarding the X.224 connection request")?;
+ let x224_response = read_tpkt(&mut upstream)
+ .await
+ .context("reading the X.224 connection confirm")?;
+
+ // The server switches to TLS immediately after the confirm, so this has to
+ // follow it with nothing in between.
+ let connector = tokio_rustls::TlsConnector::from(tls_config());
+ // A name is required and never checked; the socket already said which
+ // server this is.
+ let name =
+ rustls::pki_types::ServerName::try_from("pve-rdp").expect("a literal, valid DNS name");
+ let tls = connector
+ .connect(name, upstream)
+ .await
+ .context("the TLS handshake with the RDP server failed")?;
+
+ let chain: Vec<Vec<u8>> = tls
+ .get_ref()
+ .1
+ .peer_certificates()
+ .unwrap_or(&[])
+ .iter()
+ .map(|cert| cert.as_ref().to_vec())
+ .collect();
+
+ if chain.is_empty() {
+ bail!("the RDP server presented no certificate");
+ }
+
+ let response = RDCleanPathPdu::new_response(
+ // Reported back for the client's logs. The real address is a unix
+ // socket, which RDCleanPath has no way to express.
+ format!("vm-{vmid}"),
+ x224_response,
+ chain,
+ )
+ .and_then(|pdu| pdu.to_der())
+ .map_err(|err| anyhow::anyhow!("encoding the RDCleanPath response: {err}"))?;
+
+ ws.send(Message::Binary(response))
+ .await
+ .context("sending the RDCleanPath response")?;
+
+ info!("VM {vmid}: RDP session established");
+ relay(ws, tls, vmid).await
+}
+
+async fn next_binary<S>(ws: &mut tokio_tungstenite::WebSocketStream<S>) -> Result<Vec<u8>>
+where
+ S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
+{
+ while let Some(message) = ws.next().await {
+ match message.context("reading from the websocket")? {
+ Message::Binary(data) => return Ok(data),
+ Message::Close(_) => bail!("the client closed before sending anything"),
+ // Text is not part of this protocol; ping/pong are handled by the
+ // library and are not worth mentioning.
+ Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => {}
+ Message::Text(_) => bail!("the client sent a text frame"),
+ }
+ }
+ bail!("the websocket ended before the RDCleanPath request")
+}
+
+/// Bytes both ways until one side stops. Nothing here understands RDP.
+async fn relay<S, U>(
+ ws: tokio_tungstenite::WebSocketStream<S>,
+ tls: tokio_rustls::client::TlsStream<U>,
+ vmid: u32,
+) -> Result<()>
+where
+ S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
+ U: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
+{
+ let (mut ws_tx, mut ws_rx) = ws.split();
+ let (mut server_rx, mut server_tx) = tokio::io::split(tls);
+
+ let to_server = async {
+ let mut sent: u64 = 0;
+ while let Some(message) = ws_rx.next().await {
+ match message? {
+ Message::Binary(data) => {
+ server_tx.write_all(&data).await?;
+ sent += data.len() as u64;
+ }
+ Message::Close(_) => break,
+ _ => {}
+ }
+ }
+ Ok::<u64, anyhow::Error>(sent)
+ };
+
+ let to_client = async {
+ let mut buf = vec![0u8; 32 * 1024];
+ let mut sent: u64 = 0;
+ loop {
+ let read = server_rx.read(&mut buf).await?;
+ if read == 0 {
+ break;
+ }
+ ws_tx.send(Message::Binary(buf[..read].to_vec())).await?;
+ sent += read as u64;
+ }
+ Ok::<u64, anyhow::Error>(sent)
+ };
+
+ tokio::select! {
+ result = to_server => match result {
+ Ok(bytes) => debug!("VM {vmid}: client closed after {bytes} bytes up"),
+ Err(err) => warn!("VM {vmid}: client side ended: {err:#}"),
+ },
+ result = to_client => match result {
+ Ok(bytes) => debug!("VM {vmid}: server closed after {bytes} bytes down"),
+ Err(err) => warn!("VM {vmid}: server side ended: {err:#}"),
+ },
+ }
+
+ info!("VM {vmid}: RDP session closed");
+ Ok(())
+}
--
2.55.0
^ permalink raw reply related [flat|nested] 11+ messages in thread
end of thread, other threads:[~2026-08-25 11:36 UTC | newest]
Thread overview: 11+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
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 ` [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
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox