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 03/13] add kyber display
Date: Tue, 25 Aug 2026 13:08:35 +0200 [thread overview]
Message-ID: <20260825110849.2967694-4-alexandre.derumier@groupe-cyllene.com> (raw)
In-Reply-To: <20260825110849.2967694-1-alexandre.derumier@groupe-cyllene.com>
From: Alexandre Derumier <aderumier@groupe-cyllene.com>
kyber maps to virtio-vga, with a controller started per VM on demand by
the kyberproxy API call.
Signed-off-by: Alexandre Derumier <aderumier@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
next prev 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 ` Alexandre Derumier [this message]
2026-08-25 11:08 ` SPAM: [RFC qemu-server 04/13] add rdp display Alexandre Derumier
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-4-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 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.