]> git.ipfire.org Git - thirdparty/haproxy.git/commitdiff
MINOR: sample: add a reverse_dom converter
authorManu Nicolas <e.nicolas@criteo.com>
Mon, 27 Apr 2026 14:59:01 +0000 (14:59 +0000)
committerWilly Tarreau <w@1wt.eu>
Wed, 13 May 2026 14:49:53 +0000 (16:49 +0200)
In domain-based routing and policy rules, suffix matching on hostnames is
often easier to express as a prefix match on reversed labels. A dedicated
converter makes this convenient with existing fetches and matchers.

This also has a performance benefit for large maps. Prefix string matches use
the prefix-tree index (PAT_MATCH_BEG with pat_idx_tree_pfx), while end matches
use the string-list index (PAT_MATCH_END with pat_idx_list_str), so
reversed-label lookups can avoid linear suffix scans.

This patch adds "reverse_dom", a string converter that reverses domain labels,
ignores one optional trailing dot on input, and rejects empty labels. It
intentionally leaves trailing-dot handling to the caller so configurations can
choose between exact matches, subdomain-only matches, or an explicit dotted
form built with "concat(.)" for prefix lookups.

Examples:
  example.com      -> com.example
  mail.example.com -> com.example.mail

The documentation is updated and a reg-test covers the converter itself, the
explicit dotted form for "map_beg()", and the subdomain-only "-m beg" case.

doc/configuration.txt
reg-tests/converter/reverse_dom.map [new file with mode: 0644]
reg-tests/converter/reverse_dom.vtc [new file with mode: 0644]
src/sample.c

index 32748c976812e63d9b0b84428c7f906049888cbf..7e8c0a29500cf248de3c96409e0480ed1157dc31 100644 (file)
@@ -21146,6 +21146,7 @@ port_only                                          string       integer
 protobuf(field_number[,field_type])                binary       binary
 regsub(regex,subst[,flags])                        string       string
 reverse                                            string       string
+reverse_dom                                        string       string
 rfc7239_field(field)                               string       string
 rfc7239_is_valid                                   string       boolean
 rfc7239_n2nn                                       string       address / str
@@ -22663,6 +22664,38 @@ reverse
     # Pick a backend based on the domain suffix of the Host header:
     use_backend %[req.hdr(host),lower,reverse,map_beg(/etc/haproxy/hosts.map,default)]
 
+reverse_dom
+  Converts a string containing an FQDN-like hostname into its reversed-label
+  form. A single trailing dot on the input is ignored. Empty labels cause the
+  converter to fail.
+
+  This converter does not lowercase its input and does not strip any port.
+  It is meant to be combined with existing converters such as "lower" or
+  "host_only" when needed.
+
+  The trailing-dot policy is intentionally left to the caller. This allows
+  callers to decide whether they want to match the apex too or only
+  subdomains.
+
+  The reversed-label form is useful for large domain maps because it turns
+  domain suffix lookups into prefix lookups, allowing the use of indexed prefix
+  matchers such as "map_beg".
+
+  Examples:
+    "example.com" -> "com.example"
+    "mail.example.com" -> "com.example.mail"
+    "example.com." -> "com.example"
+
+    # match only subdomains of example.net, not the apex
+    acl example_net_sub req.hdr(Host),host_only,reverse_dom -m beg net.example.
+
+    # match only the apex
+    acl example_net_apex req.hdr(Host),host_only,reverse_dom -i net.example
+
+    # exact-or-subdomain prefix lookup using an explicit dotted form
+    http-request set-var(txn.rev_host) req.hdr(Host),host_only,reverse_dom,concat(.)
+    use_backend %[var(txn.rev_host),map_beg(/etc/haproxy/domains.map)]
+
 rfc7239_field(<field>)
   Extracts a single field/parameter from RFC 7239 compliant header value input.
 
