]> git.ipfire.org Git - thirdparty/haproxy.git/commitdiff
MINOR: acme: implement draft-ietf-acme-profiles
authorWilliam Lallemand <wlallemand@haproxy.com>
Mon, 20 Apr 2026 15:33:41 +0000 (17:33 +0200)
committerWilliam Lallemand <wlallemand@haproxy.com>
Mon, 20 Apr 2026 16:10:35 +0000 (18:10 +0200)
The ACME Profiles extension (draft-ietf-acme-profiles) allows a client
to request a specific certificate profile by including a "profile" field
in the newOrder request. This lets the CA select the appropriate
certificate issuance policy (e.g. "classic", "shortlived") for a given
order.

A new "profile" keyword is added to the acme section. When set, its
value is included in the newOrder JSON payload sent to the CA.

doc/configuration.txt
include/haproxy/acme-t.h
src/acme.c

index 67f4337af7ae8c5d771c5c28b8c276f238d3cb5e..4e6cdcbea6864a12f00cb88a48bcfe1dbcf44e1c 100644 (file)
@@ -32538,6 +32538,22 @@ map <map>
   account used. The acme task will add entries before validating the challenge
   and will remove the entries at the end of the task.
 
+profile <string>
+  Request a specific certificate profile from the CA by including a "profile"
+  field in the newOrder request. This implements draft-ietf-acme-profiles.
+
+  Profile names are CA-specific short identifiers (e.g. "classic",
+  "shortlived"). When set, the profile name is sent as-is in the newOrder
+  JSON payload. The CA is free to ignore the request or return an error if
+  the profile is not supported. When not set, no profile field is included
+  and the CA uses its default issuance policy.
+
+  See https://letsencrypt.org/docs/profiles/ for letsencrypt profiles.
+
+  Example:
+    # Request short-lived certificates
+    profile shortlived
+
 reuse-key { on | off }
   If set to "on", HAProxy won't generate a new private key and will keep the
   previous one. Rotating private keys is recommended, when enabling this option
index 091dcd708530ff71bb181473412fe862f9e4ed80..088a2ef46252f5beed2d3192bde145694e3eec39 100644 (file)
@@ -41,6 +41,7 @@ struct acme_cfg {
                int curves;                 /* NID of curves */
        } key;
        char *challenge;            /* HTTP-01, DNS-01, etc */
+       char *profile;              /* ACME profile */
        char *vars;                 /* variables put in the dpapi sink */
        char *provider;             /* DNS provider put in the dpapi sink */
        struct acme_cfg *next;
index cb419d46773cc6b3bd680f41eebd072a46778168..8b4268b3fbf5199a3376e1eb7959d79737e74f62 100644 (file)
@@ -4,6 +4,7 @@
  * Implements the ACMEv2 RFC 8555 protocol
  * Implements the following extensions to the protocol:
  *   draft-ietf-acme-dns-persist - DNS-PERSIST-01 challenge
+ *   draft-ietf-acme-profiles - Profiles Extension
  */
 
 #include "haproxy/ticks.h"
@@ -454,6 +455,35 @@ static int cfg_parse_acme_kws(char **args, int section_type, struct proxy *curpx
                        goto out;
                }
 
+       } else if (strcmp(args[0], "profile") == 0) {
+               /* save the profile name */
+               const char *p;
+
+               if (!*args[1]) {
+                       ha_alert("parsing [%s:%d]: keyword '%s' in '%s' section requires an argument\n", file, linenum, args[0], cursection);
+                       err_code |= ERR_ALERT | ERR_FATAL;
+                       goto out;
+               }
+               if (alertif_too_many_args(1, file, linenum, args, &err_code))
+                       goto out;
+
+               /* profile names are used verbatim in a JSON string; only allow
+                * alphanumeric characters, hyphens and underscores */
+               for (p = args[1]; *p; p++) {
+                       if (!isalnum((uchar)*p) && *p != '-' && *p != '_') {
+                               ha_alert("parsing [%s:%d]: keyword '%s' in '%s' section contains unauthorized character '%c'\n", file, linenum, args[0], cursection, *p);
+                               err_code |= ERR_ALERT | ERR_FATAL;
+                               goto out;
+                       }
+               }
+
+               ha_free(&cur_acme->profile);
+               cur_acme->profile = strdup(args[1]);
+               if (!cur_acme->profile) {
+                       err_code |= ERR_ALERT | ERR_FATAL;
+                       ha_alert("parsing [%s:%d]: out of memory.\n", file, linenum);
+                       goto out;
+               }
        } else if (strcmp(args[0], "map") == 0) {
                /* save the map name for thumbprint + token storage */
                if (!*args[1]) {
@@ -957,6 +987,7 @@ void deinit_acme()
                ha_free(&acme_cfgs->provider);
                ha_free(&acme_cfgs->challenge);
                ha_free(&acme_cfgs->map);
+               ha_free(&acme_cfgs->profile);
 
                free(acme_cfgs);
                acme_cfgs = next;
@@ -974,6 +1005,7 @@ static struct cfg_kw_list cfg_kws_acme = {ILH, {
        { CFG_ACME, "bits",  cfg_parse_acme_cfg_key },
        { CFG_ACME, "curves",  cfg_parse_acme_cfg_key },
        { CFG_ACME, "map",  cfg_parse_acme_kws },
+       { CFG_ACME, "profile",  cfg_parse_acme_kws },
        { CFG_ACME, "reuse-key",  cfg_parse_acme_kws },
        { CFG_ACME, "challenge-ready",  cfg_parse_acme_kws },
        { CFG_ACME, "dns-delay",  cfg_parse_acme_kws },
@@ -2014,7 +2046,10 @@ int acme_req_neworder(struct task *task, struct acme_ctx *ctx, char **errmsg)
                chunk_appendf(req_in, "%s{ \"type\": \"dns\",  \"value\": \"%s\" }", (*san == *ctx->store->conf.acme.domains) ?  "" : ",", *san);
        }
 
-       chunk_appendf(req_in, " ] }");
+       chunk_appendf(req_in, " ]");
+       if (ctx->cfg->profile)
+               chunk_appendf(req_in, ", \"profile\": \"%s\"", ctx->cfg->profile);
+       chunk_appendf(req_in, " }");
 
        TRACE_DATA("NewOrder Decode", ACME_EV_REQ, ctx, &ctx->resources.newOrder, req_in);