Implement automatic TLS/SSL certificate renewals. The validity can now be configured through webserver.tls.validity and defaults to 2 years right now.

Signed-off-by: DL6ER <dl6er@dl6er.de>
This commit is contained in:
DL6ER
2025-05-22 14:43:56 +02:00
parent 039000e644
commit 3b4521307a
14 changed files with 293 additions and 89 deletions
+3
View File
@@ -429,6 +429,8 @@ components:
properties:
cert:
type: string
validity:
type: integer
paths:
type: object
properties:
@@ -766,6 +768,7 @@ components:
restore: true
tls:
cert: "/etc/pihole/tls.pem"
validity: 730
paths:
webroot: "/var/www/html"
webhome: "/admin/"
+6 -1
View File
@@ -383,12 +383,17 @@ void parse_args(int argc, char *argv[])
printf(" RSA with domain: %s --gen-x509 /etc/pihole/tls.pem nanopi.lan rsa\n", argv[0]);
exit(EXIT_FAILURE);
}
// Read config
readFTLconf(&config, false);
// Enable stdout printing
cli_mode = true;
log_ctrl(false, true);
const char *domain = argc > 3 ? argv[3] : "pi.hole";
const bool rsa = argc > 4 && strcasecmp(argv[4], "rsa") == 0;
exit(generate_certificate(argv[2], rsa, domain) ? EXIT_SUCCESS : EXIT_FAILURE);
exit(generate_certificate(argv[2], rsa, domain, config.webserver.tls.validity.v.ui) ? EXIT_SUCCESS : EXIT_FAILURE);
}
// Parse X.509 certificate
+6
View File
@@ -1048,6 +1048,12 @@ static void initConfig(struct config *conf)
conf->webserver.tls.cert.d.s = (char*)"/etc/pihole/tls.pem";
conf->webserver.tls.cert.c = validate_filepath;
conf->webserver.tls.validity.k = "webserver.tls.validity";
conf->webserver.tls.validity.h = "Number of days the automatically generated self-signed TLS/SSL certificate will be valid for. Defaults to 730 days (= 2 years). A minimum of 7 days is enforced.\n Some devices may enforce shorter validity ranges. Note that defining a lower validity range may require you to accept the self-signed certificate more often in your browser.\n Pi-hole will regenerate certificates it created itself two days prior to expiration. If you are using your own certificate, you need to regenerate it yourself. In this case, it is advised to set the validity range to 0 days, so that Pi-hole does not try to regenerate your certificate.";
conf->webserver.tls.validity.t = CONF_UINT;
conf->webserver.tls.validity.d.ui = 730; // 2 years
conf->webserver.tls.validity.c = validate_ui_min_7_or_0;
conf->webserver.session.timeout.k = "webserver.session.timeout";
conf->webserver.session.timeout.h = "Session timeout in seconds. If a session is inactive for more than this time, it will be terminated. Sessions are continuously refreshed by the web interface, preventing sessions from timing out while the web interface is open.\n This option may also be used to make logins persistent for long times, e.g. 86400 seconds (24 hours), 604800 seconds (7 days) or 2592000 seconds (30 days). Note that the total number of concurrent sessions is limited so setting this value too high may result in users being rejected and unable to log in if there are already too many sessions active.";
conf->webserver.session.timeout.t = CONF_UINT;
+1
View File
@@ -255,6 +255,7 @@ struct config {
} session;
struct {
struct conf_item cert;
struct conf_item validity;
} tls;
struct {
struct conf_item webroot;
+11
View File
@@ -535,3 +535,14 @@ bool validate_dns_revServers(union conf_value *val, const char *key, char err[VA
// Return success
return true;
}
bool validate_ui_min_7_or_0(union conf_value *val, const char *key, char err[VALIDATOR_ERRBUF_LEN])
{
if(val->ui < 7 && val->ui != 0)
{
snprintf(err, VALIDATOR_ERRBUF_LEN, "%s: cannot be lower than 7", key);
return false;
}
return true;
}
+1
View File
@@ -26,5 +26,6 @@ bool validate_filepath_empty(union conf_value *val, const char *key, char err[VA
bool validate_filepath_dash(union conf_value *val, const char *key, char err[VALIDATOR_ERRBUF_LEN]);
bool validate_regex_array(union conf_value *val, const char *key, char err[VALIDATOR_ERRBUF_LEN]);
bool validate_dns_revServers(union conf_value *val, const char *key, char err[VALIDATOR_ERRBUF_LEN]);
bool validate_ui_min_7_or_0(union conf_value *val, const char *key, char err[VALIDATOR_ERRBUF_LEN]);
#endif // CONFIG_VALIDATOR_H
+7
View File
@@ -3275,6 +3275,13 @@ void FTL_fork_and_bind_sockets(struct passwd *ent_pw, bool dnsmasq_start)
exit(EXIT_FAILURE);
}
// Start webserver thread
if(pthread_create( &threads[WEBSERVER], &attr, webserver_thread, NULL ) != 0)
{
log_crit("Unable to create webserver thread. Exiting...");
exit(EXIT_FAILURE);
}
// Chown files if FTL started as user root but a dnsmasq config
// option states to run as a different user/group (e.g. "nobody")
if(getuid() == 0)
+4 -1
View File
@@ -245,6 +245,7 @@ enum thread_types {
NTP_CLIENT,
NTP_SERVER4,
NTP_SERVER6,
WEBSERVER,
THREADS_MAX
} __attribute__ ((packed));
@@ -325,7 +326,9 @@ enum cert_check {
CERT_CANNOT_PARSE_KEY,
CERT_DOMAIN_MISMATCH,
CERT_DOMAIN_MATCH,
CERT_OKAY
CERT_NOT_YET_VALID,
CERT_EXPIRES_SOON,
CERT_OKAY,
} __attribute__ ((packed));
enum http_method {
+1
View File
@@ -42,6 +42,7 @@ const char * const thread_names[THREADS_MAX] = {
"ntp-client",
"ntp-server4",
"ntp-server6",
"webserver",
};
// Return the (null-terminated) name of the calling thread
+53 -3
View File
@@ -30,6 +30,8 @@
#include "database/message-table.h"
// create_cli_password()
#include "config/password.h"
// thread_names
#include "signals.h"
// Server context handle
static struct mg_context *ctx = NULL;
@@ -639,7 +641,7 @@ void http_init(void)
// 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))
if(generate_certificate(config.webserver.tls.cert.v.s, false, config.webserver.domain.v.s, config.webserver.tls.validity.v.ui))
{
log_info("Created SSL/TLS certificate for %s at %s",
config.webserver.domain.v.s, config.webserver.tls.cert.v.s);
@@ -722,7 +724,7 @@ void http_init(void)
// 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);
@@ -854,8 +856,56 @@ void http_terminate(void)
// 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);
}
static void restart_http(void)
{
// Stop the server
http_terminate();
// Reinitialize the webserver
http_init();
}
void *webserver_thread(void *val)
{
(void)val;
// Set thread name
prctl(PR_SET_NAME, thread_names[WEBSERVER], 0, 0, 0);
// Initial delay until we check the certificate for the first time
thread_sleepms(WEBSERVER, 2000);
while(!killed)
{
// Check if the certificate is about to expire soon
const enum cert_check status = cert_currently_valid(config.webserver.tls.cert.v.s, 2);
if(status == CERT_EXPIRES_SOON &&
config.webserver.tls.validity.v.ui > 0 &&
is_pihole_certificate(config.webserver.tls.cert.v.s))
{
log_info("TLS certificate at %s is about to expire soon, generating new one",
config.webserver.tls.cert.v.s);
generate_certificate(config.webserver.tls.cert.v.s, false,
config.webserver.domain.v.s,
config.webserver.tls.validity.v.ui);
log_info("Restarting HTTP server");
restart_http();
log_info("Done. The new certificate is valid for %u days",
config.webserver.tls.validity.v.ui);
}
// Idle for 1 hour
thread_sleepms(WEBSERVER, 3600000);
}
log_info("Terminating webserver thread");
return NULL;
}
+1
View File
@@ -23,6 +23,7 @@
void http_init(void);
void http_terminate(void);
void *webserver_thread(void *val);
int ftl_http_redirect(struct mg_connection *conn, const int code, const char *format, ...) __attribute__((format(printf, 3, 4), nonnull(1, 3)));
in_port_t get_https_port(void) __attribute__((pure));
+179 -79
View File
@@ -12,6 +12,10 @@
#include "log.h"
#include "x509.h"
#ifndef HAVE_MBEDTLS
#define HAVE_MBEDTLS
#endif
#ifdef HAVE_MBEDTLS
# include <mbedtls/rsa.h>
# include <mbedtls/x509.h>
@@ -24,6 +28,7 @@
#define RSA_KEY_SIZE 4096
#define BUFFER_SIZE 16000
#define PIHOLE_ISSUER "CN=pi.hole,O=Pi-hole,C=DE"
static bool read_id_file(const char *filename, char *buffer, size_t buffer_size)
{
@@ -260,7 +265,7 @@ static bool write_to_file(const char *filename, const char *type, const char *su
return true;
}
bool generate_certificate(const char* certfile, bool rsa, const char *domain)
bool generate_certificate(const char* certfile, bool rsa, const char *domain, const unsigned int validity_days)
{
int ret;
mbedtls_x509write_cert ca_cert, server_cert;
@@ -339,7 +344,8 @@ bool generate_certificate(const char* certfile, bool rsa, const char *domain)
char not_before[16] = { 0 };
char not_after[16] = { 0 };
strftime(not_before, sizeof(not_before), "%Y%m%d%H%M%S", tm);
tm->tm_year += 30; // 30 years from now
tm->tm_mday += validity_days;
mktime(tm); // normalize time
// Check for leap year, and adjust the date accordingly
const bool isLeapYear = tm->tm_year % 4 == 0 && (tm->tm_year % 100 != 0 || tm->tm_year % 400 == 0);
tm->tm_mday = tm->tm_mon == 1 && tm->tm_mday == 29 && !isLeapYear ? 28 : tm->tm_mday;
@@ -355,8 +361,8 @@ bool generate_certificate(const char* certfile, bool rsa, const char *domain)
mbedtls_x509write_crt_set_subject_key_identifier(&ca_cert);
mbedtls_x509write_crt_set_issuer_key(&ca_cert, &ca_key);
mbedtls_x509write_crt_set_authority_key_identifier(&ca_cert);
mbedtls_x509write_crt_set_issuer_name(&ca_cert, "CN=pi.hole,O=Pi-hole,C=DE");
mbedtls_x509write_crt_set_subject_name(&ca_cert, "CN=pi.hole,O=Pi-hole,C=DE");
mbedtls_x509write_crt_set_issuer_name(&ca_cert, PIHOLE_ISSUER);
mbedtls_x509write_crt_set_subject_name(&ca_cert, PIHOLE_ISSUER);
mbedtls_x509write_crt_set_validity(&ca_cert, not_before, not_after);
mbedtls_x509write_crt_set_basic_constraints(&ca_cert, 1, -1);
@@ -378,7 +384,7 @@ bool generate_certificate(const char* certfile, bool rsa, const char *domain)
mbedtls_x509write_crt_set_issuer_key(&server_cert, &ca_key);
mbedtls_x509write_crt_set_authority_key_identifier(&server_cert);
// subject name set below
mbedtls_x509write_crt_set_issuer_name(&server_cert, "CN=pi.hole,O=Pi-hole,C=DE");
mbedtls_x509write_crt_set_issuer_name(&server_cert, PIHOLE_ISSUER);
mbedtls_x509write_crt_set_validity(&server_cert, not_before, not_after);
mbedtls_x509write_crt_set_basic_constraints(&server_cert, 0, -1);
@@ -469,12 +475,87 @@ static bool check_wildcard_domain(const char *domain, char *san, const size_t sa
return strncasecmp(wild_domain, san + 1, san_len - 1) == 0;
}
static bool search_domain(mbedtls_x509_crt *crt, mbedtls_x509_sequence *sans, const char *domain)
{
bool found = false;
// Loop over all SANs
while(sans != NULL)
{
// Parse the SAN
mbedtls_x509_subject_alternative_name san = { 0 };
const int ret = mbedtls_x509_parse_subject_alt_name(&sans->buf, &san);
// Check if SAN is used (otherwise ret < 0, e.g.,
// MBEDTLS_ERR_X509_FEATURE_UNAVAILABLE) and if it is a
// DNS name, skip otherwise
if(ret < 0 || san.type != MBEDTLS_X509_SAN_DNS_NAME)
goto next_san;
// Check if the SAN matches the domain
// Attention: The SAN is not NUL-terminated, so we need to
// use the length field
if(strncasecmp(domain, (char*)san.san.unstructured_name.p, san.san.unstructured_name.len) == 0)
{
found = true;
// Free resources
mbedtls_x509_free_subject_alt_name(&san);
break;
}
// Also check if the SAN is a wildcard domain and if the domain
// matches the wildcard
if(check_wildcard_domain(domain, (char*)san.san.unstructured_name.p, san.san.unstructured_name.len))
{
found = true;
// Free resources
mbedtls_x509_free_subject_alt_name(&san);
break;
}
next_san:
// Free resources
mbedtls_x509_free_subject_alt_name(&san);
// Go to next SAN
sans = sans->next;
}
if(found)
return true;
// Also check against the common name (CN) field
char subject[MBEDTLS_X509_MAX_DN_NAME_SIZE];
const size_t subject_len = mbedtls_x509_dn_gets(subject, sizeof(subject), &(crt->subject));
if(subject_len > 0)
{
// Check subjects prefixed with "CN="
if(subject_len > 3 && strncasecmp(subject, "CN=", 3) == 0)
{
// Check subject + 3 to skip the prefix
if(strncasecmp(domain, subject + 3, subject_len - 3) == 0)
found = true;
// Also check if the subject is a wildcard domain
else if(check_wildcard_domain(domain, subject + 3, subject_len - 3))
found = true;
}
// Check subject == "<domain>"
else if(strcasecmp(domain, subject) == 0)
found = true;
// Also check if the subject is a wildcard domain and if the domain
// matches the wildcard
else if(check_wildcard_domain(domain, subject, subject_len))
found = true;
}
return found;
}
// This function reads a X.509 certificate from a file and prints a
// human-readable representation of the certificate to stdout. If a domain is
// specified, we only check if this domain is present in the certificate.
// Otherwise, we print verbose human-readable information about the certificate
// and about the private key (if requested).
enum cert_check read_certificate(const char* certfile, const char *domain, const bool private_key)
enum cert_check read_certificate(const char *certfile, const char *domain, const bool private_key)
{
if(certfile == NULL && domain == NULL)
{
@@ -514,80 +595,10 @@ enum cert_check read_certificate(const char* certfile, const char *domain, const
// Parse mbedtls_x509_parse_subject_alt_names()
mbedtls_x509_sequence *sans = &crt.subject_alt_names;
bool found = false;
// When a domain is specified, possibly return early
if(domain != NULL)
{
// Loop over all SANs
while(sans != NULL)
{
// Parse the SAN
mbedtls_x509_subject_alternative_name san = { 0 };
const int ret = mbedtls_x509_parse_subject_alt_name(&sans->buf, &san);
// Check if SAN is used (otherwise ret < 0, e.g.,
// MBEDTLS_ERR_X509_FEATURE_UNAVAILABLE) and if it is a
// DNS name, skip otherwise
if(ret < 0 || san.type != MBEDTLS_X509_SAN_DNS_NAME)
goto next_san;
// Check if the SAN matches the domain
// Attention: The SAN is not NUL-terminated, so we need to
// use the length field
if(strncasecmp(domain, (char*)san.san.unstructured_name.p, san.san.unstructured_name.len) == 0)
{
found = true;
// Free resources
mbedtls_x509_free_subject_alt_name(&san);
break;
}
// Also check if the SAN is a wildcard domain and if the domain
// matches the wildcard
if(check_wildcard_domain(domain, (char*)san.san.unstructured_name.p, san.san.unstructured_name.len))
{
found = true;
// Free resources
mbedtls_x509_free_subject_alt_name(&san);
break;
}
next_san:
// Free resources
mbedtls_x509_free_subject_alt_name(&san);
// Go to next SAN
sans = sans->next;
}
// Also check against the common name (CN) field
char subject[MBEDTLS_X509_MAX_DN_NAME_SIZE];
const size_t subject_len = mbedtls_x509_dn_gets(subject, sizeof(subject), &crt.subject);
if(subject_len > 0)
{
// Check subjects prefixed with "CN="
if(subject_len > 3 && strncasecmp(subject, "CN=", 3) == 0)
{
// Check subject + 3 to skip the prefix
if(strncasecmp(domain, subject + 3, subject_len - 3) == 0)
found = true;
// Also check if the subject is a wildcard domain
else if(check_wildcard_domain(domain, subject + 3, subject_len - 3))
found = true;
}
// Check subject == "<domain>"
else if(strcasecmp(domain, subject) == 0)
found = true;
// Also check if the subject is a wildcard domain and if the domain
// matches the wildcard
else if(check_wildcard_domain(domain, subject, subject_len))
found = true;
}
// Free resources
mbedtls_x509_crt_free(&crt);
mbedtls_pk_free(&key);
return found ? CERT_DOMAIN_MATCH : CERT_DOMAIN_MISMATCH;
}
return search_domain(&crt, sans, domain) ? CERT_DOMAIN_MATCH : CERT_DOMAIN_MISMATCH;
// else: Print verbose information about the certificate
char certinfo[BUFFER_SIZE] = { 0 };
@@ -728,6 +739,95 @@ end:
return CERT_OKAY;
}
/**
* @brief Checks if the certificate at the given file path is currently valid and will remain valid for at least the specified number of days.
*
* This function loads an X.509 certificate from the specified file, verifies that it is readable and parsable,
* and checks its validity period. It ensures that the certificate is already valid (not before date is in the past)
* and that it will not expire within the next `valid_for_at_least_days` days.
*
* @param certfile Path to the certificate file to check. If NULL, the function returns CERT_FILE_NOT_FOUND.
* @param valid_for_at_least_days The minimum number of days the certificate should remain valid from now.
*
* @return enum cert_check
* - CERT_OKAY: Certificate is valid and will remain valid for at least the specified number of days.
* - CERT_FILE_NOT_FOUND: Certificate file is not specified, does not exist, or is not readable.
* - CERT_CANNOT_PARSE_CERT: Certificate file could not be parsed.
* - CERT_NOT_YET_VALID: Certificate is not yet valid (valid_from is in the future).
* - CERT_EXPIRES_SOON: Certificate will expire within the specified number of days.
*/
enum cert_check cert_currently_valid(const char *certfile, const time_t valid_for_at_least_days)
{
// If no file was specified, we do not want to recreate it
if(certfile == NULL)
return CERT_FILE_NOT_FOUND;
mbedtls_x509_crt crt;
mbedtls_x509_crt_init(&crt);
// Check if the file exists and is readable
if(access(certfile, R_OK) != 0)
{
log_err("Could not read certificate file: %s", strerror(errno));
return CERT_FILE_NOT_FOUND;
}
int rc = mbedtls_x509_crt_parse_file(&crt, certfile);
if (rc != 0)
{
log_err("Cannot parse certificate: Error code %d", rc);
return CERT_CANNOT_PARSE_CERT;
}
// Compare validity of certificate
// - crt.valid_from needs to be in the past
// - crt.valid_to should be further away than at least two days
mbedtls_x509_time now;
mbedtls_x509_time_gmtime(mbedtls_time(NULL) + valid_for_at_least_days * (24 * 3600), &now);
const bool is_valid_to = mbedtls_x509_time_cmp(&(crt.valid_to), &now) > 0;
const bool is_valid_from = mbedtls_x509_time_is_past(&(crt.valid_from));
// Free resources
mbedtls_x509_crt_free(&crt);
// Return result
if(!is_valid_from)
return CERT_NOT_YET_VALID;
if(!is_valid_to)
return CERT_EXPIRES_SOON;
return CERT_OKAY;
}
bool is_pihole_certificate(const char *certfile)
{
// Check if the file exists and is readable
if(access(certfile, R_OK) != 0)
{
log_err("Could not read certificate file: %s", strerror(errno));
return false;
}
mbedtls_x509_crt crt;
mbedtls_x509_crt_init(&crt);
int rc = mbedtls_x509_crt_parse_file(&crt, certfile);
if (rc != 0)
{
log_err("Cannot parse certificate: Error code %d", rc);
return false;
}
// Check if the issuer is "pi.hole"
const bool is_pihole_issuer = strncasecmp((char*)crt.issuer.val.p, "pi.hole", crt.issuer.val.len) == 0;
// Check if the subject is "pi.hole"
const bool is_pihole_subject = strncasecmp((char*)crt.subject.val.p, "pi.hole", crt.subject.val.len) == 0;
// Free resources
mbedtls_x509_crt_free(&crt);
return is_pihole_issuer && is_pihole_subject;
}
#else
enum cert_check read_certificate(const char* certfile, const char *domain, const bool private_key)
+6 -2
View File
@@ -19,9 +19,13 @@
#include <stdbool.h>
// ssize_t
#include <unistd.h>
// time_t
#include <time.h>
bool generate_certificate(const char* certfile, bool rsa, const char *domain);
enum cert_check read_certificate(const char* certfile, const char *domain, const bool private_key);
bool generate_certificate(const char *certfile, bool rsa, const char *domain, const unsigned int validity_days);
enum cert_check read_certificate(const char *certfile, const char *domain, const bool private_key);
enum cert_check cert_currently_valid(const char *certfile, const time_t valid_for_at_least_days);
bool is_pihole_certificate(const char *certfile);
bool init_entropy(void);
void destroy_entropy(void);
+14 -3
View File
@@ -1,7 +1,7 @@
# Pi-hole configuration file (v6.0.4-75-gc53cf00b)
# Pi-hole configuration file (v6.1-110-g8bec7efc-dirty)
# Encoding: UTF-8
# This file is managed by pihole-FTL
# Last updated on 2025-03-27 11:04:20 UTC
# Last updated on 2025-05-22 12:43:05 UTC
[dns]
# Array of upstream DNS servers used by Pi-hole
@@ -750,6 +750,17 @@
# <valid TLS certificate file (*.pem)>
cert = "/etc/pihole/test.pem" ### CHANGED, default = "/etc/pihole/tls.pem"
# Number of days the automatically generated self-signed TLS/SSL certificate will be
# valid for. Defaults to 730 days (= 2 years). A minimum of 7 days is enforced.
# Some devices may enforce shorter validity ranges. Note that defining a lower
# validity range may require you to accept the self-signed certificate more often in
# your browser.
# Pi-hole will regenerate certificates it created itself two days prior to expiration.
# If you are using your own certificate, you need to regenerate it yourself. In this
# case, it is advised to set the validity range to 0 days, so that Pi-hole does not
# try to regenerate your certificate.
validity = 730
[webserver.paths]
# Server root on the host
#
@@ -1202,7 +1213,7 @@
all = true ### CHANGED, default = false
# Configuration statistics:
# 155 total entries out of which 98 entries are default
# 156 total entries out of which 99 entries are default
# --> 57 entries are modified
# 3 entries are forced through environment:
# - misc.nice