diff --git a/reg-tests/converter/reverse_dom.map b/reg-tests/converter/reverse_dom.map
new file mode 100644 (file)
index 0000000..6c438bb
--- /dev/null
@@ -0,0 +1,2 @@
+com.example. example
+com.example.mail. mail
diff --git a/reg-tests/converter/reverse_dom.vtc b/reg-tests/converter/reverse_dom.vtc
new file mode 100644 (file)
index 0000000..4248bb6
--- /dev/null
@@ -0,0 +1,94 @@
+varnishtest "reverse_dom converter test"
+
+feature ignore_unknown_macro
+
+server s1 {
+       rxreq
+       txresp -hdr "Connection: close"
+} -repeat 8 -start
+
+haproxy h1 -conf {
+    global
+    .if feature(THREAD)
+        thread-groups 1
+    .endif
+
+    defaults
+       mode http
+       timeout connect "${HAPROXY_TEST_TIMEOUT-5s}"
+       timeout client  "${HAPROXY_TEST_TIMEOUT-5s}"
+       timeout server  "${HAPROXY_TEST_TIMEOUT-5s}"
+
+    frontend fe
+       bind "fd@${fe}"
+
+       http-request set-var(txn.rev_const) str(MaIl.EXAMPLE.com),reverse_dom
+       http-request set-var(txn.rev_host) req.hdr(Host),host_only,reverse_dom if { req.hdr(Host) -m found }
+       http-request set-var(txn.rev_host_dot) var(txn.rev_host),concat(.) if { var(txn.rev_host) -m found }
+       http-request set-var(txn.route) var(txn.rev_host_dot),map_beg(${testdir}/reverse_dom.map,miss) if { var(txn.rev_host_dot) -m found }
+       http-request set-var(txn.sub_only) str(no)
+       http-request set-var(txn.sub_only) str(yes) if { var(txn.rev_host) -m beg com.example. }
+
+       http-request return status 200 hdr X-Rev-Const "%[var(txn.rev_const)]" hdr X-Rev-Host "%[var(txn.rev_host)]" hdr X-Route "%[var(txn.route)]" hdr X-Sub-Only "%[var(txn.sub_only)]"
+
+       default_backend be
+
+    backend be
+       server s1 ${s1_addr}:${s1_port}
+} -start
+
+client c1 -connect ${h1_fe_sock} {
+       txreq -url "/" -hdr "Host: example.com"
+       rxresp
+       expect resp.status == 200
+       expect resp.http.x-rev-const == "com.EXAMPLE.MaIl"
+       expect resp.http.x-rev-host == "com.example"
+       expect resp.http.x-route == "example"
+       expect resp.http.x-sub-only == "no"
+
+       txreq -url "/" -hdr "Host: mail.example.com"
+       rxresp
+       expect resp.status == 200
+       expect resp.http.x-rev-host == "com.example.mail"
+       expect resp.http.x-route == "mail"
+       expect resp.http.x-sub-only == "yes"
+
+       txreq -url "/" -hdr "Host: example.com."
+       rxresp
+       expect resp.status == 200
+       expect resp.http.x-rev-host == "com.example"
+       expect resp.http.x-route == "example"
+       expect resp.http.x-sub-only == "no"
+
+       txreq -url "/" -hdr "Host: localhost"
+       rxresp
+       expect resp.status == 200
+       expect resp.http.x-rev-host == "localhost"
+       expect resp.http.x-route == "miss"
+       expect resp.http.x-sub-only == "no"
+
+       txreq -url "/" -hdr "Host: badexample.com"
+       rxresp
+       expect resp.status == 200
+       expect resp.http.x-rev-host == "com.badexample"
+       expect resp.http.x-route == "miss"
+       expect resp.http.x-sub-only == "no"
+
+       txreq -url "/" -hdr "Host: foo..bar"
+       rxresp
+       expect resp.status == 200
+       expect resp.http.x-rev-host == "<undef>"
+       expect resp.http.x-route == "<undef>"
+
+       txreq -url "/" -hdr "Host: .example.com"
+       rxresp
+       expect resp.status == 200
+       expect resp.http.x-rev-host == "<undef>"
+       expect resp.http.x-route == "<undef>"
+
+       txreq -url "/" -hdr "Host: ."
+       rxresp
+       expect resp.status == 200
+       expect resp.http.x-rev-host == "<undef>"
+       expect resp.http.x-route == "<undef>"
+} -run
index 19ca8caa61eb8a1d62290bfabd470c175d3f3d15..f032ec4bbe7949bcbae1ea65012626a057f42378 100644 (file)
@@ -2334,6 +2334,64 @@ static int sample_conv_reverse(const struct arg *arg_p, struct sample *smp, void
        return 1;
 }
 
