/* Pi-hole: A black hole for Internet advertisements * (c) 2019 Pi-hole, LLC (https://pi-hole.net) * Network-wide ad blocking via your own hardware. * * FTL Engine * HTTP server routines * * This file is copyright under the latest version of the EUPL. * Please see LICENSE file for your rights under this license. */ #include "FTL.h" #include "webserver/webserver.h" // api_handler() #include "api/api.h" // send_http() #include "http-common.h" // struct config #include "config/config.h" // log_web() #include "log.h" // get_nprocs() #include // file_readable() #include "files.h" // generate_certificate() #include "webserver/x509.h" // allocate_lua(), free_lua(), init_lua(), request_handler() #include "webserver/lua_web.h" // log_certificate_domain_mismatch() #include "database/message-table.h" // create_cli_password() #include "config/password.h" // Server context handle static struct mg_context *ctx = NULL; static char *error_pages = NULL; static char *prefix_webhome = NULL; static char *api_uri = NULL; static char *admin_api_uri = NULL; static char *login_uri = NULL; static char *webheaders = NULL; // Private prototypes static char *append_to_path(char *path, const char *append); /** * @brief Constructs various web paths used by the webserver. * * @return true if all paths are successfully constructed and allocated, false otherwise. */ static bool build_webpaths(void) { // Construct error_pages path error_pages = append_to_path(config.webserver.paths.webroot.v.s, config.webserver.paths.webhome.v.s); log_debug(DEBUG_API, "Error pages path: %s", error_pages); if(error_pages == NULL) { log_err("Failed to allocate memory for error_pages path!"); return false; } // Construct prefix_webhome path prefix_webhome = append_to_path(config.webserver.paths.prefix.v.s, config.webserver.paths.webhome.v.s); log_debug(DEBUG_API, "Prefix webhome path: %s", prefix_webhome); if(prefix_webhome == NULL) { log_err("Failed to allocate memory for prefix_webhome path!"); return false; } // Construct api_url path api_uri = append_to_path(config.webserver.paths.prefix.v.s, "/api"); log_debug(DEBUG_API, "API URI path: %s", api_uri); if(api_uri == NULL) { log_err("Failed to allocate memory for api_uri path!"); return false; } // Construct admin_api_uri path admin_api_uri = append_to_path(prefix_webhome, "api"); log_debug(DEBUG_API, "Admin API URI path: %s", admin_api_uri); if(admin_api_uri == NULL) { log_err("Failed to allocate memory for admin_api_uri path!"); return false; } // Construct login_uri path login_uri = append_to_path(config.webserver.paths.webhome.v.s, "login"); log_debug(DEBUG_API, "Login URI path: %s", login_uri); if(login_uri == NULL) { log_err("Failed to allocate memory for login_uri path!"); return false; } return true; } char * __attribute__((pure)) get_prefix_webhome(void) { return prefix_webhome; } char * __attribute__((pure)) get_api_uri(void) { return api_uri; } static int redirect_root_handler(struct mg_connection *conn, void *input) { // Get requested host const char *host = mg_get_header(conn, "Host"); size_t host_len = 0; if (host != NULL) { // If the "Host" is an IPv6 address, like [::1], parse until ] is found. if (*host == '[') { char *pos = strchr(host, ']'); if (!pos) { // Malformed hostname starts with '[', but no ']' found log_err("Host name format error: Found '[' without ']'"); return 0; } /* terminate after ']' */ host_len = (size_t)(pos + 1 - host); } else { char *pos = strchr(host, ':'); if (pos != NULL) { // A ':' separates hostname and port number host_len = (size_t)(pos - host); } else { // Host header only contains the host name itself host_len = strlen(host); } } } // Get requested URI const struct mg_request_info *request = mg_get_request_info(conn); const char *uri = request->local_uri_raw; // API debug logging if(config.debug.api.v.b) { log_debug(DEBUG_API, "Host header: \"%s\", extracted host: \"%.*s\"", host, (int)host_len, host); log_debug(DEBUG_API, "URI: %s", uri); } // Check if the requested host is the configured (defaulting to pi.hole) // Do not redirect if the host is anything else, e.g. localhost or a // blocked domain in IP blocking mode if(host != NULL && strncmp(host, config.webserver.domain.v.s, host_len) == 0) { // 308 Permanent Redirect from http://pi.hole -> http://pi.hole/admin/ if(strcmp(uri, "/") == 0 || strcmp(uri, config.webserver.paths.prefix.v.s) == 0) { if(strcmp(uri, prefix_webhome) == 0) { log_debug(DEBUG_API, "Not redirecting %s (matches webhome)", prefix_webhome); return 0; } log_debug(DEBUG_API, "Redirecting / --308--> %s", prefix_webhome); mg_send_http_redirect(conn, prefix_webhome, 308); return 1; } } // else: Not redirecting log_debug(DEBUG_API, "Not redirecting %s", uri); return 0; } static int redirect_admin_handler(struct mg_connection *conn, void *input) { if(config.debug.api.v.b) { // Get requested URI const struct mg_request_info *request = mg_get_request_info(conn); const char *uri = request->local_uri_raw; log_debug(DEBUG_API, "Redirecting %s --308--> %s", uri, prefix_webhome); } // 308 Permanent Redirect from [prefix] -> [prefix] mg_send_http_redirect(conn, prefix_webhome, 308); return 1; } static int redirect_lp_handler(struct mg_connection *conn, void *input) { // Get requested URI const struct mg_request_info *request = mg_get_request_info(conn); const char *uri = request->local_uri_raw; const size_t uri_len = strlen(uri); // Check if we are allowed to serve this directory by checking the // configuration setting webserver.serve_all and the requested URI to // start with something else than config.webserver.paths.webhome. If so, // send error 404 if(!config.webserver.serve_all.v.b && strncmp(uri, config.webserver.paths.webhome.v.s, strlen(config.webserver.paths.webhome.v.s)) != 0) { log_debug(DEBUG_WEBSERVER, "Not serving %s, returning 404", uri); mg_send_http_error(conn, 404, "Not Found"); return 404; } // Get query string const char *query_string = request->query_string; const size_t query_len = query_string != NULL ? strlen(query_string) : 0; // We allocate uri_len + query_len - 1 bytes, which is enough for the // new URI. The calculation is as follows: // 1. We are saving three bytes by skipping ".lp" at the end of the URI // 2. We are adding one byte for the trailing '\0' // 3. We are adding query_len bytes for the query string (if present) // 4. We are adding one byte for the '?' between URI and query string // (if present) // Total bytes required: uri_len - 3 + query_len + 1 + 1 char *new_uri = calloc(uri_len + query_len - 1, sizeof(char)); // Copy everything from before the ".lp" to the new URI to effectively // remove it strncat(new_uri, uri, uri_len - 3); // Append query string to the new URI if present if(query_len > 0) { strcat(new_uri, "?"); strcat(new_uri, query_string); } // Send a 301 redirect to the new URI log_debug(DEBUG_API, "Redirecting %s?%s ==301==> %s", uri, query_string, new_uri); mg_send_http_redirect(conn, new_uri, 301); free(new_uri); return 1; } static int log_http_message(const struct mg_connection *conn, const char *message) { log_web("%s", message); return 1; } static int log_http_access(const struct mg_connection *conn, const char *message) { // Only log when in API debugging mode if(!config.debug.api.v.b) return 1; log_web("ACCESS: %s", message); return 1; } void FTL_mbed_debug(void *user_param, int level, const char *file, int line, const char *message) { // Only log when in TLS debugging mode if(!config.debug.tls.v.b) return; (void)user_param; // Skip initial pointer in message (like 0x7f73000279e0) if present size_t len = strlen(message); if(len > 0 && message[0] == '0' && message[1] == 'x') { message = strstr(message, ": ") + 2; len = strlen(message); } // Truncate trailing newline in message if present if(len > 0 && message[len - 1] == '\n') len--; // Log the message log_web("mbedTLS(%s:%d, %d): %.*s", file, line, level, (int)len, message); } /** * @brief Redirects an HTTP request to a specified URL with a given status code. * * This function formats a URL string using a format specifier and redirects * the HTTP connection to the specified URL with the provided HTTP status code. * * @param conn Pointer to the `mg_connection` structure representing the HTTP connection. * Must not be NULL. * @param code HTTP status code to use for the redirection (e.g., 301, 302). * @param format Format string for the URL to redirect to. Must not be NULL. * Supports standard printf-style formatting. * @param ... Additional arguments for the format string. * * @return The HTTP status code used for the redirection on success, or 0 on failure. */ int __attribute__((format(printf, 3, 4), nonnull(1,3))) ftl_http_redirect(struct mg_connection *conn, const int code, const char *format, ...) { // Determine the size of the formatted string va_list args; va_start(args, format); int size = vsnprintf(NULL, 0, format, args); va_end(args); char *buffer = calloc(size + 1, sizeof(char)); if (buffer == NULL) { log_err("Memory allocation failed for redirect format!"); return 0; } // Format the string va_start(args, format); vsnprintf(buffer, size + 1, format, args); va_end(args); // Ensure null termination buffer[size] = '\0'; log_debug(DEBUG_API, "Redirecting to %s", buffer); mg_send_http_redirect(conn, buffer, code); free(buffer); return code; } #define MAXPORTS 8 static struct serverports { bool is_secure :1; bool is_redirect :1; bool is_optional :1; bool is_bound :1; char addr[INET6_ADDRSTRLEN + 2]; // +2 for square brackets around IPv6 address int port; int protocol; // 1 = IPv4, 3 = IPv6 } server_ports[MAXPORTS] = { 0 }; static in_port_t https_port = 0; /** * @brief Retrieves and logs the server ports configuration. * * This function checks if the server context is initialized and then retrieves * the configured server ports. It logs the port information and stores the * details in the `server_ports` array. It also identifies and stores the first * HTTPS port if available. * * @note If no ports are configured, a warning is logged and the function returns. * * @param void This function does not take any parameters. * @return bool Returns whether the server ports were successfully retrieved */ static bool get_server_ports(void) { if(ctx == NULL) return false; // Loop over all listening ports struct mg_server_port mgports[MAXPORTS] = { 0 }; const int ports = mg_get_server_ports(ctx, MAXPORTS, mgports); // Stop if no ports are configured if(ports < 1) { log_warn("No web server ports configured!"); return false; } // Loop over all ports for(unsigned int i = 0; i < (unsigned int)ports; i++) { // Stop if no more ports are configured if(mgports[i].protocol == 0) break; // Store port information server_ports[i].port = mgports[i].port; server_ports[i].is_secure = mgports[i].is_ssl; server_ports[i].is_redirect = mgports[i].is_redirect; server_ports[i].is_optional = mgports[i].is_optional; server_ports[i].is_bound = mgports[i].is_bound; // 1 = IPv4, 3 = IPv6 (can also be a combo-socker serving both), // the documentation in civetweb.h is wrong server_ports[i].protocol = mgports[i].protocol; // Convert listening address to string if(server_ports[i].protocol == 1) inet_ntop(AF_INET, &mgports[i].addr.sa4.sin_addr, server_ports[i].addr, INET_ADDRSTRLEN); else if(server_ports[i].protocol == 3) { char tmp[INET6_ADDRSTRLEN] = { 0 }; inet_ntop(AF_INET6, &mgports[i].addr.sa6.sin6_addr, tmp, INET6_ADDRSTRLEN); // Enclose IPv6 address in square brackets snprintf(server_ports[i].addr, sizeof(server_ports[i].addr), "[%s]", tmp); } else log_warn("Unsupported protocol for port %d", mgports[i].port); // Store (first) HTTPS port if not already set if(mgports[i].is_ssl && https_port == 0) https_port = mgports[i].port; // Print port information if(i == 0) log_info("Web server ports:"); log_info(" - %s:%d (HTTP%s, IPv%s%s%s, %s)", server_ports[i].addr, server_ports[i].port, server_ports[i].is_secure ? "S" : "", server_ports[i].protocol == 1 ? "4" : "6", server_ports[i].is_redirect ? ", redirecting" : "", server_ports[i].is_optional ? ", optional" : "", server_ports[i].is_bound ? "OK" : "NOT bound"); } return true; } in_port_t __attribute__((pure)) get_https_port(void) { return https_port; } #define MAX_URL_LEN 255 unsigned short get_api_string(char **buf, const bool domain) { // Initialize buffer to empty string size_t len = 0; // First byte has the length of the first string **buf = 0; // TXT record format: // // 0 length of first string (unsigned char n) // 1 to (n+1) first string // (n+2) length of second string (unsigned char m) // (n+3) to (n+m+3) second string // ... // This is repeated for every port, so the total length is // (n+1) + (n+m+3) + (n+m+3) + ... // // This is implemented in the loop below // Loop over all ports for(unsigned int i = 0; i < MAXPORTS; i++) { // Skip ports that are not configured or redirected if(server_ports[i].port == 0 || server_ports[i].is_redirect) continue; // Reallocate additional memory for every port const size_t bufsz = (i + 1) * MAX_URL_LEN; if((*buf = realloc(*buf, bufsz)) == NULL) { log_err("Failed to reallocate API URL buffer!"); return 0; } // Use appropriate domain const char *addr = domain ? config.webserver.domain.v.s : server_ports[i].addr; // If we bound to the wildcard address, substitute it with // 127.0.0.1 if(strcmp(addr, "0.0.0.0") == 0) addr = "127.0.0.1"; else if(strcasecmp(addr, "[::]") == 0) addr = "[::1]"; // Append API URL to buffer // We add this at buffer + 1 because the first byte is the // length of the string, which we don't know yet char *api_str = calloc(MAX_URL_LEN, sizeof(char)); const ssize_t this_len = snprintf(api_str, MAX_URL_LEN, "http%s://%s:%d%s/api/", server_ports[i].is_secure ? "s" : "", addr, server_ports[i].port, config.webserver.paths.prefix.v.s); // Check if snprintf() failed if(this_len < 0) { log_err("Failed to append API URL to buffer: %s", strerror(errno)); free(api_str); return 0; } // Check if snprintf() truncated the string (this should never // happen as we allocate enough memory for the domain to fit) if((size_t)this_len >= bufsz - len - 1) { log_err("API URL buffer too small!"); free(api_str); return 0; } // Check if this string is already present in the buffer if(memmem(*buf, len, api_str, this_len) != NULL) { // This string is already present, so skip it log_debug(DEBUG_API, "Skipping duplicate API URL: %s", api_str); free(api_str); continue; } // Append string to buffer (one byte after the current end of // the buffer to leave space for the length byte) strcpy(*buf + len + 1, api_str); free(api_str); // Set first byte to the length of the string (see breakdown // above) (*buf)[len] = (unsigned char)this_len; // Increase total length len += this_len + 1; } // Return total length return (unsigned short)len; } void http_init(void) { // Don't start web server if port is not set if(strlen(config.webserver.port.v.s) == 0) { log_warn("Not starting web server as webserver.port is empty. API will not be available!"); return; } // Get maximum number of threads for webserver char num_threads[16] = { 0 }; unsigned int threads = config.webserver.threads.v.ui; if(threads == 0) { // For compatibility with older versions, set the number of // threads to the default value (50) if it was 0. Before Pi-hole // FTL v6.0.4, the number of threads was computed in dependence // of the number of CPUs available. This is no longer the case. threads = 50; } snprintf(num_threads, sizeof(num_threads), "%u", threads); // Ensure null termination for safety num_threads[sizeof(num_threads) - 1] = '\0'; /* Initialize the library */ log_web("Initializing HTTP server on ports \"%s\"", config.webserver.port.v.s); unsigned int features = MG_FEATURES_FILES | MG_FEATURES_IPV6 | MG_FEATURES_CACHE; #ifdef HAVE_MBEDTLS features |= MG_FEATURES_TLS; #endif if(mg_init_library(features) == 0) { log_web("Initializing HTTP library failed!"); return; } if(!build_webpaths()) { log_err("Failed to build web paths, web interface will not be available!"); return; } // Construct additional headers webheaders = strdup(""); if (webheaders == NULL) { log_err("Failed to allocate memory for webheaders!"); return; } cJSON *header; cJSON_ArrayForEach(header, config.webserver.headers.v.json) { if(!cJSON_IsString(header)) { log_err("Invalid header in webserver.headers!"); continue; } // Get header value const char *h = cJSON_GetStringValue(header); // Allocate memory for the new header char *new_webheaders = realloc(webheaders, strlen(webheaders) + strlen(h) + 3); if (new_webheaders == NULL) { log_err("Failed to (re)allocate memory for webheaders!"); free(webheaders); webheaders = NULL; return; } webheaders = new_webheaders; strcat(webheaders, h); strcat(webheaders, "\r\n"); } // Prepare options for HTTP server (NULL-terminated list) const char *options[] = { "document_root", config.webserver.paths.webroot.v.s, "error_pages", error_pages, "listening_ports", config.webserver.port.v.s, "decode_url", "yes", "enable_directory_listing", "no", "num_threads", num_threads, "authentication_domain", config.webserver.domain.v.s, "additional_header", webheaders, "index_files", "index.html,index.htm,index.lp", "enable_keep_alive", "yes", "keep_alive_timeout_ms", "5000", NULL, NULL, NULL, NULL, // Leave slots for access control list (ACL) and TLS configuration at the end NULL }; // Get index of next free option // Note: The first options are always present, so start at the counting // from the end of the array. unsigned int next_option = ArraySize(options) - 6; #ifdef HAVE_MBEDTLS // Add TLS options if configured // TLS is used when webserver.port contains "s" (e.g. "443s") const bool tls_used = config.webserver.port.v.s != NULL && strchr(config.webserver.port.v.s, 's') != NULL; // Check certificate domain if // - TLS is used // - A certificate is configured // - The certificate is readable if(tls_used && config.webserver.tls.cert.v.s != NULL && strlen(config.webserver.tls.cert.v.s) > 0) { // Try to generate certificate if not present if(!file_readable(config.webserver.tls.cert.v.s)) { if(generate_certificate(config.webserver.tls.cert.v.s, false, config.webserver.domain.v.s)) { log_info("Created SSL/TLS certificate for %s at %s", config.webserver.domain.v.s, config.webserver.tls.cert.v.s); } else { log_err("Generation of SSL/TLS certificate %s failed!", config.webserver.tls.cert.v.s); } } // Check if the certificate is readable (we may have just // created it) if(file_readable(config.webserver.tls.cert.v.s)) { if(read_certificate(config.webserver.tls.cert.v.s, config.webserver.domain.v.s, false) != CERT_DOMAIN_MATCH) { log_certificate_domain_mismatch(config.webserver.tls.cert.v.s, config.webserver.domain.v.s); } options[++next_option] = "ssl_certificate"; options[++next_option] = config.webserver.tls.cert.v.s; log_info("Using SSL/TLS certificate file %s", config.webserver.tls.cert.v.s); } else { log_err("Webserver SSL/TLS certificate %s not found or not readable!", config.webserver.tls.cert.v.s); } } #endif // Add access control list if configured (last two options) if(strlen(config.webserver.acl.v.s) > 0) { options[++next_option] = "access_control_list"; // Note: The string is duplicated by CivetWeb, so it doesn't matter if // the original string is freed (config changes) after mg_start() // returns below. options[++next_option] = config.webserver.acl.v.s; } // Configure logging handlers struct mg_callbacks callbacks; memset(&callbacks, 0, sizeof(callbacks)); callbacks.log_message = log_http_message; callbacks.log_access = log_http_access; callbacks.init_lua = init_lua; // Prepare error handler struct mg_error_data error = { 0 }; char error_buffer[1024] = { 0 }; error.text_buffer_size = sizeof(error_buffer); error.text = error_buffer; // Prepare initialization data struct mg_init_data init = { 0 }; init.callbacks = &callbacks; init.user_data = NULL; init.configuration_options = options; /* Start the server */ if((ctx = mg_start2(&init, &error)) == NULL || !get_server_ports()) { log_err("Start of webserver failed!. Web interface will not be available!"); log_err(" Error: %s (error code %u.%u)", error.text, error.code, error.code_sub); log_err(" Hint: Check the webserver log at %s", config.files.log.webserver.v.s); return; } // Register API handler, use "/api" even when a prefix is defined as the // prefix should be stripped away by the reverse proxy mg_set_request_handler(ctx, "/api", api_handler, NULL); mg_set_request_handler(ctx, "/$", redirect_root_handler, NULL); if(strcmp(config.webserver.paths.webhome.v.s, "/") == 0 && config.dns.blocking.mode.v.blocking_mode == MODE_IP) { log_warn("Webhome is set to root (/) and IP blocking is enabled. This may result in the Pi-hole web interface to display in places where otherwise ads would show up"); } // Register [prefix] -> [] redirect handler if(strlen(config.webserver.paths.webhome.v.s) > 1 && config.webserver.paths.webhome.v.s[strlen(config.webserver.paths.webhome.v.s)-1] == '/') { // Replace trailing slash with end-of-string marker for matcher char *prefix_webhome_matcher = strdup(prefix_webhome); prefix_webhome_matcher[strlen(prefix_webhome_matcher)-1] = '$'; log_debug(DEBUG_API, "Redirecting %s --308--> %s", prefix_webhome, config.webserver.paths.webhome.v.s); mg_set_request_handler(ctx, prefix_webhome_matcher, redirect_admin_handler, NULL); // prefix_webhome_matcher is internally duplicated during // request configuration so it can be freed here free(prefix_webhome_matcher); } // Register **.lp -> ** redirect handler mg_set_request_handler(ctx, "**.lp$", redirect_lp_handler, NULL); // Register handler for the rest mg_set_request_handler(ctx, "**", request_handler, NULL); // Prepare prerequisites for Lua allocate_lua(login_uri, admin_api_uri, prefix_webhome); // Restore sessions from database init_api(); // Create CLI password (if enabled) create_cli_password(); } static char *append_to_path(char *path, const char *append) { const size_t path_len = strlen(path); const size_t append_len = strlen(append); const size_t total_len = path_len + append_len + 1; char *new_path = calloc(total_len, sizeof(char)); if(new_path == NULL) { log_err("Failed to allocate memory for path!"); return NULL; } strncpy(new_path, path, total_len); strncat(new_path, append, total_len); return new_path; } void FTL_rewrite_pattern(char *filename, unsigned long filename_buf_len) { log_debug(DEBUG_API, "Rewriting filename: %s", filename); const bool trailing_slash = filename[strlen(filename) - 1] == '/'; char *filename_lp = NULL; // Try index pages first if(trailing_slash) // If there is a trailing slash, append "index.lp" filename_lp = append_to_path(filename, "index.lp"); else // If there is no trailing slash, append "/index.lp" filename_lp = append_to_path(filename, "/index.lp"); // Check if the file exists. If so, rewrite the filename and return if(filename_lp != NULL && file_readable(filename_lp)) { log_debug(DEBUG_API, "Rewriting index page: %s ==> %s", filename, filename_lp); strncpy(filename, filename_lp, filename_buf_len); free(filename_lp); return; } free(filename_lp); // If there is a trailing slash, we are done if(trailing_slash) return; // Try full path with ".lp" appended filename_lp = append_to_path(filename, ".lp"); if(filename_lp == NULL) { // Failed to allocate memory for filename return; } // Check if the file exists. If so, rewrite the filename and return if(file_readable(filename_lp)) { log_debug(DEBUG_API, "Rewriting Lua page: %s ==> %s", filename, filename_lp); strncpy(filename, filename_lp, filename_buf_len); free(filename_lp); return; } // Change last occurrence of "/" to "-" (if any) char *last_slash = strrchr(filename_lp, '/'); if(last_slash != NULL) { *last_slash = '-'; if(file_readable(filename_lp)) { log_debug(DEBUG_API, "Rewriting Lua page (settings page): %s ==> %s", filename, filename_lp); strncpy(filename, filename_lp, filename_buf_len); free(filename_lp); return; } } free(filename_lp); } void http_terminate(void) { // The server may have never been started if(!ctx) return; /* Stop the server */ mg_stop(ctx); /* Un-initialize the library */ mg_exit_library(); // Remove CLI password remove_cli_password(); // Free error_pages path if(error_pages != NULL) free(error_pages); // Free webhome_matcher path if(prefix_webhome != NULL) free(prefix_webhome); // Free api_uri path if(api_uri != NULL) free(api_uri); // Free admin_api_uri path if(admin_api_uri != NULL) free(admin_api_uri); // Free login_uri path if(login_uri != NULL) free(login_uri); if(webheaders != NULL) free(webheaders); }