* [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
` (12 more replies)
0 siblings, 13 replies; 14+ 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] 14+ 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
` (11 subsequent siblings)
12 siblings, 0 replies; 14+ 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] 14+ 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
` (10 subsequent siblings)
12 siblings, 0 replies; 14+ 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] 14+ 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
` (9 subsequent siblings)
12 siblings, 0 replies; 14+ 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] 14+ 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
` (8 subsequent siblings)
12 siblings, 0 replies; 14+ 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] 14+ 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
` (7 subsequent siblings)
12 siblings, 0 replies; 14+ 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] 14+ 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
` (6 subsequent siblings)
12 siblings, 0 replies; 14+ 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] 14+ 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-qemu-kyber 08/13] Add pve-qemu-kyber: an kyber controller for the qemu console Alexandre Derumier
` (5 subsequent siblings)
12 siblings, 0 replies; 14+ messages in thread
From: Alexandre Derumier @ 2026-08-25 11:34 UTC (permalink / raw)
To: pve-devel
Signed-off-by: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
---
Makefile | 2 +-
PVE/Service/pveproxy.pm | 14 +
rdp-web/Makefile | 18 +
rdp-web/index.html.tpl | 615 +++++++++++++++++++++++++++
www/manager6/Utils.js | 31 +-
www/manager6/button/ConsoleButton.js | 21 +
www/manager6/qemu/Config.js | 6 +-
www/manager6/qemu/DisplayEdit.js | 4 +-
8 files changed, 705 insertions(+), 6 deletions(-)
create mode 100644 rdp-web/Makefile
create mode 100644 rdp-web/index.html.tpl
diff --git a/Makefile b/Makefile
index b7dfc7f..18de847 100644
--- a/Makefile
+++ b/Makefile
@@ -10,7 +10,7 @@ DSC=$(PACKAGE)_$(DEB_VERSION).dsc
DEB=$(PACKAGE)_$(DEB_VERSION)_all.deb
DESTDIR=
-SUBDIRS = aplinfo PVE bin www services configs kyber-web network-hooks test templates
+SUBDIRS = aplinfo PVE bin www services configs kyber-web rdp-web network-hooks test templates
all: $(SUBDIRS)
set -e && for i in $(SUBDIRS); do $(MAKE) -C $$i; done
diff --git a/PVE/Service/pveproxy.pm b/PVE/Service/pveproxy.pm
index bcc1353..ddc04ff 100755
--- a/PVE/Service/pveproxy.pm
+++ b/PVE/Service/pveproxy.pm
@@ -58,6 +58,7 @@ my $basedirs = {
i18n => '/usr/share/pve-i18n',
manager => '/usr/share/pve-manager',
kyber => '/usr/share/pve-kyber-web',
+ rdp => '/usr/share/pve-rdp-web',
novnc => '/usr/share/novnc-pve',
yew_mobile => '/usr/share/pve-yew-mobile-gui',
i18n_yew => '/usr/share/pve-yew-mobile-i18n',
@@ -68,6 +69,9 @@ my $basedirs = {
my $kyber_console_prefix = qr!^/api2/json/nodes/([^/]+)/qemu/(\d+)/kyber(/.*)$!;
my $kyber_proxy_socket = '/run/pvekyberproxy.sock';
+my $rdp_console_prefix = qr!^/api2/json/nodes/([^/]+)/qemu/(\d+)/rdp/([^/]+)$!;
+my $rdp_proxy_socket = '/run/pverdpproxy.sock';
+
my sub check_console_access {
my ($auth, $console_type, $node, $vmid) = @_;
@@ -97,6 +101,12 @@ sub console_proxy {
return { socket => $kyber_proxy_socket, path => $target, tls => 0 };
}
+ if (my ($node, $vmid, $token) = $path =~ $rdp_console_prefix) {
+ check_console_access($auth, 'RDP', $node, $vmid);
+
+ return { socket => $rdp_proxy_socket, path => "/$vmid/$token", tls => 0 };
+ }
+
return undef;
}
@@ -118,6 +128,7 @@ sub init {
my $dirs = {};
add_dirs($dirs, '/kyber/' => "$basedirs->{kyber}/");
+ add_dirs($dirs, '/rdp/' => "$basedirs->{rdp}/");
add_dirs($dirs, '/novnc/' => "$basedirs->{novnc}/");
add_dirs($dirs, '/pve-docs/' => "$basedirs->{docs}/");
add_dirs($dirs, '/pve-docs/api-viewer/extjs/' => "$basedirs->{extjs}/");
@@ -290,6 +301,7 @@ sub get_index {
|| $args->{mobile};
my $kyber = defined($args->{console}) && $args->{kyber};
+ my $rdp = defined($args->{console}) && $args->{rdp};
my $novnc = defined($args->{console}) && $args->{novnc};
my $xtermjs = defined($args->{console}) && $args->{xtermjs};
@@ -336,6 +348,8 @@ sub get_index {
if ($kyber) {
$dir = $basedirs->{kyber};
+ } elsif ($rdp) {
+ $dir = $basedirs->{rdp};
} elsif ($novnc) {
$dir = $basedirs->{novnc};
} elsif ($xtermjs) {
diff --git a/rdp-web/Makefile b/rdp-web/Makefile
new file mode 100644
index 0000000..f51cfc2
--- /dev/null
+++ b/rdp-web/Makefile
@@ -0,0 +1,18 @@
+include ../defines.mk
+
+RDPDIR = $(DESTDIR)/usr/share/pve-rdp-web
+
+all:
+
+.PHONY: install
+install: index.html.tpl
+ install -d $(RDPDIR)
+ install -m 0644 index.html.tpl $(RDPDIR)/index.html.tpl
+# The client itself - rdp_client.js and rdp_client_bg.wasm beside it - is
+# IronRDP built for the browser, shipped by pve-rdp-web rather than vendored
+# here: a separate upstream with a wasm toolchain that has no business in this
+# build. The page drives it directly and needs nothing else from it.
+
+.PHONY: clean distclean
+distclean: clean
+clean:
diff --git a/rdp-web/index.html.tpl b/rdp-web/index.html.tpl
new file mode 100644
index 0000000..eda7006
--- /dev/null
+++ b/rdp-web/index.html.tpl
@@ -0,0 +1,615 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>RDP console</title>
+ <style>
+ html, body {
+ margin: 0;
+ height: 100%;
+ background: #000;
+ color: #ddd;
+ font: 13px/1.5 system-ui, sans-serif;
+ overflow: hidden;
+ }
+ #container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ }
+ #canvas {
+ display: block;
+ outline: none;
+ }
+ #status {
+ position: fixed;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 2em;
+ text-align: center;
+ background: #000;
+ white-space: pre-wrap;
+ }
+ #status[hidden] { display: none; }
+ #status.failed { color: #ff8080; }
+ /* The control bar is noVNC's, down to its images: a console should not
+ look like a different product depending on which one it is. Only the
+ rules this page needs are copied - loading noVNC's stylesheet whole
+ would bring its layout with it. */
+ #rdp_control_bar_anchor {
+ position: fixed;
+ top: 0;
+ left: 0;
+ height: 100%;
+ z-index: 10;
+ transition: 0.5s ease-in-out;
+ }
+ #rdp_control_bar {
+ position: relative;
+ left: -100%;
+ height: 100%;
+ padding: 5px;
+ background-color: #1c2331;
+ border-radius: 0 12px 12px 0;
+ transition: 0.5s ease-in-out;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+ #rdp_control_bar.rdp_open { left: 0; }
+ #rdp_control_bar_handle {
+ position: absolute;
+ left: -15px;
+ top: 0;
+ transform: translateY(35px);
+ width: calc(100% + 30px);
+ height: 50px;
+ z-index: -1;
+ cursor: pointer;
+ border-radius: 6px;
+ background-color: #1c2331;
+ background-image: url("/novnc/app/images/handle_bg.svg");
+ background-repeat: no-repeat;
+ background-position: right;
+ box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.5);
+ }
+ #rdp_control_bar_handle:after {
+ content: "";
+ transition: transform 0.1s ease-in-out;
+ background: url("/novnc/app/images/handle.svg");
+ position: absolute;
+ top: 22px;
+ right: 5px;
+ width: 5px;
+ height: 6px;
+ }
+ #rdp_control_bar.rdp_open #rdp_control_bar_handle:after {
+ transform: translateX(1px) rotate(180deg);
+ }
+ .rdp_button {
+ min-width: 36px;
+ padding: 4px;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 6px;
+ background-color: transparent;
+ color: #fff;
+ cursor: pointer;
+ font-size: 11px;
+ line-height: 1.2;
+ }
+ .rdp_button:hover { background-color: rgba(255, 255, 255, 0.1); }
+ .rdp_button img { width: 24px; height: 24px; display: block; margin: auto; }
+ #rdp_power_menu {
+ display: none;
+ position: absolute;
+ left: 100%;
+ margin-left: 8px;
+ background-color: #1c2331;
+ border-radius: 6px;
+ padding: 5px;
+ box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.5);
+ }
+ #rdp_power_menu.rdp_open { display: flex; flex-direction: column; gap: 4px; }
+ #rdp_power_menu .rdp_button { white-space: nowrap; text-align: left; }
+ #container:fullscreen,
+ :fullscreen #container {
+ width: 100vw;
+ height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: #000;
+ }
+ </style>
+ </head>
+ <body>
+ <div id="rdp_control_bar_anchor">
+ <div id="rdp_control_bar">
+ <div id="rdp_control_bar_handle"></div>
+ <button class="rdp_button" id="rdp_ctrl_alt_del" title="Send Ctrl-Alt-Del">
+ <img src="/novnc/app/images/esc.svg" alt=""><span>C-A-D</span>
+ </button>
+ <button class="rdp_button" id="rdp_audio" title="Guest audio">
+ <span id="rdp_audio_label">Audio<br>off</span>
+ </button>
+ <button class="rdp_button" id="rdp_fullscreen" title="Fullscreen">
+ <img src="/novnc/app/images/fullscreen.svg" alt="">
+ </button>
+ <button class="rdp_button" id="rdp_power" title="Power">
+ <img src="/novnc/app/images/power.svg" alt="">
+ </button>
+ <div id="rdp_power_menu">
+ <button class="rdp_button" data-power="start">Start</button>
+ <button class="rdp_button" data-power="shutdown">Shutdown</button>
+ <button class="rdp_button" data-power="reboot">Reboot</button>
+ <button class="rdp_button" data-power="reset">Reset</button>
+ <button class="rdp_button" data-power="stop">Stop</button>
+ </div>
+ </div>
+ </div>
+ <div id="container"><canvas id="canvas" tabindex="0"></canvas></div>
+ <div id="status">Connecting…</div>
+
+ <!-- Installs globalThis.pveRdpAudio, which the client looks up when the
+ session starts. A classic script rather than a module: those are
+ deferred, and this has to be in place before the module runs. Shipped
+ by pve-rdp-web beside the client itself. -->
+ <script src="/rdp/rdp-audio.js"></script>
+
+ <script type="module">
+ import init, { ClipboardData, DesktopSize, DeviceEvent, InputTransaction, SessionBuilder, setup }
+ from '/rdp/rdp_client.js';
+
+ const params = new URLSearchParams(window.location.search);
+ const node = params.get('node');
+ const vmid = params.get('vmid');
+ const vmname = params.get('vmname');
+
+ document.title = vmname ? `${vmname} (${vmid}) - RDP console`
+ : `VM ${vmid} - RDP console`;
+
+ const statusEl = document.getElementById('status');
+ const canvas = document.getElementById('canvas');
+
+ const setStatus = (text, failed) => {
+ statusEl.textContent = text;
+ statusEl.classList.toggle('failed', !!failed);
+ statusEl.hidden = false;
+ };
+
+ let session = null;
+
+ // Windows scancodes, which is what RDP carries, keyed by the browser's
+ // KeyboardEvent.code. Taken from IronRDP's own web client; the high byte
+ // is the extended-key prefix.
+ const SCANCODES = {
+ AltLeft: 0x0038, AltRight: 0xE038, ArrowDown: 0xE050, ArrowLeft: 0xE04B,
+ ArrowRight: 0xE04D, ArrowUp: 0xE048, AudioVolumeDown: 0xE02E,
+ AudioVolumeMute: 0xE020, AudioVolumeUp: 0xE030, Backquote: 0x0029, Backslash: 0x002B,
+ Backspace: 0x000E, BracketLeft: 0x001A, BracketRight: 0x001B, BrowserBack: 0xE06A,
+ BrowserFavorites: 0xE066, BrowserForward: 0xE069, BrowserHome: 0xE032,
+ BrowserRefresh: 0xE067, BrowserSearch: 0xE065, BrowserStop: 0xE068, CapsLock: 0x003A,
+ Comma: 0x0033, ContextMenu: 0xE05D, ControlLeft: 0x001D, ControlRight: 0xE01D,
+ Convert: 0x0079, Copy: 0xE018, Cut: 0xE017, Delete: 0xE053, Digit0: 0x000B,
+ Digit1: 0x0002, Digit2: 0x0003, Digit3: 0x0004, Digit4: 0x0005, Digit5: 0x0006,
+ Digit6: 0x0007, Digit7: 0x0008, Digit8: 0x0009, Digit9: 0x000A, Eject: 0xE02C,
+ End: 0xE04F, Enter: 0x001C, Equal: 0x000D, Escape: 0x0001, F1: 0x003B, F10: 0x0044,
+ F11: 0x0057, F12: 0x0058, F13: 0x0064, F14: 0x0065, F15: 0x0066, F16: 0x0067,
+ F17: 0x0068, F18: 0x0069, F19: 0x006A, F2: 0x003C, F20: 0x006B, F21: 0x006C,
+ F22: 0x006D, F23: 0x006E, F24: 0x0076, F3: 0x003D, F4: 0x003E, F5: 0x003F,
+ F6: 0x0040, F7: 0x0041, F8: 0x0042, F9: 0x0043, Help: 0xE03B, Home: 0xE047,
+ Insert: 0xE052, IntlBackslash: 0x0056, IntlRo: 0x0073, IntlYen: 0x007D,
+ KanaMode: 0x0070, KeyA: 0x001E, KeyB: 0x0030, KeyC: 0x002E, KeyD: 0x0020,
+ KeyE: 0x0012, KeyF: 0x0021, KeyG: 0x0022, KeyH: 0x0023, KeyI: 0x0017, KeyJ: 0x0024,
+ KeyK: 0x0025, KeyL: 0x0026, KeyM: 0x0032, KeyN: 0x0031, KeyO: 0x0018, KeyP: 0x0019,
+ KeyQ: 0x0010, KeyR: 0x0013, KeyS: 0x001F, KeyT: 0x0014, KeyU: 0x0016, KeyV: 0x002F,
+ KeyW: 0x0011, KeyX: 0x002D, KeyY: 0x0015, KeyZ: 0x002C, Lang1: 0x0072, Lang2: 0x0071,
+ Lang3: 0x0078, Lang4: 0x0077, LaunchApp1: 0xE06B, LaunchApp2: 0xE021,
+ LaunchMail: 0xE06C, MediaPlayPause: 0xE022, MediaSelect: 0xE06D, MediaStop: 0xE024,
+ MediaTrackNext: 0xE019, MediaTrackPrevious: 0xE010, MetaLeft: 0xE05B,
+ MetaRight: 0xE05C, Minus: 0x000C, NonConvert: 0x007B, NumLock: 0xE045,
+ Numpad0: 0x0052, Numpad1: 0x004F, Numpad2: 0x0050, Numpad3: 0x0051, Numpad4: 0x004B,
+ Numpad5: 0x004C, Numpad6: 0x004D, Numpad7: 0x0047, Numpad8: 0x0048, Numpad9: 0x0049,
+ NumpadAdd: 0x004E, NumpadComma: 0x007E, NumpadDecimal: 0x0053, NumpadDivide: 0xE035,
+ NumpadEnter: 0xE01C, NumpadEqual: 0x0059, NumpadMultiply: 0x0037,
+ NumpadSubtract: 0x004A, OSLeft: 0xE05B, OSRight: 0xE05C, PageDown: 0xE051,
+ PageUp: 0xE049, Paste: 0xE00A, Pause: 0xE046, Period: 0x0034, Power: 0xE05E,
+ PrintScreen: 0xE037, Quote: 0x0028, ScrollLock: 0x0046, Semicolon: 0x0027,
+ ShiftLeft: 0x002A, ShiftRight: 0x0036, Slash: 0x0035, Sleep: 0xE05F, Space: 0x0039,
+ Tab: 0x000F, Undo: 0xE008, VolumeDown: 0xE02E, VolumeMute: 0xE020, VolumeUp: 0xE030,
+ WakeUp: 0xE063
+ };
+
+ // What to ask the server for. It is only a request: qemu-rdp answers with
+ // the guest's own framebuffer size and resizes only if the guest agrees,
+ // so nothing below may assume this is what arrives.
+ const requestedWidth = Math.max(640, Math.floor(window.innerWidth / 4) * 4);
+ const requestedHeight = Math.max(480, Math.floor(window.innerHeight / 4) * 4);
+
+ // Sized from the canvas itself, never from what was requested: the client
+ // sets the backing store to the size the server negotiated, and giving it
+ // a CSS box of a different size is what scales the picture.
+ function fitCanvas() {
+ const width = canvas.width;
+ const height = canvas.height;
+ if (!width || !height) {
+ return;
+ }
+
+ let scale = Math.min(window.innerWidth / width, window.innerHeight / height);
+ // Native size in a window, shrunk only when the guest is bigger than
+ // the window; fullscreen scales up too, which is the point of it.
+ // One factor for both axes, so a 4:3 guest letterboxes rather than
+ // distorts.
+ if (!document.fullscreenElement) {
+ scale = Math.min(scale, 1);
+ }
+
+ canvas.style.width = `${Math.floor(width * scale)}px`;
+ canvas.style.height = `${Math.floor(height * scale)}px`;
+ }
+
+ // A guest that resizes changes the backing store with no event to go
+ // with it - the SDK's canvas_resized callback is a no-op for RDP, which
+ // has no server-side resize. width and height are reflected attributes,
+ // so the change is still observable.
+ new MutationObserver(fitCanvas).observe(canvas, {
+ attributes: true,
+ attributeFilter: ['width', 'height'],
+ });
+
+ // Pointer coordinates are the guest's, not the page's: the canvas is
+ // scaled by CSS whenever the guest does not fit the window as it is.
+ function guestPosition(event) {
+ const rect = canvas.getBoundingClientRect();
+ const x = (event.clientX - rect.left) * (canvas.width / rect.width);
+ const y = (event.clientY - rect.top) * (canvas.height / rect.height);
+ return [
+ Math.max(0, Math.min(canvas.width - 1, Math.round(x))),
+ Math.max(0, Math.min(canvas.height - 1, Math.round(y))),
+ ];
+ }
+
+ function apply(...events) {
+ if (!session) {
+ return;
+ }
+ const transaction = new InputTransaction();
+ for (const event of events) {
+ transaction.addEvent(event);
+ }
+ session.applyInputs(transaction);
+ }
+
+ function bindInput() {
+ canvas.addEventListener('contextmenu', (e) => e.preventDefault());
+
+ canvas.addEventListener('mousemove', (e) => {
+ apply(DeviceEvent.mouseMove(...guestPosition(e)));
+ });
+
+ canvas.addEventListener('mousedown', (e) => {
+ e.preventDefault();
+ canvas.focus();
+ apply(
+ DeviceEvent.mouseMove(...guestPosition(e)),
+ DeviceEvent.mouseButtonPressed(e.button),
+ );
+ });
+
+ // On the window, not the canvas: a button released outside it would
+ // otherwise stay down in the guest.
+ window.addEventListener('mouseup', (e) => {
+ apply(DeviceEvent.mouseButtonReleased(e.button));
+ });
+
+ canvas.addEventListener('wheel', (e) => {
+ e.preventDefault();
+ const vertical = e.deltaY !== 0;
+ // Negated: the browser counts down as positive, RDP counts up.
+ // deltaMode's values are RotationUnit's, so it goes as it is.
+ apply(DeviceEvent.wheelRotations(
+ vertical,
+ -Math.round(vertical ? e.deltaY : e.deltaX),
+ e.deltaMode,
+ ));
+ }, { passive: false });
+
+ canvas.addEventListener('keydown', (e) => {
+ e.preventDefault();
+ const scancode = SCANCODES[e.code];
+ if (scancode !== undefined) {
+ apply(DeviceEvent.keyPressed(scancode));
+ } else if (e.key.length === 1) {
+ // A layout this table does not cover; the character itself
+ // still gets through.
+ apply(DeviceEvent.unicodePressed(e.key));
+ }
+ });
+
+ canvas.addEventListener('keyup', (e) => {
+ e.preventDefault();
+ const scancode = SCANCODES[e.code];
+ if (scancode !== undefined) {
+ apply(DeviceEvent.keyReleased(scancode));
+ } else if (e.key.length === 1) {
+ apply(DeviceEvent.unicodeReleased(e.key));
+ }
+ });
+
+ // Every key held when focus leaves would stay held in the guest.
+ canvas.addEventListener('blur', () => session?.releaseAllInputs());
+ }
+
+ // The cursor is drawn by the desktop, not into the framebuffer, so the
+ // guest's shape arrives separately and is put on the canvas as a CSS
+ // cursor.
+ function setCursorStyle(kind, data, hotspotX, hotspotY) {
+ if (kind === 'url') {
+ canvas.style.cursor = `url(${data}) ${hotspotX} ${hotspotY}, default`;
+ } else if (kind === 'none') {
+ canvas.style.cursor = 'none';
+ } else {
+ canvas.style.cursor = 'default';
+ }
+ }
+
+ // Clipboard, both ways, through the browser's own - which hands it over
+ // only to a focused document the user has granted permission to, so a
+ // refusal here is normal and not worth failing the console over.
+ //
+ // The guest's clipboard, on its way here.
+ async function onRemoteClipboardChanged(data) {
+ for (const item of data.items()) {
+ if (item.mimeType() === 'text/plain') {
+ const text = item.value();
+ try {
+ await navigator.clipboard.writeText(text);
+ // Remembered, or the poll below would read it back and
+ // announce the guest's own copy straight back at it.
+ lastLocalText = text;
+ } catch (err) {
+ console.warn('could not take the guest clipboard:', err);
+ }
+ return;
+ }
+ }
+ }
+
+ // Ours, on its way to the guest. RDP is announce-then-request: until the
+ // client says it holds a format, the guest's paste has nothing to ask
+ // for. Nothing tells a page that another application copied something,
+ // so the only way to notice is to look.
+ let lastLocalText = null;
+
+ async function announceLocalClipboard() {
+ if (!session || !document.hasFocus()) {
+ return;
+ }
+
+ let text;
+ try {
+ text = await navigator.clipboard.readText();
+ } catch (err) {
+ // Refused or unavailable - Firefox gives web pages no unprompted
+ // read at all. Not an error worth repeating every second.
+ return;
+ }
+
+ if (text === lastLocalText) {
+ return;
+ }
+ lastLocalText = text;
+
+ const data = new ClipboardData();
+ if (text.length) {
+ data.addText('text/plain', text);
+ }
+ await session.onClipboardPaste(data);
+ }
+
+ const CLIPBOARD_POLL_MS = 1000;
+ setInterval(() => {
+ announceLocalClipboard().catch((err) => {
+ console.warn('could not announce the clipboard:', err);
+ });
+ }, CLIPBOARD_POLL_MS);
+ window.addEventListener('focus', () => {
+ announceLocalClipboard().catch(() => {});
+ });
+
+ // Asked for when the guest wants what we last announced - a replay, not a
+ // fresh read: this fires while the guest has focus, and a read then is
+ // refused anyway.
+ async function onForceClipboardUpdate() {
+ const data = new ClipboardData();
+ if (lastLocalText) {
+ data.addText('text/plain', lastLocalText);
+ }
+ await session?.onClipboardPaste(data);
+ }
+
+ async function boot() {
+ if (!node || !vmid) {
+ throw new Error('missing node or vmid');
+ }
+
+ await init();
+ setup('info');
+
+ // Proxmox checks its ACL here, starts the VM's RDP server and mints
+ // the credentials. Nothing is in the URL, so a copied link grants
+ // nothing.
+ const base = `/api2/json/nodes/${node}/qemu/${vmid}`;
+ const res = await fetch(`${base}/rdpproxy`, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { CSRFPreventionToken: '[% token %]' },
+ });
+ if (!res.ok) {
+ const detail = await res.text().catch(() => '');
+ throw new Error(`could not start the console (${res.status}) ${detail}`);
+ }
+ const { data } = await res.json();
+
+ // pveproxy forwards this to pve-rdpproxy, which does the RDCleanPath
+ // handshake against the VM's server: the browser cannot drive TLS
+ // over a websocket, so the gateway does it and hands back the chain.
+ const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
+ const proxy = `${scheme}://${window.location.host}${base}/rdp/${data.token}`;
+
+ session = await new SessionBuilder()
+ .username(data.user)
+ .password(data.password)
+ // Both are required and neither is used: the VM is named by the
+ // path, and pve-rdpproxy routes on that alone.
+ .destination(`vm-${vmid}`)
+ .authToken(data.token)
+ .proxyAddress(proxy)
+ .desktopSize(new DesktopSize(requestedWidth, requestedHeight))
+ .renderCanvas(canvas)
+ .setCursorStyleCallback(setCursorStyle)
+ .setCursorStyleCallbackContext(window)
+ .remoteClipboardChangedCallback(onRemoteClipboardChanged)
+ .forceClipboardUpdateCallback(onForceClipboardUpdate)
+ .connect();
+
+ statusEl.hidden = true;
+ fitCanvas();
+ bindInput();
+ canvas.focus();
+
+ // Resolves when the session ends, however it ends.
+ const info = await session.run();
+ session = null;
+ setStatus(`Console stopped: ${info.reason()}`, true);
+ }
+
+ // The bar retracts like noVNC's: the handle is always reachable, the bar
+ // itself only when asked for.
+ const controlBar = document.getElementById('rdp_control_bar');
+ const powerMenu = document.getElementById('rdp_power_menu');
+ document.getElementById('rdp_control_bar_handle').addEventListener('click', () => {
+ controlBar.classList.toggle('rdp_open');
+ if (!controlBar.classList.contains('rdp_open')) {
+ powerMenu.classList.remove('rdp_open');
+ }
+ });
+
+ // Ctrl+Alt+Del cannot be captured by any web page - the OS takes it
+ // before a browser sees it - so it is injected as scancodes instead.
+ document.getElementById('rdp_ctrl_alt_del').addEventListener('click', () => {
+ apply(
+ DeviceEvent.keyPressed(SCANCODES.ControlLeft),
+ DeviceEvent.keyPressed(SCANCODES.AltLeft),
+ DeviceEvent.keyPressed(SCANCODES.Delete),
+ DeviceEvent.keyReleased(SCANCODES.Delete),
+ DeviceEvent.keyReleased(SCANCODES.AltLeft),
+ DeviceEvent.keyReleased(SCANCODES.ControlLeft),
+ );
+ canvas.focus();
+ });
+
+ // Browsers refuse to start an AudioContext without a user gesture, so
+ // audio cannot simply follow the session. Two ways in: the button, and
+ // the first click into the guest - which is what a user does anyway, and
+ // saves the button being the only way to discover the feature exists.
+ const audioLabel = document.getElementById('rdp_audio_label');
+
+ function showAudioState(state) {
+ const on = state === 'running';
+ audioLabel.innerHTML = on ? 'Audio<br>on' : 'Audio<br>off';
+ }
+
+ async function startAudio() {
+ if (!globalThis.pveRdpAudio || globalThis.pveRdpAudio.state === 'running') {
+ return;
+ }
+ try {
+ showAudioState(await globalThis.pveRdpAudio.resume());
+ } catch (err) {
+ console.warn('could not start guest audio:', err);
+ }
+ }
+
+ document.getElementById('rdp_audio').addEventListener('click', async () => {
+ await startAudio();
+ canvas.focus();
+ });
+
+ canvas.addEventListener('mousedown', startAudio, { once: true });
+
+ // Fullscreen takes the keyboard with it. Without the lock, combinations
+ // the browser and desktop claim - Ctrl+W, Alt+Tab, F11 - never reach the
+ // guest. It is granted only to a fullscreen document, and has to be
+ // released again or it outlives the console.
+ async function enterFullscreen() {
+ await document.documentElement.requestFullscreen();
+ try {
+ await navigator.keyboard?.lock?.();
+ } catch (err) {
+ console.warn('keyboard lock refused:', err);
+ }
+ canvas.focus();
+ }
+
+ document.getElementById('rdp_fullscreen').addEventListener('click', async () => {
+ try {
+ if (document.fullscreenElement) {
+ await document.exitFullscreen();
+ } else {
+ await enterFullscreen();
+ }
+ } catch (err) {
+ setStatus(`Fullscreen failed: ${err.message ?? err}`, true);
+ }
+ });
+
+ document.addEventListener('fullscreenchange', () => {
+ if (!document.fullscreenElement) {
+ navigator.keyboard?.unlock?.();
+ }
+ fitCanvas();
+ });
+ window.addEventListener('resize', fitCanvas);
+
+ document.getElementById('rdp_power').addEventListener('click', () => {
+ powerMenu.classList.toggle('rdp_open');
+ });
+
+ // Power goes through the API rather than the guest, so it works when the
+ // guest does not answer - which is the case the buttons exist for.
+ for (const button of powerMenu.querySelectorAll('[data-power]')) {
+ button.addEventListener('click', async () => {
+ const action = button.dataset.power;
+ powerMenu.classList.remove('rdp_open');
+ setStatus(`${button.textContent}…`);
+ try {
+ const res = await fetch(
+ `/api2/json/nodes/${node}/qemu/${vmid}/status/${action}`,
+ {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { CSRFPreventionToken: '[% token %]' },
+ },
+ );
+ if (!res.ok) {
+ throw new Error(`${res.status} ${await res.text().catch(() => '')}`);
+ }
+ statusEl.hidden = true;
+ } catch (err) {
+ setStatus(`${button.textContent} failed: ${err.message ?? err}`, true);
+ }
+ });
+ }
+
+ boot().catch((err) => {
+ console.error(err);
+ setStatus(`RDP console failed: ${err.message ?? err}`, true);
+ });
+ </script>
+ </body>
+</html>
diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
index 44ffdfe..d707ac0 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -657,6 +657,7 @@ Ext.define('PVE.Utils', {
virtio: 'VirtIO-GPU',
'virtio-gl': 'VirGL GPU',
kyber: 'Kyber',
+ rdp: 'RDP',
none: Proxmox.Utils.noneText,
},
@@ -1455,6 +1456,8 @@ Ext.define('PVE.Utils', {
Proxmox.Utils.openXtermJsViewer(consoleType, vmid, nodename, vmname, cmd);
} else if (viewer === 'kyber') {
PVE.Utils.openKyberViewer(consoleType, vmid, nodename, vmname);
+ } else if (viewer === 'rdp') {
+ PVE.Utils.openRdpViewer(consoleType, vmid, nodename, vmname);
} else if (viewer === 'vv') {
let url = '/nodes/' + nodename + '/spiceshell';
let params = {
@@ -1478,22 +1481,27 @@ Ext.define('PVE.Utils', {
},
defaultViewer: function (consoles, type) {
- var allowSpice, allowXtermjs, allowKyber;
+ var allowSpice, allowXtermjs, allowKyber, allowRdp;
if (consoles === true) {
allowSpice = true;
allowXtermjs = true;
allowKyber = true;
+ allowRdp = true;
} else if (typeof consoles === 'object') {
allowSpice = consoles.spice;
allowXtermjs = !!consoles.xtermjs;
allowKyber = !!consoles.kyber;
+ allowRdp = !!consoles.rdp;
}
let dv = PVE.UIOptions.options.console || (type === 'kvm' ? 'vv' : 'xtermjs');
- // A Kyber display serves no VNC, so nothing else can show it.
+ // Neither display serves VNC, so nothing else can show them.
if (allowKyber) {
return 'kyber';
}
+ if (allowRdp) {
+ return 'rdp';
+ }
if (dv === 'vv' && !allowSpice) {
dv = allowXtermjs ? 'xtermjs' : 'html5';
} else if (dv === 'xtermjs' && !allowXtermjs) {
@@ -1542,6 +1550,24 @@ Ext.define('PVE.Utils', {
}
},
+ // The RDP console talks to the VM's own RDP server through
+ // pve-rdpproxy rather than through noVNC, so it gets a window of its
+ // own. Its credentials are fetched by that page from rdpproxy; nothing
+ // is passed in the URL, so a copied link grants nothing on its own.
+ openRdpViewer: function (vmtype, vmid, nodename, vmname) {
+ let url = Ext.Object.toQueryString({
+ console: vmtype,
+ rdp: 1,
+ vmid: vmid,
+ vmname: vmname,
+ node: nodename,
+ });
+ let nw = window.open('?' + url, '_blank', 'innerWidth=1280,innerheight=800');
+ if (nw) {
+ nw.focus();
+ }
+ },
+
openSpiceViewer: function (url, params) {
var downloadWithName = function (uri, name) {
var link = Ext.DomHelper.append(document.body, {
@@ -1618,6 +1644,7 @@ Ext.define('PVE.Utils', {
spice: !!conf.spice,
xtermjs: !!conf.serial,
kyber: !!conf.kyber,
+ rdp: !!conf.rdp,
};
PVE.Utils.openDefaultConsoleWindow(consoles, 'kvm', vmid, nodename, vmname);
},
diff --git a/www/manager6/button/ConsoleButton.js b/www/manager6/button/ConsoleButton.js
index 63377e7..3c4406b 100644
--- a/www/manager6/button/ConsoleButton.js
+++ b/www/manager6/button/ConsoleButton.js
@@ -14,6 +14,7 @@ Ext.define('PVE.button.ConsoleButton', {
enableXtermjs: true,
// Off unless a VM says otherwise, so other guests show it greyed out.
enableKyber: false,
+ enableRdp: false,
nodename: undefined,
@@ -42,6 +43,13 @@ Ext.define('PVE.button.ConsoleButton', {
me.down('#kybermenu').setDisabled(!enable);
},
+ setEnableRdp: function (enable) {
+ var me = this;
+
+ me.enableRdp = enable;
+ me.down('#rdpmenu').setDisabled(!enable);
+ },
+
handler: function () {
// main, general, handler
let me = this;
@@ -50,6 +58,7 @@ Ext.define('PVE.button.ConsoleButton', {
spice: me.enableSpice,
xtermjs: me.enableXtermjs,
kyber: me.enableKyber,
+ rdp: me.enableRdp,
},
me.consoleType,
me.vmid,
@@ -106,6 +115,18 @@ Ext.define('PVE.button.ConsoleButton', {
view.openConsole(button.type);
},
},
+ {
+ xtype: 'menuitem',
+ itemId: 'rdpmenu',
+ text: 'RDP',
+ type: 'rdp',
+ iconCls: 'fa fa-fw fa-desktop',
+ disabled: true,
+ handler: function (button) {
+ let view = this.up('button');
+ view.openConsole(button.type);
+ },
+ },
{
text: 'xterm.js',
itemId: 'xtermjs',
diff --git a/www/manager6/qemu/Config.js b/www/manager6/qemu/Config.js
index bcf54d1..79ab012 100644
--- a/www/manager6/qemu/Config.js
+++ b/www/manager6/qemu/Config.js
@@ -232,10 +232,11 @@ Ext.define('PVE.qemu.Config', {
disabled: !caps.vms['VM.Console'],
hidden: template,
consoleType: 'kvm',
- // disable spice/xterm/kyber for default action until status api call succeeded
+ // disable spice/xterm/kyber/rdp for default action until status api call succeeded
enableSpice: false,
enableXtermjs: false,
enableKyber: false,
+ enableRdp: false,
consoleName: vm.name,
nodename: nodename,
vmid: vmid,
@@ -460,6 +461,7 @@ Ext.define('PVE.qemu.Config', {
var spice = false;
var xtermjs = false;
var kyber = false;
+ var rdp = false;
var lock;
var rec;
@@ -481,6 +483,7 @@ Ext.define('PVE.qemu.Config', {
// 'kyber', which is also the only case with a controller
// behind it.
kyber = !!s.data.get('kyber');
+ rdp = !!s.data.get('rdp');
}
rec = s.data.get('tags');
@@ -503,6 +506,7 @@ Ext.define('PVE.qemu.Config', {
consoleBtn.setEnableSpice(spice);
consoleBtn.setEnableXtermJS(xtermjs);
consoleBtn.setEnableKyber(kyber);
+ consoleBtn.setEnableRdp(rdp);
statusTxt.update({ lock: lock });
diff --git a/www/manager6/qemu/DisplayEdit.js b/www/manager6/qemu/DisplayEdit.js
index 79e1ea2..decc016 100644
--- a/www/manager6/qemu/DisplayEdit.js
+++ b/www/manager6/qemu/DisplayEdit.js
@@ -26,8 +26,8 @@ Ext.define('PVE.qemu.DisplayInputPanel', {
return '4';
} else if (val === 'std' || val.match(/^qxl\d?$/) || val === 'vmware') {
return '16';
- } else if (val.match(/^virtio/) || val === 'kyber') {
- // kyber is a virtio-vga underneath, so it takes the same
+ } else if (val.match(/^virtio/) || val === 'kyber' || val === 'rdp') {
+ // Both are a virtio-vga underneath, so they take the same
// memory as one.
return '256';
} else if (get('matchNonGUIOption')) {
--
2.55.0
^ permalink raw reply related [flat|nested] 14+ messages in thread
* [RFC pve-qemu-kyber 08/13] Add pve-qemu-kyber: an kyber controller for the qemu 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
` (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-kyberproxy 09/13] Add pve-kyberproxy Alexandre Derumier
` (4 subsequent siblings)
12 siblings, 0 replies; 14+ messages in thread
From: Alexandre Derumier @ 2026-08-25 11:34 UTC (permalink / raw)
To: pve-devel
Use the Kyber SDK with custom adapters to handle qemu output (avservice)
&& inputs (inputservice).
It's also provide clipboard && audio support
Signed-off-by: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
---
.gitignore | 12 +
.gitmodules | 3 +
Makefile | 130 ++
avservice/Cargo.lock | 1125 +++++++++
avservice/Cargo.toml | 24 +
avservice/src/main.rs | 535 +++++
debian/changelog | 5 +
debian/control | 49 +
debian/copyright | 20 +
debian/install | 2 +
debian/rules | 29 +
debian/source/format | 1 +
inputservice/Cargo.lock | 2063 +++++++++++++++++
inputservice/Cargo.toml | 30 +
inputservice/src/clipboard.rs | 318 +++
inputservice/src/main.rs | 494 ++++
kyber-desktop | 1 +
kyber-qemu-server.mk | 56 +
kycontroller.wrapper | 13 +
...-reset-log-component-count-on-uninit.patch | 12 +
...ller-configure-from-the-command-line.patch | 353 +++
src/audio.c | 494 ++++
src/dmabuf.c | 592 +++++
src/kqs.h | 194 ++
src/listener.c | 568 +++++
src/main.c | 193 ++
src/sink.c | 483 ++++
src/surface.c | 188 ++
28 files changed, 7987 insertions(+)
create mode 100644 .gitignore
create mode 100644 .gitmodules
create mode 100644 Makefile
create mode 100644 avservice/Cargo.lock
create mode 100644 avservice/Cargo.toml
create mode 100644 avservice/src/main.rs
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 inputservice/Cargo.lock
create mode 100644 inputservice/Cargo.toml
create mode 100644 inputservice/src/clipboard.rs
create mode 100644 inputservice/src/main.rs
create mode 160000 kyber-desktop
create mode 100644 kyber-qemu-server.mk
create mode 100755 kycontroller.wrapper
create mode 100644 patches/0001-txproto-reset-log-component-count-on-uninit.patch
create mode 100644 patches/0002-kycontroller-configure-from-the-command-line.patch
create mode 100644 src/audio.c
create mode 100644 src/dmabuf.c
create mode 100644 src/kqs.h
create mode 100644 src/listener.c
create mode 100644 src/main.c
create mode 100644 src/sink.c
create mode 100644 src/surface.c
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2229803
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+# Assembled by `make staging` from a built Kyber SDK; not vendored here.
+/staging/
+/kyber-qemu-server
+/src/*.o
+/avservice/target/
+/inputservice/target/
+/pve-kyber-[0-9]*/
+# the SDK writes these beside whatever runs it
+log/
+*.deb
+*.buildinfo
+*.changes
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..f7af402
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "kyber-desktop"]
+ path = kyber-desktop
+ url = https://gitlab.com/kyber/apps/kyber-desktop.git
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..92dd711
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,130 @@
+include /usr/share/dpkg/architecture.mk
+include /usr/share/dpkg/pkg-info.mk
+
+PACKAGE=pve-qemu-kyber
+ARCH:=$(DEB_HOST_ARCH)
+DEB=$(PACKAGE)_$(DEB_VERSION_UPSTREAM_REVISION)_$(ARCH).deb
+DSC=$(PACKAGE)_$(DEB_VERSION).dsc
+BUILDDIR=$(PACKAGE)-$(DEB_VERSION_UPSTREAM)
+
+# Kyber's SDK: 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_DIR = kyber-desktop
+ROOTFS = $(CURDIR)/$(KYBER_DIR)/rootfs-x86_64-linux-gnu
+
+export PKG_CONFIG_PATH := $(ROOTFS)/lib/pkgconfig:$(ROOTFS)/lib/x86_64-linux-gnu/pkgconfig:$(ROOTFS)/lib64/pkgconfig
+export LD_LIBRARY_PATH := $(ROOTFS)/lib:$(ROOTFS)/lib/x86_64-linux-gnu:$(ROOTFS)/lib64
+
+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_DIR)/Cargo.toml" && echo 1 || echo 0), 0)
+ git submodule update --init --recursive $(KYBER_DIR)
+endif
+
+# 0002 is what the per-VM design rests on: a controller configured from its
+# command line. 0001 is a txproto fix. Each applies in the repo it was cut from.
+.PHONY: patch
+patch: submodule
+ git -C $(KYBER_DIR)/kysdk/kymedia/subprojects/txproto checkout --force -- .
+ git -C $(KYBER_DIR)/kysdk/kymedia/subprojects/txproto apply $(CURDIR)/patches/0001-*.patch
+ git -C $(KYBER_DIR)/kysdk/kyctl checkout --force -- .
+ git -C $(KYBER_DIR)/kysdk/kyctl apply $(CURDIR)/patches/0002-*.patch
+
+# --- build ------------------------------------------------------------------
+# Long: FFmpeg and VLC are built from source. The SDK's own script does it, and
+# needs cargo-c and meson 1.10+ - see debian/control.
+.PHONY: sdk
+sdk: patch
+ cd $(KYBER_DIR) && ./build-linux.sh -o $(ROOTFS)
+
+# The adapters stand in for Kyber's own capture and input servers, and
+# kyber-qemu-server is what actually talks to QEMU.
+.PHONY: adapters
+adapters: sdk
+ cd avservice && cargo build --release
+ cd inputservice && cargo build --release
+
+# TX_RPATH empty: an rpath here would be the build tree, and the wrapper
+# already puts the package prefix on LD_LIBRARY_PATH.
+.PHONY: server
+server: sdk
+ $(MAKE) -f kyber-qemu-server.mk TX_RPATH= \
+ TX_INC=$(ROOTFS)/include TX_LIB=$(ROOTFS)/lib/x86_64-linux-gnu
+
+.PHONY: build
+build: adapters server
+
+# --- packaging --------------------------------------------------------------
+# Only the libraries the four binaries actually resolve against: the build tree
+# also carries a client, a player and VLC plugins a node never loads.
+.PHONY: staging
+staging: build
+ rm -rf staging
+ mkdir -p staging/bin staging/lib
+ install -m 0755 $(ROOTFS)/bin/kycontroller staging/bin/
+ install -m 0755 kyber-qemu-server staging/bin/
+ install -m 0755 avservice/target/release/kqs-avservice staging/bin/
+ install -m 0755 inputservice/target/release/kqs-inputservice staging/bin/
+ ln -sf kqs-avservice staging/bin/kyavserver
+ ln -sf kqs-inputservice staging/bin/kynputserver
+ for b in staging/bin/kycontroller staging/bin/kyber-qemu-server \
+ staging/bin/kqs-avservice staging/bin/kqs-inputservice; do \
+ ldd $$b | awk "/=> \//{print \$$3}"; \
+ done | sort -u | grep "^$(ROOTFS)" | xargs -r cp -aL -t staging/lib/
+
+.PHONY: builddir
+builddir:
+ rm -rf $(BUILDDIR)
+ $(MAKE) $(BUILDDIR)
+
+$(BUILDDIR): staging
+ rm -rf $@ $@.tmp
+ mkdir $@.tmp
+ cp -a staging debian Makefile kycontroller.wrapper $@.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]*/ staging/
+ rm -f kyber-qemu-server src/*.o
+ cd avservice && cargo clean
+ cd inputservice && cargo clean
+
+.PHONY: distclean
+distclean: clean
diff --git a/avservice/Cargo.lock b/avservice/Cargo.lock
new file mode 100644
index 0000000..441c68d
--- /dev/null
+++ b/avservice/Cargo.lock
@@ -0,0 +1,1125 @@
+# 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",
+]
+
+[[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",
+]
+
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "async-trait"
+version = "0.1.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "defmt"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
+dependencies = [
+ "bitflags 1.3.2",
+ "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 = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[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 = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
+dependencies = [
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[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-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[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 = "js-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "kqs-avservice"
+version = "0.1.0"
+dependencies = [
+ "env_logger",
+ "kyavservice-types",
+ "libc",
+ "libkypc",
+ "log",
+ "tokio",
+ "zbus",
+]
+
+[[package]]
+name = "kyavservice-types"
+version = "0.1.0"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "libkypc"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "log",
+ "rmp-serde",
+ "serde",
+ "thiserror 1.0.69",
+ "tokio",
+ "windows-sys",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[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 = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[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 = "ordered-stream"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[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 = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit",
+]
+
+[[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 = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.13.1",
+]
+
+[[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 = "rmp"
+version = "0.8.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "rmp-serde"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
+dependencies = [
+ "rmp",
+ "serde",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.13.1",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[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 = "serde_repr"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[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",
+]
+
+[[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 = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys",
+]
+
+[[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",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "tracing",
+ "windows-sys",
+]
+
+[[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 = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.13+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
+dependencies = [
+ "indexmap",
+ "toml_datetime",
+ "toml_parser",
+ "winnow",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "uds_windows"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
+dependencies = [
+ "memoffset",
+ "tempfile",
+ "windows-sys",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "uuid"
+version = "1.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
+dependencies = [
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
+dependencies = [
+ "unicode-ident",
+]
+
+[[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.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "zbus"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
+dependencies = [
+ "async-broadcast",
+ "async-recursion",
+ "async-trait",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-lite",
+ "hex",
+ "libc",
+ "ordered-stream",
+ "rustix",
+ "serde",
+ "serde_repr",
+ "tokio",
+ "tracing",
+ "uds_windows",
+ "uuid",
+ "windows-sys",
+ "winnow",
+ "zbus_macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+ "zbus_names",
+ "zvariant",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zbus_names"
+version = "4.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
+dependencies = [
+ "serde",
+ "winnow",
+ "zvariant",
+]
+
+[[package]]
+name = "zcheapstr"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "zvariant"
+version = "5.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "winnow",
+ "zcheapstr",
+ "zvariant_derive",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "5.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b84ebb462416c27cdb97f2e7f5f0ccc844da1fe2ecc7121e1b690b41318bf42"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 3.0.3",
+ "winnow",
+]
diff --git a/avservice/Cargo.toml b/avservice/Cargo.toml
new file mode 100644
index 0000000..d65503f
--- /dev/null
+++ b/avservice/Cargo.toml
@@ -0,0 +1,24 @@
+# Thin adapter that lets kycontroller drive kyber-qemu-server.
+#
+# Written in Rust purely so it can link the real libkypc rather than
+# reimplement its MessagePack IPC. It owns no media code: on KymuxStartVideo
+# it launches the C server with the kymux URI the controller assigned.
+
+[package]
+name = "kqs-avservice"
+version = "0.1.0"
+edition = "2021"
+license = "AGPL-3.0-or-later"
+
+[[bin]]
+name = "kqs-avservice"
+path = "src/main.rs"
+
+[dependencies]
+libkypc = { path = "../kyber-desktop/kysdk/kyutil/libkypc" }
+kyavservice-types = { path = "../kyber-desktop/kysdk/kymedia/kyavservice-types" }
+tokio = { version = "1", features = ["full"] }
+env_logger = "0.11"
+log = "0.4"
+zbus = { version = "5", default-features = false, features = ["tokio"] }
+libc = "0.2"
diff --git a/avservice/src/main.rs b/avservice/src/main.rs
new file mode 100644
index 0000000..8abc8a4
--- /dev/null
+++ b/avservice/src/main.rs
@@ -0,0 +1,535 @@
+// kqs-avservice - lets Kyber's controller drive kyber-qemu-server.
+//
+// kycontroller does not talk to its AV server over a URI; it spawns a child,
+// passes a Unix socket address in KYBER_PARENT_ADDR, and issues MessagePack
+// commands over it. The kymux URI that video must be published to arrives
+// inside KymuxStartVideo, so nothing downstream can be wired up until we
+// answer that conversation.
+//
+// Rather than reimplement that protocol in C, this adapter links the real
+// libkypc and translates. It carries no media code at all: on KymuxStartVideo
+// it launches the C server with the URI it was handed.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// (AGPL because it links libkypc; the C server stays LGPL in its own process.)
+
+use std::os::unix::process::CommandExt;
+use std::process::{Child, Command as SysCommand, Stdio};
+
+use kyavservice_types::{Command, Display, Event};
+use log::{error, info, warn};
+
+/// Where to find the C server. Overridable so it can be run from a build tree.
+fn server_binary() -> String {
+ std::env::var("KQS_SERVER_BIN").unwrap_or_else(|_| "kyber-qemu-server".to_string())
+}
+
+/// QEMU's D-Bus display name, matching kyber-qemu-server's own default.
+fn qemu_bus_name() -> String {
+ std::env::var("KQS_BUS_NAME").unwrap_or_else(|_| "org.qemu".to_string())
+}
+
+/// One display, the guest's console.
+fn qemu_display(width: i32, height: i32) -> Display {
+ Display {
+ id: 1,
+ name: "QEMU".to_string(),
+ width,
+ height,
+ x: 0,
+ y: 0,
+ }
+}
+
+/// Ask QEMU how big the guest console is.
+///
+/// The controller needs a display to offer before it will start video, and the
+/// client sizes its window from it. Falling back to a plausible default is
+/// better than refusing to enumerate: the guest can resize later and the
+/// stream carries its own dimensions regardless.
+async fn enumerate_qemu_displays() -> Vec<Display> {
+ const FALLBACK: (i32, i32) = (1024, 768);
+
+ // The advertised display must be the size the stream actually is. The
+ // client sizes its window and its video surface from it and does not
+ // rescale to fit, so advertising the guest's true 720x400 while sending a
+ // 1280x800 stream simply crops the right and bottom off the picture.
+ //
+ // With a fixed encoder geometry that means advertising the encoder's size
+ // and letting boot modes arrive stretched. Only when the encoder follows
+ // the guest (--width 0) do the two coincide, and then the guest's own size
+ // is the right thing to report.
+ let fixed = match (stream_dim("KQS_OUT_W", 1280), stream_dim("KQS_OUT_H", 800)) {
+ _ if follow_guest() => None,
+ (Ok(w), Ok(h)) if w > 0 && h > 0 => Some((w, h)),
+ _ => None,
+ };
+
+ let (width, height) = match fixed {
+ Some((w, h)) => {
+ info!("advertising fixed stream geometry {w}x{h}");
+ (w, h)
+ }
+ None => match query_console_size().await {
+ Ok(wh) => {
+ info!("QEMU console is {}x{}", wh.0, wh.1);
+ wh
+ }
+ Err(e) => {
+ warn!(
+ "could not read QEMU console size ({e}); offering {}x{}",
+ FALLBACK.0, FALLBACK.1
+ );
+ FALLBACK
+ }
+ },
+ };
+
+ vec![qemu_display(width, height)]
+}
+
+/// Watch the guest console and report resolution changes to the controller.
+///
+/// This is Kyber's own mechanism for host-side resolution changes: the AV
+/// service pushes a fresh display list, the controller broadcasts it to every
+/// client and forwards it to the input services as a HostConfig. The client
+/// then re-letterboxes the video and remaps its cursor into the new space.
+///
+/// It matters even though the encoder never changes size. The stream is
+/// stretched into a fixed geometry, so a 4:3 guest arrives as a 16:10 picture;
+/// telling the client the display is 4:3 makes it squeeze that picture back to
+/// the right shape, and makes the coordinates it sends land where the user
+/// pointed. Without this the aspect ratio is wrong for every guest mode that
+/// does not happen to match --width/--height.
+///
+/// Only the enumerate handler runs this: the controller spawns one AV service
+/// for display enumeration and keeps it alive precisely so it can watch, and
+/// separate ones per stream that should not also report.
+///
+/// This only runs when the encoder follows the guest. A reported display size
+/// has to match the size of the stream, because the client sizes its window and
+/// video surface from it and crops rather than rescales; reporting a guest
+/// resize the fixed-geometry encoder did not follow is exactly that mismatch.
+fn spawn_display_watcher(mut sender: libkypc::EventSender<Event>, initial: (i32, i32)) {
+ tokio::spawn(async move {
+ let mut last = initial;
+ loop {
+ tokio::time::sleep(std::time::Duration::from_secs(1)).await;
+
+ let Ok(cur) = query_console_size().await else {
+ continue;
+ };
+ if cur == last || cur.0 <= 0 || cur.1 <= 0 {
+ continue;
+ }
+
+ info!("guest resized to {}x{}; updating display list", cur.0, cur.1);
+ last = cur;
+
+ if let Err(e) = sender
+ .send(Event::DisplayListUpdated {
+ displays: vec![qemu_display(cur.0, cur.1)],
+ })
+ .await
+ {
+ warn!("could not report display change: {e:?}");
+ return;
+ }
+ }
+ });
+}
+
+/// Whether to take QEMU's scanouts as dmabuf handles rather than pixels.
+///
+/// Only meaningful when QEMU runs a -gl display on a virgl-capable device;
+/// against a plain virtio-gpu the server waits for ScanoutDMABUF2 calls that
+/// never arrive. Off by default for that reason.
+fn dmabuf() -> bool {
+ matches!(std::env::var("KQS_DMABUF").as_deref(), Ok("1") | Ok("true"))
+}
+
+/// Fixed stream dimension from the environment, defaulting to the same value
+/// kyber-qemu-server uses. Zero means "follow the guest".
+fn stream_dim(var: &str, default: i32) -> Result<i32, std::num::ParseIntError> {
+ match std::env::var(var) {
+ Ok(v) => v.parse(),
+ Err(_) => Ok(default),
+ }
+}
+
+/// Whether the stream should follow the guest's resolution instead of running
+/// at a fixed size.
+///
+/// Kyber has handled dynamic host resolution since 0.10.0, and the controller
+/// never restarts video on a display change - so upstream changes the encoder's
+/// size inside the running stream and lets the client's decoder follow. We
+/// cannot do that in process (rebuilding the pipeline mid-session crashes in
+/// teardown), so the equivalent here is to relaunch kyber-qemu-server against
+/// the same kymux endpoint: the new encoder sends a fresh SPS and IDR, which is
+/// what the client actually has to cope with either way.
+///
+/// On by default: that re-negotiation is what a guest resolution change looks
+/// like in practice, and a stream that does not follow it shows the guest a
+/// letterboxed or clipped desktop for the rest of the session. Set
+/// `KQS_FOLLOW_GUEST=0` to pin the stream to a fixed size instead.
+fn follow_guest() -> bool {
+ !matches!(
+ std::env::var("KQS_FOLLOW_GUEST").as_deref(),
+ Ok("0") | Ok("false")
+ )
+}
+
+/// Connect to the QEMU that this instance is responsible for.
+///
+/// One QEMU per VM means one D-Bus per VM: with `-display dbus,p2p=on` each
+/// guest gets a private socket instead of a name on the shared session bus,
+/// where a second VM would simply collide on org.qemu. KQS_DBUS_ADDR carries
+/// that socket; without it this is the old single-VM behaviour.
+async fn qemu_connection() -> Result<zbus::Connection, zbus::Error> {
+ match std::env::var("KQS_DBUS_ADDR") {
+ Ok(addr) if !addr.is_empty() => {
+ zbus::connection::Builder::address(addr.as_str())?
+ .build()
+ .await
+ }
+ _ => zbus::Connection::session().await,
+ }
+}
+
+async fn query_console_size() -> Result<(i32, i32), Box<dyn std::error::Error>> {
+ let conn = qemu_connection().await?;
+ let proxy = zbus::Proxy::new(
+ &conn,
+ qemu_bus_name(),
+ "/org/qemu/Display1/Console_0",
+ "org.qemu.Display1.Console",
+ )
+ .await?;
+
+ let width: u32 = proxy.get_property("Width").await?;
+ let height: u32 = proxy.get_property("Height").await?;
+ Ok((width as i32, height as i32))
+}
+
+#[derive(Default)]
+struct VideoChild {
+ child: Option<Child>,
+ /// What the running child was started with, so it can be relaunched at a
+ /// new size without another command from the controller.
+ uri: String,
+ bitrate_kbps: u32,
+ /// Geometry the running encoder was built at, or None when it follows the
+ /// guest and settles on whatever the first frame is.
+ geometry: Option<(i32, i32)>,
+}
+
+/// The audio server, which is the same binary in its audio-only mode.
+///
+/// A process of its own rather than a mode of the video one: txproto's context
+/// is process-global (see the C server's sink.c), and Kyber gives audio its own
+/// kymux endpoint anyway, so the two share nothing.
+#[derive(Default)]
+struct AudioChild {
+ child: Option<Child>,
+}
+
+impl AudioChild {
+ fn stop(&mut self) {
+ if let Some(mut c) = self.child.take() {
+ info!("stopping audio server (pid {})", c.id());
+ let _ = c.kill();
+ let _ = c.wait();
+ }
+ }
+
+ fn start(&mut self, uri: &str) -> std::io::Result<()> {
+ self.stop();
+
+ let bin = server_binary();
+ info!("launching {bin} -> {uri} (audio)");
+
+ let mut cmd = SysCommand::new(&bin);
+ cmd.arg("--kymux-audio")
+ .arg(uri)
+ .arg("--bus-name")
+ .arg(qemu_bus_name())
+ .stdout(Stdio::inherit())
+ .stderr(Stdio::inherit());
+
+ if let Ok(addr) = std::env::var("KQS_DBUS_ADDR") {
+ if !addr.is_empty() {
+ cmd.arg("--address").arg(addr);
+ }
+ }
+
+ if let Ok(bps) = std::env::var("KQS_AUDIO_BITRATE") {
+ if !bps.is_empty() {
+ cmd.arg("--audio-bitrate").arg(bps);
+ }
+ }
+
+ // Same reasoning as the video child: die with the parent rather than
+ // outlive a controller that went away.
+ unsafe {
+ cmd.pre_exec(|| {
+ libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM);
+ Ok(())
+ });
+ }
+
+ self.child = Some(cmd.spawn()?);
+ Ok(())
+ }
+}
+
+impl VideoChild {
+ fn stop(&mut self) {
+ if let Some(mut c) = self.child.take() {
+ info!("stopping kyber-qemu-server (pid {})", c.id());
+ let _ = c.kill();
+ let _ = c.wait();
+ }
+ }
+
+ fn start(&mut self, uri: &str, bitrate_kbps: u32) -> std::io::Result<()> {
+ self.uri = uri.to_string();
+ self.bitrate_kbps = bitrate_kbps;
+ self.spawn()
+ }
+
+ fn spawn(&mut self) -> std::io::Result<()> {
+ self.stop();
+
+ // Follow-the-guest starts unpinned and lets the first frame decide;
+ // every relaunch after that pins the size we resized to, so the encoder
+ // is built at it rather than at whatever frame arrives first.
+ let (w, h) = match (follow_guest(), self.geometry) {
+ (true, None) => (0, 0),
+ (true, Some(g)) => g,
+ (false, _) => (
+ stream_dim("KQS_OUT_W", 1280).unwrap_or(1280),
+ stream_dim("KQS_OUT_H", 800).unwrap_or(800),
+ ),
+ };
+
+ let bin = server_binary();
+ info!(
+ "launching {bin} -> {} at {} kbps ({})",
+ self.uri,
+ self.bitrate_kbps,
+ if w > 0 { format!("{w}x{h}") } else { "follow guest".into() }
+ );
+
+ let mut cmd = SysCommand::new(&bin);
+ cmd.arg("--kymux")
+ .arg(&self.uri)
+ .arg("--bitrate")
+ .arg(self.bitrate_kbps.to_string())
+ // Same geometry we advertised, so the client's coordinate space
+ // and the encoder's output agree.
+ .arg("--width")
+ .arg(w.to_string())
+ .arg("--height")
+ .arg(h.to_string())
+ .arg("--bus-name")
+ .arg(qemu_bus_name())
+ .stdout(Stdio::inherit())
+ .stderr(Stdio::inherit());
+
+ // Zero copy. Only usable when QEMU was started with a -gl display and a
+ // virgl-capable device, because that is what makes it hand scanouts
+ // over as dmabuf handles instead of pixels; against a plain virtio-gpu
+ // the server would sit waiting for ScanoutDMABUF2 calls that never
+ // come. It also switches the encoder to VAAPI, so it needs a render
+ // node that can encode H.264.
+ if let Ok(addr) = std::env::var("KQS_DBUS_ADDR") {
+ if !addr.is_empty() {
+ cmd.arg("--address").arg(addr);
+ }
+ }
+
+ if dmabuf() {
+ cmd.arg("--dmabuf");
+ if let Ok(node) = std::env::var("KQS_RENDER_NODE") {
+ cmd.arg("--render-node").arg(node);
+ }
+ }
+
+ // stop() covers the exits we choose to make. It does not cover the
+ // controller terminating us, which is how an AV service usually ends -
+ // and a kyber-qemu-server that outlives its parent is not idle: it keeps
+ // a D-Bus listener on the console and keeps encoding into a kymux
+ // endpoint nobody reads. Left alone they accumulate one per resolution
+ // change, all logging their own geometry into the same file, which is a
+ // good way to misread a log.
+ //
+ // PDEATHSIG has the kernel do it instead, and unlike a signal handler it
+ // still works if we are killed outright.
+ unsafe {
+ cmd.pre_exec(|| {
+ if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 {
+ return Err(std::io::Error::last_os_error());
+ }
+ Ok(())
+ });
+ }
+
+ let child = cmd.spawn()?;
+
+ info!("kyber-qemu-server running as pid {}", child.id());
+ self.child = Some(child);
+ Ok(())
+ }
+}
+
+/// Relaunch the video server whenever the guest changes resolution.
+///
+/// Runs in the AV service that owns the stream. The one handling enumeration
+/// reports the same change as a DisplayListUpdated, so the client learns the
+/// new size and the new picture arrive together - which is the pairing the
+/// client actually requires.
+fn spawn_stream_follower(video: std::sync::Arc<tokio::sync::Mutex<VideoChild>>) {
+ tokio::spawn(async move {
+ let mut last: Option<(i32, i32)> = None;
+ loop {
+ tokio::time::sleep(std::time::Duration::from_secs(1)).await;
+
+ let Ok(cur) = query_console_size().await else {
+ continue;
+ };
+ if cur.0 <= 0 || cur.1 <= 0 {
+ continue;
+ }
+ if last.is_none() {
+ last = Some(cur);
+ continue;
+ }
+ if last == Some(cur) {
+ continue;
+ }
+ last = Some(cur);
+
+ let mut v = video.lock().await;
+ if v.child.is_none() {
+ continue;
+ }
+
+ // Relaunching our own child against the same kymux endpoint does
+ // not work: the client has already latched the first producer's
+ // stream and ignores the replacement, so the picture goes black.
+ //
+ // Exit instead. The controller notices the AV service stopped and
+ // drives a real restart, which is the only way the client rebuilds
+ // its decode pipeline - Kyber's README calls restarting the video
+ // server on a topology change simpler than reconfiguring live, and
+ // this is that restart done through the controller rather than
+ // behind its back.
+ info!("guest now {}x{}; exiting so the controller restarts video", cur.0, cur.1);
+ v.stop();
+ std::process::exit(0);
+ }
+ });
+}
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+ env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
+
+ info!("kqs-avservice starting; server binary = {}", server_binary());
+
+ let mut worker = libkypc::process::connect_to_commander::<Command, Event>().await?;
+ let (mut receiver, sender) = worker.get_sender_receiver()?;
+
+ let video = std::sync::Arc::new(tokio::sync::Mutex::new(VideoChild::default()));
+ let audio = std::sync::Arc::new(tokio::sync::Mutex::new(AudioChild::default()));
+ let mut watching = false;
+ let mut following = false;
+
+ while let Ok((cmd_id, cmd)) = receiver.recv().await {
+ match cmd {
+ Command::EnumerateDisplays => {
+ let displays = enumerate_qemu_displays().await;
+ let advertised = displays
+ .first()
+ .map(|d| (d.width, d.height))
+ .unwrap_or((0, 0));
+
+ receiver
+ .accept(cmd_id, Some(Event::DisplayList { displays }))
+ .await?;
+
+ if !watching && follow_guest() {
+ spawn_display_watcher(sender.clone(), advertised);
+ watching = true;
+ }
+ }
+
+ Command::KymuxStartVideo {
+ uri,
+ bitrate,
+ display_id,
+ ..
+ } => {
+ info!("KymuxStartVideo for display {display_id}: {uri}");
+
+ // Kyber speaks bits per second; our server takes kbps.
+ let started = video.lock().await.start(&uri, (bitrate / 1000).max(1));
+ match started {
+ Ok(()) => {
+ if !following && follow_guest() {
+ spawn_stream_follower(video.clone());
+ following = true;
+ }
+ receiver.accept(cmd_id, None).await?
+ }
+ Err(e) => {
+ error!("could not launch video server: {e}");
+ receiver.reject(cmd_id, None).await?;
+ }
+ }
+ }
+
+ // Only reached when the client asked for audio and the VM has a
+ // -audiodev dbus to capture from; otherwise the server exits and
+ // says why.
+ Command::KymuxStartAudio { uri } => {
+ info!("KymuxStartAudio: {uri}");
+ match audio.lock().await.start(&uri) {
+ Ok(()) => receiver.accept(cmd_id, None).await?,
+ Err(e) => {
+ error!("could not launch audio server: {e}");
+ receiver.reject(cmd_id, None).await?;
+ }
+ }
+ }
+
+ Command::VideoSetBitrate { bitrate } => {
+ warn!("bitrate change to {bitrate} ignored (no runtime control yet)");
+ receiver.accept(cmd_id, None).await?;
+ }
+
+ Command::VideoForceIdr => {
+ warn!("force-IDR ignored (no runtime control yet)");
+ receiver.accept(cmd_id, None).await?;
+ }
+
+ Command::Stop => {
+ info!("Stop");
+ video.lock().await.stop();
+ audio.lock().await.stop();
+ receiver.accept(cmd_id, None).await?;
+ break;
+ }
+
+ other => {
+ warn!("unhandled command: {other:?}");
+ receiver.reject(cmd_id, None).await?;
+ }
+ }
+ }
+
+ video.lock().await.stop();
+ audio.lock().await.stop();
+ info!("kqs-avservice exiting");
+ Ok(())
+}
diff --git a/debian/changelog b/debian/changelog
new file mode 100644
index 0000000..a2a0eac
--- /dev/null
+++ b/debian/changelog
@@ -0,0 +1,5 @@
+pve-qemu-kyber (0.1.0) trixie; urgency=medium
+
+ * Initial release: Kyber console controller and the QEMU adapters.
+
+ -- Proxmox Support Team <support@proxmox.com> Tue, 18 Aug 2026 19:00:00 +0200
diff --git a/debian/control b/debian/control
new file mode 100644
index 0000000..8b9a762
--- /dev/null
+++ b/debian/control
@@ -0,0 +1,49 @@
+Source: pve-qemu-kyber
+Section: admin
+Priority: optional
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Uploaders: Alexandre Derumier <aderumier@groupe-cyllene.com>
+Build-Depends: debhelper-compat (= 13),
+ build-essential,
+ clang,
+ cmake,
+ git,
+ libclang-dev,
+ libdrm-dev,
+ libevdev-dev,
+ libgbm-dev,
+ libinput-dev,
+ liblcms2-dev,
+ liblua5.4-dev,
+ libpulse-dev,
+ libssl-dev,
+ libudev-dev,
+ libvulkan-dev,
+ libwayland-dev,
+ libxcb-shape0-dev,
+ libxcb-xfixes0-dev,
+ libxkbcommon-dev,
+ nasm,
+ ninja-build,
+ pkgconf,
+ python3-venv,
+ wayland-protocols,
+Standards-Version: 4.7.0.0
+
+Package: pve-qemu-kyber
+Architecture: any
+Depends: ${misc:Depends},
+ ${shlibs:Depends},
+Recommends: pve-kyberproxy,
+Description: Kyber console controller and QEMU adapters
+ The per-VM half of the Kyber console: a controller, and the two adapters that
+ stand in for Kyber own capture and input servers so that a QEMU guest is
+ streamed instead of a physical screen.
+ .
+ The controller is started per VM by qemu-server, listens on a unix socket for
+ its control plane, and is reached from a browser only through pveproxy and
+ pvekyberproxy. It spawns the adapters, which drive QEMU over its D-Bus
+ display.
+ .
+ Everything lives under /usr/lib/pve-qemu-kyber, including a bundled FFmpeg: the
+ Kyber SDK pins versions of its own and must not shadow the system ones.
diff --git a/debian/copyright b/debian/copyright
new file mode 100644
index 0000000..82f4df4
--- /dev/null
+++ b/debian/copyright
@@ -0,0 +1,20 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+Upstream-Name: pve-qemu-kyber
+
+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..0c8d04a
--- /dev/null
+++ b/debian/install
@@ -0,0 +1,2 @@
+staging/bin/* usr/lib/pve-qemu-kyber/bin/
+staging/lib/* usr/lib/pve-qemu-kyber/lib/
diff --git a/debian/rules b/debian/rules
new file mode 100755
index 0000000..e9aed14
--- /dev/null
+++ b/debian/rules
@@ -0,0 +1,29 @@
+#!/usr/bin/make -f
+
+include /usr/share/dpkg/architecture.mk
+include /usr/share/dpkg/pkg-info.mk
+
+export DEB_BUILD_MAINT_OPTIONS = hardening=-all
+
+%:
+ dh $@
+
+override_dh_update_autotools_config:
+
+# The SDK, the adapters and kyber-qemu-server are all built by the top-level
+# Makefile before dpkg-buildpackage is called, which is also what stages them.
+override_dh_auto_build:
+override_dh_auto_test:
+override_dh_auto_clean:
+
+override_dh_install:
+ dh_install
+ install -D -m 0755 kycontroller.wrapper debian/pve-qemu-kyber/usr/bin/kycontroller
+
+# The bundled libraries are the SDK own pinned builds and are not meant to
+# satisfy anything outside this prefix.
+override_dh_shlibdeps:
+ dh_shlibdeps -l/usr/lib/pve-qemu-kyber/lib -Xusr/lib/pve-qemu-kyber/lib
+
+override_dh_strip:
+override_dh_dwz:
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/inputservice/Cargo.lock b/inputservice/Cargo.lock
new file mode 100644
index 0000000..ed6c81c
--- /dev/null
+++ b/inputservice/Cargo.lock
@@ -0,0 +1,2063 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "android_log-sys"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
+
+[[package]]
+name = "android_logger"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
+dependencies = [
+ "android_log-sys",
+ "env_filter 0.1.4",
+ "log",
+]
+
+[[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 = "arraydeque"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0ffd3d69bd89910509a5d31d1f1353f38ccffdd116dd0099bbd6627f7bd8ad8"
+
+[[package]]
+name = "arrayvec"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd9fd44efafa8690358b7408d253adf110036b88f55672a933f01d616ad9b1b9"
+dependencies = [
+ "nodrop",
+]
+
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "async-trait"
+version = "0.1.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[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 = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "defmt"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
+dependencies = [
+ "bitflags 1.3.2",
+ "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 = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
+[[package]]
+name = "enum-iterator"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016"
+dependencies = [
+ "enum-iterator-derive",
+]
+
+[[package]]
+name = "enum-iterator-derive"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "env_filter"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
+dependencies = [
+ "log",
+ "regex",
+]
+
+[[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 2.0.0",
+ "jiff",
+ "log",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[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 = "evdev-rs"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d28ab5638ed883532ae91b8f0e8b5ffa6e7296c0127855d6f8f9c0a1f468889a"
+dependencies = [
+ "bitflags 2.13.1",
+ "evdev-sys",
+ "libc",
+ "log",
+]
+
+[[package]]
+name = "evdev-sys"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdcf0d489f4d9a80ac2b3b35b92fdd8fcf68d33bb67f947afe5cd36e482de576"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
+dependencies = [
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "hermit-abi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "icu_collections"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+
+[[package]]
+name = "icu_properties"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
+
+[[package]]
+name = "icu_provider"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "input"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fbdc09524a91f9cacd26f16734ff63d7dc650daffadd2b6f84d17a285bd875a9"
+dependencies = [
+ "bitflags 2.13.1",
+ "input-sys",
+ "libc",
+ "log",
+ "udev",
+]
+
+[[package]]
+name = "input-linux-sys"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c7ef95c35c8ef8d114f5e197a5ac9554dc4afdd19ae78ae1fd0fc0944cb1340"
+dependencies = [
+ "libc",
+ "nix",
+]
+
+[[package]]
+name = "input-sys"
+version = "1.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36eee07d8e02bd95bf52b2e642cf13d33701b94c6e4b04fbf1d1fb07e9cb19e7"
+
+[[package]]
+name = "io-kit-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
+dependencies = [
+ "core-foundation-sys",
+ "mach2",
+]
+
+[[package]]
+name = "io-lifetimes"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2"
+dependencies = [
+ "hermit-abi",
+ "libc",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[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 = "js-sys"
+version = "0.3.77"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "keycode"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b07873c3182aec8a0eb1a5a4e7b197d42e9d167ba78497a6ee932a82d94673ed"
+dependencies = [
+ "arraydeque",
+ "arrayvec",
+ "bitflags 1.3.2",
+ "keycode_macro",
+]
+
+[[package]]
+name = "keycode_macro"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e521ea802f5b3c7194e169d75cab431b0ff08d022f2b6047b08754b4988b89df"
+dependencies = [
+ "anyhow",
+ "heck",
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "kqs-inputservice"
+version = "0.1.0"
+dependencies = [
+ "env_logger",
+ "kynput",
+ "kynputservice-types",
+ "libkypc",
+ "log",
+ "tokio",
+ "zbus",
+]
+
+[[package]]
+name = "kycom"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "bytes",
+ "kymux-types",
+ "log",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "kymux-types"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "byteorder",
+ "bytes",
+ "kymux-util",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "kymux-util"
+version = "0.1.0"
+dependencies = [
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "kynput"
+version = "0.1.0"
+dependencies = [
+ "android_logger",
+ "async-trait",
+ "base64",
+ "bytes",
+ "core-foundation",
+ "enum-iterator",
+ "env_logger",
+ "evdev-rs",
+ "input",
+ "input-linux-sys",
+ "io-kit-sys",
+ "js-sys",
+ "keycode",
+ "kycom",
+ "libc",
+ "log",
+ "mio",
+ "png",
+ "raw-window-handle",
+ "regex",
+ "thiserror 2.0.20",
+ "tokio",
+ "users",
+ "vigem-client",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "windows-sys 0.52.0",
+ "x11",
+ "xcb",
+]
+
+[[package]]
+name = "kynputservice-types"
+version = "0.1.0"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "libkypc"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "log",
+ "rmp-serde",
+ "serde",
+ "thiserror 1.0.69",
+ "tokio",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "libudev-sys"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
+dependencies = [
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "mach2"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "memoffset"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "nix"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
+dependencies = [
+ "bitflags 1.3.2",
+ "cfg-if",
+ "libc",
+ "memoffset 0.7.1",
+ "pin-utils",
+]
+
+[[package]]
+name = "nodrop"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[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 = "ordered-stream"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
+
+[[package]]
+name = "png"
+version = "0.17.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
+dependencies = [
+ "bitflags 1.3.2",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
+[[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 = "potential_utf"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit",
+]
+
+[[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 = "quick-xml"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "raw-window-handle"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9"
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.13.1",
+]
+
+[[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 = "rmp"
+version = "0.8.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "rmp-serde"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
+dependencies = [
+ "rmp",
+ "serde",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.13.1",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[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 = "serde_repr"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[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 = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[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 = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[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 = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[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 = "tinystr"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "tracing",
+ "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 = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.13+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
+dependencies = [
+ "indexmap",
+ "toml_datetime",
+ "toml_parser",
+ "winnow",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "udev"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af4e37e9ea4401fc841ff54b9ddfc9be1079b1e89434c1a6a865dd68980f7e9f"
+dependencies = [
+ "io-lifetimes",
+ "libc",
+ "libudev-sys",
+ "pkg-config",
+]
+
+[[package]]
+name = "uds_windows"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
+dependencies = [
+ "memoffset 0.9.1",
+ "tempfile",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "users"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24cc0f6d6f267b73e5a2cadf007ba8f9bc39c6a6f9666f8cf25ea809a153b032"
+dependencies = [
+ "libc",
+ "log",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "uuid"
+version = "1.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
+dependencies = [
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "vigem-client"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b857e6f99efe1e1eb1e4dfb035de8ae7ec8ec56bd1928edcbd7c6e4427634d52"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
+dependencies = [
+ "bumpalo",
+ "log",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.50"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "once_cell",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.77"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[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.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[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.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[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.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[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.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[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.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[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.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[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.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[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.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "writeable"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+
+[[package]]
+name = "x11"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e"
+dependencies = [
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "xcb"
+version = "1.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566"
+dependencies = [
+ "bitflags 2.13.1",
+ "libc",
+ "quick-xml",
+ "x11",
+]
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zbus"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
+dependencies = [
+ "async-broadcast",
+ "async-recursion",
+ "async-trait",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-lite",
+ "hex",
+ "libc",
+ "ordered-stream",
+ "rustix",
+ "serde",
+ "serde_repr",
+ "tokio",
+ "tracing",
+ "uds_windows",
+ "uuid",
+ "windows-sys 0.61.2",
+ "winnow",
+ "zbus_macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+ "zbus_names",
+ "zvariant",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zbus_names"
+version = "4.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
+dependencies = [
+ "serde",
+ "winnow",
+ "zvariant",
+]
+
+[[package]]
+name = "zcheapstr"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "zvariant"
+version = "5.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "winnow",
+ "zcheapstr",
+ "zvariant_derive",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "5.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b84ebb462416c27cdb97f2e7f5f0ccc844da1fe2ecc7121e1b690b41318bf42"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 3.0.3",
+ "winnow",
+]
diff --git a/inputservice/Cargo.toml b/inputservice/Cargo.toml
new file mode 100644
index 0000000..b069f1b
--- /dev/null
+++ b/inputservice/Cargo.toml
@@ -0,0 +1,30 @@
+# Routes Kyber client input into the QEMU guest.
+#
+# Kyber's own kynputserver injects into the host OS; there is no "guest"
+# InputTarget. So this replaces it: same IPC, same kymux stream decoding
+# (via kynput's Rust API), but the decoded events go to QEMU's D-Bus
+# Keyboard/Mouse interfaces instead of to /dev/uinput.
+
+[package]
+name = "kqs-inputservice"
+version = "0.1.0"
+edition = "2021"
+license = "AGPL-3.0-or-later"
+
+[[bin]]
+name = "kqs-inputservice"
+path = "src/main.rs"
+
+[dependencies]
+libkypc = { path = "../kyber-desktop/kysdk/kyutil/libkypc" }
+kynputservice-types = { path = "../kyber-desktop/kysdk/kynput/kynputservice-types" }
+kynput = { path = "../kyber-desktop/kysdk/kynput/kynput" }
+tokio = { version = "1", features = ["full"] }
+zbus = { version = "5", default-features = false, features = ["tokio"] }
+env_logger = "0.11"
+log = "0.4"
+
+# kynput depends on kycom (kymux's component library), which is not published.
+# It lives in the kymux workspace inside kysdk.
+[patch.crates-io]
+kycom = { path = "../kyber-desktop/kysdk/kymux/kycom" }
diff --git a/inputservice/src/clipboard.rs b/inputservice/src/clipboard.rs
new file mode 100644
index 0000000..2c92bf4
--- /dev/null
+++ b/inputservice/src/clipboard.rs
@@ -0,0 +1,318 @@
+// Bridges QEMU's clipboard onto Kyber's.
+//
+// Kyber ships a host-side clipboard already, but it drives the X11 or Wayland
+// selection of the machine the stream comes from - see kynput's linux backend.
+// A hypervisor node has no desktop and no selection, so the guest's clipboard
+// is reached the same way its display is: over D-Bus, through the vdagent
+// channel QEMU exposes as org.qemu.Display1.Clipboard. That channel only
+// exists when the VM was started with 'clipboard=vnc', and only carries
+// anything when a vdagent is running inside the guest.
+//
+// The interface is symmetric: QEMU calls the peer as often as the peer calls
+// QEMU, so this both proxies to it and exports an object of the same name for
+// it to call back on.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::sync::{Arc, Mutex, Weak};
+use std::time::Duration;
+
+use kynput::types::{ClipboardData, ClipboardFormat};
+use kynput::{ClipboardEvent, InputConsumer, InputPacket, InputTarget, Payload};
+use log::{debug, warn};
+use tokio::sync::oneshot;
+
+/// The selection this bridges. QEMU also offers Primary and Secondary, which
+/// are X11 notions with no counterpart in Kyber's protocol or a browser's.
+const SELECTION_CLIPBOARD: u32 = 0;
+
+/// How long a guest paste waits for the client to hand its clipboard over.
+///
+/// The guest is blocked on this D-Bus call, so it cannot be generous: a client
+/// that has closed its tab would otherwise hang the paste rather than fail it.
+const REQUEST_TIMEOUT: Duration = Duration::from_secs(3);
+
+/// The mime this offers and accepts for text.
+///
+/// QEMU's vdagent normalises to UTF-8 and the Kyber payload is a Rust String,
+/// so nothing here has to transcode.
+const MIME_TEXT: &str = "text/plain;charset=utf-8";
+const MIME_HTML: &str = "text/html";
+
+fn format_from_mime(mime: &str) -> Option<ClipboardFormat> {
+ // Matched on the prefix: a peer may or may not put the charset on, and
+ // vdagent is not consistent about it between guests.
+ let base = mime.split(';').next().unwrap_or(mime).trim();
+ match base {
+ "text/plain" | "text/plain;charset=utf-8" | "UTF8_STRING" | "STRING" | "TEXT" => {
+ Some(ClipboardFormat::Text)
+ }
+ "text/html" => Some(ClipboardFormat::Html),
+ _ => None,
+ }
+}
+
+fn mime_for(format: ClipboardFormat) -> &'static str {
+ match format {
+ ClipboardFormat::Text => MIME_TEXT,
+ ClipboardFormat::Html => MIME_HTML,
+ // ClipboardFormat is non_exhaustive upstream; text is the safe answer
+ // for anything added later, since every peer understands it.
+ _ => MIME_TEXT,
+ }
+}
+
+#[derive(Default)]
+struct State {
+ /// Bumped for every Grab, which is how QEMU orders competing owners.
+ serial: u32,
+ /// A guest paste waiting on the client. Only one at a time: the guest is
+ /// blocked on it, so a second cannot arrive before this one answers.
+ pending: Option<oneshot::Sender<Option<ClipboardData>>>,
+}
+
+pub struct Bridge {
+ /// Calls into QEMU.
+ proxy: zbus::Proxy<'static>,
+ /// Sends toward the client. Weak, because the stream owns the session;
+ /// None until one is up.
+ stream: Mutex<Option<Weak<dyn InputConsumer>>>,
+ state: Mutex<State>,
+}
+
+impl Bridge {
+ pub async fn new(
+ conn: &zbus::Connection,
+ bus_name: String,
+ ) -> Result<Arc<Self>, Box<dyn std::error::Error>> {
+ let proxy = zbus::Proxy::new(
+ conn,
+ bus_name,
+ "/org/qemu/Display1/Clipboard",
+ "org.qemu.Display1.Clipboard",
+ )
+ .await?;
+
+ let bridge = Arc::new(Self {
+ proxy,
+ stream: Mutex::new(None),
+ state: Mutex::new(State::default()),
+ });
+
+ // Exported before Register, or QEMU may call back before there is an
+ // object to receive it.
+ conn.object_server()
+ .at("/org/qemu/Display1/Clipboard", Listener { bridge: bridge.clone() })
+ .await?;
+
+ bridge.proxy.call_method("Register", &()).await?;
+
+ Ok(bridge)
+ }
+
+ /// Where to send what the guest does. Set once the kymux stream is up.
+ pub fn attach(&self, stream: Weak<dyn InputConsumer>) {
+ *self.stream.lock().unwrap() = Some(stream);
+ }
+
+ fn send(&self, event: ClipboardEvent) {
+ let stream = self.stream.lock().unwrap().as_ref().and_then(Weak::upgrade);
+ let Some(stream) = stream else {
+ debug!("clipboard event with no client attached, dropped");
+ return;
+ };
+ // Target::Client: this is the half travelling away from the host.
+ let pkt = InputPacket::new(InputTarget::Client, Payload::Clipboard(event));
+ if let Err(e) = stream.consume(pkt) {
+ warn!("could not send a clipboard event to the client: {e:?}");
+ }
+ }
+
+ /// Something the client sent us.
+ pub async fn on_client_event(&self, event: ClipboardEvent) {
+ match event {
+ // The client copied. Tell QEMU we hold it now; the guest asks for
+ // the bytes later, if it ever pastes.
+ ClipboardEvent::Change { formats } => {
+ let mimes: Vec<&str> = formats.iter().copied().map(mime_for).collect();
+ let serial = {
+ let mut state = self.state.lock().unwrap();
+ state.serial = state.serial.wrapping_add(1);
+ state.serial
+ };
+ debug!("client grabbed the clipboard: {mimes:?}");
+ if let Err(e) = self
+ .proxy
+ .call_method("Grab", &(SELECTION_CLIPBOARD, serial, mimes))
+ .await
+ {
+ warn!("QEMU refused the clipboard grab: {e}");
+ }
+ }
+
+ // An answer to a request the guest is blocked on.
+ ClipboardEvent::Data(data) => self.complete_pending(Some(data)),
+ ClipboardEvent::DataUnavailable { .. } => self.complete_pending(None),
+
+ // The client wants what the guest holds.
+ ClipboardEvent::DataRequest { format } => {
+ match self.request_from_guest(format).await {
+ Some(data) => self.send(ClipboardEvent::Data(data)),
+ None => self.send(ClipboardEvent::DataUnavailable { format }),
+ }
+ }
+
+ other => debug!("unhandled clipboard event from the client: {other:?}"),
+ }
+ }
+
+ fn complete_pending(&self, data: Option<ClipboardData>) {
+ let pending = self.state.lock().unwrap().pending.take();
+ match pending {
+ Some(tx) => {
+ let _ = tx.send(data);
+ }
+ // Late, or unsolicited. The guest has already been answered.
+ None => debug!("clipboard data with nothing waiting for it"),
+ }
+ }
+
+ async fn request_from_guest(&self, format: ClipboardFormat) -> Option<ClipboardData> {
+ let mimes = [mime_for(format)];
+ let reply: (String, Vec<u8>) = match self
+ .proxy
+ .call_method("Request", &(SELECTION_CLIPBOARD, &mimes[..]))
+ .await
+ .and_then(|m| m.body().deserialize())
+ {
+ Ok(reply) => reply,
+ Err(e) => {
+ debug!("the guest had no clipboard data: {e}");
+ return None;
+ }
+ };
+
+ let (mime, bytes) = reply;
+ let text = match String::from_utf8(bytes) {
+ Ok(text) => text,
+ Err(_) => {
+ warn!("the guest clipboard was not UTF-8, dropped");
+ return None;
+ }
+ };
+
+ // Answered in whatever the guest actually sent, not what was asked
+ // for: a vdagent may downgrade html to text.
+ let mut data = match format_from_mime(&mime).unwrap_or(format) {
+ ClipboardFormat::Html => ClipboardData::Html(text),
+ _ => ClipboardData::Text(text),
+ };
+
+ // Truncated rather than refused: a client that pasted half a huge
+ // selection is better served than one that pasted nothing.
+ if data.is_too_large() {
+ warn!("guest clipboard of {} bytes truncated", data.size());
+ data.truncate();
+ }
+
+ Some(data)
+ }
+
+ /// The guest copied.
+ async fn on_guest_grab(&self, selection: u32, mimes: Vec<String>) {
+ if selection != SELECTION_CLIPBOARD {
+ return;
+ }
+
+ let mut formats: Vec<ClipboardFormat> =
+ mimes.iter().filter_map(|m| format_from_mime(m)).collect();
+ formats.dedup();
+
+ if formats.is_empty() {
+ debug!("guest grabbed the clipboard with no format we carry: {mimes:?}");
+ return;
+ }
+
+ debug!("guest grabbed the clipboard: {formats:?}");
+ self.send(ClipboardEvent::Change { formats });
+ }
+
+ /// The guest is pasting and wants what the client holds.
+ async fn on_guest_request(&self, selection: u32, mimes: Vec<String>) -> Option<(String, Vec<u8>)> {
+ if selection != SELECTION_CLIPBOARD {
+ return None;
+ }
+
+ let format = mimes.iter().find_map(|m| format_from_mime(m))?;
+
+ let rx = {
+ let mut state = self.state.lock().unwrap();
+ // A request already waiting means the last one never answered.
+ // Dropping its sender fails it rather than leaving both stuck.
+ let (tx, rx) = oneshot::channel();
+ state.pending = Some(tx);
+ rx
+ };
+
+ self.send(ClipboardEvent::DataRequest { format });
+
+ let data = match tokio::time::timeout(REQUEST_TIMEOUT, rx).await {
+ Ok(Ok(Some(data))) => data,
+ // Refused, or the client went away.
+ Ok(_) => return None,
+ Err(_) => {
+ warn!("the client did not answer a clipboard request in time");
+ self.state.lock().unwrap().pending = None;
+ return None;
+ }
+ };
+
+ let (mime, text) = match data {
+ ClipboardData::Html(s) => (MIME_HTML, s),
+ ClipboardData::Text(s) => (MIME_TEXT, s),
+ other => {
+ debug!("unhandled clipboard payload from the client: {other:?}");
+ return None;
+ }
+ };
+
+ Some((mime.to_owned(), text.into_bytes()))
+ }
+}
+
+/// The object QEMU calls back on. Every method is one QEMU initiated.
+struct Listener {
+ bridge: Arc<Bridge>,
+}
+
+#[zbus::interface(name = "org.qemu.Display1.Clipboard")]
+impl Listener {
+ async fn register(&self) {
+ debug!("QEMU registered its side of the clipboard");
+ }
+
+ async fn unregister(&self) {
+ debug!("QEMU unregistered its side of the clipboard");
+ }
+
+ async fn grab(&self, selection: u32, _serial: u32, mimes: Vec<String>) {
+ self.bridge.on_guest_grab(selection, mimes).await;
+ }
+
+ async fn release(&self, selection: u32) {
+ if selection == SELECTION_CLIPBOARD {
+ debug!("guest released the clipboard");
+ }
+ }
+
+ async fn request(
+ &self,
+ selection: u32,
+ mimes: Vec<String>,
+ ) -> zbus::fdo::Result<(String, Vec<u8>)> {
+ self.bridge
+ .on_guest_request(selection, mimes)
+ .await
+ .ok_or_else(|| zbus::fdo::Error::Failed("no clipboard data".into()))
+ }
+}
diff --git a/inputservice/src/main.rs b/inputservice/src/main.rs
new file mode 100644
index 0000000..c217d7f
--- /dev/null
+++ b/inputservice/src/main.rs
@@ -0,0 +1,494 @@
+// kqs-inputservice - routes Kyber client input into the QEMU guest.
+//
+// Kyber's kynputserver injects into the host OS: InputTarget is only Client or
+// Host, and the Linux backend writes to /dev/uinput. Streaming a guest needs
+// the events to land inside the VM instead, so this stands in for it.
+//
+// It reuses kynput's own Rust API to receive and decode the kymux input
+// stream - so the wire format and packet parsing are Kyber's, not a
+// reimplementation - and then translates the decoded events onto QEMU's
+// org.qemu.Display1.Keyboard / .Mouse D-Bus interfaces.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::sync::{Arc, Mutex, Weak};
+
+use kynput::{
+ ClipboardEvent, InputConsumer, InputKymuxService, InputNetworkStream, InputNetworkStreamMsg,
+ InputNetworkStreamObserver, InputPacket, InputTarget, Payload,
+};
+use kynputservice_types::{Command, Event};
+use log::{debug, error, info, warn};
+use tokio::sync::mpsc;
+
+mod clipboard;
+
+/// Events, already decoded, on their way to QEMU.
+#[derive(Debug)]
+enum GuestInput {
+ Key { scancode: u16, pressed: bool },
+ Button { button: u32, pressed: bool },
+ AbsPosition { x: u32, y: u32 },
+ RelMotion { dx: i32, dy: i32 },
+}
+
+/// Consumer plugged into kynput's kymux stream.
+///
+/// Runs on kynput's own thread, so it only translates and forwards; talking to
+/// D-Bus happens on the async side.
+struct GuestConsumer {
+ tx: mpsc::UnboundedSender<GuestInput>,
+ /// Clipboard events, which go to the D-Bus bridge rather than to the
+ /// injector. None when the VM has no clipboard channel.
+ clip_tx: Option<mpsc::UnboundedSender<ClipboardEvent>>,
+ /// Guest geometry, for clamping absolute positions. Shared and refreshed,
+ /// because guests resize - Ubuntu leaves GRUB's 640x480 for its desktop
+ /// mode - and a stale size clamps the cursor to part of the screen.
+ size: Arc<Mutex<(u32, u32)>>,
+ /// The space the client sends coordinates in: the size of the display the
+ /// AV service advertised, which is not the guest's size while the stream
+ /// runs at a fixed geometry. Shared, because the controller pushes a new
+ /// display list (UpdateHostConfig) whenever the guest changes resolution.
+ space: Arc<Mutex<(u32, u32)>>,
+}
+
+impl InputConsumer for GuestConsumer {
+ fn consume(&self, pkt: InputPacket) -> kynput::Result<()> {
+ debug!("packet: target={:?} type={:?}", pkt.target, pkt.get_type());
+ let ev = match pkt.payload {
+ Payload::Keyboard(k) => Some(GuestInput::Key {
+ scancode: k.scancode,
+ pressed: k.pressed,
+ }),
+
+ Payload::MouseButton(b) => {
+ // QEMU button numbers follow its own input enum:
+ // 0 left, 1 middle, 2 right, 3 wheel-up, 4 wheel-down,
+ // 5 side, 6 extra.
+ use kynput::MouseButtonType::*;
+ let button = match b.button {
+ Left => 0,
+ Middle => 1,
+ Right => 2,
+ Side => 5,
+ Extra => 6,
+ };
+ Some(GuestInput::Button {
+ button,
+ pressed: b.pressed,
+ })
+ }
+
+ Payload::MousePosition(p) => {
+ let (gw, gh) = *self.size.lock().unwrap();
+ let (sw, sh) = *self.space.lock().unwrap();
+
+ // Map from the advertised display's space into the guest's.
+ let x = p.x.max(0) as u64 * gw.max(1) as u64 / sw.max(1) as u64;
+ let y = p.y.max(0) as u64 * gh.max(1) as u64 / sh.max(1) as u64;
+
+ Some(GuestInput::AbsPosition {
+ x: (x as u32).min(gw.saturating_sub(1)),
+ y: (y as u32).min(gh.saturating_sub(1)),
+ })
+ }
+
+ Payload::MouseMove(m) => Some(GuestInput::RelMotion {
+ dx: m.dx as i32,
+ dy: m.dy as i32,
+ }),
+
+ Payload::MouseWheel(w) => {
+ // QEMU has no wheel axis: it is a button press/release pair.
+ let button = if w.dy < 0.0 { 3 } else { 4 };
+ if w.dy != 0.0 {
+ let _ = self.tx.send(GuestInput::Button {
+ button,
+ pressed: true,
+ });
+ Some(GuestInput::Button {
+ button,
+ pressed: false,
+ })
+ } else {
+ None
+ }
+ }
+
+ // Not an injected event: it goes to the D-Bus bridge, which
+ // answers on its own thread because a guest paste blocks on it.
+ Payload::Clipboard(ev) => {
+ if let Some(clip_tx) = &self.clip_tx {
+ let _ = clip_tx.send(ev);
+ } else {
+ debug!("clipboard event with no bridge, dropped");
+ }
+ None
+ }
+
+ other => {
+ debug!("ignoring {:?}", other);
+ None
+ }
+ };
+
+ if let Some(ev) = ev {
+ debug!("-> guest: {ev:?}");
+ let _ = self.tx.send(ev);
+ }
+ Ok(())
+ }
+}
+
+/// kynput requires an observer for stream lifecycle messages.
+struct StreamObserver;
+
+impl InputNetworkStreamObserver for StreamObserver {
+ fn on_message(&self, msg: InputNetworkStreamMsg) {
+ info!("input stream: {msg:?}");
+ }
+}
+
+struct QemuInput {
+ keyboard: zbus::Proxy<'static>,
+ mouse: zbus::Proxy<'static>,
+ console: zbus::Proxy<'static>,
+}
+
+impl QemuInput {
+ async fn connect(
+ conn: &zbus::Connection,
+ bus_name: String,
+ ) -> Result<Self, Box<dyn std::error::Error>> {
+ let keyboard = zbus::Proxy::new(
+ conn,
+ bus_name.clone(),
+ "/org/qemu/Display1/Console_0",
+ "org.qemu.Display1.Keyboard",
+ )
+ .await?;
+
+ let bus_name_console = bus_name.clone();
+ let mouse = zbus::Proxy::new(
+ conn,
+ bus_name,
+ "/org/qemu/Display1/Console_0",
+ "org.qemu.Display1.Mouse",
+ )
+ .await?;
+
+ // Width/Height live on Console, not on Keyboard. Querying the wrong
+ // interface fails silently and leaves coordinates clamped to a
+ // fallback size, which shows up as a cursor that cannot reach the
+ // right or bottom of a larger guest.
+ let console = zbus::Proxy::new(
+ conn,
+ bus_name_console,
+ "/org/qemu/Display1/Console_0",
+ "org.qemu.Display1.Console",
+ )
+ .await?;
+
+ Ok(Self { keyboard, mouse, console })
+ }
+
+ async fn console_size(&self) -> Option<(u32, u32)> {
+ let w: u32 = self.console.get_property("Width").await.ok()?;
+ let h: u32 = self.console.get_property("Height").await.ok()?;
+ Some((w, h))
+ }
+
+ async fn apply(&self, ev: GuestInput) {
+ let r = match ev {
+ // kynput carries XT-style scancodes and QEMU's keycode is
+ // "xtkbd + special re-encoding of the high bit", so these line up
+ // without a translation table.
+ GuestInput::Key { scancode, pressed } => {
+ let m = if pressed { "Press" } else { "Release" };
+ self.keyboard.call_method(m, &(scancode as u32,)).await.map(|_| ())
+ }
+ GuestInput::Button { button, pressed } => {
+ let m = if pressed { "Press" } else { "Release" };
+ self.mouse.call_method(m, &(button,)).await.map(|_| ())
+ }
+ GuestInput::AbsPosition { x, y } => {
+ self.mouse.call_method("SetAbsPosition", &(x, y)).await.map(|_| ())
+ }
+ GuestInput::RelMotion { dx, dy } => {
+ self.mouse.call_method("RelMotion", &(dx, dy)).await.map(|_| ())
+ }
+ };
+
+ if let Err(e) = r {
+ warn!("QEMU input call failed: {e}");
+ }
+ }
+}
+
+/// Fixed stream dimension, matching kyber-qemu-server's --width/--height.
+/// Zero or unparseable means the encoder follows the guest instead.
+fn env_dim(var: &str, default: u32) -> Option<u32> {
+ let v = match std::env::var(var) {
+ Ok(v) => v.parse().ok()?,
+ Err(_) => default,
+ };
+ (v > 0).then_some(v)
+}
+
+/// Whether the VM has the vdagent channel QEMU's clipboard needs.
+///
+/// Set by qemu-server from the VM's own 'clipboard=' setting; see
+/// PVE::QemuServer::Kyber::write_env.
+fn clipboard_enabled() -> bool {
+ matches!(
+ std::env::var("KQS_CLIPBOARD").as_deref(),
+ Ok("1") | Ok("true") | Ok("yes")
+ )
+}
+
+fn qemu_bus_name() -> String {
+ std::env::var("KQS_BUS_NAME").unwrap_or_else(|_| "org.qemu".to_string())
+}
+
+/// Owns the kymux stream so it lives as long as the session.
+struct Session {
+ _stream: Arc<InputKymuxService>,
+ _consumer: Arc<GuestConsumer>,
+}
+
+fn start_stream(
+ uri: &str,
+ tx: mpsc::UnboundedSender<GuestInput>,
+ clip_tx: Option<mpsc::UnboundedSender<ClipboardEvent>>,
+ size: Arc<Mutex<(u32, u32)>>,
+ space: Arc<Mutex<(u32, u32)>>,
+ clipboard: Option<&Arc<clipboard::Bridge>>,
+) -> Result<Session, Box<dyn std::error::Error>> {
+ {
+ let s = *space.lock().unwrap();
+ info!("client coordinate space {}x{}", s.0, s.1);
+ }
+
+ let consumer = Arc::new(GuestConsumer {
+ tx,
+ clip_tx,
+ size,
+ space,
+ });
+
+ // Receive what the client sends toward the host - that is the stream we
+ // are standing in for.
+ let stream = Arc::new(InputKymuxService::new(
+ InputTarget::Host,
+ Arc::new(StreamObserver),
+ uri,
+ ));
+
+ let weak: Weak<dyn InputConsumer> = Arc::downgrade(&consumer) as Weak<dyn InputConsumer>;
+ stream.plug_consumer(weak)?;
+
+ // The bridge sends through the same stream, so it needs it now that there
+ // is one. Weak: the session owns it.
+ if let Some(bridge) = clipboard {
+ bridge.attach(Arc::downgrade(&stream) as Weak<dyn InputConsumer>);
+ }
+
+ let runner = stream.clone();
+ std::thread::spawn(move || {
+ if let Err(e) = runner.run() {
+ error!("input stream ended: {e:?}");
+ }
+ });
+
+ Ok(Session {
+ _stream: stream,
+ _consumer: consumer,
+ })
+}
+
+/// Connect to the QEMU that this instance is responsible for.
+///
+/// One QEMU per VM means one D-Bus per VM: with `-display dbus,p2p=on` each
+/// guest gets a private socket instead of a name on the shared session bus,
+/// where a second VM would simply collide on org.qemu. KQS_DBUS_ADDR carries
+/// that socket; without it this is the old single-VM behaviour.
+async fn qemu_connection() -> Result<zbus::Connection, zbus::Error> {
+ match std::env::var("KQS_DBUS_ADDR") {
+ Ok(addr) if !addr.is_empty() => {
+ zbus::connection::Builder::address(addr.as_str())?
+ .build()
+ .await
+ }
+ _ => zbus::Connection::session().await,
+ }
+}
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+ env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
+ info!("kqs-inputservice starting");
+
+ let conn = qemu_connection().await?;
+ let qemu = Arc::new(QemuInput::connect(&conn, qemu_bus_name()).await?);
+
+ // Told to us rather than probed. QEMU exports the clipboard interface for
+ // every dbus display and accepts a peer on it whether or not the VM has a
+ // vdagent channel, so registering successfully proves nothing: without
+ // 'clipboard=vnc' there is no second peer and every copy goes nowhere.
+ // qemu-server knows, and says so here.
+ let clipboard = if clipboard_enabled() {
+ match clipboard::Bridge::new(&conn, qemu_bus_name()).await {
+ Ok(bridge) => {
+ info!("clipboard bridged to the guest");
+ Some(bridge)
+ }
+ Err(e) => {
+ // QEMU takes one clipboard peer at a time, so this is also
+ // what a second console on one VM would see.
+ warn!("could not bridge the clipboard: {e}");
+ None
+ }
+ }
+ } else {
+ info!("no guest clipboard; set 'clipboard=vnc' on the VM to enable it");
+ None
+ };
+ let size = Arc::new(Mutex::new(qemu.console_size().await.unwrap_or((1024, 768))));
+ {
+ let s = *size.lock().unwrap();
+ info!("guest console {}x{}", s.0, s.1);
+ }
+
+ // Track resolution changes so absolute coordinates keep matching the guest.
+ {
+ let qemu = qemu.clone();
+ let size = size.clone();
+ tokio::spawn(async move {
+ let mut last = *size.lock().unwrap();
+ loop {
+ tokio::time::sleep(std::time::Duration::from_secs(1)).await;
+ if let Some(cur) = qemu.console_size().await {
+ if cur != last {
+ info!("guest resized to {}x{}", cur.0, cur.1);
+ *size.lock().unwrap() = cur;
+ last = cur;
+ }
+ }
+ }
+ });
+ }
+
+ // The client sends coordinates in the space of the display the AV service
+ // advertised, which is the stream's geometry - not the guest's, while the
+ // encoder runs at a fixed size. UpdateHostConfig moves it if that ever
+ // changes; the fallback covers an encoder that follows the guest.
+ // On by default, matching the AV service: the encoder follows the guest
+ // unless it is explicitly pinned, and the pointer has to be measured in
+ // whatever space the encoder ended up using.
+ let follow_guest = !matches!(
+ std::env::var("KQS_FOLLOW_GUEST").as_deref(),
+ Ok("0") | Ok("false")
+ );
+
+ let space = Arc::new(Mutex::new(
+ match (env_dim("KQS_OUT_W", 1280), env_dim("KQS_OUT_H", 800)) {
+ _ if follow_guest => *size.lock().unwrap(),
+ (Some(w), Some(h)) => (w, h),
+ _ => *size.lock().unwrap(),
+ },
+ ));
+
+ let (tx, mut rx) = mpsc::unbounded_channel::<GuestInput>();
+
+ // Clipboard events arrive on kynput's thread and are answered with D-Bus
+ // calls, so they are handed to the runtime the same way input is.
+ let clip_tx = clipboard.as_ref().map(|bridge| {
+ let (clip_tx, mut clip_rx) = mpsc::unbounded_channel::<ClipboardEvent>();
+ let bridge = bridge.clone();
+ tokio::spawn(async move {
+ while let Some(ev) = clip_rx.recv().await {
+ bridge.on_client_event(ev).await;
+ }
+ });
+ clip_tx
+ });
+
+ // Pump decoded events into QEMU.
+ let injector = qemu.clone();
+ tokio::spawn(async move {
+ while let Some(ev) = rx.recv().await {
+ injector.apply(ev).await;
+ }
+ });
+
+ let mut worker = libkypc::process::connect_to_commander::<Command, Event>().await?;
+ let (mut receiver, _sender) = worker.get_sender_receiver()?;
+
+ let mut session: Option<Session> = None;
+
+ while let Ok((cmd_id, cmd)) = receiver.recv().await {
+ match cmd {
+ Command::StartKymux { uri, .. } => {
+ info!("StartKymux: {uri}");
+ match start_stream(
+ &uri,
+ tx.clone(),
+ clip_tx.clone(),
+ size.clone(),
+ space.clone(),
+ clipboard.as_ref(),
+ ) {
+ Ok(s) => {
+ session = Some(s);
+ // The client only builds its clipboard handler when
+ // this says the host has one.
+ receiver
+ .accept(
+ cmd_id,
+ Some(Event::Connected {
+ clipboard: clipboard.is_some(),
+ }),
+ )
+ .await?;
+ }
+ Err(e) => {
+ error!("could not start input stream: {e}");
+ receiver.reject(cmd_id, None).await?;
+ }
+ }
+ }
+
+ // TCP transport is not used by the controller path we support.
+ Command::StartTcp { .. } => {
+ warn!("StartTcp unsupported");
+ receiver.reject(cmd_id, None).await?;
+ }
+
+ // The controller sends this when the AV service reports a display
+ // change, so it carries the space the client has just switched to
+ // sending coordinates in.
+ Command::UpdateHostConfig { host_config } => {
+ if let Some(d) = host_config.display_list.first() {
+ if d.width > 0 && d.height > 0 {
+ let new = (d.width as u32, d.height as u32);
+ info!("client coordinate space now {}x{}", new.0, new.1);
+ *space.lock().unwrap() = new;
+ }
+ }
+ receiver.accept(cmd_id, None).await?;
+ }
+
+ Command::Stop(reason) => {
+ info!("Stop ({reason})");
+ session = None;
+ receiver.accept(cmd_id, None).await?;
+ worker.stop().await?;
+ break;
+ }
+ }
+ }
+
+ drop(session);
+ info!("kqs-inputservice exiting");
+ Ok(())
+}
diff --git a/kyber-desktop b/kyber-desktop
new file mode 160000
index 0000000..6c75cc2
--- /dev/null
+++ b/kyber-desktop
@@ -0,0 +1 @@
+Subproject commit 6c75cc276e40ed9cca4e9dcabe3f7e4e1d83c44f
diff --git a/kyber-qemu-server.mk b/kyber-qemu-server.mk
new file mode 100644
index 0000000..761bca0
--- /dev/null
+++ b/kyber-qemu-server.mk
@@ -0,0 +1,56 @@
+# kyber-qemu-server
+#
+# libtxproto is not packaged anywhere yet, so point at a local build tree:
+# make TXPROTO=upstream/txproto
+# See patches/README.md for the two fixes that tree needs.
+
+comma := ,
+
+# Set by the top-level Makefile from the SDK it just built.
+TX_INC ?= kyber-desktop/rootfs-x86_64-linux-gnu/include
+TX_LIB ?= kyber-desktop/rootfs-x86_64-linux-gnu/lib/x86_64-linux-gnu
+
+PKGS := gio-2.0 gio-unix-2.0 glib-2.0 libavutil libavcodec libavfilter libswscale libdrm
+
+# Empty to build without one: the package sets LD_LIBRARY_PATH in its wrapper
+# and none of the other binaries carry an rpath either.
+TX_RPATH ?= $(abspath $(TX_LIB))
+
+CFLAGS ?= -O2 -g
+CFLAGS += -std=gnu11 -Wall -Wextra -Wno-unused-parameter \
+ -I$(TX_INC) $(shell pkg-config --cflags $(PKGS))
+LDFLAGS += -L$(TX_LIB) $(if $(TX_RPATH),-Wl$(comma)-rpath$(comma)$(TX_RPATH))
+LDLIBS += -ltxproto $(shell pkg-config --libs $(PKGS))
+
+SRCS := src/main.c src/listener.c src/surface.c src/sink.c src/dmabuf.c src/audio.c
+OBJS := $(SRCS:.c=.o)
+BIN := kyber-qemu-server
+
+TOOLS := tools/tx_inject_probe tools/dmabuf_probe
+
+.PHONY: all clean tools check-txproto
+
+all: check-txproto $(BIN)
+
+check-txproto:
+ @test -f $(TX_LIB)/libtxproto.so || { \
+ echo "error: $(TX_LIB)/libtxproto.so not found."; \
+ echo " build it first - see patches/README.md"; exit 1; }
+
+$(BIN): $(OBJS)
+ $(CC) $(OBJS) -o $@ $(LDFLAGS) $(LDLIBS)
+
+src/%.o: src/%.c src/kqs.h
+ $(CC) $(CFLAGS) -c $< -o $@
+
+tools: $(TOOLS)
+
+tools/tx_inject_probe: tools/tx_inject_probe.c
+ $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) $(LDLIBS)
+
+# needs the sink and dmabuf import it is testing
+tools/dmabuf_probe: tools/dmabuf_probe.c src/dmabuf.c src/sink.c src/surface.c
+ $(CC) $(CFLAGS) $^ -o $@ $(LDFLAGS) $(LDLIBS)
+
+clean:
+ rm -f $(OBJS) $(BIN) $(TOOLS)
diff --git a/kycontroller.wrapper b/kycontroller.wrapper
new file mode 100755
index 0000000..c7184c3
--- /dev/null
+++ b/kycontroller.wrapper
@@ -0,0 +1,13 @@
+#!/bin/sh
+# kycontroller spawns "kyavserver" and "kynputserver" from PATH, and the AV
+# adapter spawns "kyber-qemu-server" the same way. All of them live in this
+# package own prefix alongside the libraries they need.
+#
+# The prefix is private on purpose. This is a bundled FFmpeg and VLC, and it
+# must not shadow the ones Debian ships - so nothing goes in /usr/lib and the
+# paths are set here rather than in ld.so.conf.
+PREFIX=/usr/lib/pve-qemu-kyber
+PATH="$PREFIX/bin:$PATH"
+LD_LIBRARY_PATH="$PREFIX/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
+export PATH LD_LIBRARY_PATH
+exec "$PREFIX/bin/kycontroller" "$@"
diff --git a/patches/0001-txproto-reset-log-component-count-on-uninit.patch b/patches/0001-txproto-reset-log-component-count-on-uninit.patch
new file mode 100644
index 0000000..8c0213a
--- /dev/null
+++ b/patches/0001-txproto-reset-log-component-count-on-uninit.patch
@@ -0,0 +1,12 @@
+diff --git a/src/log.c b/src/log.c
+index 8e0a4dd..dc4e0c9 100644
+--- a/src/log.c
++++ b/src/log.c
+@@ -1060,6 +1060,7 @@ void sp_log_uninit(void)
+ av_bprint_finalize(&log_ctx.ic[i].bpf, NULL);
+ }
+ av_freep(&log_ctx.ic);
++ log_ctx.ic_len = 0;
+
+ if (log_ctx.log_file) {
+ fflush(log_ctx.log_file);
diff --git a/patches/0002-kycontroller-configure-from-the-command-line.patch b/patches/0002-kycontroller-configure-from-the-command-line.patch
new file mode 100644
index 0000000..1988053
--- /dev/null
+++ b/patches/0002-kycontroller-configure-from-the-command-line.patch
@@ -0,0 +1,353 @@
+diff --git a/kycontroller/Cargo.toml b/kycontroller/Cargo.toml
+index 4584b46..7fa2778 100644
+--- a/kycontroller/Cargo.toml
++++ b/kycontroller/Cargo.toml
+@@ -42,7 +42,7 @@ awc = { version = "3.8.2", optional = true, default-features = false, features =
+ rustls-platform-verifier = { version = "0.6", optional = true }
+
+ # CLI tooling specifics
+-clap = { version = "4.5.40", features = ["derive"] }
++clap = { version = "4.5.40", features = ["derive", "env"] }
+ humantime = { version = "2.2.0", optional = true }
+
+ kyavservice-types = "0.1"
+diff --git a/kycontroller/src/auth/jwt.rs b/kycontroller/src/auth/jwt.rs
+index 18f301c..a5f72f0 100644
+--- a/kycontroller/src/auth/jwt.rs
++++ b/kycontroller/src/auth/jwt.rs
+@@ -89,6 +89,17 @@ pub struct Config {
+ key: ConfigKey,
+ }
+
++impl Config {
++ /// An HS256 configuration built from a key given on the command line,
++ /// for a deployment that has no configuration file at all.
++ pub fn hs256(key: String) -> Self {
++ Self {
++ algorithm: Algorithm::HS256,
++ key: ConfigKey::Plain(key),
++ }
++ }
++}
++
+ impl Default for Config {
+ /// Default configuration for development.
+ ///
+diff --git a/kycontroller/src/auth/mod.rs b/kycontroller/src/auth/mod.rs
+index 059550f..a64081c 100644
+--- a/kycontroller/src/auth/mod.rs
++++ b/kycontroller/src/auth/mod.rs
+@@ -130,6 +130,27 @@ pub struct Config {
+ oidc: OptionalBackendConfig<oidc::Config>,
+ }
+
++impl Config {
++ /// Overrides applied after the file is read, so a controller can be
++ /// configured entirely from its command line.
++ #[cfg(feature = "auth-basic")]
++ pub fn set_basic_enabled(&mut self, enabled: bool) {
++ self.basic.enabled = enabled;
++ }
++
++ #[cfg(feature = "auth-oidc")]
++ pub fn set_oidc_enabled(&mut self, enabled: bool) {
++ self.oidc.enabled = enabled;
++ }
++
++ /// Enables the JWT backend against an HS256 secret.
++ #[cfg(feature = "auth-jwt")]
++ pub fn set_jwt_hs256_key(&mut self, key: String) {
++ self.jwt.enabled = true;
++ self.jwt.inner = jwt::Config::hs256(key);
++ }
++}
++
+ #[derive(Debug, serde::Deserialize)]
+ #[serde(default)]
+ struct OptionalBackendConfig<T> {
+diff --git a/kycontroller/src/cli.rs b/kycontroller/src/cli.rs
+index ba1785f..3bdeb73 100644
+--- a/kycontroller/src/cli.rs
++++ b/kycontroller/src/cli.rs
+@@ -29,4 +29,71 @@ pub(crate) struct Cli {
+ /// executable-relative location is used.
+ #[arg(short = 'c', long = "config", value_name = "PATH")]
+ pub config: Option<PathBuf>,
++
++ /// Serve the control plane on this unix socket rather than a TCP port.
++ ///
++ /// The data plane still needs a UDP port - QUIC has no unix-socket form -
++ /// but nothing else is left listening on the network.
++ #[arg(long = "listen-socket", value_name = "PATH")]
++ pub listen_socket: Option<PathBuf>,
++
++ /// Listening port, for both the HTTP control plane and QUIC.
++ #[arg(long = "port", value_name = "PORT")]
++ pub port: Option<u16>,
++
++ /// UDP port for the data plane, when it has to differ from --port.
++ ///
++ /// Also read from the environment, so a template unit that cannot do
++ /// arithmetic on its instance name can still be handed one port per
++ /// controller.
++ #[arg(long = "dataplane-port", value_name = "PORT", env = "KYBER_DATAPLANE_PORT")]
++ pub dataplane_port: Option<u16>,
++
++ /// Address the data plane binds, rather than every interface.
++ ///
++ /// A controller reached only by a proxy on its own node wants loopback,
++ /// and the default here is the wildcard.
++ #[arg(long = "dataplane-addr", value_name = "ADDR", env = "KYBER_DATAPLANE_ADDR")]
++ pub dataplane_addr: Option<std::net::IpAddr>,
++
++ /// Directory served as the web client.
++ #[arg(long = "webclient", value_name = "PATH")]
++ pub webclient: Option<String>,
++
++ /// TLS certificate chain, in PEM form.
++ #[arg(long = "tls-cert", value_name = "PATH")]
++ pub tls_cert: Option<String>,
++
++ /// Private key for --tls-cert, in PEM form.
++ #[arg(long = "tls-key", value_name = "PATH")]
++ pub tls_key: Option<String>,
++
++ /// HS256 secret to verify JWTs against. Enables the JWT backend.
++ ///
++ /// Prefer the environment variable: anything on a command line is
++ /// readable by every user on the machine through /proc, and a signing
++ /// key is exactly the thing that must not be.
++ #[arg(
++ long = "jwt-key",
++ value_name = "SECRET",
++ env = "KYBER_JWT_KEY",
++ hide_env_values = true
++ )]
++ pub jwt_key: Option<String>,
++
++ /// Disable the basic authentication backend.
++ #[arg(long = "no-basic-auth")]
++ pub no_basic_auth: bool,
++
++ /// Disable the OIDC authentication backend.
++ #[arg(long = "no-oidc-auth")]
++ pub no_oidc_auth: bool,
++
++ /// Disable the tray icon.
++ #[arg(long = "no-tray")]
++ pub no_tray: bool,
++
++ /// Disable the watchdog.
++ #[arg(long = "no-watchdog")]
++ pub no_watchdog: bool,
+ }
+diff --git a/kycontroller/src/config.rs b/kycontroller/src/config.rs
+index fff5b05..14469f0 100644
+--- a/kycontroller/src/config.rs
++++ b/kycontroller/src/config.rs
+@@ -59,6 +59,7 @@ pub(crate) struct Config {
+ port: Option<u16>,
+ listen_mode: Option<ListenMode>,
+ dataplane_port: Option<u16>,
++ dataplane_addr: Option<std::net::IpAddr>,
+ webtransport_gen_certificate: Option<bool>,
+ tls_cert: Option<String>,
+ tls_key: Option<String>,
+@@ -70,6 +71,8 @@ pub(crate) struct Config {
+ watchdog: Option<bool>,
+ multi_client: Option<bool>,
+ webclient: Option<String>,
++ #[serde(skip)]
++ listen_socket: Option<PathBuf>,
+ }
+
+ impl Config {
+@@ -81,10 +84,67 @@ impl Config {
+ self.listen_mode.unwrap_or_default()
+ }
+
++ /// Where the control plane listens, when it is not on a TCP port.
++ pub(crate) fn listen_socket(&self) -> Option<&Path> {
++ self.listen_socket.as_deref()
++ }
++
++ /// Apply the command line over whatever the file said, so a controller can
++ /// be started with no configuration file at all.
++ ///
++ /// Only flags that were actually given have an effect; the rest leave the
++ /// file's answer, or the built-in default, alone.
++ pub(crate) fn apply_cli(&mut self, cli: &crate::cli::Cli) {
++ if let Some(path) = cli.listen_socket.clone() {
++ self.listen_socket = Some(path);
++ }
++ if let Some(port) = cli.port {
++ self.port = Some(port);
++ }
++ if let Some(port) = cli.dataplane_port {
++ self.dataplane_port = Some(port);
++ }
++ if let Some(addr) = cli.dataplane_addr {
++ self.dataplane_addr = Some(addr);
++ }
++ if let Some(webclient) = cli.webclient.clone() {
++ self.webclient = Some(webclient);
++ }
++ if let Some(cert) = cli.tls_cert.clone() {
++ self.tls_cert = Some(cert);
++ }
++ if let Some(key) = cli.tls_key.clone() {
++ self.tls_key = Some(key);
++ }
++ if cli.no_tray {
++ self.tray = Some(false);
++ }
++ if cli.no_watchdog {
++ self.watchdog = Some(false);
++ }
++
++ #[cfg(feature = "auth-jwt")]
++ if let Some(key) = cli.jwt_key.clone() {
++ self.auth.set_jwt_hs256_key(key);
++ }
++ #[cfg(feature = "auth-basic")]
++ if cli.no_basic_auth {
++ self.auth.set_basic_enabled(false);
++ }
++ #[cfg(feature = "auth-oidc")]
++ if cli.no_oidc_auth {
++ self.auth.set_oidc_enabled(false);
++ }
++ }
++
+ pub(crate) fn dataplane_port(&self) -> Option<u16> {
+ self.dataplane_port
+ }
+
++ pub(crate) fn dataplane_addr(&self) -> Option<std::net::IpAddr> {
++ self.dataplane_addr
++ }
++
+ pub(crate) fn webtransport_gen_certificate(&self) -> bool {
+ self.webtransport_gen_certificate.unwrap_or(true)
+ }
+diff --git a/kycontroller/src/kymux_controller/mod.rs b/kycontroller/src/kymux_controller/mod.rs
+index a87f1be..36d0651 100644
+--- a/kycontroller/src/kymux_controller/mod.rs
++++ b/kycontroller/src/kymux_controller/mod.rs
+@@ -68,6 +68,7 @@ fn run_connection_task(
+ /// Configuration for starting Kymux infrastructure
+ pub(crate) struct KymuxInfraConfig {
+ pub(crate) listen_mode: ListenMode,
++ pub(crate) listen_addr: Option<IpAddr>,
+ pub(crate) listening_port: u16,
+ pub(crate) webtransport_gen_certificate: bool,
+ pub(crate) cert_chain: Vec<rustls::pki_types::CertificateDer<'static>>,
+@@ -107,11 +108,14 @@ impl SharedKymuxInfra {
+ return Ok(inner.certificate_hash.clone());
+ }
+
+- let ip_addr = match config.listen_mode {
+- ListenMode::Ipv4 => IpAddr::V4(Ipv4Addr::UNSPECIFIED),
++ let ip_addr = match (config.listen_addr, config.listen_mode) {
++ // An explicit address, for a data plane that must not be reachable
++ // from off the machine.
++ (Some(addr), _) => addr,
++ (None, ListenMode::Ipv4) => IpAddr::V4(Ipv4Addr::UNSPECIFIED),
+ // Enable dual-stack: accept both IPv4 and IPv6 on this socket.
+ // On Linux this is the default; on Windows it must be set explicitly.
+- ListenMode::DualStack => IpAddr::V6(Ipv6Addr::UNSPECIFIED),
++ (None, ListenMode::DualStack) => IpAddr::V6(Ipv6Addr::UNSPECIFIED),
+ };
+
+ let addr = SocketAddr::new(ip_addr, config.listening_port);
+@@ -236,6 +240,7 @@ async fn start_kymux(
+
+ let infra_config = KymuxInfraConfig {
+ listen_mode: ctrl.config.listen_mode(),
++ listen_addr: ctrl.config.dataplane_addr(),
+ listening_port,
+ webtransport_gen_certificate: ctrl.config.webtransport_gen_certificate(),
+ cert_chain: ctrl.tls_config.cert_chain.clone(),
+diff --git a/kycontroller/src/main.rs b/kycontroller/src/main.rs
+index efc898a..98e1492 100644
+--- a/kycontroller/src/main.rs
++++ b/kycontroller/src/main.rs
+@@ -594,6 +594,34 @@ pub(crate) async fn set_bitrate(
+ HttpResponse::Ok().finish()
+ }
+
++/// Where the control plane listens.
++enum Listener {
++ Tcp(std::net::TcpListener),
++ Unix(std::os::unix::net::UnixListener),
++}
++
++impl Listener {
++ fn bind(config: &config::Config) -> std::io::Result<Self> {
++ let Some(path) = config.listen_socket() else {
++ return Ok(Self::Tcp(create_listening_socket(config)?));
++ };
++
++ // A socket left behind by a controller that did not exit cleanly would
++ // otherwise make this one fail to start, for the whole life of the
++ // node - the path is the address, and nothing else will remove it.
++ if let Err(err) = std::fs::remove_file(path) {
++ if err.kind() != std::io::ErrorKind::NotFound {
++ return Err(err);
++ }
++ }
++
++ let listener = std::os::unix::net::UnixListener::bind(path)?;
++ listener.set_nonblocking(true)?;
++
++ Ok(Self::Unix(listener))
++ }
++}
++
+ fn create_listening_socket(config: &config::Config) -> std::io::Result<std::net::TcpListener> {
+ use socket2::{Domain, Protocol, Socket, Type};
+ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
+@@ -714,7 +742,13 @@ async fn real_main(cli: cli::Cli) -> Result<()> {
+ }
+
+ // Load configuration
+- let config = config::load(cli.config.as_deref())?;
++ let mut config = config::load(cli.config.as_deref())?;
++
++ // The command line wins over the file, and is enough on its own: a
++ // controller started per VM has nothing worth writing a file for, and
++ // generating one per VM only creates something to leave behind.
++ config.apply_cli(&cli);
++ let config = config;
+
+ // Load TLS config
+ let tls_cert = config.tls_cert().unwrap_or(STREAMER_CERT);
+@@ -734,7 +768,7 @@ async fn real_main(cli: cli::Cli) -> Result<()> {
+ let process_factory = Arc::new(ProcessFactory::new(false));
+
+ let listening_port = config.port();
+- let listener = create_listening_socket(&config)?;
++ let listener = Listener::bind(&config)?;
+ let webclient_override = config.webclient().map(PathBuf::from);
+
+ let ctrl = web::Data::new(Mutex::new(Controller::new(
+@@ -858,8 +892,17 @@ async fn real_main(cli: cli::Cli) -> Result<()> {
+ app
+ }
+ })
+- .listen_rustls_0_23(listener, server_config)?
+- .run();
++ ;
++
++ // A unix socket carries no TLS: it is not reachable from the network, and
++ // the file's permissions are what decide who may speak to it. On a TCP
++ // port the certificate is still the only thing standing between the
++ // control plane and anyone who can route to it.
++ let server = match listener {
++ Listener::Tcp(listener) => server.listen_rustls_0_23(listener, server_config)?,
++ Listener::Unix(listener) => server.listen_uds(listener)?,
++ }
++ .run();
+
+ let server_handle = server.handle();
+ let tray_enabled = tray::is_supported() && ctrl_for_tray.lock().await.config.tray_enabled();
diff --git a/src/audio.c b/src/audio.c
new file mode 100644
index 0000000..2089149
--- /dev/null
+++ b/src/audio.c
@@ -0,0 +1,494 @@
+/*
+ * QEMU D-Bus audio out listener, encoded to Opus and muxed to kymux.
+ *
+ * Kyber's own audio path captures a PulseAudio monitor on the machine it runs
+ * on (kyavservice/src/audio.rs). A hypervisor node has no such monitor and the
+ * guest's audio is not on it, so this takes the same route the display does:
+ * QEMU hands over one end of a socketpair, speaks peer-to-peer D-Bus on it and
+ * calls into org.qemu.Display1.AudioOutListener, exactly as the display
+ * listener works - see listener.c, which this deliberately mirrors.
+ *
+ * The frames go straight into the encoder's src_frames FIFO rather than
+ * through a txproto IO system, which is what sink.c does for video and what
+ * lets this exist at all: txproto has no source that reads from D-Bus.
+ *
+ * SPDX-License-Identifier: LGPL-2.1-or-later
+ */
+
+#include <errno.h>
+#include <sys/socket.h>
+#include <unistd.h>
+
+#include <gio/gunixfdlist.h>
+
+#include <libtxproto/txproto.h>
+#include <libtxproto/encode.h>
+#include <libtxproto/fifo_frame.h>
+
+#include <libavutil/channel_layout.h>
+#include <libavutil/opt.h>
+
+#include "kqs.h"
+
+/*
+ * Opus codes 48kHz only, and QEMU resamples to whatever the audiodev asks
+ * for, so the pipeline is pinned here rather than resampled in between.
+ * 20ms is Opus's default frame and what Kyber's own audio path asks for.
+ */
+#define KQS_AUDIO_RATE 48000
+#define KQS_AUDIO_CHANNELS 2
+#define KQS_AUDIO_FRAME_MS 20
+#define KQS_AUDIO_FRAME_SAMPLES (KQS_AUDIO_RATE * KQS_AUDIO_FRAME_MS / 1000)
+
+static const char kqs_audio_xml[] =
+ "<node>"
+ " <interface name='org.qemu.Display1.AudioOutListener'>"
+ " <method name='Init'>"
+ " <arg type='t' name='id' direction='in'/>"
+ " <arg type='y' name='bits' direction='in'/>"
+ " <arg type='b' name='is_signed' direction='in'/>"
+ " <arg type='b' name='is_float' direction='in'/>"
+ " <arg type='u' name='freq' direction='in'/>"
+ " <arg type='y' name='nchannels' direction='in'/>"
+ " <arg type='u' name='bytes_per_frame' direction='in'/>"
+ " <arg type='u' name='bytes_per_second' direction='in'/>"
+ " <arg type='b' name='be' direction='in'/>"
+ " </method>"
+ " <method name='Fini'>"
+ " <arg type='t' name='id' direction='in'/>"
+ " </method>"
+ " <method name='SetEnabled'>"
+ " <arg type='t' name='id' direction='in'/>"
+ " <arg type='b' name='enabled' direction='in'/>"
+ " </method>"
+ " <method name='SetVolume'>"
+ " <arg type='t' name='id' direction='in'/>"
+ " <arg type='b' name='mute' direction='in'/>"
+ " <arg type='ay' name='volume' direction='in'/>"
+ " </method>"
+ " <method name='Write'>"
+ " <arg type='t' name='id' direction='in'/>"
+ " <arg type='ay' name='data' direction='in'/>"
+ " </method>"
+ " </interface>"
+ "</node>";
+
+struct KqsAudio {
+ TXMainContext *tx; /* our own: tx_init() is once per process */
+ char *kymux_uri;
+ int bitrate_bps;
+
+ GDBusConnection *peer;
+ GDBusNodeInfo *node;
+ guint reg_id;
+
+ AVBufferRef *encoder;
+ AVBufferRef *muxer;
+ AVBufferRef *fifo; /* borrowed: encoder's src_frames */
+
+ /* The stream QEMU announced in Init, and whether it is playing. */
+ guint64 stream_id;
+ gboolean have_stream;
+ gboolean enabled;
+ guint32 freq;
+ guint8 channels;
+ guint32 bytes_per_frame;
+
+ /*
+ * Opus takes fixed-size frames, and QEMU writes whatever the guest
+ * produced, so samples are accumulated here until a frame's worth exists.
+ */
+ GByteArray *pending;
+ int64_t samples_sent;
+
+ uint64_t frames;
+ uint64_t dropped;
+};
+
+/* -- pipeline ------------------------------------------------------------ */
+
+static bool audio_build(KqsAudio *a, GError **error)
+{
+ AVDictionary *opts = NULL;
+ /* Same options Kyber's own audio path uses, so a client sees no
+ * difference between a guest stream and a desktop one. */
+ av_dict_set_int(&opts, "b", a->bitrate_bps, 0);
+ av_dict_set(&opts, "application", "audio", 0);
+ av_dict_set_int(&opts, "frame_duration", KQS_AUDIO_FRAME_MS, 0);
+ av_dict_set(&opts, "vbr", "on", 0);
+
+ /*
+ * No sample rate or format here: TxEncoderOptions carries video fields
+ * only, and txproto configures an encoder from the first frame on its
+ * src_frames FIFO - which is why Init pushes a seed before committing.
+ */
+ TxEncoderOptions eopts = {
+ .enc_name = "libopus",
+ .name = "guest-audio",
+ .options = opts,
+ .pix_fmt = AV_PIX_FMT_NONE,
+ };
+
+ a->encoder = tx_encoder_create(a->tx, &eopts);
+ if (!a->encoder) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+ "libopus unavailable");
+ return false;
+ }
+
+ /*
+ * The packet sink refuses an encoder without AV_CODEC_FLAG_GLOBAL_HEADER
+ * ("Packet sink requires global header"), and linking an encoder to one
+ * asks for it explicitly - link.c calls encoder_mode_negotiate(src, 0).
+ * So the producer sets it, as sink.c does; the flag is applied when the
+ * encoder configures itself from the first frame.
+ */
+ ((EncodingContext *)a->encoder->data)->need_global_header = 1;
+
+ a->muxer = tx_packetsink_create(a->tx, a->kymux_uri);
+ if (!a->muxer) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+ "packet sink for %s failed", a->kymux_uri);
+ return false;
+ }
+
+ if (tx_link(a->tx, a->encoder, a->muxer, 0) < 0) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "tx_link failed");
+ return false;
+ }
+
+ a->fifo = ((EncodingContext *)a->encoder->data)->src_frames;
+ return true;
+}
+
+/*
+ * txproto derives the encoder's parameters from frame->opaque_ref, not from an
+ * AVCodecContext - there is none when the first frame lands. init_avctx()
+ * dereferences it unconditionally, so a frame without one segfaults inside
+ * tx_commit(); its audio branch reads time_base and bits_per_sample from here.
+ * sink.c's attach_timing() does the same for video.
+ *
+ * pts is counted in samples, so the time base is 1/rate.
+ */
+static bool audio_attach_timing(AVFrame *f)
+{
+ FormatExtraData *fe = av_mallocz(sizeof(*fe));
+ if (!fe)
+ return false;
+
+ fe->time_base = (AVRational){ 1, KQS_AUDIO_RATE };
+ fe->avg_frame_rate = (AVRational){ KQS_AUDIO_RATE, KQS_AUDIO_FRAME_SAMPLES };
+ fe->bits_per_sample = 16;
+
+ f->opaque_ref = av_buffer_create((uint8_t *)fe, sizeof(*fe), NULL, NULL, 0);
+ if (!f->opaque_ref) {
+ av_free(fe);
+ return false;
+ }
+ return true;
+}
+
+/* An empty frame of the shape the encoder should configure itself for. */
+static AVFrame *audio_blank_frame(void)
+{
+ AVFrame *f = av_frame_alloc();
+ if (!f)
+ return NULL;
+
+ f->format = AV_SAMPLE_FMT_S16;
+ f->sample_rate = KQS_AUDIO_RATE;
+ f->nb_samples = KQS_AUDIO_FRAME_SAMPLES;
+ av_channel_layout_default(&f->ch_layout, KQS_AUDIO_CHANNELS);
+
+ if (av_frame_get_buffer(f, 0) < 0) {
+ av_frame_free(&f);
+ return NULL;
+ }
+
+ av_samples_set_silence(f->data, 0, f->nb_samples, KQS_AUDIO_CHANNELS,
+ AV_SAMPLE_FMT_S16);
+ f->pts = 0;
+
+ if (!audio_attach_timing(f)) {
+ av_frame_free(&f);
+ return NULL;
+ }
+ return f;
+}
+
+/* One 20ms frame out of the accumulator. */
+static AVFrame *audio_take_frame(KqsAudio *a)
+{
+ const guint need = KQS_AUDIO_FRAME_SAMPLES * KQS_AUDIO_CHANNELS
+ * sizeof(int16_t);
+ if (a->pending->len < need)
+ return NULL;
+
+ AVFrame *f = av_frame_alloc();
+ if (!f)
+ return NULL;
+
+ f->format = AV_SAMPLE_FMT_S16;
+ f->sample_rate = KQS_AUDIO_RATE;
+ f->nb_samples = KQS_AUDIO_FRAME_SAMPLES;
+ av_channel_layout_default(&f->ch_layout, KQS_AUDIO_CHANNELS);
+
+ if (av_frame_get_buffer(f, 0) < 0) {
+ av_frame_free(&f);
+ return NULL;
+ }
+
+ memcpy(f->data[0], a->pending->data, need);
+ g_byte_array_remove_range(a->pending, 0, need);
+
+ /* In samples, which is the encoder's time base. */
+ f->pts = a->samples_sent;
+ a->samples_sent += KQS_AUDIO_FRAME_SAMPLES;
+
+ if (!audio_attach_timing(f)) {
+ av_frame_free(&f);
+ return NULL;
+ }
+ return f;
+}
+
+/* -- D-Bus --------------------------------------------------------------- */
+
+static void audio_init_stream(KqsAudio *a, GVariant *params)
+{
+ guint64 id;
+ guint8 bits, nchannels;
+ gboolean is_signed, is_float, be;
+ guint32 freq, bytes_per_frame, bytes_per_second;
+
+ /* t id, y bits, b signed, b float, u freq, y channels, u bpf, u bps, b be */
+ g_variant_get(params, "(tybbuyuub)", &id, &bits, &is_signed, &is_float,
+ &freq, &nchannels, &bytes_per_frame, &bytes_per_second, &be);
+
+ g_message("audio stream %" G_GUINT64_FORMAT ": %uHz %uch %ubit "
+ "(signed=%d float=%d be=%d)",
+ id, freq, nchannels, bits, is_signed, is_float, be);
+
+ /*
+ * QEMU converts to whatever the audiodev was configured for, so this is
+ * the format asked for on the command line. Anything else would need a
+ * resampler, and saying so is better than sending noise.
+ */
+ if (bits != 16 || is_float || !is_signed || be ||
+ freq != KQS_AUDIO_RATE || nchannels != KQS_AUDIO_CHANNELS) {
+ g_warning("unsupported audio format; expected %dHz %dch s16le",
+ KQS_AUDIO_RATE, KQS_AUDIO_CHANNELS);
+ return;
+ }
+
+ if (!a->encoder) {
+ g_autoptr(GError) error = NULL;
+ if (!audio_build(a, &error)) {
+ g_warning("audio pipeline failed: %s", error->message);
+ return;
+ }
+
+ /*
+ * The encoder takes its parameters from the first frame, and the
+ * muxer takes its from the encoder, so tx_commit() blocks until one
+ * exists. 20ms of silence costs nothing and unblocks both.
+ */
+ AVFrame *seed = audio_blank_frame();
+ if (!seed || sp_frame_fifo_push(a->fifo, seed) < 0) {
+ g_warning("could not seed the audio encoder");
+ av_frame_free(&seed);
+ return;
+ }
+ av_frame_free(&seed);
+ a->samples_sent = KQS_AUDIO_FRAME_SAMPLES;
+
+ if (tx_commit(a->tx) < 0) {
+ g_warning("tx_commit failed for audio");
+ return;
+ }
+ g_message("audio: streaming to %s", a->kymux_uri);
+ }
+
+ a->stream_id = id;
+ a->have_stream = TRUE;
+ a->freq = freq;
+ a->channels = nchannels;
+ a->bytes_per_frame = bytes_per_frame;
+}
+
+static void audio_write(KqsAudio *a, GVariant *params)
+{
+ guint64 id;
+ g_autoptr(GVariant) data = NULL;
+
+ g_variant_get(params, "(t@ay)", &id, &data);
+
+ if (!a->have_stream || id != a->stream_id || !a->enabled)
+ return;
+
+ gsize len = 0;
+ const guint8 *pcm = g_variant_get_fixed_array(data, &len, 1);
+ if (!len)
+ return;
+
+ g_byte_array_append(a->pending, pcm, len);
+
+ AVFrame *f;
+ while ((f = audio_take_frame(a))) {
+ if (sp_frame_fifo_push(a->fifo, f) < 0) {
+ /* Full: the encoder is behind. Dropping is right for audio -
+ * queueing would only add latency that never comes back. */
+ a->dropped++;
+ av_frame_free(&f);
+ continue;
+ }
+ av_frame_free(&f);
+ a->frames++;
+ }
+}
+
+static void audio_method(GDBusConnection *conn, const char *sender,
+ const char *path, const char *iface,
+ const char *method, GVariant *params,
+ GDBusMethodInvocation *inv, gpointer user_data)
+{
+ KqsAudio *a = user_data;
+
+ if (!g_strcmp0(method, "Init")) {
+ audio_init_stream(a, params);
+ } else if (!g_strcmp0(method, "Write")) {
+ audio_write(a, params);
+ } else if (!g_strcmp0(method, "SetEnabled")) {
+ guint64 id;
+ gboolean enabled;
+ g_variant_get(params, "(tb)", &id, &enabled);
+ if (a->have_stream && id == a->stream_id) {
+ a->enabled = enabled;
+ g_message("audio %s", enabled ? "playing" : "stopped");
+ /* Stale samples would be played at the wrong time on resume. */
+ if (!enabled)
+ g_byte_array_set_size(a->pending, 0);
+ }
+ } else if (!g_strcmp0(method, "Fini")) {
+ guint64 id;
+ g_variant_get(params, "(t)", &id);
+ if (a->have_stream && id == a->stream_id) {
+ a->have_stream = FALSE;
+ a->enabled = FALSE;
+ g_byte_array_set_size(a->pending, 0);
+ }
+ }
+ /* SetVolume is accepted and ignored: the guest's mixer is the guest's. */
+
+ g_dbus_method_invocation_return_value(inv, NULL);
+}
+
+static const GDBusInterfaceVTable audio_vtable = {
+ .method_call = audio_method,
+};
+
+/* -- lifecycle ----------------------------------------------------------- */
+
+KqsAudio *kqs_audio_new(GDBusConnection *bus, const char *bus_name,
+ const char *kymux_uri, int bitrate_bps,
+ GError **error)
+{
+ KqsAudio *a = g_new0(KqsAudio, 1);
+ a->kymux_uri = g_strdup(kymux_uri);
+ a->bitrate_bps = bitrate_bps;
+ a->pending = g_byte_array_new();
+
+ /* See sink.c: tx_init() is not re-entrant, so this process serves audio
+ * and nothing else. avservice launches it separately from the video one. */
+ a->tx = tx_new();
+ if (!a->tx || tx_init(a->tx) < 0) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "tx_init failed");
+ kqs_audio_free(a);
+ return NULL;
+ }
+
+ /* The pipeline waits for Init: only then is the guest's format known,
+ * and an encoder cannot be committed without a frame to configure from. */
+
+ int sv[2];
+ if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, sv) != 0) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+ "socketpair: %s", g_strerror(errno));
+ kqs_audio_free(a);
+ return NULL;
+ }
+
+ g_autoptr(GUnixFDList) fds = g_unix_fd_list_new_from_array(&sv[1], 1);
+ g_autoptr(GVariant) reply = g_dbus_connection_call_with_unix_fd_list_sync(
+ bus, bus_name, "/org/qemu/Display1/Audio",
+ "org.qemu.Display1.Audio", "RegisterOutListener",
+ g_variant_new("(h)", 0), NULL, G_DBUS_CALL_FLAGS_NONE, -1,
+ fds, NULL, NULL, error);
+
+ if (!reply) {
+ g_prefix_error(error, "RegisterOutListener failed: ");
+ close(sv[0]);
+ kqs_audio_free(a);
+ return NULL;
+ }
+
+ g_autoptr(GSocket) sock = g_socket_new_from_fd(sv[0], error);
+ if (!sock) {
+ close(sv[0]);
+ kqs_audio_free(a);
+ return NULL;
+ }
+ g_autoptr(GSocketConnection) sconn = g_socket_connection_factory_create_connection(sock);
+
+ /* QEMU is the authentication server on its end, as for the display. */
+ a->peer = g_dbus_connection_new_sync(
+ G_IO_STREAM(sconn), NULL,
+ G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT,
+ NULL, NULL, error);
+ if (!a->peer) {
+ kqs_audio_free(a);
+ return NULL;
+ }
+
+ a->node = g_dbus_node_info_new_for_xml(kqs_audio_xml, error);
+ if (!a->node) {
+ kqs_audio_free(a);
+ return NULL;
+ }
+
+ a->reg_id = g_dbus_connection_register_object(
+ a->peer, "/org/qemu/Display1/AudioOutListener",
+ a->node->interfaces[0], &audio_vtable, a, NULL, error);
+ if (!a->reg_id) {
+ kqs_audio_free(a);
+ return NULL;
+ }
+
+ g_message("audio: registered with QEMU, waiting for a stream");
+ return a;
+}
+
+void kqs_audio_stats(const KqsAudio *a, uint64_t *frames, uint64_t *dropped)
+{
+ if (frames) *frames = a ? a->frames : 0;
+ if (dropped) *dropped = a ? a->dropped : 0;
+}
+
+void kqs_audio_free(KqsAudio *a)
+{
+ if (!a)
+ return;
+
+ if (a->fifo)
+ sp_frame_fifo_push(a->fifo, NULL); /* flush sentinel, as sink.c */
+
+ if (a->reg_id && a->peer)
+ g_dbus_connection_unregister_object(a->peer, a->reg_id);
+ g_clear_pointer(&a->node, g_dbus_node_info_unref);
+ g_clear_object(&a->peer);
+
+ if (a->pending)
+ g_byte_array_free(a->pending, TRUE);
+ g_free(a->kymux_uri);
+ g_free(a);
+}
diff --git a/src/dmabuf.c b/src/dmabuf.c
new file mode 100644
index 0000000..0265b6d
--- /dev/null
+++ b/src/dmabuf.c
@@ -0,0 +1,592 @@
+/*
+ * DMA-BUF import: QEMU scanout descriptors -> VAAPI surfaces.
+ *
+ * This is the path that makes the design worth building. QEMU hands over file
+ * descriptors for the guest's scanout buffer instead of pixels, and the
+ * encoder reads them where they already are. No CPU copy, no sws_scale.
+ *
+ * The mapping goes DRM_PRIME -> VAAPI in two steps because that is how
+ * libavutil models it: wrap the fds in an AVDRMFrameDescriptor, then
+ * av_hwframe_map() that into a VAAPI frames context. txproto's encoder then
+ * sees an ordinary hardware frame.
+ *
+ * SPDX-License-Identifier: LGPL-2.1-or-later
+ */
+
+#include <drm_fourcc.h>
+#include <libavfilter/avfilter.h>
+#include <libavfilter/buffersink.h>
+#include <libavfilter/buffersrc.h>
+#include <libavutil/hwcontext.h>
+#include <libavutil/opt.h>
+#include <libavutil/hwcontext_drm.h>
+#include <libavutil/pixdesc.h>
+
+#include "kqs.h"
+
+void kqs_dmabuf_init(KqsDmabuf *d)
+{
+ memset(d, 0, sizeof(*d));
+ for (int i = 0; i < KQS_DMABUF_MAX_PLANES; i++)
+ d->fds[i] = -1;
+}
+
+void kqs_dmabuf_clear(KqsDmabuf *d)
+{
+ for (int i = 0; i < KQS_DMABUF_MAX_PLANES; i++) {
+ if (d->fds[i] >= 0)
+ close(d->fds[i]);
+ }
+ kqs_dmabuf_init(d);
+}
+
+/*
+ * QEMU sends DRM fourccs. Only the formats a virtio-gpu scanout actually
+ * produces are listed; anything else should fail loudly rather than be
+ * silently misinterpreted as the wrong colour order.
+ */
+/*
+ * The alpha-less twin of a scanout fourcc, or the fourcc itself.
+ *
+ * A scanout is already composited, so its alpha channel carries nothing, and
+ * the conversion to NV12 discards it regardless - but VAAPI on this driver
+ * refuses to import an alpha-bearing buffer at all:
+ *
+ * Failed to create surface from DRM object: 2 (resource allocation failed)
+ *
+ * and then no frame is ever mapped, the encoder never commits, and the client
+ * sits on a frozen picture with nothing in the log to suggest a format
+ * problem. The guest picks the format per mode rather than per session -
+ * 1280x800 and 1920x1080 arrive as XRGB, 800x600 and 1280x768 as ARGB - so
+ * this presents as a resolution that randomly fails to stream.
+ *
+ * This has to be applied to the DRM descriptor, not just to the AVPixelFormat:
+ * libva imports by the fourcc in the descriptor, so declaring an alpha-less
+ * AVPixelFormat while still handing over an ARGB layer changes nothing.
+ */
+uint32_t kqs_fourcc_opaque(uint32_t fourcc)
+{
+ switch (fourcc) {
+ case DRM_FORMAT_ARGB8888: return DRM_FORMAT_XRGB8888;
+ case DRM_FORMAT_ABGR8888: return DRM_FORMAT_XBGR8888;
+ default: return fourcc;
+ }
+}
+
+enum AVPixelFormat kqs_fourcc_to_av(uint32_t fourcc)
+{
+ switch (fourcc) {
+ /*
+ * Alpha is dropped on purpose. A scanout is already composited, so its
+ * alpha channel carries nothing, and it is discarded again by the
+ * conversion to NV12 - but asking VAAPI to import an alpha-bearing
+ * surface fails outright on this driver:
+ *
+ * Failed to create surface from DRM object: 2 (resource allocation
+ * failed)
+ *
+ * and then no frame is ever mapped, so the encoder never commits and the
+ * client sits on a frozen picture. The guest chooses between XRGB and
+ * ARGB per mode for reasons of its own - 1280x800 and 1920x1080 came
+ * through as XRGB, 800x600 and 1280x768 as ARGB - which is what made this
+ * look like a random failure rather than a format one.
+ */
+ case DRM_FORMAT_XRGB8888:
+ case DRM_FORMAT_ARGB8888: return AV_PIX_FMT_BGR0;
+ case DRM_FORMAT_XBGR8888:
+ case DRM_FORMAT_ABGR8888: return AV_PIX_FMT_RGB0;
+ case DRM_FORMAT_NV12: return AV_PIX_FMT_NV12;
+ default: return AV_PIX_FMT_NONE;
+ }
+}
+
+const char *kqs_fourcc_str(uint32_t f, char buf[5])
+{
+ buf[0] = (char)(f & 0xff);
+ buf[1] = (char)((f >> 8) & 0xff);
+ buf[2] = (char)((f >> 16) & 0xff);
+ buf[3] = (char)((f >> 24) & 0xff);
+ buf[4] = '\0';
+ return buf;
+}
+
+struct KqsHwCtx {
+ AVBufferRef *drm_device;
+ AVBufferRef *vaapi_device;
+ AVBufferRef *drm_frames; /* rebuilt when geometry changes */
+ AVBufferRef *vaapi_frames;
+ int frames_w, frames_h;
+ enum AVPixelFormat frames_sw;
+
+ /* RGB -> NV12, on the GPU. Rebuilt alongside the frames contexts. */
+ AVFilterGraph *graph;
+ AVFilterContext *gsrc;
+ AVFilterContext *gsink;
+ bool graph_vflip;
+ int graph_out_w, graph_out_h;
+ bool warned_linear;
+};
+
+KqsHwCtx *kqs_hw_new(const char *render_node, GError **error)
+{
+ KqsHwCtx *h = g_new0(KqsHwCtx, 1);
+ int err;
+
+ err = av_hwdevice_ctx_create(&h->drm_device, AV_HWDEVICE_TYPE_DRM,
+ render_node, NULL, 0);
+ if (err < 0) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+ "cannot open DRM device %s", render_node);
+ g_free(h);
+ return NULL;
+ }
+
+ /*
+ * Deriving VAAPI from the same DRM device keeps both on one GPU, which is
+ * what makes the import free. Creating VAAPI independently can land on a
+ * different node and silently turn the map into a copy.
+ */
+ err = av_hwdevice_ctx_create_derived(&h->vaapi_device,
+ AV_HWDEVICE_TYPE_VAAPI,
+ h->drm_device, 0);
+ if (err < 0) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+ "cannot derive VAAPI from %s", render_node);
+ av_buffer_unref(&h->drm_device);
+ g_free(h);
+ return NULL;
+ }
+
+ return h;
+}
+
+void kqs_hw_free(KqsHwCtx *h)
+{
+ if (!h)
+ return;
+ avfilter_graph_free(&h->graph);
+ av_buffer_unref(&h->drm_frames);
+ av_buffer_unref(&h->vaapi_frames);
+ av_buffer_unref(&h->vaapi_device);
+ av_buffer_unref(&h->drm_device);
+ g_free(h);
+}
+
+AVBufferRef *kqs_hw_vaapi_device(KqsHwCtx *h)
+{
+ return h->vaapi_device;
+}
+
+static bool hw_ensure_graph(KqsHwCtx *h, int w, int h_px, bool vflip,
+ int out_w, int out_h, GError **error);
+
+static bool hw_ensure_frames(KqsHwCtx *h, int w, int h_px,
+ enum AVPixelFormat sw, bool vflip,
+ int out_w, int out_h, GError **error)
+{
+ if (h->drm_frames && h->vaapi_frames &&
+ h->frames_w == w && h->frames_h == h_px && h->frames_sw == sw)
+ return h->graph && h->graph_vflip == vflip &&
+ h->graph_out_w == out_w && h->graph_out_h == out_h
+ ? true
+ : hw_ensure_graph(h, w, h_px, vflip, out_w, out_h, error);
+
+ avfilter_graph_free(&h->graph);
+ av_buffer_unref(&h->drm_frames);
+ av_buffer_unref(&h->vaapi_frames);
+
+ h->drm_frames = av_hwframe_ctx_alloc(h->drm_device);
+ if (!h->drm_frames) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "DRM frames alloc");
+ return false;
+ }
+ AVHWFramesContext *dfc = (AVHWFramesContext *)h->drm_frames->data;
+ dfc->format = AV_PIX_FMT_DRM_PRIME;
+ dfc->sw_format = sw;
+ dfc->width = w;
+ dfc->height = h_px;
+ if (av_hwframe_ctx_init(h->drm_frames) < 0) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "DRM frames init");
+ return false;
+ }
+
+ /*
+ * VAAPI encoders want NV12; the scanout is packed RGB. Mapping cannot
+ * change the colour space, so the frames context is created with the
+ * scanout's own sw_format and the encoder converts on the GPU.
+ */
+ h->vaapi_frames = av_hwframe_ctx_alloc(h->vaapi_device);
+ if (!h->vaapi_frames) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "VAAPI frames alloc");
+ return false;
+ }
+ AVHWFramesContext *vfc = (AVHWFramesContext *)h->vaapi_frames->data;
+ vfc->format = AV_PIX_FMT_VAAPI;
+ vfc->sw_format = sw;
+ vfc->width = w;
+ vfc->height = h_px;
+ if (av_hwframe_ctx_init(h->vaapi_frames) < 0) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+ "VAAPI frames init (%s %dx%d)",
+ av_get_pix_fmt_name(sw), w, h_px);
+ return false;
+ }
+
+ h->frames_w = w;
+ h->frames_h = h_px;
+ h->frames_sw = sw;
+
+ /* The graph is bound to that frames context, so it goes with it. */
+ return hw_ensure_graph(h, w, h_px, vflip, out_w, out_h, error);
+}
+
+static void drm_desc_free(void *opaque, uint8_t *data)
+{
+ av_free(data);
+}
+
+/*
+ * Wrap the descriptor QEMU sent as an AVFrame. The fds stay owned by the
+ * KqsDmabuf - libavutil does not close them here - so the caller must keep
+ * them alive until the returned frame is unreferenced.
+ */
+static AVFrame *drm_frame_from(KqsHwCtx *h, const KqsDmabuf *d, GError **error, uint64_t modifier)
+{
+ AVDRMFrameDescriptor *desc = av_mallocz(sizeof(*desc));
+ if (!desc) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "desc alloc");
+ return NULL;
+ }
+
+ desc->nb_objects = d->n_fds;
+ for (int i = 0; i < d->n_fds; i++) {
+ desc->objects[i].fd = d->fds[i];
+ desc->objects[i].size = 0;
+ desc->objects[i].format_modifier = modifier;
+ }
+
+ desc->nb_layers = 1;
+ desc->layers[0].format = kqs_fourcc_opaque(d->fourcc);
+ desc->layers[0].nb_planes = d->num_planes;
+ for (int p = 0; p < d->num_planes; p++) {
+ /* One fd may back every plane, or each plane may have its own. */
+ desc->layers[0].planes[p].object_index = (d->n_fds == 1) ? 0 : p;
+ desc->layers[0].planes[p].offset = d->offsets[p];
+ desc->layers[0].planes[p].pitch = d->strides[p];
+ }
+
+ AVFrame *f = av_frame_alloc();
+ if (!f) {
+ av_free(desc);
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "frame alloc");
+ return NULL;
+ }
+
+ f->format = AV_PIX_FMT_DRM_PRIME;
+ f->width = d->backing_width;
+ f->height = d->backing_height;
+ f->data[0] = (uint8_t *)desc;
+ f->buf[0] = av_buffer_create((uint8_t *)desc, sizeof(*desc),
+ drm_desc_free, NULL, 0);
+ if (!f->buf[0]) {
+ av_free(desc);
+ av_frame_free(&f);
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "desc buffer");
+ return NULL;
+ }
+
+ f->hw_frames_ctx = av_buffer_ref(h->drm_frames);
+ if (!f->hw_frames_ctx) {
+ av_frame_free(&f);
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "frames ref");
+ return NULL;
+ }
+
+ return f;
+}
+
+/*
+ * A virtio-gpu scanout is packed RGB and h264_vaapi has no RGB input profile -
+ * it answers "Input surface format is rgba" and then "No usable encoding
+ * profile found". Something has to convert, and it has to stay on the GPU or
+ * the whole zero-copy path is pointless.
+ *
+ * Doing it here rather than as a stage in the txproto pipeline is deliberate.
+ * A filtergraph between the import and the encoder was tried and deadlocks:
+ * seeding the filter's input leaves the encoder with no frame to configure
+ * from, so tx_commit() blocks exactly as it does on an empty encoder FIFO,
+ * one stage deeper. Converting before the frame is ever handed over keeps the
+ * pipeline a plain encoder that can still be seeded directly.
+ */
+static bool hw_ensure_graph(KqsHwCtx *h, int w, int h_px, bool vflip,
+ int out_w, int out_h, GError **error)
+{
+ int err;
+
+ avfilter_graph_free(&h->graph);
+
+ h->graph = avfilter_graph_alloc();
+ if (!h->graph) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "filter graph alloc");
+ return false;
+ }
+
+ /*
+ * Build the source in two steps rather than from an args string. The hw
+ * frames context can only be attached through AVBufferSrcParameters, and
+ * that has to happen before the filter is initialised - which rules out
+ * avfilter_graph_create_filter(), since it inits for you.
+ */
+ h->gsrc = avfilter_graph_alloc_filter(h->graph, avfilter_get_by_name("buffer"),
+ "in");
+ if (!h->gsrc) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "buffersrc alloc");
+ return false;
+ }
+
+ av_opt_set_int(h->gsrc, "width", w, AV_OPT_SEARCH_CHILDREN);
+ av_opt_set_int(h->gsrc, "height", h_px, AV_OPT_SEARCH_CHILDREN);
+ av_opt_set(h->gsrc, "pix_fmt", av_get_pix_fmt_name(AV_PIX_FMT_VAAPI),
+ AV_OPT_SEARCH_CHILDREN);
+ av_opt_set(h->gsrc, "time_base", "1/1000000", AV_OPT_SEARCH_CHILDREN);
+ av_opt_set(h->gsrc, "pixel_aspect", "1/1", AV_OPT_SEARCH_CHILDREN);
+
+ AVBufferSrcParameters *par = av_buffersrc_parameters_alloc();
+ if (!par) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "buffersrc params");
+ return false;
+ }
+ par->format = AV_PIX_FMT_VAAPI;
+ par->width = w;
+ par->height = h_px;
+ par->hw_frames_ctx = h->vaapi_frames;
+ err = av_buffersrc_parameters_set(h->gsrc, par);
+ av_freep(&par);
+ if (err < 0) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+ "buffersrc params set (%d)", err);
+ return false;
+ }
+
+ err = avfilter_init_str(h->gsrc, NULL);
+ if (err < 0) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+ "buffersrc init (%d)", err);
+ return false;
+ }
+
+ /*
+ * Scale to the encoder's geometry, not the scanout's. The encoder is built
+ * once per session and cannot be resized mid-stream, so this is what has
+ * to absorb a guest that changes resolution - exactly what swscale does on
+ * the CPU path.
+ *
+ * Leaving w/h unset passes the scanout size straight through, and the
+ * encoder then quietly emits nothing usable: OVMF hands over at 640x480,
+ * the guest comes up at 1280x800, and the client is black while every log
+ * line still reports a healthy pipeline.
+ */
+ g_autofree char *scale_arg =
+ g_strdup_printf("w=%d:h=%d:format=nv12", out_w, out_h);
+
+ AVFilterContext *scale = NULL;
+ err = avfilter_graph_create_filter(&scale,
+ avfilter_get_by_name("scale_vaapi"),
+ "nv12", scale_arg, NULL, h->graph);
+ if (err < 0) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+ "scale_vaapi create (%d) - is FFmpeg built with VAAPI "
+ "filters?", err);
+ return false;
+ }
+
+ err = avfilter_graph_create_filter(&h->gsink,
+ avfilter_get_by_name("buffersink"),
+ "out", NULL, NULL, h->graph);
+ if (err < 0) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "buffersink create");
+ return false;
+ }
+
+ /*
+ * A virgl scanout has its origin at the bottom left, the way OpenGL does,
+ * and QEMU says so per scanout with y0_top. Video wants the top row first,
+ * so an unflipped GL guest arrives upside down. transpose_vaapi does it as
+ * part of the same VPP pass, so it stays on the GPU and costs nothing
+ * measurable.
+ */
+ /*
+ * Which correction is right depends on where the scanout came from, and
+ * y0_top on its own has not been a reliable guide.
+ *
+ * Off the vhost-user-gpu helper the picture arrives the right way up even
+ * though y0_top is false, so correcting it is what breaks it: with
+ * dir=hflip the result is mirrored left to right and nothing else. That
+ * also settles an earlier doubt - transpose_vaapi does apply on this
+ * driver, and applies exactly what it is asked for, so the 180-rotated
+ * picture seen on the in-process path was a genuinely different input,
+ * not a filter that was being ignored.
+ *
+ * "none" is therefore the default. The flag stays so the in-process
+ * virtio-gpu-gl path can be corrected differently once it is usable at
+ * all - today it crashes long before orientation matters.
+ */
+ const char *dir = g_getenv("KQS_DMABUF_FLIP");
+ if (!dir || !*dir)
+ dir = "none";
+
+ AVFilterContext *flip = NULL;
+ if (vflip && g_strcmp0(dir, "none") != 0) {
+ g_autofree char *dir_arg = g_strdup_printf("dir=%s", dir);
+
+ err = avfilter_graph_create_filter(&flip,
+ avfilter_get_by_name("transpose_vaapi"),
+ "flip", dir_arg, NULL, h->graph);
+ if (err < 0) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+ "transpose_vaapi create (%d)", err);
+ return false;
+ }
+ }
+
+ if (avfilter_link(h->gsrc, 0, scale, 0) < 0 ||
+ (flip && avfilter_link(scale, 0, flip, 0) < 0) ||
+ avfilter_link(flip ? flip : scale, 0, h->gsink, 0) < 0) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "filter link");
+ return false;
+ }
+
+ /*
+ * scale_vaapi allocates its output frames from a context derived from the
+ * input's device, so the device reference has to be visible to the graph
+ * before configuration.
+ */
+ h->graph->filters[0]->hw_device_ctx = av_buffer_ref(h->vaapi_device);
+
+ err = avfilter_graph_config(h->graph, NULL);
+ if (err < 0) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+ "filter graph config (%d)", err);
+ return false;
+ }
+
+ h->graph_vflip = vflip;
+ h->graph_out_w = out_w;
+ h->graph_out_h = out_h;
+ return true;
+}
+
+/* Push one imported RGB surface through the graph, get NV12 back. */
+static AVFrame *hw_to_nv12(KqsHwCtx *h, AVFrame *va, GError **error)
+{
+ int err = av_buffersrc_add_frame_flags(h->gsrc, va,
+ AV_BUFFERSRC_FLAG_KEEP_REF);
+ av_frame_free(&va);
+ if (err < 0) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+ "buffersrc push failed (%d)", err);
+ return NULL;
+ }
+
+ AVFrame *out = av_frame_alloc();
+ if (!out) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "nv12 frame alloc");
+ return NULL;
+ }
+
+ err = av_buffersink_get_frame(h->gsink, out);
+ if (err < 0) {
+ av_frame_free(&out);
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+ "buffersink pull failed (%d)", err);
+ return NULL;
+ }
+
+ return out;
+}
+
+AVFrame *kqs_dmabuf_to_vaapi(KqsHwCtx *h, const KqsDmabuf *d,
+ int out_w, int out_h, GError **error)
+{
+ enum AVPixelFormat sw = kqs_fourcc_to_av(d->fourcc);
+ char fcc[5];
+
+ if (sw == AV_PIX_FMT_NONE) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED,
+ "unsupported scanout fourcc '%s' (0x%08x)",
+ kqs_fourcc_str(d->fourcc, fcc), d->fourcc);
+ return NULL;
+ }
+
+ if (!hw_ensure_frames(h, d->backing_width, d->backing_height, sw,
+ !d->y0_top, out_w, out_h, error))
+ return NULL;
+
+ AVFrame *drm = drm_frame_from(h, d, error, d->modifier);
+ if (!drm)
+ return NULL;
+
+ AVFrame *va = av_frame_alloc();
+ if (!va) {
+ av_frame_free(&drm);
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "va frame alloc");
+ return NULL;
+ }
+
+ va->format = AV_PIX_FMT_VAAPI;
+ va->width = d->backing_width;
+ va->height = d->backing_height;
+ va->hw_frames_ctx = av_buffer_ref(h->vaapi_frames);
+
+ int err = av_hwframe_map(va, drm, AV_HWFRAME_MAP_READ);
+ av_frame_free(&drm);
+
+ /*
+ * DRM_FORMAT_MOD_INVALID means "no modifier was negotiated", so the driver
+ * has to guess the layout, and on AMD it guesses a tiled render target.
+ * These scanouts are linear - the stride is exactly width * 4 - so when
+ * the guess loses, say so explicitly and try once more.
+ *
+ * This only ever runs after a failure, so a buffer that imports on the
+ * driver's own terms keeps taking the original path untouched.
+ */
+ if (err < 0 && d->modifier == DRM_FORMAT_MOD_INVALID) {
+ av_frame_free(&va);
+
+ AVFrame *lin = drm_frame_from(h, d, error, DRM_FORMAT_MOD_LINEAR);
+ if (!lin)
+ return NULL;
+
+ va = av_frame_alloc();
+ if (!va) {
+ av_frame_free(&lin);
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "va frame alloc");
+ return NULL;
+ }
+ va->format = AV_PIX_FMT_VAAPI;
+ va->width = d->backing_width;
+ va->height = d->backing_height;
+ va->hw_frames_ctx = av_buffer_ref(h->vaapi_frames);
+
+ err = av_hwframe_map(va, lin, AV_HWFRAME_MAP_READ);
+ av_frame_free(&lin);
+
+ if (err >= 0 && !h->warned_linear) {
+ h->warned_linear = true;
+ g_message("dmabuf: import needed an explicit linear modifier");
+ }
+ }
+
+ if (err < 0) {
+ av_frame_free(&va);
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+ "av_hwframe_map DRM_PRIME->VAAPI failed "
+ "(fourcc '%s', modifier 0x%" G_GINT64_MODIFIER "x)",
+ kqs_fourcc_str(d->fourcc, fcc),
+ (uint64_t)d->modifier);
+ return NULL;
+ }
+
+ /* The encoder wants NV12; hand it NV12 rather than what the guest drew. */
+ return hw_to_nv12(h, va, error);
+}
diff --git a/src/kqs.h b/src/kqs.h
new file mode 100644
index 0000000..df945fc
--- /dev/null
+++ b/src/kqs.h
@@ -0,0 +1,194 @@
+/*
+ * kyber-qemu-server - route a QEMU guest display into a txproto pipeline
+ *
+ * Copyright (C) 2026 Alexandre Derumier
+ * SPDX-License-Identifier: LGPL-2.1-or-later
+ *
+ * LGPL-2.1+ to match libtxproto, which this links against. Kyber's AGPL
+ * service layer stays in other processes; see the design note.
+ */
+
+#pragma once
+
+#include <stdbool.h>
+#include <stdint.h>
+
+#include <gio/gio.h>
+#include <glib.h>
+#include <libavutil/frame.h>
+#include <libavutil/pixfmt.h>
+#include <libavutil/pixdesc.h>
+#include <string.h>
+#include <unistd.h>
+
+#define KQS_LISTENER_PATH "/org/qemu/Display1/Listener"
+#define KQS_LISTENER_IFACE "org.qemu.Display1.Listener"
+
+/*
+ * Pixman format codes, as QEMU puts them on the wire:
+ *
+ * PIXMAN_FORMAT(bpp,type,a,r,g,b) =
+ * (bpp << 24) | (type << 16) | (a << 12) | (r << 8) | (g << 4) | b
+ *
+ * The AVPixelFormat mapping assumes a little-endian host: pixman names
+ * channels most-significant-first inside a 32-bit word, FFmpeg names them in
+ * memory order, so the two read as reverses of one another.
+ */
+#define KQS_PIXMAN_a8r8g8b8 0x20028888u
+#define KQS_PIXMAN_x8r8g8b8 0x20020888u
+#define KQS_PIXMAN_a8b8g8r8 0x20038888u
+#define KQS_PIXMAN_x8b8g8r8 0x20030888u
+
+enum AVPixelFormat kqs_pixman_to_av(uint32_t pixman_format);
+
+/* ---------------------------------------------------------------- surface */
+
+/*
+ * The guest display.
+ *
+ * QEMU sends one full frame via Scanout and then incremental damage
+ * rectangles via Update, so the complete image only ever exists here. Frames
+ * go to the encoder on a timer rather than per-damage: a video codec wants a
+ * steady cadence, not a burst per mouse move.
+ */
+typedef struct KqsSurface {
+ int width;
+ int height;
+ int stride;
+ int bpp;
+ enum AVPixelFormat pix_fmt;
+ uint8_t *data;
+ bool dirty;
+ uint64_t generation; /* bumped on geometry change; forces sink rebuild */
+} KqsSurface;
+
+void kqs_surface_init(KqsSurface *s);
+void kqs_surface_clear(KqsSurface *s);
+
+bool kqs_surface_scanout(KqsSurface *s, uint32_t width, uint32_t height,
+ uint32_t stride, uint32_t pixman_format,
+ const uint8_t *data, gsize len, GError **error);
+
+/*
+ * Grow the surface to a geometry QEMU reported but never scanned out.
+ *
+ * A mode change only produces a Scanout when the damage happens to cover the
+ * whole screen; text modes redraw in pieces, so the geometry changes and only
+ * Updates arrive. Those get clipped to the old surface - which is how GRUB
+ * loses its right-hand columns.
+ */
+bool kqs_surface_resize(KqsSurface *s, uint32_t width, uint32_t height,
+ GError **error);
+
+bool kqs_surface_update(KqsSurface *s, int x, int y, int width, int height,
+ uint32_t stride, uint32_t pixman_format,
+ const uint8_t *data, gsize len, GError **error);
+
+/* ----------------------------------------------------------------- dmabuf */
+
+#define KQS_DMABUF_MAX_PLANES 4
+
+/*
+ * A QEMU ScanoutDMABUF2 descriptor. Owns its file descriptors.
+ *
+ * QEMU re-sends this only when the scanout buffer itself changes; damage in
+ * between arrives as UpdateDMABUF, which carries no payload because the
+ * pixels are already visible to us through these fds.
+ */
+typedef struct KqsDmabuf {
+ int fds[KQS_DMABUF_MAX_PLANES];
+ int n_fds;
+ uint32_t offsets[KQS_DMABUF_MAX_PLANES];
+ uint32_t strides[KQS_DMABUF_MAX_PLANES];
+ int num_planes;
+ uint32_t fourcc;
+ uint64_t modifier;
+ int x, y, width, height;
+ int backing_width, backing_height;
+ bool y0_top;
+ bool valid;
+ bool dirty;
+ uint64_t generation;
+} KqsDmabuf;
+
+void kqs_dmabuf_init(KqsDmabuf *d);
+void kqs_dmabuf_clear(KqsDmabuf *d);
+
+uint32_t kqs_fourcc_opaque(uint32_t fourcc);
+enum AVPixelFormat kqs_fourcc_to_av(uint32_t fourcc);
+const char *kqs_fourcc_str(uint32_t fourcc, char buf[5]);
+
+typedef struct KqsHwCtx KqsHwCtx;
+
+KqsHwCtx *kqs_hw_new(const char *render_node, GError **error);
+void kqs_hw_free(KqsHwCtx *h);
+AVBufferRef *kqs_hw_vaapi_device(KqsHwCtx *h);
+AVFrame *kqs_dmabuf_to_vaapi(KqsHwCtx *h, const KqsDmabuf *d,
+ int out_w, int out_h, GError **error);
+
+/* ------------------------------------------------------------------- sink */
+
+/*
+ * txproto encoder + muxer, fed by pushing AVFrames straight into the
+ * encoder's public src_frames FIFO. Built lazily on the first frame, because
+ * geometry and pixel format are only known once QEMU has sent a Scanout.
+ */
+typedef struct KqsSink KqsSink;
+
+typedef struct KqsSinkConfig {
+ const char *encoder; /* libx264, h264_vaapi, hevc_nvenc, ... */
+ const char *out_url;
+ const char *out_format; /* NULL lets libavformat guess from out_url */
+ const char *kymux_uri; /* kymux://host:port/hex-endpoint; wins over out_url */
+ int bitrate_kbps;
+ int fps;
+ /*
+ * Encoder geometry, when fixed ahead of time. A guest changes resolution
+ * as it boots, and the encoder cannot follow: rebuilding it mid-session
+ * kills the pipeline. Building at the first frame's size therefore locks
+ * the stream to GRUB's 640x480 and upscales the desktop from it.
+ *
+ * Pinning the geometry instead means every frame is scaled into it, so the
+ * boot modes are upscaled - briefly, and nobody is reading GRUB closely -
+ * and the resolution the guest settles on arrives 1:1. Zero means "take it
+ * from the first frame", the old behaviour.
+ */
+ int out_w, out_h;
+ KqsHwCtx *hw; /* non-NULL selects the zero-copy path */
+} KqsSinkConfig;
+
+KqsSink *kqs_sink_new(const KqsSinkConfig *cfg);
+bool kqs_sink_push(KqsSink *sink, const KqsSurface *s, int64_t pts_us,
+ GError **error);
+bool kqs_sink_push_dmabuf(KqsSink *sink, const KqsDmabuf *d, int64_t pts_us,
+ GError **error);
+void kqs_sink_stats(const KqsSink *sink, uint64_t *pushed, uint64_t *dropped);
+void kqs_sink_free(KqsSink *sink);
+
+/* --- audio ------------------------------------------------------------- */
+/*
+ * Guest audio, from QEMU's D-Bus audio out listener to a kymux endpoint of
+ * its own. Independent of the video path: Kyber gives audio a separate
+ * endpoint, so the two never share a muxer.
+ */
+typedef struct KqsAudio KqsAudio;
+
+KqsAudio *kqs_audio_new(GDBusConnection *bus, const char *bus_name,
+ const char *kymux_uri, int bitrate_bps,
+ GError **error);
+void kqs_audio_stats(const KqsAudio *a, uint64_t *frames, uint64_t *dropped);
+void kqs_audio_free(KqsAudio *a);
+
+/* --------------------------------------------------------------- listener */
+
+typedef struct KqsListener KqsListener;
+
+/*
+ * Connect to a running QEMU, register as a display listener, and drive the
+ * sink. `bus_name` is normally "org.qemu"; `console` selects Console_N.
+ */
+KqsListener *kqs_listener_new(GDBusConnection *bus, const char *bus_name,
+ int console, KqsSink *sink, int fps,
+ bool want_dmabuf, GMainLoop *loop,
+ GError **error);
+void kqs_listener_free(KqsListener *l);
diff --git a/src/listener.c b/src/listener.c
new file mode 100644
index 0000000..839c16f
--- /dev/null
+++ b/src/listener.c
@@ -0,0 +1,568 @@
+/*
+ * QEMU D-Bus display listener.
+ *
+ * QEMU's Console.RegisterListener takes one end of a socketpair and brings up
+ * a peer-to-peer D-Bus connection on it, acting as the authentication SERVER
+ * (ui/dbus-console.c). We are therefore the CLIENT on our end, and we export
+ * org.qemu.Display1.Listener for QEMU to call into.
+ *
+ * SPDX-License-Identifier: LGPL-2.1-or-later
+ */
+
+#include <errno.h>
+#include <sys/socket.h>
+#include <unistd.h>
+
+#include <gio/gunixfdlist.h>
+
+#include "kqs.h"
+
+/*
+ * The `Interfaces` property is how QEMU chooses what to send us: it calls
+ * dbus_display_listener_implements() before setting up shared maps or DMA-BUF
+ * (see setup_shared_map and setup_scanout_dmabuf_v2 in ui/dbus-listener.c).
+ *
+ * Advertising nothing keeps QEMU on Scanout/Update, which works with any
+ * display device and needs no guest driver support. Advertising
+ * ScanoutDMABUF2 asks for scanout file descriptors instead - which QEMU only
+ * has when it is running a GL-capable device, so it needs -display dbus,gl=on
+ * with virtio-vga-gl and a guest driving virtio-gpu 3D.
+ */
+static const char kqs_listener_xml[] =
+ "<node>"
+ " <interface name='org.qemu.Display1.Listener'>"
+ " <method name='Scanout'>"
+ " <arg type='u' name='width' direction='in'/>"
+ " <arg type='u' name='height' direction='in'/>"
+ " <arg type='u' name='stride' direction='in'/>"
+ " <arg type='u' name='pixman_format' direction='in'/>"
+ " <arg type='ay' name='data' direction='in'/>"
+ " </method>"
+ " <method name='Update'>"
+ " <arg type='i' name='x' direction='in'/>"
+ " <arg type='i' name='y' direction='in'/>"
+ " <arg type='i' name='width' direction='in'/>"
+ " <arg type='i' name='height' direction='in'/>"
+ " <arg type='u' name='stride' direction='in'/>"
+ " <arg type='u' name='pixman_format' direction='in'/>"
+ " <arg type='ay' name='data' direction='in'/>"
+ " </method>"
+ " <method name='Disable'/>"
+ " <method name='MouseSet'>"
+ " <arg type='i' name='x' direction='in'/>"
+ " <arg type='i' name='y' direction='in'/>"
+ " <arg type='i' name='on' direction='in'/>"
+ " </method>"
+ " <method name='CursorDefine'>"
+ " <arg type='i' name='width' direction='in'/>"
+ " <arg type='i' name='height' direction='in'/>"
+ " <arg type='i' name='hot_x' direction='in'/>"
+ " <arg type='i' name='hot_y' direction='in'/>"
+ " <arg type='ay' name='data' direction='in'/>"
+ " </method>"
+ " <method name='UpdateDMABUF'>"
+ " <arg type='i' name='x' direction='in'/>"
+ " <arg type='i' name='y' direction='in'/>"
+ " <arg type='i' name='width' direction='in'/>"
+ " <arg type='i' name='height' direction='in'/>"
+ " </method>"
+ " <property name='Interfaces' type='as' access='read'/>"
+ " </interface>"
+ " <interface name='org.qemu.Display1.Listener.Unix.ScanoutDMABUF2'>"
+ " <method name='ScanoutDMABUF2'>"
+ " <arg type='ah' name='dmabuf' direction='in'/>"
+ " <arg type='u' name='x' direction='in'/>"
+ " <arg type='u' name='y' direction='in'/>"
+ " <arg type='u' name='width' direction='in'/>"
+ " <arg type='u' name='height' direction='in'/>"
+ " <arg type='au' name='offset' direction='in'/>"
+ " <arg type='au' name='stride' direction='in'/>"
+ " <arg type='u' name='num_planes' direction='in'/>"
+ " <arg type='u' name='fourcc' direction='in'/>"
+ " <arg type='u' name='backing_width' direction='in'/>"
+ " <arg type='u' name='backing_height' direction='in'/>"
+ " <arg type='t' name='modifier' direction='in'/>"
+ " <arg type='b' name='y0_top' direction='in'/>"
+ " </method>"
+ " </interface>"
+ "</node>";
+
+struct KqsListener {
+ GDBusConnection *peer;
+ GDBusNodeInfo *node;
+ guint reg_id;
+ guint tick_id;
+
+ KqsSurface surface;
+ KqsDmabuf dmabuf;
+ bool want_dmabuf;
+ guint dmabuf_reg_id;
+ KqsSink *sink;
+ GMainLoop *loop;
+
+ int64_t epoch_us;
+ uint64_t scanouts;
+ uint64_t updates;
+ uint64_t dmabuf_scanouts;
+
+ /* Last logged dmabuf layout, so a change in it gets a line of its own. */
+ uint32_t logged_fourcc;
+ uint64_t logged_modifier;
+ uint32_t logged_bw, logged_bh;
+
+ /* Last logged scanout geometry, so mode changes are reported once each. */
+ guint32 last_w, last_h;
+};
+
+/* ------------------------------------------------------------ dispatching */
+
+static const uint8_t *fixed_bytes(GVariant *v, gsize *len)
+{
+ return g_variant_get_fixed_array(v, len, sizeof(uint8_t));
+}
+
+/*
+ * QEMU passes scanout fds out-of-band; the 'ah' arguments are indices into
+ * the message's fd list. We take ownership of the dup'd fds and hold them
+ * until the next scanout replaces them.
+ */
+static void handle_scanout_dmabuf2(KqsListener *l, GVariant *params,
+ GDBusMethodInvocation *inv)
+{
+ g_autoptr(GVariant) fd_idx = NULL;
+ g_autoptr(GVariant) offsets = NULL;
+ g_autoptr(GVariant) strides = NULL;
+ guint32 x, y, w, h, num_planes, fourcc, bw, bh;
+ guint64 modifier;
+ gboolean y0_top;
+
+ g_variant_get(params, "(@ahuuuu@au@auuuuutb)",
+ &fd_idx, &x, &y, &w, &h, &offsets, &strides,
+ &num_planes, &fourcc, &bw, &bh, &modifier, &y0_top);
+
+ GDBusMessage *msg = g_dbus_method_invocation_get_message(inv);
+ GUnixFDList *fds = g_dbus_message_get_unix_fd_list(msg);
+
+ gsize n_idx = g_variant_n_children(fd_idx);
+ if (!fds || n_idx == 0 || n_idx > KQS_DMABUF_MAX_PLANES ||
+ num_planes == 0 || num_planes > KQS_DMABUF_MAX_PLANES) {
+ g_dbus_method_invocation_return_error(
+ inv, G_DBUS_ERROR, G_DBUS_ERROR_INVALID_ARGS,
+ "bad dmabuf descriptor (%" G_GSIZE_FORMAT " fds, %u planes)",
+ n_idx, num_planes);
+ return;
+ }
+
+ KqsDmabuf d;
+ kqs_dmabuf_init(&d);
+
+ for (gsize i = 0; i < n_idx; i++) {
+ gint32 handle;
+ g_variant_get_child(fd_idx, i, "h", &handle);
+
+ GError *err = NULL;
+ int fd = g_unix_fd_list_get(fds, handle, &err); /* dup'd for us */
+ if (fd < 0) {
+ g_warning("ScanoutDMABUF2: fd %d unavailable: %s", handle,
+ err ? err->message : "?");
+ g_clear_error(&err);
+ kqs_dmabuf_clear(&d);
+ g_dbus_method_invocation_return_error(
+ inv, G_DBUS_ERROR, G_DBUS_ERROR_INVALID_ARGS, "bad fd");
+ return;
+ }
+ d.fds[i] = fd;
+ }
+ d.n_fds = (int)n_idx;
+
+ for (guint32 p = 0; p < num_planes; p++) {
+ g_variant_get_child(offsets, p, "u", &d.offsets[p]);
+ g_variant_get_child(strides, p, "u", &d.strides[p]);
+ }
+
+ d.num_planes = (int)num_planes;
+ d.fourcc = fourcc;
+ d.modifier = modifier;
+ d.x = (int)x; d.y = (int)y;
+ d.width = (int)w; d.height = (int)h;
+ d.backing_width = (int)bw;
+ d.backing_height = (int)bh;
+ d.y0_top = y0_top;
+ d.valid = true;
+ d.dirty = true;
+
+ /* Geometry change forces the sink to rebuild its pipeline. */
+ bool geometry_changed = !l->dmabuf.valid ||
+ l->dmabuf.backing_width != d.backing_width ||
+ l->dmabuf.backing_height != d.backing_height ||
+ l->dmabuf.fourcc != d.fourcc;
+
+ d.generation = l->dmabuf.generation + (geometry_changed ? 1 : 0);
+
+ kqs_dmabuf_clear(&l->dmabuf); /* closes the previous fds */
+ l->dmabuf = d;
+
+ /*
+ * Log the first scanout, and then every time the layout changes. Logging
+ * only the first one hid the buffer that actually matters: the guest can
+ * switch fourcc mid-session - XRGB for some modes, ARGB for others - and
+ * when the ARGB one fails to import, the only descriptor in the log is the
+ * XRGB one that worked.
+ */
+ if (l->dmabuf_scanouts++ == 0 ||
+ fourcc != l->logged_fourcc || modifier != l->logged_modifier ||
+ bw != l->logged_bw || bh != l->logged_bh) {
+ char fcc[5];
+ g_message("dmabuf scanout: %ux%u (backing %ux%u) fourcc '%s' "
+ "modifier 0x%" G_GINT64_MODIFIER "x planes=%u fds=%d "
+ "offsets=[%u,%u] strides=[%u,%u] y0_top=%d",
+ w, h, bw, bh, kqs_fourcc_str(fourcc, fcc),
+ (guint64)modifier, num_planes, d.n_fds,
+ d.offsets[0], num_planes > 1 ? d.offsets[1] : 0,
+ d.strides[0], num_planes > 1 ? d.strides[1] : 0,
+ (int)d.y0_top);
+ l->logged_fourcc = fourcc;
+ l->logged_modifier = modifier;
+ l->logged_bw = bw;
+ l->logged_bh = bh;
+ }
+
+ g_dbus_method_invocation_return_value(inv, NULL);
+}
+
+static void handle_call(GDBusConnection *conn, const char *sender,
+ const char *path, const char *iface,
+ const char *method, GVariant *params,
+ GDBusMethodInvocation *inv, gpointer user_data)
+{
+ KqsListener *l = user_data;
+ GError *err = NULL;
+
+ if (g_str_equal(method, "Scanout")) {
+ guint32 w, h, stride, fmt;
+ g_autoptr(GVariant) data = NULL;
+ g_variant_get(params, "(uuuu@ay)", &w, &h, &stride, &fmt, &data);
+
+ gsize len;
+ const uint8_t *bytes = fixed_bytes(data, &len);
+
+ if (!kqs_surface_scanout(&l->surface, w, h, stride, fmt, bytes, len,
+ &err)) {
+ g_warning("Scanout rejected: %s", err->message);
+ g_clear_error(&err);
+ } else if (l->scanouts++ == 0 || w != l->last_w || h != l->last_h) {
+ /*
+ * Every geometry change, not just the first: a stride that is not
+ * width*bpp means QEMU is handing us a wider backing store than
+ * the mode, and scaling from the mode's width then drops the right
+ * edge. Text modes are where that shows up.
+ */
+ g_message("scanout: %ux%u stride=%u (%u expected) pixman=0x%08x -> %s",
+ w, h, stride, w * l->surface.bpp, fmt,
+ av_get_pix_fmt_name(l->surface.pix_fmt));
+ l->last_w = w;
+ l->last_h = h;
+ }
+
+ g_dbus_method_invocation_return_value(inv, NULL);
+ return;
+ }
+
+ if (g_str_equal(method, "Update")) {
+ gint32 x, y, w, h;
+ guint32 stride, fmt;
+ g_autoptr(GVariant) data = NULL;
+ g_variant_get(params, "(iiiiuu@ay)", &x, &y, &w, &h, &stride, &fmt,
+ &data);
+
+ gsize len;
+ const uint8_t *bytes = fixed_bytes(data, &len);
+
+ /*
+ * Damage that runs past the surface means the guest changed mode and
+ * we were never told: QEMU only promotes an Update to a Scanout when
+ * the damage covers the whole screen, and a text mode redraws in
+ * pieces. Left alone, kqs_surface_update() clips to the old width and
+ * the right-hand columns are simply dropped - which is GRUB rendering
+ * with its right edge missing.
+ *
+ * Grow to fit instead. The size damage implies is a lower bound on the
+ * real mode, so this converges as further damage arrives.
+ */
+ if (x >= 0 && y >= 0 && w > 0 && h > 0 &&
+ (x + w > l->surface.width || y + h > l->surface.height)) {
+ const uint32_t nw = MAX(x + w, l->surface.width);
+ const uint32_t nh = MAX(y + h, l->surface.height);
+
+ g_message("update %dx%d+%d+%d exceeds %dx%d surface; growing to %ux%u",
+ w, h, x, y, l->surface.width, l->surface.height, nw, nh);
+
+ if (!kqs_surface_resize(&l->surface, nw, nh, &err)) {
+ g_warning("could not grow surface: %s", err->message);
+ g_clear_error(&err);
+ }
+ }
+
+ if (!kqs_surface_update(&l->surface, x, y, w, h, stride, fmt, bytes,
+ len, &err)) {
+ g_debug("Update rejected: %s", err->message);
+ g_clear_error(&err);
+ } else {
+ l->updates++;
+ }
+
+ g_dbus_method_invocation_return_value(inv, NULL);
+ return;
+ }
+
+ if (g_str_equal(method, "Disable")) {
+ g_message("console disabled by QEMU");
+ kqs_surface_clear(&l->surface);
+ g_dbus_method_invocation_return_value(inv, NULL);
+ return;
+ }
+
+ /* Cursor and pointer state belong to kynput in phase 3; accept and
+ * discard so QEMU does not treat us as broken. */
+ if (g_str_equal(method, "MouseSet") ||
+ g_str_equal(method, "CursorDefine")) {
+ g_dbus_method_invocation_return_value(inv, NULL);
+ return;
+ }
+
+ if (g_str_equal(method, "ScanoutDMABUF2")) {
+ handle_scanout_dmabuf2(l, params, inv);
+ return;
+ }
+
+ /*
+ * Damage on a dmabuf carries no payload: the pixels are already visible
+ * to us through the fds we hold. It is purely a "something changed" tick.
+ */
+ if (g_str_equal(method, "UpdateDMABUF")) {
+ if (l->dmabuf.valid) {
+ l->dmabuf.dirty = true;
+ l->updates++;
+ }
+ g_dbus_method_invocation_return_value(inv, NULL);
+ return;
+ }
+
+ g_dbus_method_invocation_return_error(inv, G_DBUS_ERROR,
+ G_DBUS_ERROR_UNKNOWN_METHOD,
+ "unhandled method %s", method);
+}
+
+static GVariant *handle_get_property(GDBusConnection *conn, const char *sender,
+ const char *path, const char *iface,
+ const char *prop, GError **error,
+ gpointer user_data)
+{
+ if (g_str_equal(prop, "Interfaces")) {
+ KqsListener *l = user_data;
+ const char *ifaces[] = {
+ "org.qemu.Display1.Listener.Unix.ScanoutDMABUF2", NULL
+ };
+ return l->want_dmabuf ? g_variant_new_strv(ifaces, 1)
+ : g_variant_new_strv(NULL, 0);
+ }
+
+ g_set_error(error, G_DBUS_ERROR, G_DBUS_ERROR_UNKNOWN_PROPERTY,
+ "unknown property %s", prop);
+ return NULL;
+}
+
+static const GDBusInterfaceVTable kqs_vtable = {
+ .method_call = handle_call,
+ .get_property = handle_get_property,
+ .set_property = NULL,
+};
+
+/* ----------------------------------------------------------------- pacing */
+
+/*
+ * QEMU emits damage as it happens; a video encoder wants a cadence, so emit on
+ * a timer.
+ *
+ * Skipping ticks where nothing changed looks like free money and is not. A
+ * client can join or rejoin at any moment - the controller restarts a stream on
+ * a resolution change, and the user can ask for one - and it has nothing to
+ * decode until a keyframe arrives. Keyframes come out of the encoder's keyint
+ * schedule, which only advances when we feed it. So on a guest that is merely
+ * sitting there, an encoder gated on damage emits nothing at all and the new
+ * client stays black indefinitely, however healthy the rest of the pipeline is.
+ * The controller does ask for an IDR at exactly these moments, but we have no
+ * runtime control channel to the encoder to honour it.
+ *
+ * Feeding unchanged frames is cheap where it counts: static content is all
+ * skipped macroblocks, tens of bytes per frame, so it costs bitrate close to
+ * nothing. It does cost CPU for the pixel conversion, which is what --fps is
+ * for.
+ */
+static gboolean on_tick(gpointer user_data)
+{
+ KqsListener *l = user_data;
+ GError *err = NULL;
+ const int64_t pts = g_get_monotonic_time() - l->epoch_us;
+ bool ok;
+
+ if (l->dmabuf.valid) {
+ ok = kqs_sink_push_dmabuf(l->sink, &l->dmabuf, pts, &err);
+ l->dmabuf.dirty = false;
+ } else {
+ if (!l->surface.data)
+ return G_SOURCE_CONTINUE;
+ ok = kqs_sink_push(l->sink, &l->surface, pts, &err);
+ l->surface.dirty = false;
+ }
+
+ if (!ok) {
+ g_warning("sink: %s", err->message);
+ g_clear_error(&err);
+ g_main_loop_quit(l->loop);
+ l->tick_id = 0; /* GLib drops it for us; do not remove twice */
+ return G_SOURCE_REMOVE;
+ }
+
+ return G_SOURCE_CONTINUE;
+}
+
+static void on_peer_closed(GDBusConnection *conn, gboolean remote,
+ GError *error, gpointer user_data)
+{
+ KqsListener *l = user_data;
+
+ g_message("QEMU closed the listener connection%s%s",
+ error ? ": " : "", error ? error->message : "");
+ g_main_loop_quit(l->loop);
+}
+
+/* ------------------------------------------------------------------ setup */
+
+KqsListener *kqs_listener_new(GDBusConnection *bus, const char *bus_name,
+ int console, KqsSink *sink, int fps,
+ bool want_dmabuf, GMainLoop *loop,
+ GError **error)
+{
+ int sv[2];
+ if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, sv) != 0) {
+ g_set_error(error, G_IO_ERROR, g_io_error_from_errno(errno),
+ "socketpair: %s", g_strerror(errno));
+ return NULL;
+ }
+
+ /* The fd list takes ownership of sv[1]. */
+ g_autoptr(GUnixFDList) fds = g_unix_fd_list_new_from_array(&sv[1], 1);
+ g_autofree char *path =
+ g_strdup_printf("/org/qemu/Display1/Console_%d", console);
+
+ g_autoptr(GVariant) reply = g_dbus_connection_call_with_unix_fd_list_sync(
+ bus, bus_name, path, "org.qemu.Display1.Console", "RegisterListener",
+ g_variant_new("(h)", 0), NULL, G_DBUS_CALL_FLAGS_NONE, -1,
+ fds, NULL, NULL, error);
+
+ if (!reply) {
+ close(sv[0]);
+ g_prefix_error(error, "RegisterListener on %s failed: ", path);
+ return NULL;
+ }
+
+ KqsListener *l = g_new0(KqsListener, 1);
+ l->sink = sink;
+ l->loop = loop;
+ l->want_dmabuf = want_dmabuf;
+ l->epoch_us = g_get_monotonic_time();
+ kqs_surface_init(&l->surface);
+ kqs_dmabuf_init(&l->dmabuf);
+
+ g_autoptr(GSocket) sock = g_socket_new_from_fd(sv[0], error);
+ if (!sock) {
+ close(sv[0]);
+ g_free(l);
+ return NULL;
+ }
+ g_autoptr(GSocketConnection) stream =
+ g_socket_connection_factory_create_connection(sock);
+
+ /*
+ * QEMU is the authentication server and generated the GUID, so pass NULL
+ * and connect as client. Delay message processing until the object is
+ * exported, or QEMU's first Scanout can arrive before we can answer it.
+ */
+ l->peer = g_dbus_connection_new_sync(
+ G_IO_STREAM(stream), NULL,
+ G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT |
+ G_DBUS_CONNECTION_FLAGS_DELAY_MESSAGE_PROCESSING,
+ NULL, NULL, error);
+
+ if (!l->peer) {
+ g_free(l);
+ return NULL;
+ }
+
+ g_dbus_connection_set_exit_on_close(l->peer, FALSE);
+
+ l->node = g_dbus_node_info_new_for_xml(kqs_listener_xml, error);
+ if (!l->node) {
+ kqs_listener_free(l);
+ return NULL;
+ }
+
+ l->reg_id = g_dbus_connection_register_object(
+ l->peer, KQS_LISTENER_PATH, l->node->interfaces[0], &kqs_vtable,
+ l, NULL, error);
+
+ if (l->reg_id == 0) {
+ kqs_listener_free(l);
+ return NULL;
+ }
+
+ if (want_dmabuf) {
+ GDBusInterfaceInfo *dmabuf_iface =
+ g_dbus_node_info_lookup_interface(
+ l->node, "org.qemu.Display1.Listener.Unix.ScanoutDMABUF2");
+
+ l->dmabuf_reg_id = g_dbus_connection_register_object(
+ l->peer, KQS_LISTENER_PATH, dmabuf_iface, &kqs_vtable, l, NULL,
+ error);
+
+ if (l->dmabuf_reg_id == 0) {
+ kqs_listener_free(l);
+ return NULL;
+ }
+ }
+
+ g_signal_connect(l->peer, "closed", G_CALLBACK(on_peer_closed), l);
+ g_dbus_connection_start_message_processing(l->peer);
+
+ l->tick_id = g_timeout_add(MAX(1, 1000 / fps), on_tick, l);
+
+ g_message("registered as display listener on %s (fps cap %d, %s path)",
+ path, fps, want_dmabuf ? "dmabuf" : "cpu");
+ return l;
+}
+
+void kqs_listener_free(KqsListener *l)
+{
+ if (!l)
+ return;
+
+ if (l->tick_id)
+ g_source_remove(l->tick_id);
+ if (l->dmabuf_reg_id && l->peer)
+ g_dbus_connection_unregister_object(l->peer, l->dmabuf_reg_id);
+ if (l->reg_id && l->peer)
+ g_dbus_connection_unregister_object(l->peer, l->reg_id);
+ if (l->node)
+ g_dbus_node_info_unref(l->node);
+ if (l->peer)
+ g_object_unref(l->peer);
+
+ g_message("listener: %" G_GUINT64_FORMAT " scanouts, %" G_GUINT64_FORMAT
+ " dmabuf scanouts, %" G_GUINT64_FORMAT " updates",
+ l->scanouts, l->dmabuf_scanouts, l->updates);
+
+ kqs_surface_clear(&l->surface);
+ kqs_dmabuf_clear(&l->dmabuf);
+ g_free(l);
+}
diff --git a/src/main.c b/src/main.c
new file mode 100644
index 0000000..1dea654
--- /dev/null
+++ b/src/main.c
@@ -0,0 +1,193 @@
+/*
+ * kyber-qemu-server - entry point.
+ *
+ * SPDX-License-Identifier: LGPL-2.1-or-later
+ */
+
+#include <signal.h>
+#include <stdlib.h>
+
+#include <glib-unix.h>
+
+#include "kqs.h"
+
+static char *opt_bus_name = NULL;
+static char *opt_address = NULL;
+static char *opt_out = NULL;
+static char *opt_format = NULL;
+static char *opt_encoder = NULL;
+static int opt_console = 0;
+static int opt_fps = 60;
+static int opt_bitrate = 8000;
+/*
+ * Default to a normal desktop mode rather than to the guest's first frame:
+ * see KqsSinkConfig.out_w. --width 0 restores follow-the-guest.
+ */
+static int opt_width = 1280;
+static int opt_height = 800;
+static gboolean opt_dmabuf = FALSE;
+static char *opt_render_node = NULL;
+static char *opt_kymux = NULL;
+static char *opt_kymux_audio = NULL;
+static int opt_audio_bitrate = 128000;
+
+static const GOptionEntry entries[] = {
+ { "bus-name", 'n', 0, G_OPTION_ARG_STRING, &opt_bus_name,
+ "QEMU bus name (default: org.qemu)", "NAME" },
+ { "address", 'a', 0, G_OPTION_ARG_STRING, &opt_address,
+ "connect to this D-Bus address instead of the session bus", "ADDR" },
+ { "console", 'c', 0, G_OPTION_ARG_INT, &opt_console,
+ "console index (default: 0)", "N" },
+ { "out", 'o', 0, G_OPTION_ARG_STRING, &opt_out,
+ "output URL (default: kqs.mkv)", "URL" },
+ { "format", 'f', 0, G_OPTION_ARG_STRING, &opt_format,
+ "muxer format; guessed from --out if omitted", "FMT" },
+ { "encoder", 'e', 0, G_OPTION_ARG_STRING, &opt_encoder,
+ "encoder name (default: libx264)", "ENC" },
+ { "fps", 0, 0, G_OPTION_ARG_INT, &opt_fps,
+ "maximum frames emitted per second (default: 60)", "N" },
+ { "bitrate", 'b', 0, G_OPTION_ARG_INT, &opt_bitrate,
+ "target bitrate in kbps (default: 8000)", "KBPS" },
+ { "kymux", 'k', 0, G_OPTION_ARG_STRING, &opt_kymux,
+ "stream to kymux instead of a file, e.g. "
+ "kymux://127.0.0.1:9000/1 (endpoint is hex)", "URI" },
+ { "kymux-audio", 'A', 0, G_OPTION_ARG_STRING, &opt_kymux_audio,
+ "stream guest audio to this kymux endpoint instead of the display. "
+ "Needs QEMU started with -audiodev dbus", "URI" },
+ { "audio-bitrate", 0, 0, G_OPTION_ARG_INT, &opt_audio_bitrate,
+ "Opus bitrate in bits per second", "BPS" },
+ { "dmabuf", 'D', 0, G_OPTION_ARG_NONE, &opt_dmabuf,
+ "request DMA-BUF scanouts and encode on the GPU (needs QEMU "
+ "-display dbus,gl=on with a GL device)", NULL },
+ { "width", 0, 0, G_OPTION_ARG_INT, &opt_width,
+ "encoder width, 0 to follow the guest (default: 1280)", "PX" },
+ { "height", 0, 0, G_OPTION_ARG_INT, &opt_height,
+ "encoder height, 0 to follow the guest (default: 800)", "PX" },
+ { "render-node", 0, 0, G_OPTION_ARG_STRING, &opt_render_node,
+ "DRM render node for --dmabuf (default: /dev/dri/renderD128)", "PATH" },
+ { NULL }
+};
+
+static gboolean on_signal(gpointer loop)
+{
+ g_message("shutting down");
+ g_main_loop_quit(loop);
+ return G_SOURCE_REMOVE;
+}
+
+int main(int argc, char **argv)
+{
+ g_autoptr(GError) error = NULL;
+ g_autoptr(GOptionContext) octx =
+ g_option_context_new("- stream a QEMU guest display via txproto");
+
+ g_option_context_add_main_entries(octx, entries, NULL);
+ if (!g_option_context_parse(octx, &argc, &argv, &error)) {
+ g_printerr("%s\n", error->message);
+ return 2;
+ }
+
+ if (!opt_bus_name) opt_bus_name = g_strdup("org.qemu");
+ if (!opt_out) opt_out = g_strdup("kqs.mkv");
+ if (!opt_render_node) opt_render_node = g_strdup("/dev/dri/renderD128");
+ if (!opt_encoder)
+ opt_encoder = g_strdup(opt_dmabuf ? "h264_vaapi" : "libx264");
+
+ if (opt_fps < 1 || opt_fps > 240) {
+ g_printerr("--fps must be between 1 and 240\n");
+ return 2;
+ }
+
+ g_autoptr(GDBusConnection) bus = NULL;
+ if (opt_address) {
+ bus = g_dbus_connection_new_for_address_sync(
+ opt_address,
+ G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT |
+ G_DBUS_CONNECTION_FLAGS_MESSAGE_BUS_CONNECTION,
+ NULL, NULL, &error);
+ } else {
+ bus = g_bus_get_sync(G_BUS_TYPE_SESSION, NULL, &error);
+ }
+
+ if (!bus) {
+ g_printerr("cannot reach QEMU's bus: %s\n", error->message);
+ g_printerr("hint: start QEMU with -display dbus\n");
+ return 1;
+ }
+
+ if (opt_kymux_audio) {
+ /*
+ * Audio only. A process serves one or the other: txproto's context is
+ * process-global (see sink.c), and Kyber gives audio its own kymux
+ * endpoint anyway, so there is nothing to share.
+ */
+ g_autoptr(GMainLoop) aloop = g_main_loop_new(NULL, FALSE);
+ KqsAudio *audio = kqs_audio_new(bus, opt_bus_name, opt_kymux_audio,
+ opt_audio_bitrate, &error);
+ if (!audio) {
+ g_printerr("%s\n", error->message);
+ g_printerr("hint: start QEMU with -audiodev dbus and an audio device\n");
+ return 1;
+ }
+
+ g_unix_signal_add(SIGINT, on_signal, aloop);
+ g_unix_signal_add(SIGTERM, on_signal, aloop);
+ g_main_loop_run(aloop);
+
+ uint64_t frames = 0, dropped = 0;
+ kqs_audio_stats(audio, &frames, &dropped);
+ g_message("audio: %" G_GUINT64_FORMAT " frames encoded, %"
+ G_GUINT64_FORMAT " dropped", frames, dropped);
+ kqs_audio_free(audio);
+ return 0;
+ }
+
+ KqsHwCtx *hw = NULL;
+ if (opt_dmabuf) {
+ hw = kqs_hw_new(opt_render_node, &error);
+ if (!hw) {
+ g_printerr("%s\n", error->message);
+ return 1;
+ }
+ g_message("zero-copy path via %s", opt_render_node);
+ }
+
+ KqsSinkConfig scfg = {
+ .encoder = opt_encoder,
+ .out_url = opt_out,
+ .out_format = opt_format,
+ .kymux_uri = opt_kymux,
+ .bitrate_kbps = opt_bitrate,
+ .fps = opt_fps,
+ .out_w = opt_width,
+ .out_h = opt_height,
+ .hw = hw,
+ };
+
+ KqsSink *sink = kqs_sink_new(&scfg);
+ g_autoptr(GMainLoop) loop = g_main_loop_new(NULL, FALSE);
+
+ KqsListener *listener = kqs_listener_new(bus, opt_bus_name, opt_console,
+ sink, opt_fps, opt_dmabuf, loop,
+ &error);
+ if (!listener) {
+ g_printerr("%s\n", error->message);
+ kqs_sink_free(sink);
+ return 1;
+ }
+
+ g_unix_signal_add(SIGINT, on_signal, loop);
+ g_unix_signal_add(SIGTERM, on_signal, loop);
+
+ g_main_loop_run(loop);
+
+ uint64_t pushed = 0, dropped = 0;
+ kqs_sink_stats(sink, &pushed, &dropped);
+ g_message("frames: %" G_GUINT64_FORMAT " encoded, %" G_GUINT64_FORMAT
+ " dropped", pushed, dropped);
+
+ kqs_listener_free(listener);
+ kqs_sink_free(sink);
+ kqs_hw_free(hw);
+ return 0;
+}
diff --git a/src/sink.c b/src/sink.c
new file mode 100644
index 0000000..12d0244
--- /dev/null
+++ b/src/sink.c
@@ -0,0 +1,483 @@
+/*
+ * txproto sink: pushes guest frames into an encoder's src_frames FIFO.
+ *
+ * Two producers feed the same submit path:
+ * - the software path converts a KqsSurface with sws_scale
+ * - the zero-copy path maps a QEMU dmabuf straight into a VAAPI surface
+ *
+ * The ordering is not arbitrary, and getting it wrong hangs the process.
+ * tx_commit() initialises the muxer, the muxer needs stream parameters from
+ * the encoder, and the encoder only derives those in context_full_config(),
+ * which peeks the first frame off src_frames and blocks until one exists.
+ * So: create, link, seed one frame, THEN commit.
+ *
+ * SPDX-License-Identifier: LGPL-2.1-or-later
+ */
+
+#include <libtxproto/txproto.h>
+#include <libtxproto/encode.h>
+#include <libtxproto/fifo_frame.h>
+
+#include <libavutil/opt.h>
+#include <libswscale/swscale.h>
+
+#include "kqs.h"
+
+/* Encoder input format for the software path. */
+#define KQS_ENC_PIX_FMT AV_PIX_FMT_YUV420P
+
+struct KqsSink {
+ KqsSinkConfig cfg;
+ char *encoder_name;
+ char *out_url;
+ char *out_format;
+ char *kymux_uri;
+ KqsHwCtx *hw; /* borrowed; non-NULL means zero-copy */
+
+ TXMainContext *tx;
+ AVBufferRef *encoder;
+ AVBufferRef *muxer; /* file muxer or kymux packet sink */
+ AVBufferRef *fifo; /* borrowed: encoder's src_frames */
+
+ struct SwsContext *sws;
+ int sws_w, sws_h;
+ int sws_out_w, sws_out_h;
+ enum AVPixelFormat sws_in;
+
+ bool committed;
+ uint64_t built_generation;
+ int built_w, built_h;
+ int64_t last_pts; /* enforces strictly increasing timestamps */
+
+ uint64_t pushed;
+ uint64_t dropped;
+};
+
+KqsSink *kqs_sink_new(const KqsSinkConfig *cfg)
+{
+ KqsSink *s = g_new0(KqsSink, 1);
+
+ s->cfg = *cfg;
+ s->encoder_name = g_strdup(cfg->encoder);
+ s->out_url = g_strdup(cfg->out_url);
+ s->out_format = g_strdup(cfg->out_format);
+ s->kymux_uri = g_strdup(cfg->kymux_uri);
+ s->hw = cfg->hw;
+ s->sws_in = AV_PIX_FMT_NONE;
+ s->last_pts = INT64_MIN;
+
+ return s;
+}
+
+/*
+ * Drop the pipeline. `final` frees the txproto context itself; otherwise it is
+ * kept so a rebuild can reuse it.
+ *
+ * The context has to survive a rebuild. txproto's logging is process-global
+ * and sp_log_init() opens by tearing it down, so a second tx_init() is not a
+ * supported path - patches/0002 stops it segfaulting outright, but the
+ * pipeline it produced then ran without ever delivering a frame. Destroying
+ * just the components and rebuilding them in place keeps tx_init() to exactly
+ * one call per process, which is the only pattern txproto is exercised on.
+ */
+static void sink_teardown_full(KqsSink *s, bool final)
+{
+ if (s->tx) {
+ if (s->muxer)
+ tx_destroy(s->tx, &s->muxer);
+ if (s->encoder)
+ tx_destroy(s->tx, &s->encoder);
+
+ if (final) {
+ tx_free(s->tx);
+ s->tx = NULL;
+ }
+ }
+
+ s->encoder = s->muxer = s->fifo = NULL;
+ s->committed = false;
+ s->last_pts = INT64_MIN;
+}
+
+static void sink_teardown(KqsSink *s)
+{
+ sink_teardown_full(s, false);
+}
+
+/*
+ * Timer ticks can coalesce, so two frames occasionally carry the same
+ * microsecond. Muxers reject non-monotonic DTS, so nudge instead.
+ */
+static int64_t next_pts(KqsSink *s, int64_t pts_us)
+{
+ if (pts_us <= s->last_pts)
+ pts_us = s->last_pts + 1;
+ s->last_pts = pts_us;
+ return pts_us;
+}
+
+/*
+ * txproto reads pacing metadata from opaque_ref, not from an AVCodecContext -
+ * there isn't one when the first frame lands.
+ */
+static bool attach_timing(KqsSink *s, AVFrame *f, int64_t pts_us,
+ GError **error)
+{
+ FormatExtraData *fe = av_mallocz(sizeof(*fe));
+ if (!fe) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "extradata alloc");
+ return false;
+ }
+ fe->time_base = (AVRational){ 1, 1000000 };
+ fe->avg_frame_rate = (AVRational){ s->cfg.fps, 1 };
+ fe->bits_per_sample = 8;
+
+ f->opaque_ref = av_buffer_create((uint8_t *)fe, sizeof(*fe), NULL, NULL, 0);
+ if (!f->opaque_ref) {
+ av_free(fe);
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "opaque_ref");
+ return false;
+ }
+
+ f->pts = next_pts(s, pts_us);
+ return true;
+}
+
+/*
+ * Software path. txproto does no pixel conversion - encode.c asserts
+ * in_f->format == avctx->pix_fmt - so the producer converts.
+ */
+static AVFrame *surface_to_frame(KqsSink *s, const KqsSurface *surf,
+ int64_t pts_us, GError **error)
+{
+ /*
+ * Once committed, keep emitting the geometry the encoder was built with
+ * and rescale into it. A mid-session teardown is not an option on the
+ * kymux path: the endpoint and codec configuration are negotiated once
+ * per session, so rebuilding drops the stream the client is decoding.
+ */
+ const int out_w = s->committed ? s->built_w
+ : s->cfg.out_w ? s->cfg.out_w : surf->width;
+ const int out_h = s->committed ? s->built_h
+ : s->cfg.out_h ? s->cfg.out_h : surf->height;
+
+ if (!s->sws || s->sws_w != surf->width || s->sws_h != surf->height ||
+ s->sws_in != surf->pix_fmt || s->sws_out_w != out_w ||
+ s->sws_out_h != out_h) {
+ sws_freeContext(s->sws);
+ s->sws = sws_getContext(surf->width, surf->height, surf->pix_fmt,
+ out_w, out_h, KQS_ENC_PIX_FMT,
+ SWS_BILINEAR, NULL, NULL, NULL);
+ if (!s->sws) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+ "sws_getContext failed");
+ return NULL;
+ }
+ s->sws_w = surf->width;
+ s->sws_h = surf->height;
+ s->sws_in = surf->pix_fmt;
+ s->sws_out_w = out_w;
+ s->sws_out_h = out_h;
+ }
+
+ AVFrame *f = av_frame_alloc();
+ if (!f) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "av_frame_alloc");
+ return NULL;
+ }
+
+ f->width = out_w;
+ f->height = out_h;
+ f->format = KQS_ENC_PIX_FMT;
+
+ if (av_frame_get_buffer(f, 0) < 0) {
+ av_frame_free(&f);
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "frame buffer alloc");
+ return NULL;
+ }
+
+ const uint8_t *src[4] = { surf->data, NULL, NULL, NULL };
+ const int src_stride[4] = { surf->stride, 0, 0, 0 };
+ sws_scale(s->sws, src, src_stride, 0, surf->height, f->data, f->linesize);
+
+ if (!attach_timing(s, f, pts_us, error)) {
+ av_frame_free(&f);
+ return NULL;
+ }
+ return f;
+}
+
+static bool sink_build(KqsSink *s, AVFrame *seed, uint64_t generation,
+ GError **error)
+{
+ /* One context per process: see sink_teardown_full(). */
+ if (!s->tx) {
+ s->tx = tx_new();
+ if (!s->tx || tx_init(s->tx) < 0) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "tx_init failed");
+ return false;
+ }
+ }
+
+ AVDictionary *copts = NULL;
+ av_dict_set_int(&copts, "b", (int64_t)s->cfg.bitrate_kbps * 1000, 0);
+ if (!s->hw) {
+ /* x264 knobs; VAAPI rejects them. */
+ av_dict_set(&copts, "preset", "ultrafast", 0);
+ av_dict_set(&copts, "tune", "zerolatency", 0);
+ }
+
+ /*
+ * A client joining mid-stream cannot decode until it sees an IDR, and
+ * x264's default GOP of 250 frames can be many seconds away - which shows
+ * up as a black window that never resolves. Until force-IDR is plumbed
+ * through from the controller, emit one per second so a late joiner syncs
+ * promptly. Cheap here: an idle desktop codes an IDR in a few hundred
+ * bytes.
+ */
+ av_dict_set_int(&copts, "g", s->cfg.fps, 0);
+
+ TxEncoderOptions eopts = {
+ .enc_name = s->encoder_name,
+ .name = "guest",
+ .options = copts,
+ /*
+ * Leave pix_fmt unset on the hardware path. txproto only
+ * auto-detects the surface's real sw_format when ctx->pix_fmt is
+ * NONE (encode.c:101); setting it to AV_PIX_FMT_VAAPI overrides that
+ * and ends up as hwfc->sw_format = vaapi, which cannot init.
+ */
+ .pix_fmt = s->hw ? AV_PIX_FMT_NONE : KQS_ENC_PIX_FMT,
+ };
+
+ s->encoder = tx_encoder_create(s->tx, &eopts);
+ if (!s->encoder) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+ "encoder '%s' unavailable", s->encoder_name);
+ return false;
+ }
+
+ /*
+ * kymux carries codec configuration out-of-band, so it needs SPS/PPS in
+ * extradata rather than inline: sp_packet_sink_set_encoding_ctx() refuses
+ * an encoder without AV_CODEC_FLAG_GLOBAL_HEADER ("Packet sink requires
+ * global header"). The flag is applied when the encoder configures itself
+ * from the first frame, so set it now, before anything is pushed.
+ */
+ if (s->kymux_uri)
+ ((EncodingContext *)s->encoder->data)->need_global_header = 1;
+
+ /*
+ * Two possible sinks. The packet sink is what Kyber's own AV server uses
+ * (kyavservice/src/kymux.rs): a TCP connection to kymux carrying framed
+ * packets, which kymux then muxes onto its QUIC session to the client.
+ * The file muxer is for offline inspection.
+ */
+ if (s->kymux_uri) {
+ s->muxer = tx_packetsink_create(s->tx, s->kymux_uri);
+ if (!s->muxer) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+ "cannot connect packet sink to '%s' "
+ "(is kymux listening?)", s->kymux_uri);
+ return false;
+ }
+ } else {
+ s->muxer = tx_muxer_create(s->tx, s->out_url, s->out_format, NULL, NULL);
+ if (!s->muxer) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+ "cannot open output '%s'", s->out_url);
+ return false;
+ }
+ }
+
+ if (tx_link(s->tx, s->encoder, s->muxer,
+ &(TXLinkOptions){ .autostart = 1 }) < 0) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "tx_link failed");
+ return false;
+ }
+
+ /*
+ * The hardware path used to stop here: a virtio-gpu scanout is packed RGB,
+ * h264_vaapi has no RGB input profile, and it answered "Input surface
+ * format is rgba" then "No usable encoding profile found".
+ *
+ * A txproto filtergraph running scale_vaapi between the import and the
+ * encoder was the obvious shape and deadlocks - seeding the filter's input
+ * does not give the encoder a frame to configure from, so tx_commit()
+ * blocks exactly as it does on an empty encoder FIFO, one stage deeper.
+ * The conversion happens in kqs_dmabuf_to_vaapi() instead, which hands us
+ * NV12 and leaves this a plain encoder that can still be seeded directly.
+ */
+
+ s->fifo = ((EncodingContext *)s->encoder->data)->src_frames;
+
+ /* Seed before commit, or commit deadlocks. See file header. */
+ int err = sp_frame_fifo_push(s->fifo, seed);
+ if (err < 0) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+ "seed push failed (%d)", err);
+ return false;
+ }
+
+ if (tx_commit(s->tx) < 0) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "tx_commit failed");
+ return false;
+ }
+
+ s->committed = true;
+ s->built_generation = generation;
+ s->built_w = seed->width;
+ s->built_h = seed->height;
+ s->pushed++;
+
+ g_message("sink: %dx%d %s -> %s (%s, %d kbps)",
+ seed->width, seed->height,
+ av_get_pix_fmt_name(seed->format),
+ s->kymux_uri ? s->kymux_uri : s->out_url,
+ s->encoder_name, s->cfg.bitrate_kbps);
+
+ return true;
+}
+
+/*
+ * Act on a guest resolution change, before any frame is converted.
+ *
+ * Returns false if this frame should be skipped entirely.
+ */
+static bool sink_check_geometry(KqsSink *s, uint64_t generation)
+{
+ if (!s->committed || generation == s->built_generation)
+ return true;
+
+ /*
+ * Rebuilding was impossible until txproto's sp_log_uninit() stopped
+ * leaving a stale component count behind (see patches/0002): the second
+ * tx_init() in a process segfaulted. Opt-in until a real client is shown
+ * to follow the new geometry - Kyber's README suggests restarting the
+ * video server on topology changes rather than reconfiguring live.
+ */
+ if (!s->kymux_uri || g_strcmp0(g_getenv("KQS_REBUILD"), "1") == 0) {
+ g_message("sink: geometry changed, rebuilding pipeline");
+ sink_teardown(s);
+ return true;
+ }
+
+ /*
+ * Rescale into the established size instead. The endpoint and codec
+ * configuration are negotiated once per session, so a mid-session
+ * teardown drops the stream the client is decoding.
+ */
+ g_message("sink: guest resized, rescaling to %dx%d", s->built_w, s->built_h);
+ s->built_generation = generation;
+ return true;
+}
+
+/*
+ * Common tail for both producers. Takes ownership of `f`.
+ *
+ * A guest resolution change is a teardown, not a reconfigure: the encoder
+ * derived geometry from its first frame and has no path to revise it.
+ */
+static bool sink_submit(KqsSink *s, AVFrame *f, uint64_t generation,
+ GError **error)
+{
+ bool ok = true;
+
+ if (!s->committed) {
+ ok = sink_build(s, f, generation, error);
+ av_frame_free(&f);
+ return ok;
+ }
+
+ int err = sp_frame_fifo_push(s->fifo, f);
+ av_frame_free(&f);
+
+ if (err == AVERROR(ENOBUFS)) {
+ /* Encoder is behind. Dropping is correct: never stall QEMU. */
+ s->dropped++;
+ return true;
+ }
+ if (err < 0) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+ "frame push failed (%d)", err);
+ return false;
+ }
+
+ s->pushed++;
+ return ok;
+}
+
+bool kqs_sink_push(KqsSink *s, const KqsSurface *surf, int64_t pts_us,
+ GError **error)
+{
+ if (!surf->data)
+ return true;
+
+ /*
+ * Decide about the geometry change before converting, not after. The
+ * conversion scales into the size the encoder was built at, so a frame
+ * made first would seed the rebuilt pipeline with the old geometry - the
+ * pipeline rebuilds, and comes back at exactly the size it was leaving.
+ */
+ if (!sink_check_geometry(s, surf->generation))
+ return true;
+
+ AVFrame *f = surface_to_frame(s, surf, pts_us, error);
+ if (!f)
+ return false;
+
+ return sink_submit(s, f, surf->generation, error);
+}
+
+bool kqs_sink_push_dmabuf(KqsSink *s, const KqsDmabuf *d, int64_t pts_us,
+ GError **error)
+{
+ if (!d->valid)
+ return true;
+
+ if (!s->hw) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+ "dmabuf frame with no hardware context");
+ return false;
+ }
+
+ /* Same rule as the CPU path: once committed, the encoder's geometry wins. */
+ const int out_w = s->committed ? s->built_w
+ : s->cfg.out_w ? s->cfg.out_w : d->backing_width;
+ const int out_h = s->committed ? s->built_h
+ : s->cfg.out_h ? s->cfg.out_h : d->backing_height;
+
+ AVFrame *f = kqs_dmabuf_to_vaapi(s->hw, d, out_w, out_h, error);
+ if (!f)
+ return false;
+
+ if (!attach_timing(s, f, pts_us, error)) {
+ av_frame_free(&f);
+ return false;
+ }
+
+ return sink_submit(s, f, d->generation, error);
+}
+
+void kqs_sink_stats(const KqsSink *s, uint64_t *pushed, uint64_t *dropped)
+{
+ if (pushed) *pushed = s->pushed;
+ if (dropped) *dropped = s->dropped;
+}
+
+void kqs_sink_free(KqsSink *s)
+{
+ if (!s)
+ return;
+
+ if (s->fifo)
+ sp_frame_fifo_push(s->fifo, NULL); /* flush sentinel */
+
+ sink_teardown_full(s, true);
+ sws_freeContext(s->sws);
+ g_free(s->encoder_name);
+ g_free(s->out_url);
+ g_free(s->out_format);
+ g_free(s->kymux_uri);
+ g_free(s);
+}
diff --git a/src/surface.c b/src/surface.c
new file mode 100644
index 0000000..101124c
--- /dev/null
+++ b/src/surface.c
@@ -0,0 +1,188 @@
+/*
+ * Guest display surface: applies QEMU Scanout/Update into a persistent image.
+ *
+ * SPDX-License-Identifier: LGPL-2.1-or-later
+ */
+
+#include <string.h>
+
+#include "kqs.h"
+
+enum AVPixelFormat kqs_pixman_to_av(uint32_t f)
+{
+ switch (f) {
+ /* 32-bit word ARGB -> memory B,G,R,A on little-endian */
+ case KQS_PIXMAN_a8r8g8b8: return AV_PIX_FMT_BGRA;
+ case KQS_PIXMAN_x8r8g8b8: return AV_PIX_FMT_BGR0;
+ case KQS_PIXMAN_a8b8g8r8: return AV_PIX_FMT_RGBA;
+ case KQS_PIXMAN_x8b8g8r8: return AV_PIX_FMT_RGB0;
+ default: return AV_PIX_FMT_NONE;
+ }
+}
+
+void kqs_surface_init(KqsSurface *s)
+{
+ memset(s, 0, sizeof(*s));
+ s->pix_fmt = AV_PIX_FMT_NONE;
+}
+
+void kqs_surface_clear(KqsSurface *s)
+{
+ g_free(s->data);
+ kqs_surface_init(s);
+}
+
+static bool surface_realloc(KqsSurface *s, uint32_t width, uint32_t height,
+ enum AVPixelFormat pf, GError **error)
+{
+ if (pf == AV_PIX_FMT_NONE) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED,
+ "unsupported pixel format");
+ return false;
+ }
+ if (width == 0 || height == 0 || width > 16384 || height > 16384) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
+ "implausible geometry %ux%u", width, height);
+ return false;
+ }
+
+ if (s->data && s->width == (int)width && s->height == (int)height &&
+ s->pix_fmt == pf)
+ return true; /* geometry unchanged */
+
+ /*
+ * Carry the old picture into the new buffer, scaled, instead of starting
+ * black.
+ *
+ * QEMU repaints by damage, and after a mode change it only sends the parts
+ * that change - it will not resend what it already drew. A blank buffer
+ * therefore stays blank wherever nothing happens to be repainted, which is
+ * what a black screen with a live mouse cursor actually is: the cursor's
+ * damage is the only thing arriving.
+ *
+ * Nearest-neighbour is deliberate. This content is wrong by definition -
+ * it is the previous mode's picture - and it only has to hold for the
+ * fraction of a second before real damage covers it. Quality is beside the
+ * point; not being black is the point.
+ */
+ uint8_t *old = s->data;
+ const int old_w = s->width;
+ const int old_h = s->height;
+ const int old_str = s->stride;
+ const enum AVPixelFormat old_pf = s->pix_fmt;
+
+ s->width = (int)width;
+ s->height = (int)height;
+ s->bpp = 4; /* every format above is 32bpp */
+ s->stride = s->width * s->bpp;
+ s->pix_fmt = pf;
+ s->data = g_malloc0((gsize)s->stride * s->height);
+ s->generation++; /* tells the sink to rebuild */
+
+ if (old && old_w > 0 && old_h > 0 && old_pf == pf) {
+ for (int y = 0; y < s->height; y++) {
+ const int sy = (int)((int64_t)y * old_h / s->height);
+ const uint8_t *src = old + (gsize)sy * old_str;
+ uint32_t *dst = (uint32_t *)(s->data + (gsize)y * s->stride);
+
+ for (int x = 0; x < s->width; x++) {
+ const int sx = (int)((int64_t)x * old_w / s->width);
+ dst[x] = ((const uint32_t *)src)[sx];
+ }
+ }
+ }
+
+ g_free(old);
+ return true;
+}
+
+bool kqs_surface_scanout(KqsSurface *s, uint32_t width, uint32_t height,
+ uint32_t stride, uint32_t pixman_format,
+ const uint8_t *data, gsize len, GError **error)
+{
+ enum AVPixelFormat pf = kqs_pixman_to_av(pixman_format);
+ if (pf == AV_PIX_FMT_NONE) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED,
+ "unsupported pixman format 0x%08x", pixman_format);
+ return false;
+ }
+
+ if (!surface_realloc(s, width, height, pf, error))
+ return false;
+
+ const gsize need = (gsize)stride * height;
+ if (len < need) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
+ "Scanout short by %" G_GSIZE_FORMAT " bytes "
+ "(got %" G_GSIZE_FORMAT ", need %" G_GSIZE_FORMAT ")",
+ need - len, len, need);
+ return false;
+ }
+
+ const int copy = MIN((int)stride, s->stride);
+ for (int y = 0; y < s->height; y++)
+ memcpy(s->data + (gsize)y * s->stride,
+ data + (gsize)y * stride, copy);
+
+ s->dirty = true;
+ return true;
+}
+
+bool kqs_surface_resize(KqsSurface *s, uint32_t width, uint32_t height,
+ GError **error)
+{
+ /* Nothing to grow into yet; the first Scanout will size us. */
+ if (!s->data || s->pix_fmt == AV_PIX_FMT_NONE)
+ return true;
+ if (s->width == (int)width && s->height == (int)height)
+ return true;
+
+ if (!surface_realloc(s, width, height, s->pix_fmt, error))
+ return false;
+
+ s->dirty = true;
+ return true;
+}
+
+bool kqs_surface_update(KqsSurface *s, int x, int y, int width, int height,
+ uint32_t stride, uint32_t pixman_format,
+ const uint8_t *data, gsize len, GError **error)
+{
+ if (!s->data) {
+ /* Update before Scanout: nothing to composite into. */
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_NOT_INITIALIZED,
+ "Update before any Scanout");
+ return false;
+ }
+
+ if (kqs_pixman_to_av(pixman_format) != s->pix_fmt) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
+ "Update format 0x%08x does not match surface",
+ pixman_format);
+ return false;
+ }
+
+ /* Clip to the surface; QEMU should never exceed it, but damage
+ * rectangles race geometry changes. */
+ if (x < 0) { width += x; x = 0; }
+ if (y < 0) { height += y; y = 0; }
+ if (x >= s->width || y >= s->height || width <= 0 || height <= 0)
+ return true;
+ width = MIN(width, s->width - x);
+ height = MIN(height, s->height - y);
+
+ const gsize need = (gsize)stride * (height - 1) + (gsize)width * s->bpp;
+ if (len < need) {
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
+ "Update short by %" G_GSIZE_FORMAT " bytes", need - len);
+ return false;
+ }
+
+ for (int row = 0; row < height; row++)
+ memcpy(s->data + (gsize)(y + row) * s->stride + (gsize)x * s->bpp,
+ data + (gsize)row * stride,
+ (gsize)width * s->bpp);
+
+ s->dirty = true;
+ return true;
+}
--
2.55.0
^ permalink raw reply related [flat|nested] 14+ messages in thread
* [RFC pve-kyberproxy 09/13] Add pve-kyberproxy
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-qemu-kyber 08/13] Add pve-qemu-kyber: an kyber controller for the qemu 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
` (3 subsequent siblings)
12 siblings, 0 replies; 14+ messages in thread
From: Alexandre Derumier @ 2026-08-25 11:34 UTC (permalink / raw)
To: pve-devel
Control plane: forwards the console's control plane from pveproxy to
the VM's controller.
Dataplane : terminates WebTransport on one UDP port, routing sessions by
token;
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=...
Signed-off-by: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
---
.gitignore | 6 +
Cargo.lock | 1870 +++++++++++++++++++
Cargo.toml | 53 +
Makefile | 74 +
debian/changelog | 5 +
debian/control | 24 +
debian/copyright | 20 +
debian/install | 1 +
debian/pve-kyberproxy.pvekyberproxy.service | 32 +
debian/pvekyberproxy.default | 12 +
debian/rules | 43 +
debian/source/format | 1 +
src/control.rs | 506 +++++
src/main.rs | 139 ++
src/relay.rs | 234 +++
src/webtransport.rs | 225 +++
16 files changed, 3245 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-kyberproxy.pvekyberproxy.service
create mode 100644 debian/pvekyberproxy.default
create mode 100755 debian/rules
create mode 100644 debian/source/format
create mode 100644 src/control.rs
create mode 100644 src/main.rs
create mode 100644 src/relay.rs
create mode 100644 src/webtransport.rs
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f1d47f0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+/target/
+/vendor/
+/pve-kyberproxy-[0-9]*/
+*.deb
+*.buildinfo
+*.changes
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..b1ad2be
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,1870 @@
+# 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 = "asn1-rs"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
+dependencies = [
+ "asn1-rs-derive",
+ "asn1-rs-impl",
+ "displaydoc",
+ "nom",
+ "num-traits",
+ "rusticata-macros",
+ "thiserror",
+ "time",
+]
+
+[[package]]
+name = "asn1-rs-derive"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "asn1-rs-impl"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bit-vec"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "block-buffer"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[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 = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[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 = "const-oid"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
+
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
+dependencies = [
+ "hybrid-array",
+]
+
+[[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 1.3.2",
+ "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",
+]
+
+[[package]]
+name = "der-parser"
+version = "10.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
+dependencies = [
+ "asn1-rs",
+ "displaydoc",
+ "nom",
+ "num-bigint",
+ "num-traits",
+ "rusticata-macros",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+
+[[package]]
+name = "digest"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
+dependencies = [
+ "block-buffer",
+ "const-oid",
+ "crypto-common",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[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 = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[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 = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi",
+ "wasip2",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "httlib-huffman"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a9fcbcc408c5526c3ab80d534e5c86e7967c1fb7aa0a8c76abd1edc27deb877"
+
+[[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 = "hybrid-array"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
+dependencies = [
+ "typenum",
+]
+
+[[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",
+ "want",
+]
+
+[[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 = "icu_collections"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+
+[[package]]
+name = "icu_properties"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
+
+[[package]]
+name = "icu_provider"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[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 = "js-sys"
+version = "0.3.85"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "litemap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[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 = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-integer"
+version = "0.1.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "octets"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "866cb5af6f3aa3c1b44c3c2d79d22165fbb1b102e1b3fb499864bfe34736ec4b"
+
+[[package]]
+name = "oid-registry"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
+dependencies = [
+ "asn1-rs",
+]
+
+[[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 = "openssl-probe"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
+[[package]]
+name = "pem"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
+dependencies = [
+ "base64",
+ "serde_core",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[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.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[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-kyberproxy"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "env_logger",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "libc",
+ "log",
+ "rcgen",
+ "serde",
+ "serde_json",
+ "tokio",
+ "tokio-rustls",
+ "wtransport",
+]
+
+[[package]]
+name = "quinn"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
+dependencies = [
+ "bytes",
+ "getrandom 0.3.4",
+ "lru-slab",
+ "rand",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "rand"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
+dependencies = [
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "rcgen"
+version = "0.14.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4"
+dependencies = [
+ "pem",
+ "ring",
+ "rustls-pki-types",
+ "time",
+ "x509-parser",
+ "yasna",
+]
+
+[[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 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rusticata-macros"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
+dependencies = [
+ "nom",
+]
+
+[[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-native-certs"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
+dependencies = [
+ "openssl-probe",
+ "rustls-pki-types",
+ "schannel",
+ "security-framework",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "web-time",
+ "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 = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "schannel"
+version = "0.1.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "security-framework"
+version = "3.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
+dependencies = [
+ "bitflags 2.13.1",
+ "core-foundation",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[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 = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "sha2"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
+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 = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[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 = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[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 = "time"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[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 = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[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 = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.2+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.108"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.108"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.108"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.108"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[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 = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+
+[[package]]
+name = "writeable"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+
+[[package]]
+name = "wtransport"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4273ce3157a3262a68665f8d3f20a0ac0c5b8a69ffd67f05ae986832ebec036"
+dependencies = [
+ "bytes",
+ "pem",
+ "quinn",
+ "rcgen",
+ "rustls",
+ "rustls-native-certs",
+ "rustls-pki-types",
+ "sha2",
+ "socket2",
+ "thiserror",
+ "time",
+ "tokio",
+ "tracing",
+ "url",
+ "wtransport-proto",
+ "x509-parser",
+]
+
+[[package]]
+name = "wtransport-proto"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aad9059572c7dbd6901ccef37f3b7321678cd708dcf58a64b1921dbeab7bfede"
+dependencies = [
+ "httlib-huffman",
+ "octets",
+ "thiserror",
+ "url",
+]
+
+[[package]]
+name = "x509-parser"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
+dependencies = [
+ "asn1-rs",
+ "data-encoding",
+ "der-parser",
+ "lazy_static",
+ "nom",
+ "oid-registry",
+ "ring",
+ "rusticata-macros",
+ "thiserror",
+ "time",
+]
+
+[[package]]
+name = "yasna"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
+dependencies = [
+ "bit-vec",
+ "time",
+]
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[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 = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..0f3807a
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,53 @@
+[package]
+name = "pve-kyberproxy"
+version = "0.1.0"
+edition = "2021"
+description = "QUIC front door for per-VM Kyber consoles on a Proxmox node"
+license = "AGPL-3.0-or-later"
+
+[[bin]]
+name = "pvekyberproxy"
+path = "src/main.rs"
+
+[features]
+# The unauthenticated TCP control plane, for working on this without a Proxmox
+# around it. On by default so a local checkout builds what the run script
+# expects; a package for a node is built with --no-default-features, and then
+# the flag does not exist rather than merely being unused. Its dependencies -
+# a TLS stack and a certificate generator - go with it.
+default = ["dev-tcp", "webtransport"]
+dev-tcp = ["dep:rcgen", "dep:tokio-rustls"]
+# The single-port data plane. Off leaves the per-session UDP relay in place.
+webtransport = ["dep:wtransport"]
+
+[dependencies]
+# Nothing from Kyber. The relay moves datagrams and the control plane moves
+# JSON, so neither half needs the protocol crates - which is the clearest sign
+# that not terminating was the right call.
+
+anyhow = "1"
+clap = { version = "4", features = ["derive", "env"] }
+env_logger = "0.11"
+http-body-util = "0.1"
+hyper = { version = "1", features = ["server", "client", "http1"] }
+hyper-util = { version = "0.1", features = ["tokio"] }
+# getgrnam, to hand the control socket to pveproxy's group by name. Already in
+# the tree as a transitive dependency; named here because we call it.
+libc = "0.2"
+log = "0.4"
+# Self-signed TLS for --insecure-listen-tcp only. The client builds every
+# control-plane URL as https:// with no way to say otherwise, so a plain
+# listener there is not a lesser version of the real thing - it is one no
+# client can talk to at all. ring rather than the default aws-lc-rs: it is a
+# throwaway certificate on loopback, and ring needs no C toolchain to build.
+rcgen = { version = "0.14", default-features = false, features = ["crypto", "pem", "ring"], optional = true }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "signal", "sync", "time"] }
+# A WebTransport server and client on quinn. Terminating is the price of one
+# UDP port for the node: nothing in an encrypted datagram says which VM it is
+# for, so the only way to share a port is to read the session's path, and the
+# only way to read it is to decrypt. Default features are self-signed + ring,
+# so no C toolchain and no CA.
+wtransport = { version = "0.7", optional = true }
+tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "logging", "tls12"], optional = true }
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..d86d080
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,74 @@
+include /usr/share/dpkg/architecture.mk
+include /usr/share/dpkg/pkg-info.mk
+
+PACKAGE=pve-kyberproxy
+ARCH:=$(DEB_HOST_ARCH)
+
+DEB=$(PACKAGE)_$(DEB_VERSION_UPSTREAM_REVISION)_$(ARCH).deb
+DEB_DBG=$(PACKAGE)-dbgsym_$(DEB_VERSION_UPSTREAM_REVISION)_$(ARCH).deb
+
+DSC=$(PACKAGE)_$(DEB_VERSION).dsc
+BUILDDIR=$(PACKAGE)-$(DEB_VERSION_UPSTREAM)
+
+all: $(DEB)
+
+# Dependencies are vendored rather than taken from librust-*-dev: the hyper 1.x
+# stack this needs is not packaged in Debian, and a console proxy is not a good
+# reason to ask for it to be. Same arrangement proxmox-biome uses.
+.PHONY: vendor
+vendor:
+ rm -rf vendor
+ cargo vendor --locked vendor
+
+.PHONY: builddir
+builddir:
+ rm -rf $(BUILDDIR)
+ $(MAKE) $(BUILDDIR)
+
+$(BUILDDIR): vendor
+ rm -rf $@ $@.tmp
+ mkdir $@.tmp
+ cp -a src/ Cargo.toml Cargo.lock $@.tmp/
+ cp -a debian/ $@.tmp/debian
+ cp -a vendor/ $@.tmp/vendor
+ mkdir -p $@.tmp/.cargo
+ printf '[source.crates-io]\nreplace-with = "vendored-sources"\n\n[source.vendored-sources]\ndirectory = "vendor"\n' \
+ > $@.tmp/.cargo/config.toml
+ mv $@.tmp $@
+
+.PHONY: deb
+deb: $(DEB)
+$(DEB) $(DEB_DBG) &: $(BUILDDIR)
+ cd $(BUILDDIR); dpkg-buildpackage -b -us -uc
+ lintian $(DEB)
+
+# 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: upload
+upload: UPLOAD_DIST ?= $(DEB_DISTRIBUTION)
+upload: $(DEB) $(DEB_DBG)
+ tar cf - $(DEB) $(DEB_DBG) | ssh repoman@repo.proxmox.com -- upload --product pve --dist $(UPLOAD_DIST) --arch $(ARCH)
+
+.PHONY: clean
+clean:
+ rm -rf *~ debian/*~ *.deb *.changes *.buildinfo $(PACKAGE)-[0-9]*/ vendor/
+ cargo clean
+
+.PHONY: distclean
+distclean: clean
diff --git a/debian/changelog b/debian/changelog
new file mode 100644
index 0000000..30572a2
--- /dev/null
+++ b/debian/changelog
@@ -0,0 +1,5 @@
+pve-kyberproxy (0.1.0) trixie; urgency=medium
+
+ * Initial release: QUIC front door for per-VM Kyber consoles.
+
+ -- Proxmox Support Team <support@proxmox.com> Tue, 18 Aug 2026 15:00:00 +0200
diff --git a/debian/control b/debian/control
new file mode 100644
index 0000000..77404be
--- /dev/null
+++ b/debian/control
@@ -0,0 +1,24 @@
+Source: pve-kyberproxy
+Section: admin
+Priority: optional
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Uploaders: Alexandre Derumier <aderumier@groupe-cyllene.com>
+Build-Depends: cargo,
+ debhelper-compat (= 13),
+Standards-Version: 4.7.0.0
+
+Package: pve-kyberproxy
+Architecture: any
+Depends: ${misc:Depends},
+ ${shlibs:Depends},
+Description: front door for Proxmox VE Kyber consoles
+ One daemon per node, in front of the per-VM Kyber console controllers.
+ .
+ It forwards the console's control plane, which arrives from pveproxy on a
+ unix socket, to the controller belonging to the VM named in the path, and
+ rewrites the one response that tells a client where its data plane lives.
+ .
+ The data plane itself is relayed rather than terminated: the daemon moves
+ UDP datagrams between the client and the controller, holding no key and
+ reassembling no stream, so the client's QUIC connection runs end to end and
+ stays one congestion-controlled path.
diff --git a/debian/copyright b/debian/copyright
new file mode 100644
index 0000000..8b5623f
--- /dev/null
+++ b/debian/copyright
@@ -0,0 +1,20 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+Upstream-Name: pve-kyberproxy
+
+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..9c75aa2
--- /dev/null
+++ b/debian/install
@@ -0,0 +1 @@
+target/release/pvekyberproxy usr/bin/
diff --git a/debian/pve-kyberproxy.pvekyberproxy.service b/debian/pve-kyberproxy.pvekyberproxy.service
new file mode 100644
index 0000000..d9b1e86
--- /dev/null
+++ b/debian/pve-kyberproxy.pvekyberproxy.service
@@ -0,0 +1,32 @@
+[Unit]
+Description=PVE Kyber Console Proxy
+# One per node. It routes by path and by port, so nothing restarts it when a
+# VM does.
+Documentation=file:///usr/share/doc/pve-kyberproxy/kyber-proxy.md
+After=network.target
+
+[Service]
+Type=simple
+# pveproxy has already authenticated the caller, so this socket's permissions
+# are what stand between an unprivileged process and every console on the node.
+# --insecure-listen-tcp must never appear here.
+# Node-local rather than datacenter.cfg: what decides it is the node's
+# firewall. Same arrangement as /etc/default/pveproxy.
+EnvironmentFile=-/etc/default/pvekyberproxy
+
+ExecStart=/usr/bin/pvekyberproxy \
+ --listen-socket /run/pvekyberproxy.sock \
+ --run-dir /var/run/qemu-server \
+ --socket-group www-data \
+ $DATAPLANE_ARGS
+
+# pveproxy runs as www-data; the controllers' sockets are root's.
+User=root
+Group=root
+RuntimeDirectory=pvekyberproxy
+
+Restart=on-failure
+RestartSec=1
+
+[Install]
+WantedBy=multi-user.target
diff --git a/debian/pvekyberproxy.default b/debian/pvekyberproxy.default
new file mode 100644
index 0000000..2907be7
--- /dev/null
+++ b/debian/pvekyberproxy.default
@@ -0,0 +1,12 @@
+# Configuration for pvekyberproxy, the Kyber console's front door.
+#
+# DATAPLANE_ARGS chooses how console video reaches a browser. Uncomment one.
+# Changing it restarts the daemon, which drops every open console.
+
+# One UDP port for the node: WebTransport is terminated here and each session
+# routed by the token in its path. Needs the client from pve-kyber-web.
+DATAPLANE_ARGS="--webtransport-port 63100"
+
+# Or relay datagrams unread, at one UDP port per open console - nothing in an
+# encrypted datagram says which VM it is for, so the port is the routing key.
+#DATAPLANE_ARGS="--relay-ports 63100-63150"
diff --git a/debian/rules b/debian/rules
new file mode 100755
index 0000000..8deefff
--- /dev/null
+++ b/debian/rules
@@ -0,0 +1,43 @@
+#!/usr/bin/make -f
+
+include /usr/share/dpkg/architecture.mk
+include /usr/share/dpkg/pkg-info.mk
+
+#export DH_VERBOSE=1
+
+%:
+ dh $@
+
+override_dh_update_autotools_config:
+
+# The vendored crates carry Cargo.toml.orig, which cargo checksums and dh_clean
+# would otherwise delete as build residue - taking the build with it.
+override_dh_clean:
+ dh_clean -X Cargo.toml.orig
+
+# --no-default-features drops the dev-tcp feature, and with it
+# --insecure-listen-tcp: an unauthenticated control plane has no business
+# existing in a packaged binary, rather than merely being left out of the
+# unit file.
+override_dh_auto_build:
+ cargo build --release --no-default-features --features webtransport
+
+override_dh_auto_test:
+ cargo test --release --no-default-features --features webtransport
+
+override_dh_auto_clean:
+ cargo clean
+
+# The unit is pvekyberproxy.service, not pve-kyberproxy.service - PVE's
+# services do not carry the dash its package name does (pveproxy, spiceproxy,
+# pvedaemon). debhelper only finds a unit named after the package on its own,
+# so the name has to be spelled out or the unit is silently left out of the
+# .deb entirely.
+override_dh_installsystemd:
+ dh_installsystemd --name=pvekyberproxy
+
+# A conffile, so a node's choice of data plane survives an upgrade.
+override_dh_install:
+ dh_install
+ install -D -m 0644 debian/pvekyberproxy.default \
+ debian/pve-kyberproxy/etc/default/pvekyberproxy
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/control.rs b/src/control.rs
new file mode 100644
index 0000000..cd457fa
--- /dev/null
+++ b/src/control.rs
@@ -0,0 +1,506 @@
+// The control plane, forwarded to the VM's controller.
+//
+// pveproxy hands this everything under /nodes/{node}/qemu/{vmid}/kyber/ over a
+// unix socket, having already authenticated the caller and checked its ACL;
+// nothing here re-checks that.
+//
+// Requests pass through untouched except start_mux, which would otherwise name
+// a controller no browser can reach. /ws is a websocket rather than a
+// request/response, so its upgrade is spliced instead of forwarded.
+
+use std::net::{IpAddr, Ipv4Addr, SocketAddr};
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+use anyhow::{anyhow, Context, Result};
+use http_body_util::{BodyExt, Full};
+use hyper::body::{Bytes, Incoming};
+use hyper::header::{CONNECTION, UPGRADE};
+use hyper::server::conn::http1;
+use hyper::service::service_fn;
+use hyper::{Request, Response, StatusCode};
+use hyper_util::rt::TokioIo;
+use log::{debug, info, warn};
+use tokio::net::{UnixListener, UnixStream};
+#[cfg(feature = "dev-tcp")]
+use tokio::net::TcpListener;
+#[cfg(feature = "dev-tcp")]
+use tokio_rustls::TlsAcceptor;
+
+use crate::relay;
+
+pub struct Control {
+ /// Where the per-VM controller sockets live.
+ pub run_dir: PathBuf,
+ /// UDP ports the relay may use, when a firewall needs them knowable.
+ pub relay_ports: Option<relay::PortRange>,
+ /// The single-port data plane, when it is serving instead of the relay.
+ #[cfg(feature = "webtransport")]
+ pub gateway: Option<std::sync::Arc<crate::webtransport::Gateway>>,
+}
+
+impl Control {
+ fn controller_socket(&self, vmid: u32) -> PathBuf {
+ self.run_dir.join(format!("{vmid}.kyber.sock"))
+ }
+}
+
+/// Split a proxied path into the VM it names and the controller path under it.
+/// A path that does not match is a bug on our side, so it is refused.
+fn split_path(path: &str) -> Option<(u32, String)> {
+ let rest = path.strip_prefix("/api2/json").unwrap_or(path);
+ let rest = rest.strip_prefix("/nodes/")?;
+ let (_node, rest) = rest.split_once('/')?;
+ let rest = rest.strip_prefix("qemu/")?;
+ let (vmid, rest) = rest.split_once('/')?;
+ let vmid: u32 = vmid.parse().ok()?;
+ let rest = rest.strip_prefix("kyber")?;
+
+ if rest.is_empty() {
+ return Some((vmid, "/".to_string()));
+ }
+ if !rest.starts_with('/') {
+ return None;
+ }
+
+ Some((vmid, rest.to_string()))
+}
+
+/// Give the socket to one group and take it from everyone else. chown before
+/// chmod, or it would be world-connectable for as long as the chown took.
+fn restrict_socket(socket: &Path, group: &str) -> Result<()> {
+ use std::ffi::CString;
+ use std::os::unix::ffi::OsStrExt;
+ use std::os::unix::fs::PermissionsExt;
+
+ let name = CString::new(group).context("group name")?;
+ // getgrnam rather than /etc/group: the answer can come from anywhere NSS
+ // is configured to ask, and on a node that is not always a file.
+ let entry = unsafe { libc::getgrnam(name.as_ptr()) };
+ if entry.is_null() {
+ return Err(anyhow!("no group named '{group}' on this system"));
+ }
+ let gid = unsafe { (*entry).gr_gid };
+
+ let path = CString::new(socket.as_os_str().as_bytes()).context("socket path")?;
+ // -1 leaves the owner alone; only the group is being changed.
+ if unsafe { libc::chown(path.as_ptr(), libc::uid_t::MAX, gid) } != 0 {
+ return Err(std::io::Error::last_os_error()).context("chown");
+ }
+
+ std::fs::set_permissions(socket, std::fs::Permissions::from_mode(0o660)).context("chmod")?;
+
+ Ok(())
+}
+
+pub async fn serve(control: Arc<Control>, socket: &Path, group: Option<&str>) -> Result<()> {
+ // A socket left by an unclean exit would make every start fail.
+ match std::fs::remove_file(socket) {
+ Ok(()) => {}
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
+ Err(err) => return Err(err).context("removing a stale control socket"),
+ }
+
+ let listener = UnixListener::bind(socket)
+ .with_context(|| format!("binding {}", socket.display()))?;
+
+ // The whole of this socket's access control, and not optional: at the
+ // umask's default pveproxy cannot connect to it at all.
+ if let Some(group) = group {
+ restrict_socket(socket, group)
+ .with_context(|| format!("handing {} to group {group}", socket.display()))?;
+ }
+
+ info!("control plane on {}", socket.display());
+
+ loop {
+ let (stream, _) = listener.accept().await.context("accepting")?;
+ let control = control.clone();
+
+ tokio::spawn(async move {
+ let service = service_fn(move |req| handle(control.clone(), req));
+ // /ws is a websocket; without this the 101 is written and the
+ // connection dropped underneath it.
+ if let Err(err) = http1::Builder::new()
+ .serve_connection(TokioIo::new(stream), service)
+ .with_upgrades()
+ .await
+ {
+ debug!("control connection ended: {err}");
+ }
+ });
+ }
+}
+
+/// The same service on a TCP address, for working without a Proxmox around it.
+/// What it skips is authentication, not TLS: the client builds every
+/// control-plane URL as https:// with no way to say otherwise.
+#[cfg(feature = "dev-tcp")]
+pub async fn serve_tcp(
+ control: Arc<Control>,
+ addr: std::net::SocketAddr,
+ tls: TlsAcceptor,
+) -> Result<()> {
+ let listener = TcpListener::bind(addr)
+ .await
+ .with_context(|| format!("binding {addr}"))?;
+ info!("control plane on {addr}, unauthenticated");
+
+ loop {
+ let (stream, peer) = listener.accept().await.context("accepting")?;
+ let control = control.clone();
+ let tls = tls.clone();
+
+ tokio::spawn(async move {
+ // Per connection: a failed handshake must not take the listener
+ // down, and one fails whenever a certificate is not yet accepted.
+ let stream = match tls.accept(stream).await {
+ Ok(stream) => stream,
+ Err(err) => {
+ debug!("TLS handshake with {peer} failed: {err}");
+ return;
+ }
+ };
+
+ let service = service_fn(move |req| handle(control.clone(), req));
+ if let Err(err) = http1::Builder::new()
+ .serve_connection(TokioIo::new(stream), service)
+ .with_upgrades()
+ .await
+ {
+ debug!("control connection ended: {err}");
+ }
+ });
+ }
+}
+
+/// A throwaway certificate for the address above, generated rather than stored:
+/// a key on disk that nothing needs is only something to leak.
+#[cfg(feature = "dev-tcp")]
+pub fn self_signed_tls(addr: std::net::SocketAddr) -> Result<TlsAcceptor> {
+ use tokio_rustls::rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer};
+ use tokio_rustls::rustls::ServerConfig;
+
+ let names = vec!["localhost".to_string(), addr.ip().to_string()];
+ let certified = rcgen::generate_simple_self_signed(names)
+ .context("generating a certificate for the TCP control plane")?;
+
+ let cert = certified.cert.der().clone();
+ let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(certified.signing_key.serialize_der()));
+
+ // Passed in rather than installed process-wide, which would be a global
+ // fact set by a development flag.
+ let provider = Arc::new(tokio_rustls::rustls::crypto::ring::default_provider());
+ let mut config = ServerConfig::builder_with_provider(provider)
+ .with_safe_default_protocol_versions()
+ .context("selecting TLS versions")?
+ .with_no_client_auth()
+ .with_single_cert(vec![cert], key)
+ .context("building the TLS configuration")?;
+
+ // hyper serves HTTP/1.1; say so or a browser may negotiate h2.
+ config.alpn_protocols = vec![b"http/1.1".to_vec()];
+
+ Ok(TlsAcceptor::from(Arc::new(config)))
+}
+
+fn bad_request(msg: &str) -> Response<Full<Bytes>> {
+ let mut response = Response::new(Full::new(Bytes::from(msg.to_string())));
+ *response.status_mut() = StatusCode::BAD_REQUEST;
+ response
+}
+
+fn bad_gateway(msg: String) -> Response<Full<Bytes>> {
+ let mut response = Response::new(Full::new(Bytes::from(msg)));
+ *response.status_mut() = StatusCode::BAD_GATEWAY;
+ response
+}
+
+/// Whether this request asks to stop speaking HTTP. Connection is a list and
+/// its token is case-insensitive, so neither header compares whole.
+fn wants_upgrade(req: &Request<Incoming>) -> bool {
+ let connection = req
+ .headers()
+ .get(CONNECTION)
+ .and_then(|value| value.to_str().ok())
+ .map(|value| {
+ value
+ .split(',')
+ .any(|token| token.trim().eq_ignore_ascii_case("upgrade"))
+ })
+ .unwrap_or(false);
+
+ connection && req.headers().contains_key(UPGRADE)
+}
+
+async fn handle(
+ control: Arc<Control>,
+ req: Request<Incoming>,
+) -> Result<Response<Full<Bytes>>, std::convert::Infallible> {
+ let path = req.uri().path().to_string();
+
+ let Some((vmid, rest)) = split_path(&path) else {
+ warn!("control plane asked for a path it does not serve: {path}");
+ return Ok(bad_request("not a Kyber console path\n"));
+ };
+
+ match forward(&control, vmid, &rest, req).await {
+ Ok(response) => Ok(response),
+ Err(err) => {
+ warn!("VM {vmid}: {rest} failed: {err:#}");
+ Ok(bad_gateway(format!("{err:#}\n")))
+ }
+ }
+}
+
+async fn forward(
+ control: &Control,
+ vmid: u32,
+ rest: &str,
+ mut req: Request<Incoming>,
+) -> Result<Response<Full<Bytes>>> {
+ let socket = control.controller_socket(vmid);
+ let stream = UnixStream::connect(&socket)
+ .await
+ .with_context(|| format!("no controller for VM {vmid} at {}", socket.display()))?;
+
+ let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(stream))
+ .await
+ .context("HTTP handshake with the controller")?;
+
+ // Claimed before the request is taken apart.
+ let upgrading = wants_upgrade(&req);
+ let client_upgrade = upgrading.then(|| hyper::upgrade::on(&mut req));
+
+ if upgrading {
+ tokio::spawn(async move {
+ if let Err(err) = conn.with_upgrades().await {
+ debug!("controller connection ended: {err}");
+ }
+ });
+ } else {
+ tokio::spawn(async move {
+ if let Err(err) = conn.await {
+ debug!("controller connection ended: {err}");
+ }
+ });
+ }
+
+ // Rebuild against the controller's path, query string included.
+ let target = match req.uri().query() {
+ Some(query) => format!("{rest}?{query}"),
+ None => rest.to_string(),
+ };
+
+ let (parts, body) = req.into_parts();
+ let body = body
+ .collect()
+ .await
+ .context("reading the request body")?
+ .to_bytes();
+
+ let mut upstream = Request::builder()
+ .method(parts.method.clone())
+ .uri(target)
+ .body(Full::new(body))
+ .context("building the upstream request")?;
+
+ // Everything the client sent, minus Host.
+ for (name, value) in parts.headers.iter() {
+ if name == hyper::header::HOST {
+ continue;
+ }
+ upstream.headers_mut().insert(name, value.clone());
+ }
+ upstream
+ .headers_mut()
+ .insert(hyper::header::HOST, "localhost".parse().unwrap());
+
+ let mut response = sender
+ .send_request(upstream)
+ .await
+ .context("forwarding to the controller")?;
+
+ // Once the controller agrees, this connection stops being HTTP: splice the
+ // two halves and copy until one stops.
+ if response.status() == StatusCode::SWITCHING_PROTOCOLS {
+ let Some(client_upgrade) = client_upgrade else {
+ return Err(anyhow!(
+ "the controller upgraded a request that did not ask to be upgraded"
+ ));
+ };
+ let upstream_upgrade = hyper::upgrade::on(&mut response);
+
+ tokio::spawn(async move {
+ let (client, controller) = match tokio::try_join!(client_upgrade, upstream_upgrade) {
+ Ok(pair) => pair,
+ Err(err) => {
+ debug!("VM {vmid}: websocket upgrade never completed: {err}");
+ return;
+ }
+ };
+
+ let mut client = TokioIo::new(client);
+ let mut controller = TokioIo::new(controller);
+ match tokio::io::copy_bidirectional(&mut client, &mut controller).await {
+ Ok((to_controller, to_client)) => debug!(
+ "VM {vmid}: websocket closed after {to_controller} up, {to_client} down"
+ ),
+ Err(err) => debug!("VM {vmid}: websocket ended: {err}"),
+ }
+ });
+
+ let (parts, _) = response.into_parts();
+ let mut out = Response::new(Full::new(Bytes::new()));
+ *out.status_mut() = parts.status;
+ // Every header: Sec-WebSocket-Accept is computed from a key we never
+ // saw, so the controller's own answer is what the client needs.
+ for (name, value) in parts.headers.iter() {
+ out.headers_mut().insert(name, value.clone());
+ }
+ return Ok(out);
+ }
+
+ let (parts, body) = response.into_parts();
+ let body = body
+ .collect()
+ .await
+ .context("reading the controller's response")?
+ .to_bytes();
+
+ let body = if rest == "/kymux/start_mux" && parts.status.is_success() {
+ rewrite_start_mux(
+ vmid,
+ body,
+ control.relay_ports.as_ref(),
+ #[cfg(feature = "webtransport")]
+ control.gateway.as_ref(),
+ )
+ .await?
+ } else {
+ body
+ };
+
+ let mut out = Response::new(Full::new(body));
+ *out.status_mut() = parts.status;
+ for (name, value) in parts.headers.iter() {
+ // Length changes under the rewrite; hyper sets it again.
+ if name == hyper::header::CONTENT_LENGTH {
+ continue;
+ }
+ out.headers_mut().insert(name, value.clone());
+ }
+
+ Ok(out)
+}
+
+/// Point the client at this daemon rather than at the controller. Relaying
+/// replaces only the port, since TLS still runs to the controller; terminating
+/// replaces the certificate hash too, because the server is then this daemon.
+/// The controller reports its certificate hash as plain hex.
+#[cfg(feature = "webtransport")]
+fn parse_hash(text: &str) -> Option<[u8; 32]> {
+ let text = text.trim();
+ if text.len() != 64 {
+ return None;
+ }
+ let mut out = [0u8; 32];
+ for (i, byte) in out.iter_mut().enumerate() {
+ *byte = u8::from_str_radix(text.get(i * 2..i * 2 + 2)?, 16).ok()?;
+ }
+ Some(out)
+}
+
+async fn rewrite_start_mux(
+ vmid: u32,
+ body: Bytes,
+ ports: Option<&relay::PortRange>,
+ #[cfg(feature = "webtransport")] gateway: Option<&std::sync::Arc<crate::webtransport::Gateway>>,
+) -> Result<Bytes> {
+ let mut json: serde_json::Value =
+ serde_json::from_slice(&body).context("start_mux answered with something that is not JSON")?;
+
+ let object = json
+ .as_object_mut()
+ .ok_or_else(|| anyhow!("start_mux answered with a JSON value that is not an object"))?;
+
+ let upstream_port = object
+ .get("port")
+ .and_then(|port| port.as_u64())
+ .ok_or_else(|| anyhow!("start_mux answered without a port"))?;
+ let upstream_port =
+ u16::try_from(upstream_port).context("start_mux answered with a port that is not one")?;
+
+ let upstream = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), upstream_port);
+
+ #[cfg(feature = "webtransport")]
+ if let Some(gateway) = gateway {
+ // One port for the node: the client is told this daemon's port and
+ // certificate, and routes itself by the token it already has.
+ let token = object
+ .get("token")
+ .and_then(|token| token.as_str())
+ .ok_or_else(|| anyhow!("start_mux answered without a token"))?
+ .to_string();
+
+ let cert_hash = object
+ .get("certificate_hash")
+ .and_then(|hash| hash.get("hash"))
+ .and_then(|hash| hash.as_str())
+ .and_then(parse_hash);
+
+ gateway
+ .register(
+ token,
+ crate::webtransport::Upstream {
+ vmid,
+ addr: upstream,
+ cert_hash,
+ },
+ )
+ .await;
+
+ object.insert("port".to_string(), serde_json::Value::from(gateway.port));
+ if let Some(hash) = object
+ .get_mut("certificate_hash")
+ .and_then(|hash| hash.get_mut("hash"))
+ {
+ *hash = serde_json::Value::from(gateway.certificate_hash());
+ }
+
+ return Ok(Bytes::from(serde_json::to_vec(&json)?));
+ }
+
+ let session = relay::start(vmid, upstream, ports)
+ .await
+ .context("opening a relay for this session")?;
+
+ object.insert("port".to_string(), serde_json::Value::from(session.port));
+
+ Ok(Bytes::from(serde_json::to_vec(&json)?))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::split_path;
+
+ #[test]
+ fn splits_a_console_path() {
+ assert_eq!(
+ split_path("/api2/json/nodes/pve1/qemu/100/kyber/capabilities"),
+ Some((100, "/capabilities".to_string()))
+ );
+ assert_eq!(
+ split_path("/nodes/pve1/qemu/100/kyber/kymux/start_mux"),
+ Some((100, "/kymux/start_mux".to_string()))
+ );
+ }
+
+ #[test]
+ fn refuses_anything_else() {
+ assert_eq!(split_path("/nodes/pve1/qemu/100/vncwebsocket"), None);
+ assert_eq!(split_path("/nodes/pve1/qemu/abc/kyber/x"), None);
+ // A prefix that only looks like ours.
+ assert_eq!(split_path("/nodes/pve1/qemu/100/kyberproxy"), None);
+ }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..0f8638c
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,139 @@
+// pvekyberproxy - the front door for Kyber consoles on a Proxmox node.
+//
+// One per node. The control plane arrives from pveproxy on a unix socket, the
+// data plane over QUIC; both are forwarded to the VM's controller, which
+// listens only on this machine.
+//
+// See docs/kyber-proxy.md for why it is shaped this way.
+
+mod control;
+mod relay;
+#[cfg(feature = "webtransport")]
+mod webtransport;
+
+#[cfg(feature = "dev-tcp")]
+use std::net::SocketAddr;
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use anyhow::{anyhow, Context, Result};
+use clap::Parser;
+use log::info;
+#[cfg(feature = "dev-tcp")]
+use log::warn;
+
+#[derive(Debug, Parser)]
+#[command(version, about = "QUIC front door for Kyber consoles")]
+struct Cli {
+ /// Unix socket pveproxy forwards the control plane to.
+ #[arg(long, default_value = "/run/pvekyberproxy.sock")]
+ listen_socket: PathBuf,
+
+ /// Where the per-VM controller sockets live.
+ #[arg(long, default_value = "/run/qemu-server")]
+ run_dir: PathBuf,
+
+ /// Serve the data plane on one UDP port, by terminating WebTransport.
+ /// The alternative, --relay-ports, reads nothing but costs a port per
+ /// console.
+ #[cfg(feature = "webtransport")]
+ #[arg(long, value_name = "PORT", conflicts_with = "relay_ports")]
+ webtransport_port: Option<u16>,
+
+ /// UDP ports the relay may use, as LOW-HIGH. One per open console, chosen
+ /// by the kernel when unset - which no firewall rule can express.
+ #[arg(long, value_name = "LOW-HIGH")]
+ relay_ports: Option<relay::PortRange>,
+
+ /// Group given access to the control socket. pveproxy runs as www-data and
+ /// cannot connect to the mode bind leaves.
+ #[arg(long, value_name = "GROUP")]
+ socket_group: Option<String>,
+
+ /// Also serve the control plane on a TCP address, with no authentication.
+ ///
+ /// For working without a Proxmox around it: anyone who can reach this can
+ /// open any console on the node. It still serves HTTPS, because the client
+ /// has no way to ask for anything else.
+ #[cfg(feature = "dev-tcp")]
+ #[arg(long, value_name = "ADDR")]
+ insecure_listen_tcp: Option<SocketAddr>,
+}
+
+#[tokio::main]
+async fn main() -> Result<()> {
+ env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
+
+ let cli = Cli::parse();
+
+ if let Some(range) = &cli.relay_ports {
+ info!("data-plane relay confined to UDP {range}");
+ }
+
+ #[cfg(feature = "webtransport")]
+ let (gateway, gateway_config) = match cli.webtransport_port {
+ Some(port) => {
+ let (gateway, config) = webtransport::Gateway::new(port)?;
+ (Some(gateway), Some(config))
+ }
+ None => (None, None),
+ };
+ #[cfg(not(feature = "webtransport"))]
+ let gateway: Option<std::convert::Infallible> = None;
+
+ let control = Arc::new(control::Control {
+ run_dir: cli.run_dir,
+ relay_ports: cli.relay_ports,
+ #[cfg(feature = "webtransport")]
+ gateway: gateway.clone(),
+ });
+
+ #[cfg(feature = "webtransport")]
+ let data_plane = gateway
+ .clone()
+ .zip(gateway_config)
+ .map(|(gateway, config)| tokio::spawn(webtransport::serve(gateway, config)));
+
+ #[cfg(not(feature = "dev-tcp"))]
+ let tcp: Option<tokio::task::JoinHandle<Result<()>>> = None;
+
+ #[cfg(feature = "dev-tcp")]
+ let tcp = match cli.insecure_listen_tcp {
+ None => None,
+ Some(addr) => {
+ warn!("serving the control plane on {addr} with NO authentication");
+ // Before the listener, so a bad certificate stops the daemon here.
+ let tls = control::self_signed_tls(addr)?;
+ Some(tokio::spawn({
+ let control = control.clone();
+ async move { control::serve_tcp(control, addr, tls).await }
+ }))
+ }
+ };
+
+ tokio::select! {
+ result = async {
+ #[cfg(feature = "webtransport")]
+ match data_plane {
+ Some(task) => task.await.unwrap_or_else(|err| Err(anyhow!("{err}"))),
+ None => std::future::pending().await,
+ }
+ #[cfg(not(feature = "webtransport"))]
+ std::future::pending::<Result<()>>().await
+ } => result.context("data plane"),
+ result = control::serve(control.clone(), &cli.listen_socket, cli.socket_group.as_deref()) => {
+ result.context("control plane")
+ }
+ result = async {
+ match tcp {
+ Some(task) => task.await.unwrap_or_else(|err| Err(anyhow!("{err}"))),
+ // Nothing to wait for; leave the decision to the others.
+ None => std::future::pending().await,
+ }
+ } => result.context("TCP control plane"),
+ _ = tokio::signal::ctrl_c() => {
+ info!("stopping");
+ Ok(())
+ }
+ }
+}
diff --git a/src/relay.rs b/src/relay.rs
new file mode 100644
index 0000000..286c9b1
--- /dev/null
+++ b/src/relay.rs
@@ -0,0 +1,234 @@
+// The data plane, relayed rather than terminated: datagrams in, datagrams out,
+// no key held and no stream reassembled.
+//
+// Nothing in an encrypted QUIC packet says which VM it is for, so each session
+// gets a port of its own and the port is the routing key. See webtransport.rs
+// for the alternative, which spends a session's privacy for one port.
+
+use std::net::SocketAddr;
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use anyhow::{Context, Result};
+use log::{debug, info, warn};
+use tokio::net::UdpSocket;
+use tokio::sync::Mutex;
+
+/// A console silent this long has gone; the client keeps alive well inside it.
+const IDLE_TIMEOUT: Duration = Duration::from_secs(120);
+
+/// The UDP maximum, not the expected MTU: `recvfrom` truncates an oversized
+/// datagram silently, and a QUIC packet with its tail cut off is discarded by
+/// the peer with nothing logged anywhere.
+const MAX_DATAGRAM: usize = 65535;
+
+pub struct Session {
+ /// The port the client was told to use.
+ pub port: u16,
+}
+
+/// Open a relay from a fresh port to a VM's controller.
+///
+/// Dual-stack: the browser picks the address family from DNS, so an IPv4-only
+/// relay answers some clients and silently fails others. `range` exists for
+/// firewalls, which cannot express an ephemeral port.
+pub async fn start(vmid: u32, upstream: SocketAddr, range: Option<&PortRange>) -> Result<Session> {
+ let socket = bind_relay(range).await?;
+ let port = socket.local_addr().context("reading the relay port")?.port();
+
+ info!("VM {vmid}: relaying UDP {port} to {upstream}");
+
+ tokio::spawn(async move {
+ if let Err(err) = run(vmid, socket, upstream).await {
+ debug!("VM {vmid}: relay on {port} ended: {err:#}");
+ }
+ info!("VM {vmid}: relay on {port} closed");
+ });
+
+ Ok(Session { port })
+}
+
+/// A closed range of UDP ports the relay may use.
+#[derive(Debug, Clone, Copy)]
+pub struct PortRange {
+ pub low: u16,
+ pub high: u16,
+}
+
+impl std::str::FromStr for PortRange {
+ type Err = anyhow::Error;
+
+ fn from_str(text: &str) -> Result<Self> {
+ let (low, high) = text
+ .split_once('-')
+ .ok_or_else(|| anyhow::anyhow!("expected LOW-HIGH, got '{text}'"))?;
+ let low: u16 = low.trim().parse().context("low port")?;
+ let high: u16 = high.trim().parse().context("high port")?;
+ if low > high {
+ return Err(anyhow::anyhow!("port range {low}-{high} is backwards"));
+ }
+ Ok(Self { low, high })
+ }
+}
+
+impl std::fmt::Display for PortRange {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}-{}", self.low, self.high)
+ }
+}
+
+/// Bind the client-facing socket, dual-stack where the host allows it.
+async fn bind_relay(range: Option<&PortRange>) -> Result<UdpSocket> {
+ let mut last: Option<std::io::Error> = None;
+
+ match range {
+ None => {
+ // The kernel picks; try IPv6 first so the socket is dual-stack.
+ match UdpSocket::bind(("::", 0)).await {
+ Ok(socket) => return Ok(socket),
+ Err(err) => last = Some(err),
+ }
+ match UdpSocket::bind(("0.0.0.0", 0)).await {
+ Ok(socket) => {
+ warn!("relay bound IPv4-only: {}", last.as_ref().unwrap());
+ return Ok(socket);
+ }
+ Err(err) => last = Some(err),
+ }
+ }
+ Some(range) => {
+ for port in range.low..=range.high {
+ match UdpSocket::bind(("::", port)).await {
+ Ok(socket) => return Ok(socket),
+ Err(err) => last = Some(err),
+ }
+ match UdpSocket::bind(("0.0.0.0", port)).await {
+ Ok(socket) => {
+ warn!("relay bound IPv4-only on {port}");
+ return Ok(socket);
+ }
+ Err(err) => last = Some(err),
+ }
+ }
+ return Err(anyhow::anyhow!(
+ "no free port in {range}: {}",
+ last.map(|e| e.to_string()).unwrap_or_default()
+ ));
+ }
+ }
+
+ Err(last.unwrap()).context("binding a relay port")
+}
+
+async fn run(vmid: u32, client_side: UdpSocket, upstream: SocketAddr) -> Result<()> {
+ // A second socket, so replies are told apart by where they arrived.
+ // Matched to the controller's family; a v4-mapped socket cannot send to a
+ // v4 address it did not bind for.
+ let bind_addr = if upstream.is_ipv4() { "127.0.0.1" } else { "::1" };
+ let upstream_side = UdpSocket::bind((bind_addr, 0))
+ .await
+ .context("binding the controller side")?;
+ upstream_side
+ .connect(upstream)
+ .await
+ .context("pointing the controller side at the controller")?;
+
+ let client_side = Arc::new(client_side);
+ let upstream_side = Arc::new(upstream_side);
+
+ // Pinned at the first datagram. Following whoever spoke last would let
+ // anyone who can reach the port take the stream, and nothing here can
+ // authenticate a migration: path validation runs inside the encryption.
+ // A client that really does move reconnects instead.
+ let client_addr: Arc<Mutex<Option<SocketAddr>>> = Arc::new(Mutex::new(None));
+ let last = Arc::new(Mutex::new(Instant::now()));
+
+ let to_upstream = {
+ let client_side = client_side.clone();
+ let upstream_side = upstream_side.clone();
+ let client_addr = client_addr.clone();
+ let last = last.clone();
+
+ async move {
+ let mut buf = vec![0u8; MAX_DATAGRAM];
+ let mut turned_away: u64 = 0;
+ loop {
+ let (read, from) = client_side.recv_from(&mut buf).await?;
+
+ {
+ let mut pinned = client_addr.lock().await;
+ match *pinned {
+ None => {
+ info!("VM {vmid}: relay bound to {from}");
+ *pinned = Some(from);
+ }
+ Some(client) if client != from => {
+ // Not forwarded: the controller should not spend
+ // cycles decrypting a stranger's datagram.
+ turned_away += 1;
+ if turned_away.is_power_of_two() {
+ warn!(
+ "VM {vmid}: {turned_away} datagram(s) from {from} \
+ ignored, this relay belongs to {client}"
+ );
+ }
+ continue;
+ }
+ Some(_) => {}
+ }
+ }
+
+ *last.lock().await = Instant::now();
+ upstream_side.send(&buf[..read]).await?;
+ }
+ #[allow(unreachable_code)]
+ Ok::<(), std::io::Error>(())
+ }
+ };
+
+ let to_client = {
+ let client_side = client_side.clone();
+ let upstream_side = upstream_side.clone();
+ let client_addr = client_addr.clone();
+ let last = last.clone();
+
+ async move {
+ let mut buf = vec![0u8; MAX_DATAGRAM];
+ loop {
+ let read = upstream_side.recv(&mut buf).await?;
+ *last.lock().await = Instant::now();
+ let Some(addr) = *client_addr.lock().await else {
+ // The controller spoke first, which it cannot do: it has
+ // nothing to say until a client has said something.
+ continue;
+ };
+ client_side.send_to(&buf[..read], addr).await?;
+ }
+ #[allow(unreachable_code)]
+ Ok::<(), std::io::Error>(())
+ }
+ };
+
+ // No session state, so silence is the only end-of-console signal.
+ tokio::select! {
+ result = to_upstream => result.context("client side")?,
+ result = to_client => result.context("controller side")?,
+ _ = idle(last.clone()) => {
+ debug!("VM {vmid}: relay idle");
+ }
+ }
+
+ Ok(())
+}
+
+/// Resolves once nothing has crossed either way for IDLE_TIMEOUT. A timestamp
+/// rather than a peek at the socket, which two tasks already hold.
+async fn idle(last: Arc<Mutex<Instant>>) {
+ loop {
+ let elapsed = last.lock().await.elapsed();
+ if elapsed >= IDLE_TIMEOUT {
+ return;
+ }
+ tokio::time::sleep(IDLE_TIMEOUT - elapsed).await;
+ }
+}
diff --git a/src/webtransport.rs b/src/webtransport.rs
new file mode 100644
index 0000000..a284362
--- /dev/null
+++ b/src/webtransport.rs
@@ -0,0 +1,225 @@
+// The data plane on one UDP port, by terminating WebTransport instead of
+// relaying datagrams.
+//
+// Sharing a port means reading the session's path, which means decrypting.
+// That splits one congestion-controlled path into two, which matters in front
+// of a distant backend and not in front of loopback.
+//
+// Sessions are routed by the token the controller already minted: the proxy
+// sees it when it rewrites start_mux, and the client sends it in the path.
+
+use std::collections::HashMap;
+use std::time::Duration;
+use std::net::{IpAddr, Ipv4Addr, SocketAddr};
+use std::sync::Arc;
+
+use anyhow::{anyhow, Context, Result};
+use log::{debug, info, warn};
+use tokio::sync::Mutex;
+use wtransport::endpoint::IncomingSession;
+use wtransport::tls::{Sha256Digest, Sha256DigestFmt};
+use wtransport::{ClientConfig, Endpoint, Identity, ServerConfig};
+
+/// Keep-alive defaults to off in quinn, and a console encodes almost nothing
+/// while its screen is still - so without this a connection carrying a static
+/// desktop idles out and the console drops for no visible reason.
+const KEEP_ALIVE: Duration = Duration::from_secs(5);
+
+/// Long enough to ride out a lost keep-alive or two, short enough that a dead
+/// peer is noticed.
+const IDLE_TIMEOUT: Duration = Duration::from_secs(30);
+
+/// Where a session's traffic is going, learned from the controller's own
+/// answer to start_mux.
+#[derive(Clone)]
+pub struct Upstream {
+ pub vmid: u32,
+ pub addr: SocketAddr,
+ /// The controller's certificate hash, as it reported it.
+ pub cert_hash: Option<[u8; 32]>,
+}
+
+pub struct Gateway {
+ /// The one port every console arrives on.
+ pub port: u16,
+ cert_hash: String,
+ sessions: Mutex<HashMap<String, Upstream>>,
+}
+
+impl Gateway {
+ /// A certificate of this daemon's own, and its hash for start_mux. The
+ /// client pins by hash, so no CA is involved.
+ pub fn new(port: u16) -> Result<(Arc<Self>, ServerConfig)> {
+ let identity =
+ Identity::self_signed(["localhost", "127.0.0.1", "::1"]).context("certificate")?;
+
+ // The client wants plain hex; wtransport formats it dotted.
+ let cert_hash = identity
+ .certificate_chain()
+ .as_slice()
+ .first()
+ .ok_or_else(|| anyhow!("a certificate with no certificate in it"))?
+ .hash()
+ .fmt(Sha256DigestFmt::DottedHex)
+ .replace(':', "");
+
+ let config = ServerConfig::builder()
+ .with_bind_default(port)
+ .with_identity(identity)
+ .keep_alive_interval(Some(KEEP_ALIVE))
+ .max_idle_timeout(Some(IDLE_TIMEOUT))
+ .context("idle timeout")?
+ .build();
+
+ Ok((
+ Arc::new(Self {
+ port,
+ cert_hash,
+ sessions: Mutex::new(HashMap::new()),
+ }),
+ config,
+ ))
+ }
+
+ pub fn certificate_hash(&self) -> &str {
+ &self.cert_hash
+ }
+
+ /// Remember where a token leads, so its session can be routed.
+ pub async fn register(&self, token: String, upstream: Upstream) {
+ let mut sessions = self.sessions.lock().await;
+ info!(
+ "VM {}: session registered for {} on the shared port",
+ upstream.vmid, upstream.addr
+ );
+ sessions.insert(token, upstream);
+ }
+
+ async fn lookup(&self, token: &str) -> Option<Upstream> {
+ self.sessions.lock().await.get(token).cloned()
+ }
+}
+
+pub async fn serve(gateway: Arc<Gateway>, config: ServerConfig) -> Result<()> {
+ let endpoint = Endpoint::server(config).context("binding the WebTransport endpoint")?;
+ info!(
+ "data plane on UDP {} (WebTransport), certificate {}",
+ gateway.port,
+ &gateway.cert_hash[..16]
+ );
+
+ loop {
+ let incoming = endpoint.accept().await;
+ let gateway = gateway.clone();
+ tokio::spawn(async move {
+ if let Err(err) = accept(gateway, incoming).await {
+ info!("session ended: {err:#}");
+ }
+ });
+ }
+}
+
+async fn accept(gateway: Arc<Gateway>, incoming: IncomingSession) -> Result<()> {
+ let request = incoming.await.context("awaiting the session request")?;
+
+ // A token this daemon did not issue is refused: start_mux registers every
+ // console before the client is told where to go.
+ let token = request.path().trim_start_matches('/').to_string();
+ let Some(upstream) = gateway.lookup(&token).await else {
+ warn!("a session arrived with a token this proxy did not issue");
+ request.not_found().await;
+ return Ok(());
+ };
+
+ let vmid = upstream.vmid;
+ let client = request.accept().await.context("accepting the session")?;
+
+ let controller = connect_upstream(&upstream)
+ .await
+ .with_context(|| format!("VM {vmid}: connecting to the controller"))?;
+
+ info!("VM {vmid}: session open");
+ let result = pipe(client, controller).await;
+ info!("VM {vmid}: session closed");
+ result
+}
+
+async fn connect_upstream(upstream: &Upstream) -> Result<wtransport::Connection> {
+ // Pinned by the hash the controller reported - the one the client would
+ // have pinned directly. No hash means nothing to pin, so refuse.
+ let hash = upstream
+ .cert_hash
+ .ok_or_else(|| anyhow!("the controller reported no certificate to pin"))?;
+ // Loopback, not the wildcard with_bind_default picks: exactly one port
+ // should be reachable from the network.
+ let config = ClientConfig::builder()
+ .with_bind_address(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0))
+ .with_server_certificate_hashes([Sha256Digest::new(hash)])
+ .keep_alive_interval(Some(KEEP_ALIVE))
+ .max_idle_timeout(Some(IDLE_TIMEOUT))
+ .context("idle timeout")?
+ .build();
+
+ let endpoint = Endpoint::client(config).context("client endpoint")?;
+ let url = format!("https://{}/", upstream.addr);
+ endpoint.connect(url).await.context("connect").map_err(Into::into)
+}
+
+/// Copy streams and datagrams both ways without reading them: kyproto stays a
+/// conversation between the client and the controller.
+async fn pipe(client: wtransport::Connection, controller: wtransport::Connection) -> Result<()> {
+ let client = Arc::new(client);
+ let controller = Arc::new(controller);
+
+ let datagrams_up = forward_datagrams(client.clone(), controller.clone());
+ let datagrams_down = forward_datagrams(controller.clone(), client.clone());
+ let bidi_up = forward_bidi(client.clone(), controller.clone());
+ let bidi_down = forward_bidi(controller.clone(), client.clone());
+ let uni_up = forward_uni(client.clone(), controller.clone());
+ let uni_down = forward_uni(controller.clone(), client.clone());
+
+ // Either side finishing ends the session.
+ tokio::select! {
+ result = datagrams_up => result.context("datagrams to the controller"),
+ result = datagrams_down => result.context("datagrams to the client"),
+ result = bidi_up => result.context("streams to the controller"),
+ result = bidi_down => result.context("streams to the client"),
+ result = uni_up => result.context("one-way streams to the controller"),
+ result = uni_down => result.context("one-way streams to the client"),
+ }
+}
+
+async fn forward_datagrams(from: Arc<wtransport::Connection>, to: Arc<wtransport::Connection>) -> Result<()> {
+ loop {
+ let datagram = from.receive_datagram().await?;
+ to.send_datagram(datagram.payload())?;
+ }
+}
+
+async fn forward_bidi(from: Arc<wtransport::Connection>, to: Arc<wtransport::Connection>) -> Result<()> {
+ loop {
+ let (mut from_send, mut from_recv) = from.accept_bi().await?;
+ let (mut to_send, mut to_recv) = to.open_bi().await?.await?;
+
+ tokio::spawn(async move {
+ let up = tokio::io::copy(&mut from_recv, &mut to_send);
+ let down = tokio::io::copy(&mut to_recv, &mut from_send);
+ if let Err(err) = tokio::try_join!(up, down) {
+ debug!("stream ended: {err}");
+ }
+ });
+ }
+}
+
+async fn forward_uni(from: Arc<wtransport::Connection>, to: Arc<wtransport::Connection>) -> Result<()> {
+ loop {
+ let mut from_recv = from.accept_uni().await?;
+ let mut to_send = to.open_uni().await?.await?;
+
+ tokio::spawn(async move {
+ if let Err(err) = tokio::io::copy(&mut from_recv, &mut to_send).await {
+ debug!("one-way stream ended: {err}");
+ }
+ });
+ }
+}
--
2.55.0
^ permalink raw reply related [flat|nested] 14+ 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
` (8 preceding siblings ...)
2026-08-25 11:34 ` [RFC pve-kyberproxy 09/13] Add pve-kyberproxy 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
` (2 subsequent siblings)
12 siblings, 0 replies; 14+ 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] 14+ 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
` (9 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
2026-08-25 11:34 ` [RFC pve-rdp-web 13/13] add pve-rdp-web: console's webassembly client Alexandre Derumier
12 siblings, 0 replies; 14+ 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] 14+ 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
` (10 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
2026-08-25 11:34 ` [RFC pve-rdp-web 13/13] add pve-rdp-web: console's webassembly client Alexandre Derumier
12 siblings, 0 replies; 14+ 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] 14+ messages in thread
* [RFC pve-rdp-web 13/13] add pve-rdp-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
` (11 preceding siblings ...)
2026-08-25 11:34 ` [RFC pve-rdpproxy 12/13] Add pve-rdpproxy Alexandre Derumier
@ 2026-08-25 11:34 ` Alexandre Derumier
12 siblings, 0 replies; 14+ messages in thread
From: Alexandre Derumier @ 2026-08-25 11:34 UTC (permalink / raw)
To: pve-devel
The console page use IronRDP WASM SDK directly, with some modifications:
- 0001 pins ironrdp-web by revision and add qoi/qoiz compression instead
Rremote FX.
- ironrdp/add_audio_support plays guest audio
Signed-off-by: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
---
.gitignore | 5 +
.gitmodules | 3 +
Cargo.lock | 4100 +++++++++++++++++
Makefile | 149 +
debian/changelog | 5 +
debian/control | 19 +
debian/copyright | 38 +
debian/install | 1 +
debian/rules | 10 +
debian/source/format | 1 +
ironrdp-wasm | 1 +
js/rdp-audio-worklet.js | 106 +
js/rdp-audio.js | 222 +
.../0001-pin-ironrdp-web-to-a-revision.patch | 33 +
patches/ironrdp/add_audio_support.patch | 362 ++
15 files changed, 5055 insertions(+)
create mode 100644 .gitignore
create mode 100644 .gitmodules
create mode 100644 Cargo.lock
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 ironrdp-wasm
create mode 100644 js/rdp-audio-worklet.js
create mode 100644 js/rdp-audio.js
create mode 100644 patches/0001-pin-ironrdp-web-to-a-revision.patch
create mode 100644 patches/ironrdp/add_audio_support.patch
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b35dabf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+/sdk/
+/pve-rdp-web-[0-9]*/
+*.deb
+*.changes
+*.buildinfo
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..731b9c1
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "ironrdp-wasm"]
+ path = ironrdp-wasm
+ url = https://github.com/electerm/ironrdp-wasm.git
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..c2237b3
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,4100 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "addchain"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e33f6a175ec6a9e0aca777567f9ff7c3deefc255660df887e7fa3585e9801d8"
+dependencies = [
+ "num-bigint 0.3.3",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aead"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
+dependencies = [
+ "crypto-common 0.2.2",
+ "inout",
+]
+
+[[package]]
+name = "aes"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58"
+dependencies = [
+ "cipher",
+ "cpubits",
+ "cpufeatures 0.3.0",
+]
+
+[[package]]
+name = "aes-gcm"
+version = "0.11.0-rc.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da8c919c118108f144adecad74b425b804ad075580d605d9b33c2d6d1c62a2f8"
+dependencies = [
+ "aead",
+ "aes",
+ "cipher",
+ "ctr",
+ "ghash",
+ "subtle",
+]
+
+[[package]]
+name = "aes-kw"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41ac571010bd60765c56085a4f1d412012a9be2663b1a2f2b19b49318653fd0d"
+dependencies = [
+ "aes",
+ "const-oid 0.10.2",
+]
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "asn1-rs"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
+dependencies = [
+ "asn1-rs-derive",
+ "asn1-rs-impl",
+ "displaydoc",
+ "nom",
+ "num-traits",
+ "rusticata-macros",
+ "thiserror",
+]
+
+[[package]]
+name = "asn1-rs-derive"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "asn1-rs-impl"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "async-dnssd"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d49ffe175ab45bbfd74b548313d9d7cdfff27161a94b007b52eeeb5f9aaa15e"
+dependencies = [
+ "bitflags 1.3.2",
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-util",
+ "libc",
+ "log",
+ "pin-utils",
+ "pkg-config",
+ "tokio",
+ "winapi",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "async-trait"
+version = "0.1.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "atomic-polyfill"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4"
+dependencies = [
+ "critical-section",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "aws-lc-rs"
+version = "1.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
+dependencies = [
+ "aws-lc-sys",
+ "zeroize",
+]
+
+[[package]]
+name = "aws-lc-sys"
+version = "0.44.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
+dependencies = [
+ "cc",
+ "cmake",
+ "dunce",
+ "fs_extra",
+ "pkg-config",
+]
+
+[[package]]
+name = "base16ct"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
+name = "bit_field"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "bitvec"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837"
+dependencies = [
+ "funty",
+ "radium",
+ "tap",
+ "wyz",
+]
+
+[[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 = "block-buffer"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "block-padding"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
+
+[[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 = "cbc"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
+name = "cc"
+version = "1.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
+dependencies = [
+ "find-msvc-tools",
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chacha20"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
+name = "cipher"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
+dependencies = [
+ "block-buffer 0.12.1",
+ "crypto-common 0.2.2",
+ "inout",
+]
+
+[[package]]
+name = "cmake"
+version = "0.1.58"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "cmov"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
+
+[[package]]
+name = "console_error_panic_hook"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
+name = "const-oid"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
+
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "cpubits"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "critical-section"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
+
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
+
+[[package]]
+name = "crypto-bigint"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271"
+dependencies = [
+ "cpubits",
+ "ctutils",
+ "getrandom 0.4.3",
+ "hybrid-array",
+ "num-traits",
+ "rand_core 0.10.1",
+ "serdect",
+ "subtle",
+ "zeroize",
+]
+
+[[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 = "crypto-common"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
+dependencies = [
+ "getrandom 0.4.3",
+ "hybrid-array",
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "crypto-mac"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25fab6889090c8133f3deb8f73ba3c65a7f456f66436fc012a1b1e272b1e103e"
+dependencies = [
+ "generic-array",
+ "subtle",
+]
+
+[[package]]
+name = "crypto-primes"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f"
+dependencies = [
+ "crypto-bigint",
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "cryptoki"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff765b99fc49f3116c9a908484486a2b92fd73c48da45c3a69716471c6cc56c6"
+dependencies = [
+ "bitflags 2.13.1",
+ "cryptoki-sys",
+ "libloading",
+ "log",
+ "secrecy",
+]
+
+[[package]]
+name = "cryptoki-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1fd850498411e4057f1cba79e6e2bc7cbe960544c1046ab46d4685c403a1121"
+dependencies = [
+ "libloading",
+]
+
+[[package]]
+name = "ctr"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
+name = "ctutils"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
+dependencies = [
+ "cmov",
+ "subtle",
+]
+
+[[package]]
+name = "curve25519-dalek"
+version = "5.0.0-rc.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c906a87e53a36ff795d72e06e8162a83c5436e3ea89e942a9cb9fc083f0a384f"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "curve25519-dalek-derive",
+ "digest 0.11.3",
+ "fiat-crypto",
+ "rustc_version",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "curve25519-dalek-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "data-encoding"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid 0.9.6",
+ "zeroize",
+]
+
+[[package]]
+name = "der"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d"
+dependencies = [
+ "const-oid 0.10.2",
+ "der_derive",
+ "flagset",
+ "pem-rfc7468",
+ "zeroize",
+]
+
+[[package]]
+name = "der-parser"
+version = "10.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
+dependencies = [
+ "asn1-rs",
+ "displaydoc",
+ "nom",
+ "num-traits",
+ "rusticata-macros",
+]
+
+[[package]]
+name = "der_derive"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59600e2c2d636fde9b65e99cc6445ac770c63d3628195ff39932b8d6d7409903"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+
+[[package]]
+name = "des"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer 0.10.4",
+ "crypto-common 0.1.7",
+ "subtle",
+]
+
+[[package]]
+name = "digest"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
+dependencies = [
+ "block-buffer 0.12.1",
+ "const-oid 0.10.2",
+ "crypto-common 0.2.2",
+ "ctutils",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
+[[package]]
+name = "ecdsa"
+version = "0.17.0-rc.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c72d1455753a703ad4b90ed2a759f2bc4562024a303176439cf6e593b5ade4"
+dependencies = [
+ "der 0.8.1",
+ "digest 0.11.3",
+ "elliptic-curve",
+ "rfc6979",
+ "signature",
+ "spki 0.8.0",
+ "zeroize",
+]
+
+[[package]]
+name = "ed25519"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
+dependencies = [
+ "signature",
+]
+
+[[package]]
+name = "ed25519-dalek"
+version = "3.0.0-rc.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1685663e23882cd8517dcbcb1c23a6ebff4433c22dfb681d760219b62cd1b849"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "rand_core 0.10.1",
+ "sha2",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "either"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
+
+[[package]]
+name = "elliptic-curve"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65"
+dependencies = [
+ "base16ct",
+ "crypto-bigint",
+ "crypto-common 0.2.2",
+ "digest 0.11.3",
+ "ff",
+ "group",
+ "hkdf",
+ "hybrid-array",
+ "pem-rfc7468",
+ "pkcs8 0.11.0",
+ "rand_core 0.10.1",
+ "sec1",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "ff"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f"
+dependencies = [
+ "rand_core 0.10.1",
+ "subtle",
+]
+
+[[package]]
+name = "fiat-crypto"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
+
+[[package]]
+name = "flagset"
+version = "0.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "libz-sys",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "fs_extra"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
+
+[[package]]
+name = "funty"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
+
+[[package]]
+name = "futures"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[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-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "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",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi 6.0.0",
+ "rand_core 0.10.1",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "ghash"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
+dependencies = [
+ "polyval",
+]
+
+[[package]]
+name = "gloo-net"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6420f887c48417e9e86c6cf61274eb231830cccc100e49613f7952e269a1fe1"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-sink",
+ "gloo-utils",
+ "http",
+ "js-sys",
+ "pin-project",
+ "thiserror",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "gloo-timers"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "gloo-utils"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4202275d95a142fa209a1e35e91c250a710c5600731372cd3464a39ed01573d6"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "group"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4"
+dependencies = [
+ "ff",
+ "rand_core 0.10.1",
+ "subtle",
+]
+
+[[package]]
+name = "h2"
+version = "0.4.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "hash32"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
+dependencies = [
+ "byteorder",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heapless"
+version = "0.7.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
+dependencies = [
+ "atomic-polyfill",
+ "hash32",
+ "rustc_version",
+ "spin",
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "hkdf"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
+dependencies = [
+ "hmac 0.13.0",
+]
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "hmac"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
+dependencies = [
+ "digest 0.11.3",
+]
+
+[[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 = "hybrid-array"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
+dependencies = [
+ "subtle",
+ "typenum",
+ "zeroize",
+]
+
+[[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",
+ "h2",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "system-configuration",
+ "tokio",
+ "tower-service",
+ "tracing",
+ "windows-registry",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+
+[[package]]
+name = "icu_properties"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
+
+[[package]]
+name = "icu_provider"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "inout"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
+dependencies = [
+ "block-padding",
+ "hybrid-array",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
+
+[[package]]
+name = "iron-remote-desktop"
+version = "0.7.1"
+dependencies = [
+ "console_error_panic_hook",
+ "tracing",
+ "tracing-subscriber",
+ "tracing-web",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "ironrdp"
+version = "0.17.0"
+dependencies = [
+ "ironrdp-client",
+ "ironrdp-cliprdr",
+ "ironrdp-connector",
+ "ironrdp-core",
+ "ironrdp-displaycontrol",
+ "ironrdp-dvc",
+ "ironrdp-graphics",
+ "ironrdp-input",
+ "ironrdp-pdu",
+ "ironrdp-rdpdr",
+ "ironrdp-rdpsnd",
+ "ironrdp-server",
+ "ironrdp-session",
+ "ironrdp-svc",
+]
+
+[[package]]
+name = "ironrdp-acceptor"
+version = "0.10.0"
+dependencies = [
+ "ironrdp-async",
+ "ironrdp-connector",
+ "ironrdp-core",
+ "ironrdp-pdu",
+ "ironrdp-svc",
+ "tracing",
+]
+
+[[package]]
+name = "ironrdp-ainput"
+version = "0.8.0"
+dependencies = [
+ "bitflags 2.13.1",
+ "ironrdp-core",
+ "ironrdp-dvc",
+ "num-derive 0.5.1",
+ "num-traits",
+]
+
+[[package]]
+name = "ironrdp-async"
+version = "0.10.0"
+dependencies = [
+ "bytes",
+ "ironrdp-connector",
+ "ironrdp-core",
+ "ironrdp-pdu",
+ "tracing",
+]
+
+[[package]]
+name = "ironrdp-bulk"
+version = "0.1.1"
+
+[[package]]
+name = "ironrdp-cfg"
+version = "0.1.0"
+dependencies = [
+ "ironrdp-propertyset",
+]
+
+[[package]]
+name = "ironrdp-client"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "futures-util",
+ "ironrdp-cfg",
+ "ironrdp-connector",
+ "ironrdp-core",
+ "ironrdp-displaycontrol",
+ "ironrdp-dvc",
+ "ironrdp-echo",
+ "ironrdp-graphics",
+ "ironrdp-pdu",
+ "ironrdp-propertyset",
+ "ironrdp-rail",
+ "ironrdp-rdcleanpath",
+ "ironrdp-rdpei",
+ "ironrdp-session",
+ "ironrdp-svc",
+ "ironrdp-tls",
+ "ironrdp-tokio",
+ "smallvec",
+ "tokio",
+ "tokio-tungstenite",
+ "tracing",
+ "url",
+ "x509-cert",
+]
+
+[[package]]
+name = "ironrdp-cliprdr"
+version = "0.7.0"
+dependencies = [
+ "bitflags 2.13.1",
+ "ironrdp-core",
+ "ironrdp-pdu",
+ "ironrdp-svc",
+ "tracing",
+]
+
+[[package]]
+name = "ironrdp-cliprdr-format"
+version = "0.2.0"
+dependencies = [
+ "ironrdp-core",
+ "png",
+]
+
+[[package]]
+name = "ironrdp-connector"
+version = "0.10.0"
+dependencies = [
+ "ironrdp-core",
+ "ironrdp-error",
+ "ironrdp-pdu",
+ "ironrdp-svc",
+ "picky",
+ "picky-asn1-der",
+ "picky-asn1-x509",
+ "rand 0.9.5",
+ "sspi",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "ironrdp-core"
+version = "0.2.1"
+dependencies = [
+ "ironrdp-error",
+]
+
+[[package]]
+name = "ironrdp-displaycontrol"
+version = "0.8.0"
+dependencies = [
+ "ironrdp-core",
+ "ironrdp-dvc",
+ "ironrdp-pdu",
+ "ironrdp-svc",
+ "tracing",
+]
+
+[[package]]
+name = "ironrdp-dvc"
+version = "0.8.0"
+dependencies = [
+ "ironrdp-core",
+ "ironrdp-pdu",
+ "ironrdp-svc",
+ "tracing",
+]
+
+[[package]]
+name = "ironrdp-echo"
+version = "0.4.0"
+dependencies = [
+ "ironrdp-core",
+ "ironrdp-dvc",
+ "ironrdp-pdu",
+ "tracing",
+]
+
+[[package]]
+name = "ironrdp-error"
+version = "0.2.0"
+
+[[package]]
+name = "ironrdp-futures"
+version = "0.8.0"
+dependencies = [
+ "futures-util",
+ "ironrdp-async",
+]
+
+[[package]]
+name = "ironrdp-graphics"
+version = "0.9.0"
+dependencies = [
+ "bit_field",
+ "bitflags 2.13.1",
+ "bitvec",
+ "byteorder",
+ "ironrdp-core",
+ "ironrdp-pdu",
+ "num-derive 0.5.1",
+ "num-traits",
+ "wide",
+ "yuv",
+]
+
+[[package]]
+name = "ironrdp-input"
+version = "0.7.0"
+dependencies = [
+ "bitvec",
+ "ironrdp-pdu",
+ "smallvec",
+]
+
+[[package]]
+name = "ironrdp-pdu"
+version = "0.9.0"
+dependencies = [
+ "bit_field",
+ "bitflags 2.13.1",
+ "byteorder",
+ "der-parser",
+ "hmac 0.12.1",
+ "ironrdp-core",
+ "ironrdp-error",
+ "md-5 0.10.6",
+ "num-bigint 0.4.8",
+ "num-derive 0.5.1",
+ "num-integer",
+ "num-traits",
+ "pkcs1 0.7.5",
+ "sha1 0.11.0",
+ "tap",
+ "x509-cert",
+]
+
+[[package]]
+name = "ironrdp-propertyset"
+version = "0.1.0"
+
+[[package]]
+name = "ironrdp-rail"
+version = "0.1.0"
+dependencies = [
+ "ironrdp-core",
+ "ironrdp-svc",
+]
+
+[[package]]
+name = "ironrdp-rdcleanpath"
+version = "0.2.2"
+dependencies = [
+ "der 0.8.1",
+]
+
+[[package]]
+name = "ironrdp-rdpdr"
+version = "0.7.0"
+dependencies = [
+ "bitflags 2.13.1",
+ "getrandom 0.3.4",
+ "ironrdp-core",
+ "ironrdp-error",
+ "ironrdp-pdu",
+ "ironrdp-svc",
+ "tracing",
+]
+
+[[package]]
+name = "ironrdp-rdpei"
+version = "0.1.0"
+dependencies = [
+ "bitflags 2.13.1",
+ "ironrdp-core",
+ "ironrdp-dvc",
+ "ironrdp-pdu",
+ "ironrdp-svc",
+ "tracing",
+]
+
+[[package]]
+name = "ironrdp-rdpfile"
+version = "0.1.0"
+dependencies = [
+ "ironrdp-propertyset",
+]
+
+[[package]]
+name = "ironrdp-rdpsnd"
+version = "0.9.0"
+dependencies = [
+ "bitflags 2.13.1",
+ "ironrdp-core",
+ "ironrdp-pdu",
+ "ironrdp-svc",
+ "tracing",
+]
+
+[[package]]
+name = "ironrdp-server"
+version = "0.13.0"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "bytes",
+ "ironrdp-acceptor",
+ "ironrdp-ainput",
+ "ironrdp-async",
+ "ironrdp-cliprdr",
+ "ironrdp-core",
+ "ironrdp-displaycontrol",
+ "ironrdp-dvc",
+ "ironrdp-echo",
+ "ironrdp-graphics",
+ "ironrdp-pdu",
+ "ironrdp-rdpsnd",
+ "ironrdp-svc",
+ "ironrdp-tokio",
+ "qoicoubeh",
+ "rand 0.9.5",
+ "rayon",
+ "rustls-pemfile",
+ "tokio",
+ "tokio-rustls",
+ "tracing",
+ "x509-cert",
+ "zstd-safe",
+]
+
+[[package]]
+name = "ironrdp-session"
+version = "0.11.0"
+dependencies = [
+ "ironrdp-bulk",
+ "ironrdp-core",
+ "ironrdp-displaycontrol",
+ "ironrdp-dvc",
+ "ironrdp-error",
+ "ironrdp-graphics",
+ "ironrdp-pdu",
+ "ironrdp-rdpei",
+ "ironrdp-svc",
+ "qoicoubeh",
+ "tracing",
+ "zstd-safe",
+]
+
+[[package]]
+name = "ironrdp-svc"
+version = "0.8.0"
+dependencies = [
+ "bitflags 2.13.1",
+ "ironrdp-core",
+ "ironrdp-pdu",
+]
+
+[[package]]
+name = "ironrdp-tls"
+version = "0.2.2"
+dependencies = [
+ "tokio",
+]
+
+[[package]]
+name = "ironrdp-tokio"
+version = "0.10.0"
+dependencies = [
+ "ironrdp-async",
+ "ironrdp-connector",
+ "reqwest",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "ironrdp-vmconnect"
+version = "0.1.0"
+dependencies = [
+ "ironrdp-async",
+ "ironrdp-connector",
+ "ironrdp-core",
+ "ironrdp-pdu",
+ "tracing",
+]
+
+[[package]]
+name = "ironrdp-web"
+version = "0.0.0"
+dependencies = [
+ "anyhow",
+ "base64",
+ "chrono",
+ "futures-channel",
+ "futures-util",
+ "getrandom 0.2.17",
+ "getrandom 0.3.4",
+ "getrandom 0.4.3",
+ "gloo-net",
+ "gloo-timers",
+ "iron-remote-desktop",
+ "ironrdp",
+ "ironrdp-cliprdr-format",
+ "ironrdp-core",
+ "ironrdp-futures",
+ "ironrdp-pdu",
+ "ironrdp-propertyset",
+ "ironrdp-rdcleanpath",
+ "ironrdp-rdpfile",
+ "ironrdp-svc",
+ "ironrdp-vmconnect",
+ "js-sys",
+ "png",
+ "resize",
+ "rgb",
+ "semver",
+ "smallvec",
+ "tap",
+ "time",
+ "tracing",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "x509-cert",
+]
+
+[[package]]
+name = "iso7816"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd3c7e91da489667bb054f9cd2f1c60cc2ac4478a899f403d11dbc62189215b0"
+dependencies = [
+ "heapless",
+]
+
+[[package]]
+name = "iso7816-tlv"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7660d28d24a831d690228a275d544654a30f3b167a8e491cf31af5fe5058b546"
+dependencies = [
+ "untrusted",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jobserver"
+version = "0.1.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
+dependencies = [
+ "getrandom 0.4.3",
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "keccak"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffd9697dc4a9a62e2da93389f34400b77a28f0287711263cabb203b3ccb9c0e4"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "libloading"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
+dependencies = [
+ "cfg-if",
+ "windows-link",
+]
+
+[[package]]
+name = "libz-sys"
+version = "1.1.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9"
+dependencies = [
+ "cc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "litemap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "md-5"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
+dependencies = [
+ "cfg-if",
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "md-5"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
+dependencies = [
+ "cfg-if",
+ "digest 0.11.3",
+]
+
+[[package]]
+name = "md4"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da5ac363534dce5fabf69949225e174fbf111a498bf0ff794c8ea1fba9f3dda"
+dependencies = [
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[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 = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f6f7833f2cbf2360a6cfd58cd41a53aa7a90bd4c202f5b1c7dd2ed73c57b2c3"
+dependencies = [
+ "autocfg",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-derive"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "num-derive"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4e98dc3b890f6c23a0f9d3d491a2823d0dea0fa656302a13dd225fa924112a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "oid"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c19903c598813dba001b53beeae59bb77ad4892c5c1b9b3500ce4293a0d06c2"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "p256"
+version = "0.14.0-rc.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c855a8d2ffd346aa03122626f22e96e3aa75e3bfe64e6bf6cb82f71821ed6ae7"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primefield",
+ "primeorder",
+ "sha2",
+]
+
+[[package]]
+name = "p384"
+version = "0.14.0-rc.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62941b68907ddf996ac20f0debf700c236ccc3d874637731a93c631129ca042f"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "fiat-crypto",
+ "primefield",
+ "primeorder",
+ "sha2",
+]
+
+[[package]]
+name = "p521"
+version = "0.14.0-rc.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dd6f2fe6e76c8d5e8828e92aafa463777d1e72e70b78acc724214757e92479a"
+dependencies = [
+ "base16ct",
+ "ecdsa",
+ "elliptic-curve",
+ "primefield",
+ "primeorder",
+ "sha2",
+]
+
+[[package]]
+name = "pbkdf2"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
+dependencies = [
+ "digest 0.11.3",
+ "hmac 0.13.0",
+]
+
+[[package]]
+name = "pem-rfc7468"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9"
+dependencies = [
+ "base64ct",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "picky"
+version = "7.0.0-rc.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1ae9cd78eb1d61be4790713d28368cf71c844218fcd91542768805423f02666"
+dependencies = [
+ "aes",
+ "aes-gcm",
+ "aes-kw",
+ "base64",
+ "cbc",
+ "crypto-bigint",
+ "crypto-common 0.2.2",
+ "ctr",
+ "curve25519-dalek",
+ "des",
+ "digest 0.11.3",
+ "ecdsa",
+ "ed25519-dalek",
+ "hex",
+ "hmac 0.13.0",
+ "http",
+ "inout",
+ "md-5 0.11.0",
+ "p256",
+ "p384",
+ "p521",
+ "pbkdf2",
+ "picky-asn1",
+ "picky-asn1-der",
+ "picky-asn1-x509",
+ "pkcs1 0.8.0-rc.4",
+ "primeorder",
+ "rand 0.10.2",
+ "rand_core 0.10.1",
+ "rc2",
+ "rsa",
+ "rustcrypto-ff",
+ "rustcrypto-ff_derive",
+ "rustcrypto-group",
+ "serde",
+ "serde_json",
+ "sha1 0.11.0",
+ "sha2",
+ "sha3",
+ "thiserror",
+ "x25519-dalek",
+ "zeroize",
+]
+
+[[package]]
+name = "picky-asn1"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ff038f9360b934342fb3c0a1d6e82c438a2624b51c3c6e3e6d7cf252b6f3ee3"
+dependencies = [
+ "oid",
+ "serde",
+ "serde_bytes",
+ "time",
+ "zeroize",
+]
+
+[[package]]
+name = "picky-asn1-der"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d413165e4bf7f808b9a27cbaba657657a2921f0965db833f488c4d4be96dcd2e"
+dependencies = [
+ "picky-asn1",
+ "serde",
+ "serde_bytes",
+]
+
+[[package]]
+name = "picky-asn1-x509"
+version = "0.15.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "859d4117bd1b1dc5646359ee7243c50c5000c0920ea2d1fb120335a2f4c684b8"
+dependencies = [
+ "base64",
+ "crypto-bigint",
+ "oid",
+ "picky-asn1",
+ "picky-asn1-der",
+ "serde",
+ "widestring",
+ "zeroize",
+]
+
+[[package]]
+name = "picky-krb"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d188f3192356068dbdba54bddbca6fd0f7a09565d3861eeb8efe1ab77ae8e97"
+dependencies = [
+ "aes",
+ "block-padding",
+ "byteorder",
+ "cbc",
+ "cipher",
+ "crypto-bigint",
+ "des",
+ "hmac 0.13.0",
+ "inout",
+ "oid",
+ "pbkdf2",
+ "picky-asn1",
+ "picky-asn1-der",
+ "picky-asn1-x509",
+ "rand 0.10.2",
+ "rand_core 0.10.1",
+ "serde",
+ "sha1 0.11.0",
+ "thiserror",
+ "uuid",
+]
+
+[[package]]
+name = "pin-project"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "pkcs1"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
+dependencies = [
+ "der 0.7.10",
+ "pkcs8 0.10.2",
+ "spki 0.7.3",
+]
+
+[[package]]
+name = "pkcs1"
+version = "0.8.0-rc.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e"
+dependencies = [
+ "der 0.8.1",
+ "spki 0.8.0",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der 0.7.10",
+ "spki 0.7.3",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
+dependencies = [
+ "der 0.8.1",
+ "spki 0.8.0",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
+
+[[package]]
+name = "png"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
+dependencies = [
+ "bitflags 2.13.1",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "polyval"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd"
+dependencies = [
+ "cpubits",
+ "cpufeatures 0.3.0",
+ "universal-hash",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "primefield"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4"
+dependencies = [
+ "crypto-bigint",
+ "crypto-common 0.2.2",
+ "ff",
+ "rand_core 0.10.1",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "primeorder"
+version = "0.14.0-rc.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e56e6d67fdf5744e9e245ae571450fe584b91f5af261d0e40163b618e53a1f6"
+dependencies = [
+ "elliptic-curve",
+ "once_cell",
+ "primefield",
+ "serdect",
+]
+
+[[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 = "qoicoubeh"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9b82aa3fef8a980075775b8c46f874823b5b4a15de327d2dbb3b6fd818480ba"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "radium"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
+
+[[package]]
+name = "rand"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
+dependencies = [
+ "rand_chacha",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
+dependencies = [
+ "chacha20",
+ "getrandom 0.4.3",
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
+[[package]]
+name = "rayon"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
+dependencies = [
+ "either",
+ "rayon-core",
+]
+
+[[package]]
+name = "rayon-core"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
+dependencies = [
+ "crossbeam-deque",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "rc2"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ceda21af1ae61033b63175653a1af86cae399d79cd03ca80ba347eb3a6c4a7fe"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
+name = "rdp-client"
+version = "0.1.0"
+dependencies = [
+ "ironrdp-web",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-core",
+ "h2",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "resize"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71725ecd5e0197b54fe859055b108688472ab6a358f8fbe5cee4a556b1b5bfea"
+dependencies = [
+ "rgb",
+]
+
+[[package]]
+name = "rfc6979"
+version = "0.6.0-pre.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9935425142ac6e252364413291d96c8bc9898d0876a801824c7af4eae397b689"
+dependencies = [
+ "ctutils",
+ "hmac 0.13.0",
+]
+
+[[package]]
+name = "rgb"
+version = "0.8.53"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rsa"
+version = "0.10.0-rc.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf"
+dependencies = [
+ "const-oid 0.10.2",
+ "crypto-bigint",
+ "crypto-primes",
+ "digest 0.11.3",
+ "pkcs1 0.8.0-rc.4",
+ "pkcs8 0.11.0",
+ "rand_core 0.10.1",
+ "signature",
+ "spki 0.8.0",
+ "zeroize",
+]
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustcrypto-ff"
+version = "0.14.0-rc.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd2a8adb347447693cd2ba0d218c4b66c62da9b0a5672b17b981e4291ec65ff6"
+dependencies = [
+ "bitvec",
+ "rand_core 0.10.1",
+ "rustcrypto-ff_derive",
+ "subtle",
+]
+
+[[package]]
+name = "rustcrypto-ff_derive"
+version = "0.14.0-rc.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cda22ea03582974ab5687fc131eba2dc78e258e7eef4d7e01bcd0522ed79f66"
+dependencies = [
+ "addchain",
+ "num-bigint 0.3.3",
+ "num-integer",
+ "num-traits",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "rustcrypto-group"
+version = "0.14.0-rc.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "369f9b61aa45933c062c9f6b5c3c50ab710687eca83dd3802653b140b43f85ed"
+dependencies = [
+ "rand_core 0.10.1",
+ "rustcrypto-ff",
+ "subtle",
+]
+
+[[package]]
+name = "rusticata-macros"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
+dependencies = [
+ "nom",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.43"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
+dependencies = [
+ "aws-lc-rs",
+ "log",
+ "once_cell",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pemfile"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[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 = [
+ "aws-lc-rs",
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "safe_arch"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "sec1"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d"
+dependencies = [
+ "base16ct",
+ "ctutils",
+ "der 0.8.1",
+ "hybrid-array",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "secrecy"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_bytes"
+version = "0.11.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[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 = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serdect"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e"
+dependencies = [
+ "base16ct",
+ "serde",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "sha1"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "digest 0.11.3",
+]
+
+[[package]]
+name = "sha2"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "digest 0.11.3",
+]
+
+[[package]]
+name = "sha3"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759"
+dependencies = [
+ "digest 0.11.3",
+ "keccak",
+ "sponge-cursor",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signature"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
+dependencies = [
+ "digest 0.11.3",
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[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 = "spin"
+version = "0.9.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
+dependencies = [
+ "lock_api",
+]
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der 0.7.10",
+]
+
+[[package]]
+name = "spki"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f"
+dependencies = [
+ "base64ct",
+ "der 0.8.1",
+]
+
+[[package]]
+name = "sponge-cursor"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
+
+[[package]]
+name = "sspi"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c3bd2a45e7e24fd72eba100ced3fd847e05fe4657d7f067eb06d1a894f0d4e5"
+dependencies = [
+ "async-dnssd",
+ "async-recursion",
+ "bitflags 2.13.1",
+ "bytemuck",
+ "byteorder",
+ "cfg-if",
+ "crypto-bigint",
+ "crypto-mac",
+ "cryptoki",
+ "curve25519-dalek",
+ "ed25519-dalek",
+ "futures",
+ "getrandom 0.3.4",
+ "hmac 0.13.0",
+ "md-5 0.11.0",
+ "md4",
+ "num-derive 0.4.2",
+ "num-traits",
+ "oid",
+ "p256",
+ "p384",
+ "p521",
+ "picky",
+ "picky-asn1",
+ "picky-asn1-der",
+ "picky-asn1-x509",
+ "picky-krb",
+ "pkcs1 0.8.0-rc.4",
+ "primeorder",
+ "rand 0.10.2",
+ "rand_core 0.10.1",
+ "rsa",
+ "rustcrypto-ff",
+ "rustcrypto-ff_derive",
+ "rustcrypto-group",
+ "rustls",
+ "serde",
+ "sha1 0.11.0",
+ "sha2",
+ "time",
+ "tokio",
+ "tracing",
+ "url",
+ "uuid",
+ "widestring",
+ "windows",
+ "windows-registry",
+ "winscard",
+ "zeroize",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[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 = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "system-configuration"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
+dependencies = [
+ "bitflags 2.13.1",
+ "core-foundation",
+ "system-configuration-sys",
+]
+
+[[package]]
+name = "system-configuration-sys"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "tap"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
+
+[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[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 = "thread_local"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "time"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
+dependencies = [
+ "deranged",
+ "js-sys",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tls_codec"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b"
+dependencies = [
+ "tls_codec_derive",
+ "zeroize",
+]
+
+[[package]]
+name = "tls_codec_derive"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[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",
+ "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.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
+dependencies = [
+ "futures-util",
+ "log",
+ "tokio",
+ "tungstenite",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "libc",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags 2.13.1",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "nu-ansi-term",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "time",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "tracing-web"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e6a141feebd51f8d91ebfd785af50fca223c570b86852166caa3b141defe7c"
+dependencies = [
+ "js-sys",
+ "tracing-core",
+ "tracing-subscriber",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "tungstenite"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
+dependencies = [
+ "bytes",
+ "data-encoding",
+ "http",
+ "httparse",
+ "log",
+ "rand 0.9.5",
+ "sha1 0.10.7",
+ "thiserror",
+]
+
+[[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 = "universal-hash"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96"
+dependencies = [
+ "crypto-common 0.2.2",
+ "ctutils",
+]
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "uuid"
+version = "1.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
+dependencies = [
+ "getrandom 0.4.3",
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.77"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wide"
+version = "0.7.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03"
+dependencies = [
+ "bytemuck",
+ "safe_arch",
+]
+
+[[package]]
+name = "widestring"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
+dependencies = [
+ "windows-collections",
+ "windows-core",
+ "windows-future",
+ "windows-numerics",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
+dependencies = [
+ "windows-core",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
+dependencies = [
+ "windows-core",
+ "windows-link",
+ "windows-threading",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-numerics"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
+dependencies = [
+ "windows-core",
+ "windows-link",
+]
+
+[[package]]
+name = "windows-registry"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
+dependencies = [
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[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-threading"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
+dependencies = [
+ "windows-link",
+]
+
+[[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 = "winscard"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12dafb3c1468d0a3f5440e21e51614b53d1fdc62c9f82cc861c447906d09c69a"
+dependencies = [
+ "bitflags 2.13.1",
+ "crypto-bigint",
+ "flate2",
+ "iso7816",
+ "iso7816-tlv",
+ "num-derive 0.4.2",
+ "num-traits",
+ "picky",
+ "picky-asn1-x509",
+ "rsa",
+ "sha1 0.11.0",
+ "time",
+ "tracing",
+ "uuid",
+ "widestring",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "writeable"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+
+[[package]]
+name = "wyz"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
+dependencies = [
+ "tap",
+]
+
+[[package]]
+name = "x25519-dalek"
+version = "3.0.0-rc.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eee64e8620caa64914d669b1f68f858aaff54e2d0f9ad3b30a613b58a1baa83e"
+dependencies = [
+ "curve25519-dalek",
+ "rand_core 0.10.1",
+ "zeroize",
+]
+
+[[package]]
+name = "x509-cert"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "105ef4642d9cb137ef83d623d0e4bf08b8adf69e9918ca904a174adb6d3d038b"
+dependencies = [
+ "const-oid 0.10.2",
+ "der 0.8.1",
+ "spki 0.8.0",
+ "tls_codec",
+]
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "yuv"
+version = "0.8.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "220655e1c245693beb13b377d3174b9efcb1645018d1d4ec53903fc211b135ae"
+dependencies = [
+ "num-traits",
+]
+
+[[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 = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+dependencies = [
+ "zeroize_derive",
+]
+
+[[package]]
+name = "zeroize_derive"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+
+[[package]]
+name = "zstd-safe"
+version = "7.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
+dependencies = [
+ "zstd-sys",
+]
+
+[[package]]
+name = "zstd-sys"
+version = "2.0.16+zstd.1.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..0eb470c
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,149 @@
+include /usr/share/dpkg/architecture.mk
+include /usr/share/dpkg/pkg-info.mk
+
+PACKAGE=pve-rdp-web
+DEB=$(PACKAGE)_$(DEB_VERSION)_all.deb
+DSC=$(PACKAGE)_$(DEB_VERSION).dsc
+BUILDDIR=$(PACKAGE)-$(DEB_VERSION_UPSTREAM)
+
+# ironrdp-wasm: MIT like this package, but a separate upstream, 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 you need `git log` to read.
+#
+# The crate itself is two lines re-exporting IronRDP's own ironrdp-web; what it
+# really carries is the build recipe. Pinned by commit - it has no releases.
+RDP_WEB_DIR = ironrdp-wasm
+
+# IronRDP itself. Normally a plain git dependency of the crate above, but the
+# audio patch is against this tree, so it is cloned and overridden with
+# [patch]. Pinned to the same revision `patches/0001` pins the dependency to -
+# the two must agree or cargo resolves two copies of every IronRDP type.
+IRONRDP_DIR = ironrdp
+IRONRDP_REV = f24685cde66bd290a3f7d35f506b9d8840452b92
+
+# The two files the console page loads, plus the audio sink it installs before
+# connecting. The sink is ours and lives here rather than in pve-manager: it is
+# the other half of patches/ironrdp/add_audio_support and has to move with it.
+SDK_FILES = rdp_client.js rdp_client_bg.wasm
+JS_FILES = js/rdp-audio.js js/rdp-audio-worklet.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 "$(RDP_WEB_DIR)/Cargo.toml" && echo 1 || echo 0), 0)
+ git submodule update --init --recursive $(RDP_WEB_DIR)
+endif
+
+# Fetched once and left alone afterwards, as above. Cloned rather than made a
+# submodule because it is an override, not an input we track: the revision is
+# one greppable line here and in patches/0001.
+.PHONY: ironrdp
+ironrdp:
+ifeq ($(shell test -d "$(IRONRDP_DIR)/crates" && echo 1 || echo 0), 0)
+ git clone --quiet https://github.com/Devolutions/IronRDP.git $(IRONRDP_DIR)
+endif
+ git -C $(IRONRDP_DIR) checkout --quiet --force $(IRONRDP_REV)
+
+.PHONY: patch
+patch: submodule ironrdp
+ git -C $(RDP_WEB_DIR) checkout --force -- .
+ set -e; for p in $(CURDIR)/patches/000*.patch; do \
+ git -C $(RDP_WEB_DIR) apply "$$p"; \
+ done
+ git -C $(IRONRDP_DIR) checkout --force -- .
+ # checkout restores tracked files but leaves untracked ones behind, and
+ # the audio patch adds a module - without this its create-file hunk fails
+ # to reapply on the second build. `target` is excluded so a reset does not
+ # throw away the compiled dependencies with it.
+ git -C $(IRONRDP_DIR) clean -fdq -e /target
+ set -e; for p in $(CURDIR)/patches/ironrdp/*.patch; do \
+ git -C $(IRONRDP_DIR) apply "$$p"; \
+ done
+ # Relative, so the tree can live anywhere. Appended rather than carried in
+ # patches/0001 because the path is a property of this checkout, not of the
+ # crate.
+ printf '\n[patch."https://github.com/Devolutions/IronRDP.git"]\nironrdp-web = { path = "../$(IRONRDP_DIR)/crates/ironrdp-web" }\n' \
+ >> $(RDP_WEB_DIR)/Cargo.toml
+
+# --- build ------------------------------------------------------------------
+# wasm-pack and its node wrapper are skipped: they run cargo, wasm-bindgen and
+# wasm-opt in turn, and all three are already here for pve-kyber-web. Doing it
+# directly costs one Makefile rule and removes node from the build entirely.
+.PHONY: wasm
+wasm: patch
+ # Upstream ships no lockfile, so this one is ours: without it every build
+ # re-resolves the transitive dependencies at whatever is current that day,
+ # and two builds of the same tree produce different clients.
+ install -m 0644 $(CURDIR)/Cargo.lock $(RDP_WEB_DIR)/Cargo.lock
+ # The CLI has to match the crate, or the glue will not load the module.
+ cd $(RDP_WEB_DIR) && \
+ want=$$(sed -n '/^name = "wasm-bindgen"$$/{n;s/^version = "\(.*\)"/\1/p;q}' Cargo.lock); \
+ have=$$(wasm-bindgen --version | awk '{print $$2}'); \
+ 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
+ # --locked so a lockfile that no longer matches Cargo.toml stops the build
+ # rather than being silently rewritten, which is the whole point of it.
+ cd $(RDP_WEB_DIR) && cargo build --locked --target wasm32-unknown-unknown --release
+ cd $(RDP_WEB_DIR) && wasm-bindgen --target web --no-typescript --out-dir pkg \
+ target/wasm32-unknown-unknown/release/rdp_client.wasm
+ cd $(RDP_WEB_DIR) && wasm-opt pkg/rdp_client_bg.wasm -o pkg/rdp_client_bg.wasm -Os -g
+
+# --- packaging --------------------------------------------------------------
+.PHONY: sdk
+sdk: wasm
+ rm -rf sdk && mkdir sdk
+ for f in $(SDK_FILES); do install -m 0644 "$(RDP_WEB_DIR)/pkg/$$f" sdk/; done
+ install -m 0644 $(JS_FILES) sdk/
+
+.PHONY: builddir
+builddir:
+ rm -rf $(BUILDDIR)
+ $(MAKE) $(BUILDDIR)
+
+$(BUILDDIR): sdk
+ rm -rf $@ $@.tmp
+ mkdir $@.tmp
+ cp -a sdk debian Makefile js $@.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..900fd08
--- /dev/null
+++ b/debian/changelog
@@ -0,0 +1,5 @@
+pve-rdp-web (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..e0015b1
--- /dev/null
+++ b/debian/control
@@ -0,0 +1,19 @@
+Source: pve-rdp-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-rdp-web
+Architecture: all
+Depends: ${misc:Depends},
+Description: RDP client for the Proxmox VE console
+ IronRDP compiled to WebAssembly: the RDP client the console page drives, and
+ its JavaScript glue.
+ .
+ It reaches a VM through pveproxy and pve-rdpproxy, which performs the TLS
+ handshake on its behalf - a browser cannot drive one over a websocket.
diff --git a/debian/copyright b/debian/copyright
new file mode 100644
index 0000000..6e9cc34
--- /dev/null
+++ b/debian/copyright
@@ -0,0 +1,38 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+Upstream-Name: ironrdp-wasm
+Source: https://github.com/electerm/ironrdp-wasm
+
+Files: *
+Copyright: 2026 Marc-Andre Moreau
+ 2024-2026 electerm contributors
+License: MIT
+
+Files: debian/*
+Copyright: 2026 Proxmox Server Solutions GmbH <support@proxmox.com>
+License: MIT
+
+Comment: The wasm itself is built from IronRDP (Devolutions), MIT OR
+ Apache-2.0, pinned by revision - see patches/0001.
+
+License: MIT
+ MIT License
+ .
+ Copyright (c) 2026 Marc-André Moreau
+ .
+ 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.
\ No newline at end of file
diff --git a/debian/install b/debian/install
new file mode 100644
index 0000000..5cc3bdf
--- /dev/null
+++ b/debian/install
@@ -0,0 +1 @@
+sdk/* usr/share/pve-rdp-web/
diff --git a/debian/rules b/debian/rules
new file mode 100755
index 0000000..5df993d
--- /dev/null
+++ b/debian/rules
@@ -0,0 +1,10 @@
+#!/usr/bin/make -f
+%:
+ dh $@
+
+# The wasm is built by the top-level Makefile before dpkg-buildpackage runs,
+# which is also what stages it. The Makefile carried into the build dir has a
+# distclean that would remove sdk/.
+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/ironrdp-wasm b/ironrdp-wasm
new file mode 160000
index 0000000..103e36b
--- /dev/null
+++ b/ironrdp-wasm
@@ -0,0 +1 @@
+Subproject commit 103e36ba0bcfeb1950b2ff2d94cc4897588fcfc9
diff --git a/js/rdp-audio-worklet.js b/js/rdp-audio-worklet.js
new file mode 100644
index 0000000..3a72737
--- /dev/null
+++ b/js/rdp-audio-worklet.js
@@ -0,0 +1,106 @@
+/*
+ * Playback end of the RDP audio path: a ring buffer drained by the audio
+ * thread.
+ *
+ * Deliberately not a SharedArrayBuffer. The usual low-latency recipe shares
+ * one with the main thread, but SAB requires cross-origin isolation
+ * (COOP/COEP), which the Proxmox console page cannot assume behind pveproxy.
+ * postMessage copies each block instead: a few hundred samples every 20 ms,
+ * which costs far less than the isolation headers would.
+ */
+
+const CHANNELS = 2;
+/* About 400 ms. Large enough to ride out a scheduling hiccup, small enough that
+ * recovering from an underrun is not audible as a long delay. */
+const CAPACITY = 48000 * 0.4;
+
+class RdpAudioProcessor extends AudioWorkletProcessor {
+ constructor() {
+ super();
+
+ this.buf = [];
+ for (let c = 0; c < CHANNELS; c++) {
+ this.buf.push(new Float32Array(CAPACITY));
+ }
+ this.read = 0;
+ this.write = 0;
+ this.filled = 0;
+
+ /*
+ * Start muted and wait for a cushion to build. Playing the first block
+ * the moment it lands guarantees an underrun on the next one.
+ */
+ this.priming = true;
+ this.primeTarget = 48000 * 0.06;
+
+ this.port.onmessage = (e) => {
+ const d = e.data;
+ if (d.reset) {
+ this.read = this.write = this.filled = 0;
+ this.priming = true;
+ return;
+ }
+ this.push(d.channels);
+ };
+ }
+
+ push(channels) {
+ const n = channels[0].length;
+
+ /* Overflow means the far end is producing faster than this clock
+ * consumes. Drop the oldest rather than the newest: the newest is the
+ * one the user is waiting to hear. */
+ if (this.filled + n > CAPACITY) {
+ const drop = this.filled + n - CAPACITY;
+ this.read = (this.read + drop) % CAPACITY;
+ this.filled -= drop;
+ }
+
+ for (let c = 0; c < CHANNELS; c++) {
+ const src = channels[Math.min(c, channels.length - 1)];
+ let w = this.write;
+ for (let i = 0; i < n; i++) {
+ this.buf[c][w] = src[i];
+ w = w + 1 === CAPACITY ? 0 : w + 1;
+ }
+ }
+ this.write = (this.write + n) % CAPACITY;
+ this.filled += n;
+
+ if (this.priming && this.filled >= this.primeTarget) {
+ this.priming = false;
+ }
+ }
+
+ process(inputs, outputs) {
+ const out = outputs[0];
+ const n = out[0].length;
+
+ if (this.priming || this.filled < n) {
+ for (let c = 0; c < out.length; c++) {
+ out[c].fill(0);
+ }
+ /* Rebuild the cushion after an underrun instead of stuttering
+ * block by block. */
+ if (!this.priming && this.filled < n) {
+ this.priming = true;
+ }
+ return true;
+ }
+
+ for (let c = 0; c < out.length; c++) {
+ const src = this.buf[Math.min(c, CHANNELS - 1)];
+ let r = this.read;
+ for (let i = 0; i < n; i++) {
+ out[c][i] = src[r];
+ r = r + 1 === CAPACITY ? 0 : r + 1;
+ }
+ }
+ this.read = (this.read + n) % CAPACITY;
+ this.filled -= n;
+
+ return true;
+ }
+}
+
+registerProcessor('rdp-audio', RdpAudioProcessor);
diff --git a/js/rdp-audio.js b/js/rdp-audio.js
new file mode 100644
index 0000000..6b974b9
--- /dev/null
+++ b/js/rdp-audio.js
@@ -0,0 +1,222 @@
+/*
+ * Guest audio for the RDP console.
+ *
+ * Installs `globalThis.pveRdpAudio`, which the wasm client looks up once when a
+ * session starts and calls for every wave block the server sends. A global
+ * rather than a client callback because adding one to IronRDP's SessionBuilder
+ * would mean patching `iron-remote-desktop` and its TypeScript glue too, for a
+ * hook only this console uses.
+ *
+ * Opus arrives as bare packets - no Ogg framing - which is exactly what
+ * WebCodecs wants. PCM is handled without a decoder at all.
+ *
+ * Browsers will not start an AudioContext without a user gesture, so the
+ * context stays suspended until `resume()` is called from a click. Blocks that
+ * arrive before then are decoded and discarded rather than queued, so audio
+ * begins at the moment the user allows it rather than replaying a backlog.
+ */
+
+(function () {
+ 'use strict';
+
+ const SAMPLE_RATE = 48000;
+ const WORKLET_URL = '/rdp/rdp-audio-worklet.js';
+
+ let ctx = null;
+ let node = null;
+ let gain = null;
+ let decoder = null;
+ let starting = null;
+ let ts = 0;
+ let warned = false;
+
+ function warnOnce(...args) {
+ if (!warned) {
+ warned = true;
+ console.warn('[rdp-audio]', ...args);
+ }
+ }
+
+ /* One AudioContext and one worklet per page, created on the first block. */
+ async function start(workletUrl) {
+ if (node) {
+ return;
+ }
+ if (starting) {
+ return starting;
+ }
+
+ starting = (async () => {
+ ctx = new (window.AudioContext || window.webkitAudioContext)({
+ sampleRate: SAMPLE_RATE,
+ latencyHint: 'interactive',
+ });
+ await ctx.audioWorklet.addModule(workletUrl || WORKLET_URL);
+
+ node = new AudioWorkletNode(ctx, 'rdp-audio', {
+ numberOfInputs: 0,
+ numberOfOutputs: 1,
+ outputChannelCount: [2],
+ });
+ gain = ctx.createGain();
+ node.connect(gain).connect(ctx.destination);
+ })();
+
+ return starting;
+ }
+
+ function toWorklet(channels) {
+ if (!node || !ctx || ctx.state !== 'running') {
+ return;
+ }
+ node.port.postMessage(
+ { channels },
+ channels.map((c) => c.buffer),
+ );
+ }
+
+ /* AudioData -> plain Float32Arrays, one per channel. */
+ function emit(frame) {
+ try {
+ const n = frame.numberOfFrames;
+ const count = Math.min(frame.numberOfChannels, 2);
+ const channels = [];
+
+ for (let c = 0; c < count; c++) {
+ const out = new Float32Array(n);
+ /* planar float is the only layout guaranteed across browsers;
+ * ask for it explicitly rather than assuming the native one. */
+ frame.copyTo(out, { planeIndex: c, format: 'f32-planar' });
+ channels.push(out);
+ }
+ toWorklet(channels);
+ } catch (e) {
+ warnOnce('cannot read decoded frame:', e);
+ } finally {
+ frame.close();
+ }
+ }
+
+ function ensureDecoder() {
+ if (decoder && decoder.state !== 'closed') {
+ return decoder;
+ }
+ if (typeof AudioDecoder === 'undefined') {
+ warnOnce('WebCodecs AudioDecoder unavailable; guest audio needs a recent browser');
+ return null;
+ }
+
+ decoder = new AudioDecoder({
+ output: emit,
+ error: (e) => {
+ warnOnce('decoder error:', e);
+ decoder = null;
+ },
+ });
+ decoder.configure({
+ codec: 'opus',
+ sampleRate: SAMPLE_RATE,
+ numberOfChannels: 2,
+ });
+ return decoder;
+ }
+
+ /* s16le interleaved -> planar float, for servers that offer no Opus. */
+ function pcmToPlanar(bytes, channelCount) {
+ const pcm = new Int16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 1);
+ const frames = Math.floor(pcm.length / channelCount);
+ const channels = [];
+
+ for (let c = 0; c < channelCount; c++) {
+ const out = new Float32Array(frames);
+ for (let i = 0; i < frames; i++) {
+ out[i] = pcm[i * channelCount + c] / 32768;
+ }
+ channels.push(out);
+ }
+ return channels;
+ }
+
+ globalThis.pveRdpAudio = {
+ /* Called by the wasm client for each wave block. `data` is a
+ * Uint8Array owned by the caller, so anything kept must be copied. */
+ wave(data, _serverTs, opus, channels, sampleRate) {
+ start().catch((e) => warnOnce('cannot start audio:', e));
+
+ if (!ctx || ctx.state !== 'running') {
+ /* Suspended: no gesture yet. Drop rather than queue. */
+ return;
+ }
+
+ if (!opus) {
+ if (sampleRate !== SAMPLE_RATE) {
+ warnOnce(`PCM at ${sampleRate} Hz not resampled; expected ${SAMPLE_RATE}`);
+ return;
+ }
+ toWorklet(pcmToPlanar(data, channels || 2));
+ return;
+ }
+
+ const dec = ensureDecoder();
+ if (!dec) {
+ return;
+ }
+
+ /* The server's timestamp has an arbitrary epoch (MS-RDPEA leaves
+ * it to the implementation), so it is not a media clock. Count
+ * decoded time instead; the ring buffer does the real pacing. */
+ const chunk = new EncodedAudioChunk({
+ type: 'key',
+ timestamp: ts,
+ data: data.slice(),
+ });
+ ts += 20000; /* one 20 ms Opus frame, in microseconds */
+
+ try {
+ dec.decode(chunk);
+ } catch (e) {
+ warnOnce('decode failed:', e);
+ }
+ },
+
+ /* SNDC_SETVOLUME, per channel, 0..0xFFFF. One GainNode, so the two are
+ * averaged - a stereo balance control would need a StereoPannerNode
+ * and the guest's own mixer already offers one. */
+ volume(left, right) {
+ if (!gain) {
+ return;
+ }
+ const v = (left + right) / 2 / 0xffff;
+ gain.gain.value = Math.min(1, Math.max(0, v));
+ },
+
+ close() {
+ try {
+ if (decoder && decoder.state !== 'closed') {
+ decoder.close();
+ }
+ if (node) {
+ node.port.postMessage({ reset: true });
+ }
+ } catch (e) {
+ /* teardown races the session ending; nothing to do */
+ }
+ decoder = null;
+ ts = 0;
+ },
+
+ /* The console page calls this from a click handler: browsers refuse to
+ * start an AudioContext without one. */
+ async resume(workletUrl) {
+ await start(workletUrl);
+ if (ctx && ctx.state !== 'running') {
+ await ctx.resume();
+ }
+ return ctx ? ctx.state : 'closed';
+ },
+
+ get state() {
+ return ctx ? ctx.state : 'closed';
+ },
+ };
+})();
diff --git a/patches/0001-pin-ironrdp-web-to-a-revision.patch b/patches/0001-pin-ironrdp-web-to-a-revision.patch
new file mode 100644
index 0000000..97a9217
--- /dev/null
+++ b/patches/0001-pin-ironrdp-web-to-a-revision.patch
@@ -0,0 +1,33 @@
+From 7da6daa20712ff4ef8c0f18cfec0ce0171b7fe88 Mon Sep 17 00:00:00 2001
+From: Alexandre Derumier <aderumier@groupe-cyllene.com>
+Date: Wed, 19 Aug 2026 14:12:52 +0200
+Subject: [PATCH] pin ironrdp-web to a revision, and ask for QOI
+
+A git dependency on branch = "master" resolves to whatever IronRDP happens to
+be on the day of the build, so two builds of the same tree can differ. A
+package needs a fixed input.
+
+qoi and qoiz are opt-in. Without them the client advertises RemoteFX alone, so
+that is what the server encodes with - lossy, fixed quantization, visibly
+flattened colour, and slow. QOI is lossless, and qemu-rdp prefers it.
+---
+diff --git a/Cargo.toml b/Cargo.toml
+index d6279a9..5baa400 100644
+--- a/Cargo.toml
++++ b/Cargo.toml
+@@ -9,5 +9,12 @@ crate-type = ["cdylib"]
+ [dependencies]
+ # WASM dependencies
+ wasm-bindgen = "0.2"
+-ironrdp-web = { git = "https://github.com/Devolutions/IronRDP.git", branch = "master" }
+-web-sys = { version = "0.3", features = ["console"] }
+\ No newline at end of file
++# Pinned by revision, not branch: "master" makes every build pull whatever
++# IronRDP happens to be that day, which a package cannot do.
++#
++# qoi/qoiz are off by default, and a client that does not offer them leaves the
++# server with RemoteFX - a lossy wavelet codec whose fixed quantization visibly
++# flattens colour. QOI is lossless and cheaper to encode; qemu-rdp already
++# builds with qoiz, and prefers it when the client asks.
++ironrdp-web = { git = "https://github.com/Devolutions/IronRDP.git", rev = "f24685cde66bd290a3f7d35f506b9d8840452b92", features = ["qoiz", "qoi"] }
++web-sys = { version = "0.3", features = ["console"] }
diff --git a/patches/ironrdp/add_audio_support.patch b/patches/ironrdp/add_audio_support.patch
new file mode 100644
index 0000000..3b5d58f
--- /dev/null
+++ b/patches/ironrdp/add_audio_support.patch
@@ -0,0 +1,362 @@
+From: Alexandre Derumier <aderumier@groupe-cyllene.com>
+Subject: [PATCH] play guest audio in the browser
+
+ironrdp-web attaches the RDPSND channel only alongside a printer, and with
+NoopRdpsndBackend - a handler advertising no formats. That is not merely
+silent: the server never reaches the end of format negotiation, so its
+handler's start() never runs and every wave block is dropped before it is
+sent. MS-RDPEFS Appendix A<1> made the channel a prerequisite for RDPDR,
+which is the only reason it was attached at all.
+
+qemu-rdp offers Opus at 48 kHz, which browsers decode natively through
+WebCodecs, so there is nothing to transcode on either side. Attach the
+channel unconditionally with a handler that advertises Opus first and PCM
+as a fallback, and hand the blocks to the page.
+
+The split follows the printer backend: RdpsndClientHandler is Send and JS
+values are not, so the SVC-side handler holds only an mpsc proxy while the
+sink that owns the JS lives in the session event loop.
+
+The sink is looked up as globalThis.pveRdpAudio rather than passed as a
+builder callback: adding one to the SessionBuilder trait would mean
+patching iron-remote-desktop and its TypeScript glue as well, for a hook
+only this console uses.
+---
+--- a/crates/ironrdp-web/src/lib.rs
++++ b/crates/ironrdp-web/src/lib.rs
+@@ -24,6 +24,7 @@
+ mod printer;
+ mod rdp_file;
+ mod session;
++mod sound;
+
+ mod wasm_bridge {
+ use tracing::debug;
+--- a/crates/ironrdp-web/src/session.rs
++++ b/crates/ironrdp-web/src/session.rs
+@@ -28,7 +28,7 @@
+ use ironrdp::pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo};
+ use ironrdp::rdpdr::Rdpdr;
+ use ironrdp::rdpdr::pdu::efs::{DEFAULT_PRINTER_DRIVER_NAME, MICROSOFT_PRINT_TO_PDF_DRIVER_NAME};
+-use ironrdp::rdpsnd::client::{NoopRdpsndBackend, Rdpsnd};
++use ironrdp::rdpsnd::client::Rdpsnd;
+ use ironrdp::session::image::DecodedImage;
+ use ironrdp::session::{ActiveStageBuilder, ActiveStageOutput, GracefulDisconnectReason};
+ use ironrdp_core::WriteBuf;
+@@ -48,6 +48,7 @@
+ use crate::input::InputTransaction;
+ use crate::network_client::WasmNetworkClient;
+ use crate::printer::{JsPrinterStreamCallbacks, WasmPrinter, WasmPrinterBackend, wasm_printer_pair};
++use crate::sound::{SoundBackendMessage, WasmRdpsndBackend, WasmSound};
+
+ const DEFAULT_WIDTH: u16 = 1280;
+ const DEFAULT_HEIGHT: u16 = 720;
+@@ -493,6 +494,7 @@
+ kdc_proxy_url,
+ clipboard_backend: clipboard.as_ref().map(|clip| clip.backend()),
+ printer_backend,
++ sound_backend: WasmRdpsndBackend::new(input_events_tx.clone()),
+ printer_device_id,
+ printer_name,
+ printer_driver_name,
+@@ -537,6 +539,9 @@
+ /// Printer backend → event loop: a print job finished and its bytes are
+ /// ready for delivery to JS. See [`crate::printer::PrinterBackendMessage`].
+ Printer(crate::printer::PrinterBackendMessage),
++ /// RDPSND backend → event loop: a wave block (or volume change) arrived
++ /// from the server. See [`crate::sound`].
++ Sound(SoundBackendMessage),
+ FastPath(FastPathInputEvents),
+ Resize {
+ width: u32,
+@@ -642,6 +647,7 @@
+
+ let mut clipboard = self.clipboard.borrow_mut().take().expect("run called only once");
+ let mut wasm_printer = self.printer.borrow_mut().take().expect("run called only once");
++ let mut wasm_sound = WasmSound::new();
+
+ let mut framed = ironrdp_futures::LocalFuturesFramed::new(rdp_reader);
+
+@@ -868,6 +874,13 @@
+ }
+ Vec::new()
+ }
++ RdpInputEvent::Sound(message) => {
++ // Same split as the printer: the backend is Send
++ // and lives in the SVC processor, the JS sink is
++ // !Send and lives here.
++ wasm_sound.process_message(message);
++ Vec::new()
++ }
+ RdpInputEvent::TerminateSession => {
+ active_stage.graceful_shutdown()
+ .context("graceful shutdown")?
+@@ -1546,6 +1559,8 @@
+ kdc_proxy_url: Option<String>,
+ clipboard_backend: Option<WasmClipboardBackend>,
+ printer_backend: Option<WasmPrinterBackend>,
++ /// Always present: the RDPSND channel is attached unconditionally.
++ sound_backend: WasmRdpsndBackend,
+ printer_device_id: u32,
+ printer_name: String,
+ printer_driver_name: String,
+@@ -1598,6 +1613,7 @@
+ kdc_proxy_url,
+ clipboard_backend,
+ printer_backend,
++ sound_backend,
+ printer_device_id,
+ printer_name,
+ printer_driver_name,
+@@ -1616,11 +1632,14 @@
+ connector.attach_static_channel(CliprdrClient::new(Box::new(clipboard_backend)));
+ }
+
++ // Unconditional: the channel carries guest audio in its own right, and
++ // MS-RDPEFS Appendix A<1> separately makes it a prerequisite for RDPDR, so
++ // one attachment serves both. Upstream attached it only alongside a
++ // printer, and with a handler advertising no formats - which stalls the
++ // server before it finishes negotiating.
++ connector.attach_static_channel(Rdpsnd::new(Box::new(sound_backend)));
++
+ if let Some(printer_backend) = printer_backend {
+- // Windows servers only speak on RDPDR when RDPSND is advertised too
+- // (MS-RDPEFS Appendix A<1>). We do not play audio in the web client,
+- // but the no-op RDPSND processor satisfies that channel dependency.
+- connector.attach_static_channel(Rdpsnd::new(Box::new(NoopRdpsndBackend)));
+ connector.attach_static_channel(
+ Rdpdr::new(Box::new(printer_backend), computer_name).with_printer_driver(
+ printer_device_id,
+--- a/crates/ironrdp-web/src/sound.rs
++++ b/crates/ironrdp-web/src/sound.rs
+@@ -0,0 +1,230 @@
++//! Browser audio sink for the RDPSND static channel.
++//!
++//! Architecture mirrors the printer backend ([`crate::printer`]), for the same
++//! reason: [`RdpsndClientHandler`] is `Send`, JS values are not.
++//!
++//! * [`WasmRdpsndBackend`] lives on the SVC processor side. It holds the
++//! advertised format list and an mpsc proxy - never a `JsValue`.
++//! * [`WasmSound`] lives in the session event loop, owns the JS callbacks and
++//! hands wave blocks to the page.
++//!
++//! Upstream attaches RDPSND with a no-op handler, and only when a printer is
++//! configured: MS-RDPEFS Appendix A<1> makes the channel a prerequisite for
++//! RDPDR, and the web client had nothing to play what arrived with. A client
++//! advertising no formats is not merely silent - the server never reaches the
++//! end of format negotiation, so nothing downstream of it runs either.
++//!
++//! Opus is listed first because it is what we would rather be sent: browsers
++//! decode it natively through WebCodecs, and `qemu-rdp` already encodes it, so
++//! neither side has to transcode. PCM is kept as a fallback for servers that
++//! do not offer Opus. Both entries must match the server's own table field for
++//! field - it selects by structural equality, not by a subset match - so the
++//! constants below are deliberately spelled out rather than derived.
++
++use std::borrow::Cow;
++
++use futures_channel::mpsc;
++use ironrdp::rdpsnd::client::RdpsndClientHandler;
++use ironrdp::rdpsnd::pdu::{AudioFormat, AudioFormatFlags, PitchPdu, VolumePdu, WaveFormat};
++use tracing::{debug, trace, warn};
++use wasm_bindgen::prelude::*;
++
++use crate::session::RdpInputEvent;
++
++/// Opus, 48 kHz stereo. `n_avg_bytes_per_sec` and `n_block_align` describe the
++/// *decoded* stream, as MS-RDPEA requires for a compressed format.
++fn opus_48k() -> AudioFormat {
++ AudioFormat {
++ format: WaveFormat::OPUS,
++ n_channels: 2,
++ n_samples_per_sec: 48000,
++ n_avg_bytes_per_sec: 192000,
++ n_block_align: 4,
++ bits_per_sample: 16,
++ data: None,
++ }
++}
++
++/// Uncompressed fallback, same shape.
++fn pcm_48k() -> AudioFormat {
++ AudioFormat {
++ format: WaveFormat::PCM,
++ n_channels: 2,
++ n_samples_per_sec: 48000,
++ n_avg_bytes_per_sec: 192000,
++ n_block_align: 4,
++ bits_per_sample: 16,
++ data: None,
++ }
++}
++
++/// Messages sent from the RDPSND backend to the session event loop.
++#[derive(Debug)]
++pub(crate) enum SoundBackendMessage {
++ /// One wave block, already in the negotiated format.
++ Wave {
++ /// True when the payload is an Opus packet rather than raw PCM.
++ opus: bool,
++ channels: u16,
++ sample_rate: u32,
++ /// Server timestamp, milliseconds. Advisory: MS-RDPEA lets the server
++ /// pick the epoch, so this is only useful as a relative measure.
++ ts: u32,
++ data: Vec<u8>,
++ },
++ /// Per-channel volume, 0..=0xFFFF.
++ Volume { left: u16, right: u16 },
++ /// The server closed the stream.
++ Close,
++}
++
++/// SVC-side handler. `Send`, and holds no JS state.
++#[derive(Debug)]
++pub(crate) struct WasmRdpsndBackend {
++ formats: Vec<AudioFormat>,
++ tx: mpsc::UnboundedSender<RdpInputEvent>,
++}
++
++impl WasmRdpsndBackend {
++ pub(crate) fn new(tx: mpsc::UnboundedSender<RdpInputEvent>) -> Self {
++ Self {
++ formats: vec![opus_48k(), pcm_48k()],
++ tx,
++ }
++ }
++
++ fn send(&self, message: SoundBackendMessage) {
++ if let Err(e) = self.tx.unbounded_send(RdpInputEvent::Sound(message)) {
++ // The loop is gone: the session is ending. Not worth a warning per
++ // 20 ms block.
++ trace!("Failed to send audio to the event loop: {e}");
++ }
++ }
++}
++
++impl RdpsndClientHandler for WasmRdpsndBackend {
++ fn get_flags(&self) -> AudioFormatFlags {
++ // VOLUME asks the server to send us SNDC_SETVOLUME; the page applies
++ // it with a GainNode. ALIVE is added by the channel itself.
++ AudioFormatFlags::VOLUME
++ }
++
++ fn get_formats(&self) -> &[AudioFormat] {
++ &self.formats
++ }
++
++ fn wave(&mut self, format: &AudioFormat, ts: u32, data: Cow<'_, [u8]>) {
++ if data.is_empty() {
++ return;
++ }
++
++ self.send(SoundBackendMessage::Wave {
++ opus: format.format == WaveFormat::OPUS,
++ channels: format.n_channels,
++ sample_rate: format.n_samples_per_sec,
++ ts,
++ data: data.into_owned(),
++ });
++ }
++
++ fn set_volume(&mut self, volume: VolumePdu) {
++ self.send(SoundBackendMessage::Volume {
++ left: volume.volume_left,
++ right: volume.volume_right,
++ });
++ }
++
++ fn set_pitch(&mut self, _pitch: PitchPdu) {
++ // MS-RDPEA leaves this optional and no server we target sends it.
++ }
++
++ fn close(&mut self) {
++ self.send(SoundBackendMessage::Close);
++ }
++}
++
++/// Event-loop side. Owns the JS callbacks, so it is `!Send` and never crosses
++/// into the SVC processor.
++pub(crate) struct WasmSound {
++ sink: Option<js_sys::Object>,
++ warned: bool,
++}
++
++impl WasmSound {
++ /// Looks the sink up once, at session start.
++ ///
++ /// A global rather than a builder callback: adding one to the
++ /// `SessionBuilder` trait would mean patching `iron-remote-desktop` and
++ /// its TypeScript glue as well, for a hook only this console uses. The
++ /// page installs `globalThis.pveRdpAudio` before it connects.
++ pub(crate) fn new() -> Self {
++ let sink = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("pveRdpAudio"))
++ .ok()
++ .and_then(|v| v.dyn_into::<js_sys::Object>().ok());
++
++ if sink.is_none() {
++ debug!("No globalThis.pveRdpAudio installed; guest audio will be dropped");
++ }
++
++ Self { sink, warned: false }
++ }
++
++ fn call(&mut self, method: &str, args: &js_sys::Array) {
++ let Some(sink) = self.sink.as_ref() else {
++ return;
++ };
++
++ let func = js_sys::Reflect::get(sink, &JsValue::from_str(method))
++ .ok()
++ .and_then(|v| v.dyn_into::<js_sys::Function>().ok());
++
++ let Some(func) = func else {
++ if !self.warned {
++ warn!("globalThis.pveRdpAudio has no {method}() method");
++ self.warned = true;
++ }
++ return;
++ };
++
++ if let Err(e) = func.apply(sink, args) {
++ if !self.warned {
++ warn!("pveRdpAudio.{method}() threw: {e:?}");
++ self.warned = true;
++ }
++ }
++ }
++
++ pub(crate) fn process_message(&mut self, message: SoundBackendMessage) {
++ match message {
++ SoundBackendMessage::Wave {
++ opus,
++ channels,
++ sample_rate,
++ ts,
++ data,
++ } => {
++ // Copies into the JS heap. A wave block is one 20 ms frame,
++ // so this is a few hundred bytes for Opus.
++ let buf = js_sys::Uint8Array::from(data.as_slice());
++ let args = js_sys::Array::of5(
++ &buf.into(),
++ &JsValue::from_f64(f64::from(ts)),
++ &JsValue::from_bool(opus),
++ &JsValue::from_f64(f64::from(channels)),
++ &JsValue::from_f64(f64::from(sample_rate)),
++ );
++ self.call("wave", &args);
++ }
++ SoundBackendMessage::Volume { left, right } => {
++ let args = js_sys::Array::of2(
++ &JsValue::from_f64(f64::from(left)),
++ &JsValue::from_f64(f64::from(right)),
++ );
++ self.call("volume", &args);
++ }
++ SoundBackendMessage::Close => {
++ self.call("close", &js_sys::Array::new());
++ }
++ }
++ }
++}
--
2.55.0
^ permalink raw reply related [flat|nested] 14+ messages in thread
end of thread, other threads:[~2026-08-26 9:30 UTC | newest]
Thread overview: 14+ 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-qemu-kyber 08/13] Add pve-qemu-kyber: an kyber controller for the qemu console Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-kyberproxy 09/13] Add pve-kyberproxy 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
2026-08-25 11:34 ` [RFC pve-rdp-web 13/13] add pve-rdp-web: console's webassembly client Alexandre Derumier
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.