+/* Reverses the order of labels in an FQDN-like string. A single trailing dot
+ * on input is ignored. Empty labels are rejected.
+ */
+static int sample_conv_reverse_dom(const struct arg *arg_p, struct sample *smp, void *private)
+{
+       const char *input = smp->data.u.str.area;
+       struct buffer *trash;
+       int input_len = smp->data.u.str.data;
+       int out = 0;
+       int label_end;
+       int label_start;
+       int label_len;
+
+       if (!input_len)
+               return 0;
+
+       if (input[input_len - 1] == '.') {
+               input_len--;
+               if (!input_len)
+                       return 0;
+       }
+
+       if (input[0] == '.')
+               return 0;
+
+       trash = get_trash_chunk_sz(input_len + 1);
+       if (!trash)
+               return 0;
+
+       label_end = input_len;
+       while (label_end > 0) {
+               label_start = label_end - 1;
+               while (label_start >= 0 && input[label_start] != '.')
+                       label_start--;
+               label_start++;
+
+               if (label_start == label_end)
+                       return 0;
+
+               label_len = label_end - label_start;
+               memcpy(trash->area + out, input + label_start, label_len);
+               out += label_len;
+
+               if (label_start == 0)
+                       break;
+
+               trash->area[out++] = '.';
+               label_end = label_start - 1;
+       }
+
+       trash->area[out] = 0;
+       trash->data = out;
+       smp->data.u.str = *trash;
+       smp->data.type = SMP_T_STR;
+       smp->flags &= ~SMP_F_CONST;
+       return 1;
+}
+
 /* takes the IPv4 mask in args[0] and an optional IPv6 mask in args[1] */
 static int sample_conv_ipmask(const struct arg *args, struct sample *smp, void *private)
 {
@@ -5797,10 +5855,11 @@ static struct sample_conv_kw_list sample_conv_kws = {ILH, {
        { "param",   sample_conv_param,        ARG2(1,STR,STR),       sample_conv_param_check,  SMP_T_STR,  SMP_T_STR  },
        { "regsub",  sample_conv_regsub,       ARG3(2,REG,STR,STR),   sample_conv_regsub_check, SMP_T_STR,  SMP_T_STR  },
        { "sha1",    sample_conv_sha1,         0,                     NULL,                     SMP_T_BIN,  SMP_T_BIN  },
-       { "strcmp",  sample_conv_strcmp,       ARG1(1,STR),           smp_check_strcmp,         SMP_T_STR,  SMP_T_SINT },
-       { "host_only", sample_conv_host_only,  0,                     NULL,                     SMP_T_STR,  SMP_T_STR  },
-       { "port_only", sample_conv_port_only,  0,                     NULL,                     SMP_T_STR,  SMP_T_SINT },
+       { "strcmp",      sample_conv_strcmp,      ARG1(1,STR),        smp_check_strcmp,         SMP_T_STR,  SMP_T_SINT },
+       { "host_only",   sample_conv_host_only,   0,                  NULL,                     SMP_T_STR,  SMP_T_STR  },
+       { "port_only",   sample_conv_port_only,   0,                  NULL,                     SMP_T_STR,  SMP_T_SINT },
        { "reverse",     sample_conv_reverse,     0,                  NULL,                     SMP_T_STR,  SMP_T_STR  },
+       { "reverse_dom", sample_conv_reverse_dom, 0,                  NULL,                     SMP_T_STR,  SMP_T_STR  },
 
        /* gRPC converters. */
        { "ungrpc", sample_conv_ungrpc,    ARG2(1,PBUF_FNUM,STR), sample_conv_protobuf_check, SMP_T_BIN, SMP_T_BIN  },