]> git.ipfire.org Git - thirdparty/haproxy.git/commitdiff
MINOR: acme: add the acme section in the configuration parser
authorWilliam Lallemand <wlallemand@haproxy.com>
Wed, 2 Apr 2025 08:54:19 +0000 (10:54 +0200)
committerWilliam Lallemand <wlallemand@haproxy.com>
Fri, 11 Apr 2025 23:29:27 +0000 (01:29 +0200)
Add a configuration parser for the new acme section, the section is
configured this way:

    acme letsencrypt
        uri https://acme-staging-v02.api.letsencrypt.org/directory
        account account.key
        contact foobar@example.com
        challenge HTTP-01

When unspecified, the challenge defaults to HTTP-01, and the account key
to "<section_name>.account.key".

Section are stored in a linked list containing acme_cfg structures, the
configuration parsing is mostly resolved in the postsection parser
cfg_postsection_acme() which is called after the parsing of an acme section.

Makefile
include/haproxy/acme-t.h [new file with mode: 0644]
include/haproxy/cfgparse.h
src/acme.c [new file with mode: 0644]

index 72a696b2a1c7e4bfd11d9f5c397e11add44bf1bf..4b87a6a094568c2495defaf73dcf24d5130dd26b 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -631,7 +631,7 @@ ifneq ($(USE_OPENSSL:0=),)
   USE_SSL         := $(if $(USE_SSL:0=),$(USE_SSL:0=),implicit)
   OPTIONS_OBJS += src/ssl_sock.o src/ssl_ckch.o src/ssl_ocsp.o src/ssl_crtlist.o     \
                   src/ssl_sample.o src/cfgparse-ssl.o src/ssl_gencert.o              \
-                  src/ssl_utils.o src/jwt.o src/ssl_clienthello.o src/jws.o
+                  src/ssl_utils.o src/jwt.o src/ssl_clienthello.o src/jws.o src/acme.o
 endif
 
 ifneq ($(USE_ENGINE:0=),)
diff --git a/include/haproxy/acme-t.h b/include/haproxy/acme-t.h
new file mode 100644 (file)
index 0000000..127660e
--- /dev/null
@@ -0,0 +1,23 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+#ifndef _ACME_T_H_
+#define _ACME_T_H_
+
+#include <haproxy/openssl-compat.h>
+
+/* acme section configuration */
+struct acme_cfg {
+       char *filename;             /* config filename */
+       int linenum;                /* config linenum */
+       char *name;                 /* section name */
+       char *uri;                  /* directory URL */
+       struct {
+               char *contact;      /* email associated to account */
+               char *file;         /* account key filename */
+               EVP_PKEY *pkey;     /* account PKEY */
+               char *thumbprint;   /* account PKEY JWS thumbprint */
+       } account;
+       char *challenge;            /* HTTP-01, DNS-01, etc */
+       struct acme_cfg *next;
+};
+
+#endif
index 760d54e01bdba44adc97e69255303594c30b9606..a20a30e80271dd3c20738e6f87a137868a7378ed 100644 (file)
@@ -39,6 +39,7 @@ struct acl_cond;
 #define CFG_CRTLIST    5
 #define CFG_CRTSTORE    6
 #define CFG_TRACES      7
