commit 3319b8ab9364242670218517ccdfec6e9ab9a6bf
parent 3eb3023eb5b4e796da8a797e465f55154ab3c4c5
Author: Evgeny Grin (Karlson2k) <k2k@drgrin.dev>
Date: Fri, 21 Aug 2026 19:26:37 +0200
Added example of ACME HTTP-01 file-based responder
Diffstat:
3 files changed, 683 insertions(+), 1 deletion(-)
diff --git a/src/examples2/acme/.gitignore b/src/examples2/acme/.gitignore
@@ -1 +1,2 @@
/acme_http_01_redirect
+/acme_http_01_files
diff --git a/src/examples2/acme/Makefile.am b/src/examples2/acme/Makefile.am
@@ -21,4 +21,5 @@ $(top_builddir)/src/mhd2/libmicrohttpd2.la: $(top_builddir)/src/mhd2/Makefile
# example programs
noinst_PROGRAMS = \
- acme_http_01_redirect
+ acme_http_01_redirect \
+ acme_http_01_files
diff --git a/src/examples2/acme/acme_http_01_files.c b/src/examples2/acme/acme_http_01_files.c
@@ -0,0 +1,680 @@
+/* SPDX-License-Identifier: 0BSD */
+/*
+ This file is part of GNU libmicrohttpd.
+ Copyright (C) 2026 Evgeny Grin (Karlson2k)
+
+ Permission to use, copy, modify, and/or distribute this software for
+ any purpose with or without fee is hereby granted.
+
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL
+ WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
+ OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
+ FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
+ DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
+ AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+ OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+*/
+/**
+ * @file src/examples2/acme/acme_http_01_files.c
+ * @brief Serving of the prepared ACME HTTP-01 challenge files
+ * @author Karlson2k (Evgeny Grin)
+ */
+
+/* A minimal HTTP server that serves prepared ACME HTTP-01 challenge files.
+ Nothing else is served.
+
+ Typically it works together with an ACME client that has written the
+ files. The files are looked up in the ".well-known/acme-challenge/"
+ subdirectory of the directory given by the "--webroot" option, which
+ is the current directory by default.
+ The ACME servers always use port 80. Another port may be given if
+ the traffic from port 80 is forwarded to it.
+
+ The example can be used as a ready-to-run program, and its code can be
+ taken into an application as well: the daemon request processing callback
+ (req_cb()) and the data shared by all the requests
+ (struct RequestHandlingContextData) do not depend on how the daemon is
+ created. Everything else is just the stand-alone program around them.
+
+ Any path other than a challenge path is answered with "404 Not Found",
+ a challenge path requested with a method other than GET or HEAD with
+ "405 Method Not Allowed".
+
+ Usage: acme_http_01_files [--webroot DIR] PORT
+ The order of the arguments is not significant. */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <fcntl.h>
+#ifdef _WIN32
+# include <io.h> /* for open() */
+#endif
+#include <microhttpd2.h>
+
+#ifndef O_BINARY
+/* Only W32 distinguishes the binary and the text modes. */
+# define O_BINARY 0
+#endif
+
+/* Check whether a directory name ends exactly where the next path component
+ starts, so that no separator has to be inserted after it. On W32 a name
+ may also end with the drive letter, like "C:", which makes the resulting
+ path relative to the current directory on that drive. */
+#ifndef _WIN32
+# define IS_PATH_END(chr) ('/' == (chr))
+#else
+# define IS_PATH_END(chr) \
+ (('/' == (chr)) || ('\\' == (chr)) || (':' == (chr)))
+#endif
+
+/**
+ * The maximum length of the path of a challenge file, without
+ * the terminating null character.
+ */
+#define MAX_PATH_LEN 1023
+
+/**
+ * The minimal length of the challenge token.
+ * The token carries at least 128 bits of entropy, which needs at least
+ * twenty-two characters of the base64url alphabet.
+ * See RFC 8555, section 8.3.
+ */
+#define MIN_TOKEN_LEN 22
+
+/**
+ * The fixed prefix of the path of the ACME HTTP-01 challenge resources.
+ */
+static const char chlng_prfx[] = MHD_ACME_HTTP_01_CHALLENGE_PATH_PREFIX;
+
+/**
+ * The length of #chlng_prfx, without the terminating null character.
+ */
+static const size_t chlng_prfx_len = sizeof(chlng_prfx) / sizeof(char) - 1;
+
+/**
+ * The data shared by all requests handled by this example.
+ */
+struct RequestHandlingContextData
+{
+ /**
+ * The name of the directory with the challenge files, ending with
+ * the directory separator.
+ */
+ const char *challenge_dir;
+
+ /**
+ * The length of #challenge_dir, without the terminating null character.
+ */
+ size_t challenge_dir_len;
+
+ /**
+ * If not zero, every served challenge is reported to standard error.
+ */
+ int log_challenges;
+
+ /**
+ * The response for every request that is not answered with a challenge
+ * file: another resource, a missing file or a too long path.
+ */
+ struct MHD_Response *resp_not_found;
+
+ /**
+ * The response for the ACME challenge resources requested with
+ * an unsupported method.
+ */
+ struct MHD_Response *resp_not_allowed;
+};
+
+
+/**
+ * Parse the TCP port number given on the command line.
+ *
+ * @param str the string to parse
+ * @param[out] port_out the resulting port number, set only on success
+ * @return non-zero if succeed,
+ * zero if @p str is not a valid port number
+ */
+static int
+parse_port (const char *str,
+ uint_least16_t *port_out)
+{
+ unsigned long value;
+ char *endptr;
+
+ if ('\0' == str[0])
+ return 0;
+
+ value = strtoul (str,
+ &endptr,
+ 10);
+ if ('\0' != endptr[0])
+ return 0;
+ /* A negative number is wrapped by strtoul() to a large value and
+ is rejected by the range check below. */
+ if ((1UL > value) || (65535UL < value))
+ return 0;
+
+ *port_out = (uint_least16_t)value;
+
+ return 1;
+}
+
+
+/**
+ * Check the challenge token taken from the request.
+ *
+ * The token is used as a file name, so this check is not optional: it makes
+ * any directory traversal impossible, as neither '.' nor '/' belongs to the
+ * base64url alphabet. See RFC 8555, section 8.3.
+ *
+ * @param token the token to check
+ * @param token_len the length of @p token, without the terminating null
+ * character
+ * @return non-zero if the token can be used as a file name,
+ * zero otherwise
+ */
+static int
+check_token (const char *token,
+ size_t token_len)
+{
+ size_t i;
+
+ if (MIN_TOKEN_LEN > token_len)
+ return 0;
+
+ for (i = 0; token_len > i; ++i)
+ {
+ const unsigned char chr = (unsigned char)token[i];
+
+ if ((('A' > chr) || ('Z' < chr))
+ && (('a' > chr) || ('z' < chr))
+ && (('0' > chr) || ('9' < chr))
+ && ('-' != chr) && ('_' != chr))
+ return 0;
+ }
+
+ return 1;
+}
+
+
+/**
+ * Create a re-usable response with a short HTML body.
+ *
+ * @param sc the HTTP status code of the response
+ * @param page_len the length of @p page, without the terminating null
+ * character
+ * @param page the body of the response, must be valid for the whole
+ * lifetime of the response
+ * @param allow the value of the "Allow:" header,
+ * NULL if the header is not needed
+ * @return the new response,
+ * NULL if failed
+ */
+static struct MHD_Response *
+make_error_response (enum MHD_HTTP_StatusCode sc,
+ size_t page_len,
+ const char *page,
+ const char *allow)
+{
+ struct MHD_ResponseOptionAndValue reusable;
+ struct MHD_Response *r;
+
+ /* The option is kept in a variable: the address of the value returned by
+ the option helper cannot be taken in every supported language mode. */
+ reusable = MHD_R_OPTION_REUSABLE (MHD_YES);
+
+ r = MHD_response_from_buffer_static (sc,
+ page_len,
+ page);
+ if (NULL != r)
+ {
+ if (MHD_SC_OK ==
+ MHD_response_add_header (r,
+ MHD_HTTP_HEADER_CONTENT_TYPE,
+ "text/html"))
+ {
+ if ((NULL == allow)
+ || (MHD_SC_OK ==
+ MHD_response_add_header (r,
+ MHD_HTTP_HEADER_ALLOW,
+ allow)))
+ {
+ if (MHD_SC_OK ==
+ MHD_response_set_option (r,
+ &reusable))
+ return r; /* Success exit point */
+ }
+ }
+
+ /* Below is a clean-up path */
+ MHD_response_destroy (r);
+ }
+
+ return NULL; /* Failure exit point */
+}
+
+
+/**
+ * Check whether the request is for an ACME HTTP-01 challenge resource.
+ * The token is checked as well, so the token of a matching request can be
+ * used as a file name.
+ *
+ * @param path the requested path
+ * @return non-zero if the request is for a challenge resource,
+ * zero otherwise
+ */
+static int
+is_req_acme_http_01_challenge (const struct MHD_String *MHD_RESTRICT path)
+{
+ return ((chlng_prfx_len < path->len)
+ && (0 == memcmp (path->cstr,
+ chlng_prfx,
+ chlng_prfx_len))
+ && (check_token (path->cstr + chlng_prfx_len,
+ path->len - chlng_prfx_len)));
+}
+
+
+/**
+ * Report the challenge that is being served.
+ *
+ * @param method the HTTP method used for the request, GET or HEAD
+ * @param token the token of the challenge resource
+ * @param token_len the length of @p token, without the terminating null
+ * character
+ */
+static void
+log_challenge (enum MHD_HTTP_Method method,
+ const char *token,
+ size_t token_len)
+{
+ /* No other method reaches this point. */
+ fprintf (stderr,
+ "Replying to the %s request for challenge %.*s\n",
+ (MHD_HTTP_METHOD_HEAD == method) ? "HEAD" : "GET",
+ (int)token_len,
+ token);
+}
+
+
+/**
+ * Handle the request for an ACME HTTP-01 challenge resource.
+ *
+ * @param request the request to handle
+ * @param path the requested path, must be a challenge resource path
+ * @param method the HTTP method used for the request
+ * @param context_data the data shared by all the requests
+ * @return the action to perform for the @p request
+ */
+static const struct MHD_Action *
+handle_acme_http_01_challenge (
+ struct MHD_Request *MHD_RESTRICT request,
+ const struct MHD_String *MHD_RESTRICT path,
+ enum MHD_HTTP_Method method,
+ const struct RequestHandlingContextData *const context_data)
+{
+ const size_t token_len = path->len - chlng_prfx_len;
+ char file_name[MAX_PATH_LEN + 1];
+ int fd;
+ struct MHD_Response *r;
+
+ /* Only the GET and the HEAD methods are allowed for the challenge
+ resources. */
+ if ((MHD_HTTP_METHOD_GET != method)
+ && (MHD_HTTP_METHOD_HEAD != method))
+ return MHD_action_from_response (request,
+ context_data->resp_not_allowed);
+
+ /* The name of the file is the challenge directory with the token of
+ the requested resource appended. */
+ if (MAX_PATH_LEN - context_data->challenge_dir_len < token_len)
+ return MHD_action_from_response (request,
+ context_data->resp_not_found);
+
+ memcpy (file_name,
+ context_data->challenge_dir,
+ context_data->challenge_dir_len);
+ memcpy (file_name + context_data->challenge_dir_len,
+ path->cstr + chlng_prfx_len,
+ token_len);
+ file_name[context_data->challenge_dir_len + token_len] = '\0';
+
+ fd = open (file_name,
+ O_RDONLY | O_BINARY);
+ if (0 > fd)
+ return MHD_action_from_response (request,
+ context_data->resp_not_found);
+
+ /* The file is sent whole and unchanged: RFC 8555, section 8.3, requires
+ the value of the resource to be the ASCII representation of the key
+ authorization. The size is not given, so the file is sent to its end.
+ The descriptor is closed by MHD in any case. */
+ r = MHD_response_from_fd (MHD_HTTP_STATUS_OK,
+ fd,
+ 0,
+ MHD_SIZE_UNKNOWN);
+ if (NULL != r)
+ {
+ if (MHD_SC_OK ==
+ MHD_response_add_header (r,
+ MHD_HTTP_HEADER_CONTENT_TYPE,
+ "application/octet-stream"))
+ {
+ if (context_data->log_challenges)
+ log_challenge (method,
+ path->cstr + chlng_prfx_len,
+ token_len);
+
+ return MHD_action_from_response (request,
+ r); /* Success exit point */
+ }
+
+ /* Below is a clean-up path */
+ MHD_response_destroy (r);
+ }
+ return MHD_action_abort_request (request); /* Failure exit point */
+}
+
+
+/**
+ * The handler of the incoming requests.
+ *
+ * @param cls the pointer to the shared #RequestHandlingContextData
+ * @param request the request to handle
+ * @param path the requested path
+ * @param method the HTTP method used for the request
+ * @param upload_size the size of the request content, unused
+ * @return the action to perform for the @p request
+ */
+static MHD_FN_PAR_NONNULL_ (2) MHD_FN_PAR_NONNULL_ (3)
+const struct MHD_Action *
+req_cb (void *cls,
+ struct MHD_Request *MHD_RESTRICT request,
+ const struct MHD_String *MHD_RESTRICT path,
+ enum MHD_HTTP_Method method,
+ uint_fast64_t upload_size)
+{
+ const struct RequestHandlingContextData *const context_data =
+ (const struct RequestHandlingContextData *)cls;
+
+ /* If the request is for an ACME HTTP-01 challenge resource, handle it
+ accordingly. */
+ if (is_req_acme_http_01_challenge (path))
+ return handle_acme_http_01_challenge (request,
+ path,
+ method,
+ context_data);
+
+ /* Here other requests can be handled for different needs.
+ This example just sends 404 (Not Found) replies. */
+ (void)upload_size; /* Unused */
+
+ return MHD_action_from_response (request,
+ context_data->resp_not_found);
+}
+
+
+/**
+ * Create the responses used for every request that is not a successfully
+ * served challenge file.
+ *
+ * @param[out] context_data the data to set the responses in
+ * @return non-zero if succeed,
+ * zero if failed
+ */
+static int
+init_responses (struct RequestHandlingContextData *context_data)
+{
+ static const char page_not_found[] =
+ "<html><body>Not found</body></html>";
+ static const char page_not_allowed[] =
+ "<html><body>Method not allowed</body></html>";
+
+ context_data->resp_not_found =
+ make_error_response (MHD_HTTP_STATUS_NOT_FOUND,
+ sizeof(page_not_found) / sizeof(char) - 1,
+ page_not_found,
+ NULL);
+ if (NULL != context_data->resp_not_found)
+ {
+ /* The challenge resource is read-only. The "Allow:" header is required
+ for this status code by RFC 9110, section 15.5.6. */
+ context_data->resp_not_allowed =
+ make_error_response (MHD_HTTP_STATUS_METHOD_NOT_ALLOWED,
+ sizeof(page_not_allowed) / sizeof(char) - 1,
+ page_not_allowed,
+ "GET, HEAD");
+ if (NULL != context_data->resp_not_allowed)
+ return 1; /* Success exit point */
+
+ /* Below is a clean-up path */
+ MHD_response_destroy (context_data->resp_not_found);
+ }
+
+ return 0; /* Failure exit point */
+}
+
+
+/**
+ * Destroy the responses created by init_responses().
+ *
+ * @param[in] context_data the data with the responses to destroy
+ */
+static void
+deinit_responses (struct RequestHandlingContextData *context_data)
+{
+ MHD_response_destroy (context_data->resp_not_allowed);
+ MHD_response_destroy (context_data->resp_not_found);
+}
+
+
+/**
+ * Create, configure, start and run the daemon until the user presses ENTER.
+ *
+ * @param data the data shared by all requests, must be valid until this
+ * function returns
+ * @param port the TCP port to listen on
+ * @return zero if the daemon has been started and stopped normally,
+ * the exit code of the program otherwise
+ */
+static int
+run_daemon (struct RequestHandlingContextData *data,
+ uint_least16_t port)
+{
+ struct MHD_Daemon *d;
+ int ret;
+
+ if (!init_responses (data))
+ {
+ fprintf (stderr,
+ "Failed to create a response object.\n");
+ return 4;
+ }
+
+ d = MHD_daemon_create (&req_cb,
+ data);
+ if (NULL == d)
+ {
+ fprintf (stderr,
+ "Failed to create MHD daemon.\n");
+ deinit_responses (data);
+ return 3;
+ }
+
+ ret = 0;
+ if (MHD_SC_OK !=
+ MHD_DAEMON_SET_OPTIONS (
+ d,
+ MHD_D_OPTION_WM_WORKER_THREADS (1),
+ MHD_D_OPTION_BIND_PORT (MHD_AF_AUTO,
+ port),
+ /* This daemon is exposed to the open Internet and talks to
+ well-behaving clients only, so the protocol is enforced strictly.
+ The nearest stricter level is taken if this exact level is not
+ available in the library build. */
+ MHD_D_OPTION_PROTOCOL_STRICT_LEVEL (MHD_PSL_STRICT,
+ MHD_USL_THIS_OR_STRICTER),
+ /* The following three options are an optional optimisation only:
+ this daemon uses none of these features, so MHD is told not to
+ spend any resources on them. Everything works without them. */
+ MHD_D_OPTION_DISABLE_COOKIES (MHD_YES),
+ MHD_D_OPTION_DISALLOW_UPGRADE (MHD_YES),
+ MHD_D_OPTION_DISALLOW_SUSPEND_RESUME (MHD_YES)))
+ {
+ fprintf (stderr,
+ "Failed to set MHD daemon run parameters.\n");
+ ret = 3;
+ }
+ else if (MHD_SC_OK !=
+ MHD_daemon_start (d))
+ {
+ fprintf (stderr,
+ "Failed to start MHD daemon.\n");
+ ret = 3;
+ }
+ else
+ {
+ printf ("The MHD daemon is listening on port %u and serves\n"
+ "the challenge files from %s\n"
+ "Press ENTER to stop.\n",
+ (unsigned int)port,
+ data->challenge_dir);
+ (void)fgetc (stdin);
+ }
+ printf ("Stopping... ");
+ fflush (stdout);
+ MHD_daemon_destroy (d);
+ printf ("OK\n");
+ deinit_responses (data);
+
+ return ret;
+}
+
+
+/**
+ * Parse the command line arguments.
+ *
+ * The arguments may be given in any order. All the errors are reported
+ * by this function.
+ *
+ * @param argc the number of the command line arguments
+ * @param argv the command line arguments
+ * @param[out] port_out the TCP port to listen on, set only on success
+ * @param[out] webroot_out the name of the webroot directory, set only
+ * on success
+ * @return non-zero if succeed,
+ * zero if the arguments are not valid
+ */
+static int
+parse_cmd_line (int argc,
+ char *const *argv,
+ uint_least16_t *port_out,
+ const char **webroot_out)
+{
+ const char *webroot = "./";
+ const char *port_str = NULL;
+ int i;
+
+ for (i = 1; argc > i; ++i)
+ {
+ if (0 == strcmp (argv[i],
+ "--webroot"))
+ {
+ ++i;
+ if (argc <= i)
+ {
+ fprintf (stderr,
+ "The \"--webroot\" option requires the directory name.\n");
+ return 0;
+ }
+ webroot = argv[i];
+ }
+ else if (NULL == port_str)
+ port_str = argv[i];
+ else
+ {
+ fprintf (stderr,
+ "Usage:\n%s [--webroot DIR] PORT\n",
+ argv[0]);
+ return 0;
+ }
+ }
+ if (NULL == port_str)
+ {
+ fprintf (stderr,
+ "Usage:\n%s [--webroot DIR] PORT\n",
+ argv[0]);
+ return 0;
+ }
+ if (!parse_port (port_str,
+ port_out))
+ {
+ fprintf (stderr,
+ "The PORT must be a numeric value between 1 and 65535.\n");
+ return 0;
+ }
+ if ('\0' == webroot[0])
+ {
+ fprintf (stderr,
+ "The directory name must not be empty.\n");
+ return 0;
+ }
+
+ *webroot_out = webroot;
+
+ return 1;
+}
+
+
+/**
+ * Run the example.
+ *
+ * @param argc the number of the command line arguments
+ * @param argv the command line arguments: the TCP port to listen on and
+ * the optional "--webroot" option with the directory name
+ * @return zero if succeed,
+ * non-zero otherwise
+ */
+int
+main (int argc,
+ char *const *argv)
+{
+ struct RequestHandlingContextData data;
+ char challenge_dir[MAX_PATH_LEN + 1];
+ const char *webroot;
+ size_t webroot_len;
+ size_t skip;
+ uint_least16_t port;
+
+ if (!parse_cmd_line (argc,
+ argv,
+ &port,
+ &webroot))
+ return 1;
+
+ webroot_len = strlen (webroot);
+ /* The prefix starts with the separator, which is not needed if the given
+ directory name ends where the next path component starts. */
+ skip = IS_PATH_END (webroot[webroot_len - 1]) ? 1 : 0;
+ if (MAX_PATH_LEN - (chlng_prfx_len - skip) < webroot_len)
+ {
+ fprintf (stderr,
+ "The directory name is too long.\n");
+ return 2;
+ }
+ memcpy (challenge_dir,
+ webroot,
+ webroot_len);
+ /* The terminating null character is copied together with the prefix. */
+ memcpy (challenge_dir + webroot_len,
+ chlng_prfx + skip,
+ chlng_prfx_len - skip + 1);
+ data.challenge_dir = challenge_dir;
+ data.challenge_dir_len = webroot_len + chlng_prfx_len - skip;
+ /* A real application would take this from its configuration. */
+ data.log_challenges = !0;
+
+ return run_daemon (&data,
+ port);
+}