libmicrohttpd2

HTTP server C library (MHD 2.x, alpha)
Log | Files | Refs | README | LICENSE

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:
Msrc/examples2/acme/.gitignore | 1+
Msrc/examples2/acme/Makefile.am | 3++-
Asrc/examples2/acme/acme_http_01_files.c | 680+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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); +}