+#define CFG_ACME        8
 
 /* various keyword modifiers */
 enum kw_mod {
diff --git a/src/acme.c b/src/acme.c
new file mode 100644 (file)
index 0000000..4b352a0
--- /dev/null
@@ -0,0 +1,351 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+
+/*
+ * Implements the ACMEv2 RFC 8555 protocol
+ */
+
+#include <stddef.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+
+#include <import/ebsttree.h>
+#include <import/mjson.h>
+
+#include <haproxy/acme-t.h>
+
+#include <haproxy/cfgparse.h>
+#include <haproxy/errors.h>
+#include <haproxy/jws.h>
+#include <haproxy/ssl_ckch.h>
+#include <haproxy/ssl_sock.h>
+#include <haproxy/tools.h>
+
+static struct acme_cfg *acme_cfgs = NULL;
+static struct acme_cfg *cur_acme = NULL;
+
+/* Return an existing acme_cfg section */
+struct acme_cfg *get_acme_cfg(const char *name)
+{
+       struct acme_cfg *tmp_acme = acme_cfgs;
+
+       /* first check if the ID was already used */
+       while (tmp_acme) {
+               if (strcmp(tmp_acme->name, name) == 0)
+                       return tmp_acme;
+
+               tmp_acme = tmp_acme->next;
+       }
+       return NULL;
+}
+
+/* Return an existing section section OR create one and return it */
+struct acme_cfg *new_acme_cfg(const char *name)
+{
+       struct acme_cfg *ret = NULL;
+
+       /* first check if the ID was already used. return it if that's the case */
+       if ((ret = get_acme_cfg(name)) != NULL)
+               goto out;
+
+       /* If there wasn't any section with this name, just create one */
+       ret = calloc(1, sizeof(*ret));
+       if (!ret)
+               return NULL;
+
+       ret->name = strdup(name);
+       /* 0 on the linenum just mean it was not initialized yet */
+       ret->linenum = 0;
+
+       ret->challenge = strdup("HTTP-01"); /* default value */
+
+       ret->next = acme_cfgs;
+       acme_cfgs = ret;
+
+out:
+       return ret;
+}
+
+/* acme section parser
+ * Fill the acme_cfgs linked list
+ */
+static int cfg_parse_acme(const char *file, int linenum, char **args, int kwm)
+{
+       struct cfg_kw_list *kwl;
+       const char *best;
+       int index;
+       int rc = 0;
+       int err_code = 0;
+       char *errmsg = NULL;
+
+       if (strcmp(args[0], "acme") == 0) {
+               struct acme_cfg *tmp_acme = acme_cfgs;
+
+               if (alertif_too_many_args(1, file, linenum, args, &err_code))
+                       goto out;
+
+               if (!*args[1]) {
+                       err_code |= ERR_ALERT | ERR_FATAL;
+                       ha_alert("parsing [%s:%d]: section '%s' requires an ID argument.\n", file, linenum, cursection);
+                       goto out;
+               }
+
+               cur_acme = new_acme_cfg(args[1]);
+               if (!cur_acme) {
+                       err_code |= ERR_ALERT | ERR_FATAL;
+                       ha_alert("parsing [%s:%d]: out of memory.\n", file, linenum);
+                       goto out;
+               }
+
+
+               /* first check if the ID was already used */
+               if (cur_acme->linenum > 0) {
+                       /* an unitialized section is created when parsing the "acme" keyword in a crt-store, with a
+                        * linenum <= 0, however, when the linenum > 0, it means we already created a section with this
+                        * name */
+                       err_code |= ERR_ALERT | ERR_FATAL;
+                       ha_alert("parsing [%s:%d]: acme section '%s' already exists (%s:%d).\n",
+                                       file, linenum, args[1], tmp_acme->filename, tmp_acme->linenum);
+                       goto out;
+               }
+
+               cur_acme->filename = (char *)file;
+               cur_acme->linenum = linenum;
+
+               goto out;
+       }
+
+       list_for_each_entry(kwl, &cfg_keywords.list, list) {
+               for (index = 0; kwl->kw[index].kw != NULL; index++) {
+                       if (kwl->kw[index].section != CFG_ACME)
+                               continue;
+                       if (strcmp(kwl->kw[index].kw, args[0]) == 0) {
+                               if (check_kw_experimental(&kwl->kw[index], file, linenum, &errmsg)) {
+                                       ha_alert("%s\n", errmsg);
+                                       err_code |= ERR_ALERT | ERR_FATAL | ERR_ABORT;
+                                       goto out;
+                               }
+
+                               /* prepare error message just in case */
+                               rc = kwl->kw[index].parse(args, CFG_ACME, NULL, NULL, file, linenum, &errmsg);
+                               if (rc & ERR_ALERT) {
+                                       ha_alert("parsing [%s:%d] : %s\n", file, linenum, errmsg);
+                                       err_code |= rc;
+                                       goto out;
+                               }
+                               else if (rc & ERR_WARN) {
+                                       ha_warning("parsing [%s:%d] : %s\n", file, linenum, errmsg);
+                                       err_code |= rc;
+                                       goto out;
+                               }
+                               goto out;
+                       }
+               }
+       }
+
+       best = cfg_find_best_match(args[0], &cfg_keywords.list, CFG_ACME, NULL);
+       if (best)
+               ha_alert("parsing [%s:%d] : unknown keyword '%s' in '%s' section; did you mean '%s' maybe ?\n", file, linenum, args[0], cursection, best);
+       else
+               ha_alert("parsing [%s:%d] : unknown keyword '%s' in '%s' section\n", file, linenum, args[0], cursection);
+       err_code |= ERR_ALERT | ERR_FATAL;
+       goto out;
+
+out:
+       if (err_code & ERR_FATAL)
+               err_code |= ERR_ABORT;
+       free(errmsg);
+       return err_code;
+
+
+}
+
+static int cfg_parse_acme_kws(char **args, int section_type, struct proxy *curpx, const struct proxy *defpx,
+                              const char *file, int linenum, char **err)
+{
+       int err_code = 0;
+       char *errmsg = NULL;
+
+       if (strcmp(args[0], "uri") == 0) {
+               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;
+               cur_acme->uri = strdup(args[1]);
+               if (!cur_acme->uri) {
+                       err_code |= ERR_ALERT | ERR_FATAL;
+                       ha_alert("parsing [%s:%d]: out of memory.\n", file, linenum);
+                       goto out;
+               }
+       } else if (strcmp(args[0], "contact") == 0) {
+               /* save the contact email */
+               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;
+
+               cur_acme->account.contact = strdup(args[1]);
+               if (!cur_acme->account.contact) {
+                       err_code |= ERR_ALERT | ERR_FATAL;
+                       ha_alert("parsing [%s:%d]: out of memory.\n", file, linenum);
+                       goto out;
+               }
+       } else if (strcmp(args[0], "account") == 0) {
+               /* save the filename of the account key */
+               if (!*args[1]) {
+                       ha_alert("parsing [%s:%d]: keyword '%s' in '%s' section requires a filename argument\n", file, linenum, args[0], cursection);
+                       err_code |= ERR_ALERT | ERR_FATAL;
+                       goto out;
+               }
+               if (alertif_too_many_args(2, file, linenum, args, &err_code))
+                       goto out;
+
+               cur_acme->account.file = strdup(args[1]);
+               if (!cur_acme->account.file) {
+                       err_code |= ERR_ALERT | ERR_FATAL;
+                       ha_alert("parsing [%s:%d]: out of memory.\n", file, linenum);
+                       goto out;
+               }
+       } else if (strcmp(args[0], "challenge") == 0) {
+               if ((!*args[1]) ||  (strcmp("HTTP-01", args[1]) != 0 && (strcmp("DNS-01", args[1]) != 0))) {
+                       ha_alert("parsing [%s:%d]: keyword '%s' in '%s' section requires a challenge type: HTTP-01 or DNS-01\n", file, linenum, args[0], cursection);
+                       err_code |= ERR_ALERT | ERR_FATAL;
+                       goto out;
+               }
+
+               if (alertif_too_many_args(2, file, linenum, args, &err_code))
+                       goto out;
+
+               cur_acme->challenge = strdup(args[1]);
+               if (!cur_acme->challenge) {
+                       err_code |= ERR_ALERT | ERR_FATAL;
+                       ha_alert("parsing [%s:%d]: out of memory.\n", file, linenum);
+                       goto out;
+               }
+       } else if (*args[0] != 0) {
+               ha_alert("parsing [%s:%d]: unknown keyword '%s' in '%s' section\n", file, linenum, args[0], cursection);
+               err_code |= ERR_ALERT | ERR_FATAL;
+               goto out;
+       }
+out:
+       free(errmsg);
+       return err_code;
+}
+
+/* Initialize stuff once the section is parsed */
+static int cfg_postsection_acme()
+{
+       struct acme_cfg *cur_acme = acme_cfgs;
+       struct ckch_store *store;
+       int err_code = 0;
+       char *errmsg = NULL;
+       char *path;
+       struct stat st;
+
+       /* TODO: generate a key at startup and dumps on the filesystem
+        * TODO: use the standard ckch loading for the account key (need a store with only a key)
+        */
+
+       /* if account key filename is unspecified, choose a filename for it */
+       if (!cur_acme->account.file) {
+               if (!memprintf(&cur_acme->account.file, "%s.account.key", cur_acme->name)) {
+                       err_code |= ERR_ALERT | ERR_FATAL | ERR_ABORT;
+                       ha_alert("acme: out of memory.\n");
+                       goto out;
+               }
+       }
+
+       path = cur_acme->account.file;
+
+       store = ckch_store_new(path);
+       if (!store) {
+               ha_alert("acme: out of memory.\n");
+               err_code |= ERR_ALERT | ERR_FATAL | ERR_ABORT;
+               goto out;
+       }
+       /* tries to open the account key  */
+       if (stat(path, &st) == 0) {
+               if (ssl_sock_load_key_into_ckch(path, NULL, store->data, &errmsg)) {
+                       memprintf(&errmsg, "%s'%s' is present but cannot be read or parsed.\n", errmsg && *errmsg ? errmsg : NULL, path);
+                       if (errmsg && *errmsg)
+                               indent_msg(&errmsg, 8);
+                       err_code |= ERR_ALERT | ERR_FATAL | ERR_ABORT;
+                       ha_alert("acme: %s\n", errmsg);
+                       goto out;
+               }
+               /* ha_notice("acme: reading account key '%s' for id '%s'.\n", path, cur_acme->name); */
+       } else {
+               ha_alert("%s '%s' is not present and can't be generated, please provide an account file.\n", errmsg, path);
+               err_code |= ERR_ALERT | ERR_FATAL | ERR_ABORT;
+               goto out;
+       }
+
+
+       if (store->data->key == NULL) {
+               ha_alert("acme: No Private Key found in '%s'.\n", path);
+               err_code |= ERR_ALERT | ERR_FATAL | ERR_ABORT;
+               goto out;
+       }
+
+       cur_acme->account.pkey = store->data->key;
+
+       trash.data = jws_thumbprint(cur_acme->account.pkey, trash.area, trash.size);
+
+       cur_acme->account.thumbprint = strndup(trash.area, trash.data);
+       if (!cur_acme->account.thumbprint) {
+               ha_alert("acme: out of memory.\n");
+               err_code |= ERR_ALERT | ERR_FATAL | ERR_ABORT;
+               goto out;
+       }
+
+       /* insert into the ckchs tree */
+       ebst_insert(&ckchs_tree, &store->node);
+
+out:
+       ha_free(&errmsg);
+       return err_code;
+}
+
+void deinit_acme()
+{
+       struct acme_cfg *next = NULL;
+
+       while (acme_cfgs) {
+
+               next = acme_cfgs->next;
+               ha_free(&acme_cfgs->name);
+               ha_free(&acme_cfgs->uri);
+               ha_free(&acme_cfgs->account.contact);
+               ha_free(&acme_cfgs->account.file);
+               ha_free(&acme_cfgs->account.thumbprint);
+
+               free(acme_cfgs);
+               acme_cfgs = next;
+       }
+}
+
+static struct cfg_kw_list cfg_kws_acme = {ILH, {
+       { CFG_ACME, "uri",  cfg_parse_acme_kws },
+       { CFG_ACME, "contact",  cfg_parse_acme_kws },
+       { CFG_ACME, "account",  cfg_parse_acme_kws },
+       { CFG_ACME, "challenge",  cfg_parse_acme_kws },
+       { 0, NULL, NULL },
+}};
+
+INITCALL1(STG_REGISTER, cfg_register_keywords, &cfg_kws_acme);
+
+REGISTER_CONFIG_SECTION("acme", cfg_parse_acme, cfg_postsection_acme);
+
+
+/*
+ * Local variables:
+ *  c-indent-level: 8
+ *  c-basic-offset: 8
+ * End:
+ */