all lists on lists.proxmox.com
 help / color / mirror / Atom feed
* [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&#8230;</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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal