From: Gal Pressman <gal@nvidia.com>
To: <netdev@vger.kernel.org>
Cc: Michal Kubecek <mkubecek@suse.cz>,
Jakub Kicinski <kuba@kernel.org>, "Gal Pressman" <gal@nvidia.com>,
Nimrod Oren <noren@nvidia.com>
Subject: [PATCH ethtool-next] netlink: rss: Add set support
Date: Tue, 7 Jul 2026 09:00:52 +0300 [thread overview]
Message-ID: <20260707060052.3523136-1-gal@nvidia.com> (raw)
The kernel has exposed ETHTOOL_MSG_RSS_SET, RSS_CREATE_ACT and
RSS_DELETE_ACT since v6.17, but ethtool's -X has remained ioctl only.
Add nl_srss() so the command goes through netlink by default, the ioctl
path remains as fallback for older kernels.
All CLI keywords accepted by the ioctl path are handled. nl_srss()
dispatches by parsed args:
context new -> ETHTOOL_MSG_RSS_CREATE_ACT
context N delete -> ETHTOOL_MSG_RSS_DELETE_ACT
otherwise -> ETHTOOL_MSG_RSS_SET
Reviewed-by: Nimrod Oren <noren@nvidia.com>
Signed-off-by: Gal Pressman <gal@nvidia.com>
---
ethtool.c | 10 +-
internal.h | 5 +
netlink/extapi.h | 2 +
| 510 +++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 522 insertions(+), 5 deletions(-)
diff --git a/ethtool.c b/ethtool.c
index 3076c128bb34..c8b6a38c288d 100644
--- a/ethtool.c
+++ b/ethtool.c
@@ -1207,8 +1207,7 @@ err:
return 2;
}
-static int parse_hkey(char **rss_hkey, u32 key_size,
- const char *rss_hkey_string)
+int parse_hkey(char **rss_hkey, u32 key_size, const char *rss_hkey_string)
{
if (!key_size) {
fprintf(stderr,
@@ -4207,9 +4206,9 @@ out:
return 0;
}
-static int fill_indir_table(u32 *indir_size, u32 *indir, int rxfhindir_default,
- int rxfhindir_start, int rxfhindir_equal,
- char **rxfhindir_weight, u32 num_weights)
+int fill_indir_table(u32 *indir_size, u32 *indir, int rxfhindir_default,
+ int rxfhindir_start, int rxfhindir_equal,
+ char **rxfhindir_weight, u32 num_weights)
{
u32 i;
@@ -6215,6 +6214,7 @@ static const struct option args[] = {
{
.opts = "-X|--set-rxfh-indir|--rxfh",
.func = do_srxfh,
+ .nlfunc = nl_srss,
.help = "Set Rx flow hash indirection table and/or RSS hash key",
.xhelp = " [ context %d|new ]\n"
" [ equal N | weight W0 W1 ... | default ]\n"
diff --git a/internal.h b/internal.h
index 19fff417ab0f..50d43ccd844a 100644
--- a/internal.h
+++ b/internal.h
@@ -286,6 +286,11 @@ int test_fclose(FILE *fh);
int ioctl_init(struct cmd_context *ctx, bool no_dev);
int send_ioctl(struct cmd_context *ctx, void *cmd);
+int parse_hkey(char **rss_hkey, u32 key_size, const char *rss_hkey_string);
+int fill_indir_table(u32 *indir_size, u32 *indir, int rxfhindir_default,
+ int rxfhindir_start, int rxfhindir_equal,
+ char **rxfhindir_weight, u32 num_weights);
+
void dump_hex(FILE *f, const u8 *data, int len, int offset);
/* National Semiconductor DP83815, DP83816 */
diff --git a/netlink/extapi.h b/netlink/extapi.h
index b0e458804297..e10af6230bb3 100644
--- a/netlink/extapi.h
+++ b/netlink/extapi.h
@@ -50,6 +50,7 @@ int nl_smodule(struct cmd_context *ctx);
int nl_monitor(struct cmd_context *ctx);
int nl_getmodule(struct cmd_context *ctx);
int nl_grss(struct cmd_context *ctx);
+int nl_srss(struct cmd_context *ctx);
int nl_plca_get_cfg(struct cmd_context *ctx);
int nl_plca_set_cfg(struct cmd_context *ctx);
int nl_plca_get_status(struct cmd_context *ctx);
@@ -130,6 +131,7 @@ nl_get_eeprom_page(struct cmd_context *ctx __maybe_unused,
#define nl_gmodule NULL
#define nl_smodule NULL
#define nl_grss NULL
+#define nl_srss NULL
#define nl_plca_get_cfg NULL
#define nl_plca_set_cfg NULL
#define nl_plca_get_status NULL
--git a/netlink/rss.c b/netlink/rss.c
index 83cc50416dc7..c0fbba8bc2ac 100644
--- a/netlink/rss.c
+++ b/netlink/rss.c
@@ -4,7 +4,10 @@
* Implementation of "ethtool -x <dev>"
*/
+#include <ctype.h>
#include <errno.h>
+#include <stddef.h>
+#include <stdlib.h>
#include <string.h>
#include <stdio.h>
@@ -246,3 +249,510 @@ int nl_grss(struct cmd_context *ctx)
err:
return nlctx->exit_code ?: 1;
}
+
+/* RSS_SET */
+enum {
+ SRSS_PARAM_EQUAL,
+ SRSS_PARAM_WEIGHT,
+ SRSS_PARAM_START,
+ SRSS_PARAM_DEFAULT,
+ SRSS_PARAM_HKEY,
+ SRSS_PARAM_HFUNC,
+ SRSS_PARAM_XFRM,
+ SRSS_PARAM_CONTEXT,
+ SRSS_PARAM_DELETE,
+};
+
+struct srss_cmd {
+ unsigned long present;
+ u32 equal;
+ u32 start;
+ char **weight;
+ u32 num_weights;
+ char *hkey;
+ char *hfunc;
+ u32 input_xfrm;
+ u32 context;
+};
+
+#define SRSS_HAS(cmd, p) ((cmd)->present & (1UL << (p)))
+
+struct rss_sizes {
+ u32 indir_size;
+ u32 key_size;
+};
+
+static int rss_sizes_reply_cb(const struct nlmsghdr *nlhdr, void *data)
+{
+ const struct nlattr *tb[ETHTOOL_A_RSS_MAX + 1] = {};
+ struct rss_sizes *sizes = data;
+ DECLARE_ATTR_TB_INFO(tb);
+ int ret;
+
+ ret = mnl_attr_parse(nlhdr, GENL_HDRLEN, attr_cb, &tb_info);
+ if (ret < 0)
+ return MNL_CB_ERROR;
+
+ if (tb[ETHTOOL_A_RSS_INDIR])
+ sizes->indir_size =
+ mnl_attr_get_payload_len(tb[ETHTOOL_A_RSS_INDIR]) /
+ sizeof(u32);
+
+ if (tb[ETHTOOL_A_RSS_HKEY])
+ sizes->key_size =
+ mnl_attr_get_payload_len(tb[ETHTOOL_A_RSS_HKEY]);
+
+ return MNL_CB_OK;
+}
+
+static int rss_get_sizes(struct nl_context *nlctx, u32 *indir_size,
+ u32 *key_size)
+{
+ struct nl_socket *nlsk = nlctx->ethnl_socket;
+ struct nl_msg_buff *msgbuff = &nlsk->msgbuff;
+ struct rss_sizes sizes = {};
+ int ret;
+
+ ret = msg_init(nlctx, msgbuff, ETHTOOL_MSG_RSS_GET,
+ NLM_F_REQUEST | NLM_F_ACK);
+ if (ret < 0)
+ return ret;
+
+ if (ethnla_fill_header(msgbuff, ETHTOOL_A_RSS_HEADER,
+ nlctx->devname, 0))
+ return -EMSGSIZE;
+
+ ret = nlsock_sendmsg(nlsk, NULL);
+ if (ret < 0)
+ return ret;
+
+ ret = nlsock_process_reply(nlsk, rss_sizes_reply_cb, &sizes);
+ if (ret < 0)
+ return ret;
+
+ *indir_size = sizes.indir_size;
+ *key_size = sizes.key_size;
+ return 0;
+}
+
+static int rss_resolve_hfunc(struct nl_context *nlctx, const char *name,
+ u32 *hfunc)
+{
+ const struct stringset *hash_funcs;
+ unsigned int i, count;
+ int ret;
+
+ ret = netlink_init_ethnl2_socket(nlctx);
+ if (ret < 0) {
+ fprintf(stderr, "Cannot get hash function names\n");
+ return ret;
+ }
+
+ hash_funcs = global_stringset(ETH_SS_RSS_HASH_FUNCS,
+ nlctx->ethnl2_socket);
+ if (!hash_funcs) {
+ fprintf(stderr, "Cannot get hash function names\n");
+ return -ENOENT;
+ }
+
+ count = get_count(hash_funcs);
+ for (i = 0; i < count; i++) {
+ if (!strcmp(get_string(hash_funcs, i), name)) {
+ *hfunc = (u32)1 << i;
+ return 0;
+ }
+ }
+
+ fprintf(stderr, "Unknown hash function: %s\n", name);
+ return -EINVAL;
+}
+
+static int rss_create_reply_cb(const struct nlmsghdr *nlhdr,
+ void *data __maybe_unused)
+{
+ const struct nlattr *tb[ETHTOOL_A_RSS_MAX + 1] = {};
+ DECLARE_ATTR_TB_INFO(tb);
+ int ret;
+
+ ret = mnl_attr_parse(nlhdr, GENL_HDRLEN, attr_cb, &tb_info);
+ if (ret < 0)
+ return MNL_CB_ERROR;
+
+ if (tb[ETHTOOL_A_RSS_CONTEXT])
+ printf("New RSS context is %u\n",
+ mnl_attr_get_u32(tb[ETHTOOL_A_RSS_CONTEXT]));
+
+ return MNL_CB_OK;
+}
+
+static int srss_parse_weight(struct nl_context *nlctx,
+ uint16_t type __maybe_unused,
+ const void *data __maybe_unused,
+ struct nl_msg_buff *msgbuff __maybe_unused,
+ void *dest)
+{
+ struct srss_cmd *cmd = dest;
+
+ cmd->weight = nlctx->argp;
+ while (nlctx->argc > 0 &&
+ isdigit((unsigned char)(*nlctx->argp)[0])) {
+ nlctx->argp++;
+ nlctx->argc--;
+ cmd->num_weights++;
+ }
+
+ if (cmd->num_weights == 0) {
+ fprintf(stderr, "'weight' requires at least one value\n");
+ return -EINVAL;
+ }
+
+ return 0;
+}
+
+static const struct lookup_entry_u32 srss_xfrm_values[] = {
+ { .arg = "none", .val = 0 },
+ { .arg = "symmetric-xor", .val = RXH_XFRM_SYM_XOR },
+ { .arg = "symmetric-or-xor", .val = RXH_XFRM_SYM_OR_XOR },
+ {}
+};
+
+static int srss_parse_context(struct nl_context *nlctx,
+ uint16_t type __maybe_unused,
+ const void *data __maybe_unused,
+ struct nl_msg_buff *msgbuff __maybe_unused,
+ void *dest)
+{
+ struct srss_cmd *cmd = dest;
+ const char *arg = *nlctx->argp;
+
+ nlctx->argp++;
+ nlctx->argc--;
+
+ if (!strcmp(arg, "new")) {
+ cmd->context = ETH_RXFH_CONTEXT_ALLOC;
+ } else {
+ u32 v;
+
+ if (parse_u32(arg, &v) ||
+ v < 1 || v > ETH_RXFH_CONTEXT_ALLOC - 1) {
+ fprintf(stderr, "Invalid 'context' value\n");
+ return -EINVAL;
+ }
+ cmd->context = v;
+ }
+
+ return 0;
+}
+
+/* "default" and "delete" are flags with no extra arg, just being seen
+ * (recorded in cmd->present) is enough.
+ */
+static int srss_parse_flag(struct nl_context *nlctx __maybe_unused,
+ uint16_t type __maybe_unused,
+ const void *data __maybe_unused,
+ struct nl_msg_buff *msgbuff __maybe_unused,
+ void *dest __maybe_unused)
+{
+ return 0;
+}
+
+static const struct param_parser srss_params[] = {
+ [SRSS_PARAM_EQUAL] = {
+ .arg = "equal",
+ .handler = nl_parse_direct_u32,
+ .dest_offset = offsetof(struct srss_cmd, equal),
+ .min_argc = 1,
+ .alt_group = 1,
+ },
+ [SRSS_PARAM_WEIGHT] = {
+ .arg = "weight",
+ .handler = srss_parse_weight,
+ .min_argc = 1,
+ .alt_group = 1,
+ },
+ [SRSS_PARAM_START] = {
+ .arg = "start",
+ .handler = nl_parse_direct_u32,
+ .dest_offset = offsetof(struct srss_cmd, start),
+ .min_argc = 1,
+ },
+ [SRSS_PARAM_DEFAULT] = {
+ .arg = "default",
+ .handler = srss_parse_flag,
+ .alt_group = 1,
+ },
+ [SRSS_PARAM_HKEY] = {
+ .arg = "hkey",
+ .handler = nl_parse_string,
+ .dest_offset = offsetof(struct srss_cmd, hkey),
+ .min_argc = 1,
+ },
+ [SRSS_PARAM_HFUNC] = {
+ .arg = "hfunc",
+ .handler = nl_parse_string,
+ .dest_offset = offsetof(struct srss_cmd, hfunc),
+ .min_argc = 1,
+ },
+ [SRSS_PARAM_XFRM] = {
+ .arg = "xfrm",
+ .handler = nl_parse_lookup_u32,
+ .handler_data = srss_xfrm_values,
+ .dest_offset = offsetof(struct srss_cmd, input_xfrm),
+ .min_argc = 1,
+ },
+ [SRSS_PARAM_CONTEXT] = {
+ .arg = "context",
+ .handler = srss_parse_context,
+ .min_argc = 1,
+ },
+ [SRSS_PARAM_DELETE] = {
+ .arg = "delete",
+ .handler = srss_parse_flag,
+ .alt_group = 1,
+ },
+ {}
+};
+
+static int srss_validate_cmd(const struct srss_cmd *cmd)
+{
+ if (SRSS_HAS(cmd, SRSS_PARAM_EQUAL) &&
+ (cmd->equal < 1 || cmd->equal > INT_MAX)) {
+ fprintf(stderr, "Invalid 'equal' value\n");
+ return -EINVAL;
+ }
+
+ if (SRSS_HAS(cmd, SRSS_PARAM_START) && cmd->start > INT_MAX) {
+ fprintf(stderr, "Invalid 'start' value\n");
+ return -EINVAL;
+ }
+
+ if (SRSS_HAS(cmd, SRSS_PARAM_DELETE)) {
+ static const struct {
+ unsigned int param;
+ const char *name;
+ } delete_forbidden[] = {
+ { SRSS_PARAM_HKEY, "hkey" },
+ { SRSS_PARAM_HFUNC, "hfunc" },
+ { SRSS_PARAM_XFRM, "xfrm" },
+ { SRSS_PARAM_START, "start" },
+ };
+ unsigned int i;
+
+ if (!SRSS_HAS(cmd, SRSS_PARAM_CONTEXT)) {
+ fprintf(stderr,
+ "Delete option requires context option\n");
+ return -EINVAL;
+ }
+ if (cmd->context == ETH_RXFH_CONTEXT_ALLOC) {
+ fprintf(stderr,
+ "Delete and 'context new' are mutually exclusive\n");
+ return -EINVAL;
+ }
+ for (i = 0; i < ARRAY_SIZE(delete_forbidden); i++) {
+ if (SRSS_HAS(cmd, delete_forbidden[i].param)) {
+ fprintf(stderr,
+ "Delete and %s options are mutually exclusive\n",
+ delete_forbidden[i].name);
+ return -EINVAL;
+ }
+ }
+ }
+
+ if (SRSS_HAS(cmd, SRSS_PARAM_START) &&
+ SRSS_HAS(cmd, SRSS_PARAM_DEFAULT)) {
+ fprintf(stderr,
+ "Start and default options are mutually exclusive\n");
+ return -EINVAL;
+ }
+
+ if (SRSS_HAS(cmd, SRSS_PARAM_START) &&
+ !(SRSS_HAS(cmd, SRSS_PARAM_EQUAL) ||
+ SRSS_HAS(cmd, SRSS_PARAM_WEIGHT))) {
+ fprintf(stderr,
+ "Start must be used with equal or weight options\n");
+ return -EINVAL;
+ }
+
+ if (SRSS_HAS(cmd, SRSS_PARAM_DEFAULT) &&
+ SRSS_HAS(cmd, SRSS_PARAM_CONTEXT)) {
+ fprintf(stderr,
+ "Default and context options are mutually exclusive\n");
+ return -EINVAL;
+ }
+
+ return 0;
+}
+
+int nl_srss(struct cmd_context *ctx)
+{
+ struct nl_context *nlctx = ctx->nlctx;
+ u32 indir_size = 0, key_size = 0;
+ struct nl_msg_buff *msgbuff;
+ struct srss_cmd cmd = {};
+ struct nl_socket *nlsk;
+ unsigned int msg_type;
+ char *hkey = NULL;
+ u32 *indir = NULL;
+ u32 hfunc_val = 0;
+ int ret;
+
+ if (netlink_cmd_check(ctx, ETHTOOL_MSG_RSS_SET, false))
+ return -EOPNOTSUPP;
+
+ if (!ctx->argc) {
+ fprintf(stderr, "ethtool (-X): parameters missing\n");
+ ret = 1;
+ goto out;
+ }
+
+ nlctx->cmd = "-X";
+ nlctx->argp = ctx->argp;
+ nlctx->argc = ctx->argc;
+ nlctx->devname = ctx->devname;
+ nlsk = nlctx->ethnl_socket;
+ msgbuff = &nlsk->msgbuff;
+
+ ret = nl_parser(nlctx, srss_params, &cmd, PARSER_GROUP_NONE, NULL);
+ if (ret < 0)
+ goto out;
+
+ ret = srss_validate_cmd(&cmd);
+ if (ret)
+ goto out;
+
+ if (SRSS_HAS(&cmd, SRSS_PARAM_DELETE))
+ msg_type = ETHTOOL_MSG_RSS_DELETE_ACT;
+ else if (cmd.context == ETH_RXFH_CONTEXT_ALLOC)
+ msg_type = ETHTOOL_MSG_RSS_CREATE_ACT;
+ else
+ msg_type = ETHTOOL_MSG_RSS_SET;
+
+ if (msg_type != ETHTOOL_MSG_RSS_SET &&
+ netlink_cmd_check(ctx, msg_type, false))
+ return -EOPNOTSUPP;
+
+ if (SRSS_HAS(&cmd, SRSS_PARAM_HKEY) ||
+ SRSS_HAS(&cmd, SRSS_PARAM_EQUAL) ||
+ SRSS_HAS(&cmd, SRSS_PARAM_WEIGHT) ||
+ SRSS_HAS(&cmd, SRSS_PARAM_DEFAULT)) {
+ ret = rss_get_sizes(nlctx, &indir_size, &key_size);
+ if (ret < 0)
+ goto out;
+ }
+
+ if (indir_size == 0 && (SRSS_HAS(&cmd, SRSS_PARAM_EQUAL) ||
+ SRSS_HAS(&cmd, SRSS_PARAM_WEIGHT) ||
+ SRSS_HAS(&cmd, SRSS_PARAM_DEFAULT))) {
+ fprintf(stderr,
+ "Device does not support RX indirection table\n");
+ ret = -EINVAL;
+ goto out;
+ }
+
+ ret = msg_init(nlctx, msgbuff, msg_type, NLM_F_REQUEST | NLM_F_ACK);
+ if (ret < 0)
+ goto out;
+
+ if (ethnla_fill_header(msgbuff, ETHTOOL_A_RSS_HEADER,
+ ctx->devname, 0)) {
+ ret = -EMSGSIZE;
+ goto out;
+ }
+
+ if (msg_type == ETHTOOL_MSG_RSS_DELETE_ACT) {
+ if (ethnla_put_u32(msgbuff, ETHTOOL_A_RSS_CONTEXT,
+ cmd.context)) {
+ ret = -EMSGSIZE;
+ goto out;
+ }
+ goto send;
+ }
+
+ /* For SET: include context id if targeting a non-default context.
+ * For CREATE: omit context (kernel auto-allocates).
+ */
+ if (msg_type == ETHTOOL_MSG_RSS_SET &&
+ SRSS_HAS(&cmd, SRSS_PARAM_CONTEXT)) {
+ if (ethnla_put_u32(msgbuff, ETHTOOL_A_RSS_CONTEXT,
+ cmd.context)) {
+ ret = -EMSGSIZE;
+ goto out;
+ }
+ }
+
+ if (SRSS_HAS(&cmd, SRSS_PARAM_HKEY)) {
+ ret = parse_hkey(&hkey, key_size, cmd.hkey);
+ if (ret)
+ goto out;
+
+ if (ethnla_put(msgbuff, ETHTOOL_A_RSS_HKEY, key_size, hkey)) {
+ ret = -EMSGSIZE;
+ goto out;
+ }
+ }
+
+ if (SRSS_HAS(&cmd, SRSS_PARAM_HFUNC)) {
+ ret = rss_resolve_hfunc(nlctx, cmd.hfunc, &hfunc_val);
+ if (ret < 0)
+ goto out;
+
+ if (ethnla_put_u32(msgbuff, ETHTOOL_A_RSS_HFUNC, hfunc_val)) {
+ ret = -EMSGSIZE;
+ goto out;
+ }
+ }
+
+ if (SRSS_HAS(&cmd, SRSS_PARAM_XFRM)) {
+ if (ethnla_put_u32(msgbuff, ETHTOOL_A_RSS_INPUT_XFRM,
+ cmd.input_xfrm)) {
+ ret = -EMSGSIZE;
+ goto out;
+ }
+ }
+
+ if (SRSS_HAS(&cmd, SRSS_PARAM_EQUAL) ||
+ SRSS_HAS(&cmd, SRSS_PARAM_WEIGHT) ||
+ SRSS_HAS(&cmd, SRSS_PARAM_DEFAULT)) {
+ u32 table_size = indir_size;
+
+ indir = calloc(indir_size, sizeof(*indir));
+ if (!indir) {
+ ret = -ENOMEM;
+ goto out;
+ }
+
+ ret = fill_indir_table(
+ &table_size, indir, SRSS_HAS(&cmd, SRSS_PARAM_DEFAULT),
+ SRSS_HAS(&cmd, SRSS_PARAM_START) ? cmd.start : 0,
+ SRSS_HAS(&cmd, SRSS_PARAM_EQUAL) ? cmd.equal : 0,
+ SRSS_HAS(&cmd, SRSS_PARAM_WEIGHT) ? cmd.weight : NULL,
+ cmd.num_weights);
+ if (ret)
+ goto out;
+
+ /* fill_indir_table() sets table_size to 0 to signal "reset to
+ * default" (sent as a zero-length INDIR attribute, which the
+ * kernel only accepts on the default context).
+ */
+ if (ethnla_put(msgbuff, ETHTOOL_A_RSS_INDIR,
+ table_size * sizeof(*indir), indir)) {
+ ret = -EMSGSIZE;
+ goto out;
+ }
+ }
+
+send:
+ ret = nlsock_sendmsg(nlsk, NULL);
+ if (ret < 0)
+ goto out;
+
+ if (msg_type == ETHTOOL_MSG_RSS_CREATE_ACT)
+ ret = nlsock_process_reply(nlsk, rss_create_reply_cb, nlctx);
+ else
+ ret = nlsock_process_reply(nlsk, nomsg_reply_cb, nlctx);
+
+out:
+ free(indir);
+ free(hkey);
+ return ret < 0 ? 1 : ret;
+}
--
2.54.0
reply other threads:[~2026-07-07 6:01 UTC|newest]
Thread overview: [no followups] expand[flat|nested] mbox.gz Atom feed
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=20260707060052.3523136-1-gal@nvidia.com \
--to=gal@nvidia.com \
--cc=kuba@kernel.org \
--cc=mkubecek@suse.cz \
--cc=netdev@vger.kernel.org \
--cc=noren@nvidia.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