public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
To: pve-devel@lists.proxmox.com
Cc: Alexandre Derumier <aderumier@groupe-cyllene.com>
Subject: SPAM: [RFC qemu-server 04/13] add rdp display
Date: Tue, 25 Aug 2026 13:08:36 +0200	[thread overview]
Message-ID: <20260825110849.2967694-5-alexandre.derumier@groupe-cyllene.com> (raw)
In-Reply-To: <20260825110849.2967694-1-alexandre.derumier@groupe-cyllene.com>

From: Alexandre Derumier <aderumier@groupe-cyllene.com>

rdp maps to virtio-vga and start a per-VM qemu-rdp on a unix socket

Signed-off-by: Alexandre Derumier <aderumier@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




  parent reply	other threads:[~2026-08-25 11:09 UTC|newest]

Thread overview: 11+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-08-25 11:08 SPAM: [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:08 ` SPAM: [RFC pve-http-server 01/13] anyevent : proxy a path prefix to a local http proxy Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC qemu-server 02/13] add D-Bus display support Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC qemu-server 03/13] add kyber display Alexandre Derumier
2026-08-25 11:08 ` Alexandre Derumier [this message]
2026-08-25 11:08 ` SPAM: [RFC qemu-server 05/13] add experimental kyber-gl display Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC pve-manager 06/13] ui: add kyber console Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC pve-manager 07/13] ui: add rdp console Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC pve-kyber-web 10/13] add pve-kyber-web: console's webassembly client Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC pve-qemu-rdp 11/13] Add pve-qemu-rdp: an RDP server for the console Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC pve-rdpproxy 12/13] Add pve-rdpproxy Alexandre Derumier

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260825110849.2967694-5-alexandre.derumier@groupe-cyllene.com \
    --to=alexandre.derumier@groupe-cyllene.com \
    --cc=aderumier@groupe-cyllene.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal