public inbox for pve-devel@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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal