Skip to content

Commit 91d23ef

Browse files
committed
webserver: serve every configured TLS port, and DoH behind a reverse proxy
Three gaps in how the front terminator exposes TLS: 1. `split_terminator_ports()` only ever captured the *first* secure entry of `webserver.port`. Every further "...s" entry was still dropped from the list handed to CivetWeb, so nothing served it and nothing reported it. A documented configuration such as "80or,443os,8080,4443s" - the example in the `webserver.port` help text itself - silently lost 4443. The terminator now takes an array of listeners and its accept thread polls all of them, so every configured TLS port is served and reported in the API port list. Entries that would bind the same socket are collapsed first: the default "443os,[::]:443os" names one dual-stack socket and then its IPv6 half, and binding both is simply `EADDRINUSE`. HTTP/3 stays on the first port, as the QUIC listener is a single socket - Alt-Svc names the HTTP/3 port explicitly, so advertising it from the other ports remains correct. 2. The IPv4 and IPv6 halves of one port could not coexist. `fill_bind_addr()` maps "0.0.0.0" to the v4-mapped `::ffff:0.0.0.0`, which accepts IPv4 only, while every socket was bound dual-stack - so "0.0.0.0:443s,[::]:443s" served IPv4 only, with no way to express the split. `IPV6_V6ONLY` is now set for an explicit IPv6 literal and cleared otherwise, so the two halves bind as separate sockets. This is a behavior change: a configuration naming *only* a bracketed TLS entry, e.g. "[::]:443s", no longer answers over IPv4 through v4-mapped addresses. That matches what `webserver.port` documents ("[::]:80" is IPv6 only) and what CivetWeb does for plaintext ports; the default port list is unaffected, as it names both a bare and a bracketed entry. 3. Plaintext `/dns-query` was always refused with 426, leaving no way to put Pi-hole's DoH behind an external TLS-terminating reverse proxy. The new `dns.dohReverseProxy` (off by default) serves DoH on the plaintext `webserver.port` entries for exactly that deployment: the client-facing connection is still HTTPS, terminated at the proxy, which then forwards plain HTTP over a trusted hop. With the option off, the 426 is unchanged. Queries arriving this way are attributed to the proxy rather than to the end client. Honoring `X-Forwarded-For` would need a trusted-proxy list to not be spoofable, so that is left for later. Signed-off-by: DL6ER <dl6er@dl6er.de>
1 parent 0af0d74 commit 91d23ef

7 files changed

Lines changed: 386 additions & 110 deletions

File tree

src/api/docs/content/specs/config.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,8 @@ components:
299299
type: string
300300
doh:
301301
type: boolean
302+
dohReverseProxy:
303+
type: boolean
302304
dot:
303305
type: boolean
304306
blocking:
@@ -774,6 +776,7 @@ components:
774776
upstreams: [ "127.0.0.1#5353", "8.8.8.8" ]
775777
upstreamCA: ""
776778
doh: true
779+
dohReverseProxy: false
777780
dot: true
778781
CNAMEdeepInspect: true
779782
blockESNI: true

src/config/config.c

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,13 @@ void initConfig(struct config *conf)
618618
conf->dns.doh.f = FLAG_RESTART_FTL;
619619
conf->dns.doh.c = validate_stub;
620620

621+
conf->dns.dohReverseProxy.k = "dns.dohReverseProxy";
622+
conf->dns.dohReverseProxy.h = "Also serve DoH on the plaintext (non-TLS) webserver.port entries. Enable this only when an external reverse proxy (nginx, Traefik, Caddy, HAProxy, ...) terminates TLS in front of Pi-hole and forwards plain HTTP to it over a trusted hop such as localhost or a protected LAN segment. The client-facing connection stays HTTPS; the proxy handles the encryption. Disabled by default: without such a proxy this would expose DNS queries in cleartext to anyone on the path, which is exactly what DoH exists to prevent. Bind the plaintext port to the proxy's network only, e.g. webserver.port = \"127.0.0.1:8080,443s\".";
623+
conf->dns.dohReverseProxy.t = CONF_BOOL;
624+
conf->dns.dohReverseProxy.d.b = false;
625+
conf->dns.dohReverseProxy.f = FLAG_RESTART_FTL;
626+
conf->dns.dohReverseProxy.c = validate_stub;
627+
621628
conf->dns.dot.k = "dns.dot";
622629
conf->dns.dot.h = "Enable the inbound DNS-over-TLS (DoT) server on port 853. When enabled, FTL terminates DoT connections directly (RFC 7858) so downstream clients can use this Pi-hole as their encrypted resolver. Requires a valid TLS certificate (the same one configured for the webserver).";
623630
conf->dns.dot.t = CONF_BOOL;

src/config/config.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ struct config {
157157
struct conf_item revServers;
158158
struct conf_item upstreamCA;
159159
struct conf_item doh;
160+
struct conf_item dohReverseProxy;
160161
struct conf_item dot;
161162
struct {
162163
struct conf_item name;

src/webserver/terminator.c

Lines changed: 135 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,8 @@ static uint64_t mono_ms(void)
219219
// Terminator state. There is a single terminator instance for the whole
220220
// process, mirroring the single CivetWeb context in webserver.c.
221221
static SSL_CTX *ssl_ctx = NULL;
222-
static int listen_fd = -1;
222+
static int listen_fds[TERMINATOR_MAX_LISTENERS];
223+
static unsigned n_listen_fds = 0;
223224
static int backend_port = 0;
224225
static volatile bool running = false;
225226
static pthread_t accept_tid;
@@ -309,11 +310,11 @@ static SSL_CTX *create_server_ctx(const char *cert_path)
309310
return c;
310311
}
311312

312-
// Bind a dual-stack (IPv4 + IPv6) TCP listener on the given port, all
313-
// interfaces. Returns the fd or -1.
313+
// Bind a TCP listener for the given address and port: dual-stack for a bare
314+
// port, IPv6-only for an explicit IPv6 literal. Returns the fd or -1.
314315
// Fill a dual-stack sockaddr_in6 for the given bind address and port. addr may be
315316
// NULL or empty (bind all interfaces), an IPv6 literal, or an IPv4 literal (bound
316-
// as a v4-mapped address on the IPv6 socket, honouring IPV6_V6ONLY=off). Returns
317+
// as a v4-mapped address on the IPv6 socket). Returns
317318
// false on an unparsable address so the caller fails closed rather than silently
318319
// widening the scope to all interfaces.
319320
static bool fill_bind_addr(struct sockaddr_in6 *sa, const char *addr, int port)
@@ -339,6 +340,35 @@ static bool fill_bind_addr(struct sockaddr_in6 *sa, const char *addr, int port)
339340
return false;
340341
}
341342

343+
// EAGAIN and EWOULDBLOCK are the same value on Linux; test both only where they
344+
// actually differ so -Wlogical-op stays quiet.
345+
#if defined(EWOULDBLOCK) && EWOULDBLOCK != EAGAIN
346+
#define WOULDBLOCK(e) ((e) == EAGAIN || (e) == EWOULDBLOCK)
347+
#else
348+
#define WOULDBLOCK(e) ((e) == EAGAIN)
349+
#endif
350+
351+
// Whether addr is an explicit IPv6 literal. Such an entry is bound IPv6-only, so
352+
// "0.0.0.0:443" and "[::]:443" can coexist as the two halves of one port, while a
353+
// bare port (empty addr) stays dual-stack and covers both. This matches the
354+
// webserver.port semantics documented for CivetWeb, where "[::]:80" is IPv6 only.
355+
static bool addr_is_v6_literal(const char *addr)
356+
{
357+
struct in6_addr tmp;
358+
return addr != NULL && addr[0] != '\0' && inet_pton(AF_INET6, addr, &tmp) == 1;
359+
}
360+
361+
// Put fd into non-blocking mode. Returns 0 or -1. Used by the TCP and QUIC
362+
// listeners as well as the HTTP/2 and HTTP/3 gateways, so it lives outside their
363+
// conditional blocks.
364+
static int set_nonblocking(int fd)
365+
{
366+
const int fl = fcntl(fd, F_GETFL, 0);
367+
if(fl < 0)
368+
return -1;
369+
return fcntl(fd, F_SETFL, fl | O_NONBLOCK);
370+
}
371+
342372
static int bind_listener(const char *addr, int port)
343373
{
344374
// SOCK_CLOEXEC so the fd is not inherited across FTL's execvp() self-restart,
@@ -352,9 +382,10 @@ static int bind_listener(const char *addr, int port)
352382

353383
const int on = 1;
354384
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
355-
// Accept both IPv4 (as v4-mapped) and IPv6 on this single socket.
356-
const int off = 0;
357-
setsockopt(fd, IPPROTO_IPV6, IPV6_V6ONLY, &off, sizeof(off));
385+
// A bare port accepts both IPv4 (as v4-mapped) and IPv6 on this one socket;
386+
// an explicit IPv6 literal is bound IPv6-only.
387+
const int v6only = addr_is_v6_literal(addr) ? 1 : 0;
388+
setsockopt(fd, IPPROTO_IPV6, IPV6_V6ONLY, &v6only, sizeof(v6only));
358389

359390
struct sockaddr_in6 sa;
360391
if(!fill_bind_addr(&sa, addr, port))
@@ -376,6 +407,14 @@ static int bind_listener(const char *addr, int port)
376407
close(fd);
377408
return -1;
378409
}
410+
// Non-blocking: the accept loop polls several listeners, so draining one must
411+
// never block the others. Accepted sockets do not inherit this.
412+
if(set_nonblocking(fd) != 0)
413+
{
414+
log_err("Terminator: set_nonblocking() on port %d failed: %s", port, strerror(errno));
415+
close(fd);
416+
return -1;
417+
}
379418
return fd;
380419
}
381420

@@ -1023,23 +1062,6 @@ static void terminator_h1_serve(SSL *ssl, int client_fd)
10231062
// backend socket, growing byte buffer, and hop-by-hop header filter are common.
10241063
// ---------------------------------------------------------------------------
10251064

1026-
// EAGAIN and EWOULDBLOCK are the same value on Linux; test both only where they
1027-
// actually differ so -Wlogical-op stays quiet.
1028-
#if defined(EWOULDBLOCK) && EWOULDBLOCK != EAGAIN
1029-
#define WOULDBLOCK(e) ((e) == EAGAIN || (e) == EWOULDBLOCK)
1030-
#else
1031-
#define WOULDBLOCK(e) ((e) == EAGAIN)
1032-
#endif
1033-
1034-
// Put fd into non-blocking mode. Returns 0 or -1.
1035-
static int set_nonblocking(int fd)
1036-
{
1037-
const int fl = fcntl(fd, F_GETFL, 0);
1038-
if(fl < 0)
1039-
return -1;
1040-
return fcntl(fd, F_SETFL, fl | O_NONBLOCK);
1041-
}
1042-
10431065
// Open a non-blocking plaintext TCP socket to the CivetWeb backend on loopback.
10441066
// Sets *connected to true if connect() completed immediately (common on
10451067
// loopback), false if still in progress (EINPROGRESS). Returns the fd or -1.
@@ -3935,8 +3957,9 @@ static int bind_udp(const char *addr, int port)
39353957
}
39363958
const int on = 1;
39373959
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
3938-
const int off = 0;
3939-
setsockopt(fd, IPPROTO_IPV6, IPV6_V6ONLY, &off, sizeof(off));
3960+
// Same IPv4/IPv6 scoping rule as the TCP listener above.
3961+
const int v6only = addr_is_v6_literal(addr) ? 1 : 0;
3962+
setsockopt(fd, IPPROTO_IPV6, IPV6_V6ONLY, &v6only, sizeof(v6only));
39403963

39413964
struct sockaddr_in6 sa;
39423965
if(!fill_bind_addr(&sa, addr, port))
@@ -4161,30 +4184,28 @@ static void *handle_conn(void *arg)
41614184
return NULL;
41624185
}
41634186

4164-
// Accept loop thread: hand each accepted connection to a detached handler.
4165-
static void *accept_loop(void *arg)
4187+
// Accept everything pending on one ready listener, handing each connection to a
4188+
// detached handler. Returns false if the listener is gone and we should stop.
4189+
static bool accept_ready(int lfd, pthread_attr_t *attr)
41664190
{
4167-
(void)arg;
4168-
prctl(PR_SET_NAME, "terminator", 0, 0, 0);
4169-
4170-
pthread_attr_t attr;
4171-
pthread_attr_init(&attr);
4172-
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
4173-
4174-
while(running)
4191+
for(;;)
41754192
{
41764193
struct sockaddr_storage peer;
41774194
socklen_t plen = sizeof(peer);
4178-
const int client_fd = accept4(listen_fd, (struct sockaddr *)&peer, &plen, SOCK_CLOEXEC);
4195+
const int client_fd = accept4(lfd, (struct sockaddr *)&peer, &plen, SOCK_CLOEXEC);
41794196
if(client_fd < 0)
41804197
{
41814198
if(errno == EINTR)
41824199
continue;
4200+
if(WOULDBLOCK(errno))
4201+
return running; // backlog drained
41834202
if(!running)
4184-
break; // listener shut down by terminator_stop()
4185-
// Transient error (e.g. EMFILE); avoid a tight spin.
4203+
return false; // listener shut down by terminator_stop()
4204+
// Transient error (e.g. EMFILE). A failed accept() leaves the
4205+
// connection queued, so poll() would report this listener ready
4206+
// again immediately; back off instead of spinning on it.
41864207
poll(NULL, 0, 100);
4187-
continue;
4208+
return true;
41884209
}
41894210

41904211
// Cap concurrent handlers so a connection flood cannot exhaust the
@@ -4225,7 +4246,7 @@ static void *accept_loop(void *arg)
42254246
ha->ctx = ssl_ctx;
42264247

42274248
pthread_t tid;
4228-
if(pthread_create(&tid, &attr, handle_conn, ha) != 0)
4249+
if(pthread_create(&tid, attr, handle_conn, ha) != 0)
42294250
{
42304251
log_err("Terminator: pthread_create() failed: %s", strerror(errno));
42314252
SSL_CTX_free(ha->ctx);
@@ -4235,20 +4256,61 @@ static void *accept_loop(void *arg)
42354256
close(client_fd);
42364257
}
42374258
}
4259+
}
4260+
4261+
// Accept loop thread: poll every public TLS listener and service whichever are
4262+
// ready, draining each in turn.
4263+
static void *accept_loop(void *arg)
4264+
{
4265+
(void)arg;
4266+
prctl(PR_SET_NAME, "terminator", 0, 0, 0);
4267+
4268+
pthread_attr_t attr;
4269+
pthread_attr_init(&attr);
4270+
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
4271+
4272+
while(running)
4273+
{
4274+
struct pollfd pfds[TERMINATOR_MAX_LISTENERS];
4275+
for(unsigned i = 0; i < n_listen_fds; i++)
4276+
{
4277+
pfds[i].fd = listen_fds[i];
4278+
pfds[i].events = POLLIN;
4279+
pfds[i].revents = 0;
4280+
}
4281+
// Bounded wait so terminator_stop() is noticed even if no client connects.
4282+
const int pr = poll(pfds, n_listen_fds, 100);
4283+
if(pr <= 0)
4284+
{
4285+
if(pr < 0 && errno != EINTR)
4286+
poll(NULL, 0, 100); // transient error, avoid a tight spin
4287+
continue;
4288+
}
4289+
for(unsigned i = 0; i < n_listen_fds && running; i++)
4290+
if(pfds[i].revents != 0 && !accept_ready(pfds[i].fd, &attr))
4291+
break;
4292+
}
42384293

42394294
pthread_attr_destroy(&attr);
42404295
return NULL;
42414296
}
42424297

4243-
bool terminator_start(const char *bind_addr, int public_port, int be_port, const char *cert_path)
4298+
bool terminator_start(const struct terminator_listener *listeners, unsigned n_listeners,
4299+
int be_port, const char *cert_path)
42444300
{
42454301
if(running)
42464302
{
42474303
log_warn("Terminator: already running");
42484304
return false;
42494305
}
4250-
if(cert_path == NULL || public_port <= 0 || be_port <= 0)
4306+
if(cert_path == NULL || listeners == NULL || n_listeners == 0 || be_port <= 0)
42514307
return false;
4308+
if(n_listeners > TERMINATOR_MAX_LISTENERS)
4309+
{
4310+
log_warn("Terminator: %u TLS ports configured, serving only the first %d",
4311+
n_listeners, TERMINATOR_MAX_LISTENERS);
4312+
n_listeners = TERMINATOR_MAX_LISTENERS;
4313+
}
42524314

42534315
// Per-boot secret authenticating our PROXY headers to the loopback backend.
42544316
// Usually already generated by webserver.c (it passes the hex form to the
@@ -4264,8 +4326,16 @@ bool terminator_start(const char *bind_addr, int public_port, int be_port, const
42644326
if(ssl_ctx == NULL)
42654327
return false;
42664328

4267-
listen_fd = bind_listener(bind_addr, public_port);
4268-
if(listen_fd < 0)
4329+
// Bind every configured TLS port. A port that cannot be bound is reported and
4330+
// skipped rather than taking the others down with it.
4331+
n_listen_fds = 0;
4332+
for(unsigned i = 0; i < n_listeners; i++)
4333+
{
4334+
const int fd = bind_listener(listeners[i].addr, listeners[i].port);
4335+
if(fd >= 0)
4336+
listen_fds[n_listen_fds++] = fd;
4337+
}
4338+
if(n_listen_fds == 0)
42694339
{
42704340
SSL_CTX_free(ssl_ctx);
42714341
ssl_ctx = NULL;
@@ -4278,21 +4348,26 @@ bool terminator_start(const char *bind_addr, int public_port, int be_port, const
42784348
{
42794349
log_err("Terminator: failed to start accept thread: %s", strerror(errno));
42804350
running = false;
4281-
close(listen_fd);
4282-
listen_fd = -1;
4351+
for(unsigned i = 0; i < n_listen_fds; i++)
4352+
close(listen_fds[i]);
4353+
n_listen_fds = 0;
42834354
SSL_CTX_free(ssl_ctx);
42844355
ssl_ctx = NULL;
42854356
return false;
42864357
}
42874358
accept_tid_valid = true;
42884359

4289-
log_info("TLS terminator listening on %s#%d, forwarding to 127.0.0.1:%d",
4290-
(bind_addr && bind_addr[0]) ? bind_addr : "*", public_port, be_port);
4360+
for(unsigned i = 0; i < n_listeners; i++)
4361+
log_info("TLS terminator listening on %s#%d, forwarding to 127.0.0.1:%d",
4362+
(listeners[i].addr && listeners[i].addr[0]) ? listeners[i].addr : "*",
4363+
listeners[i].port, be_port);
42914364

42924365
#ifdef HAVE_HTTP3
4293-
// Serve HTTP/3 over QUIC on the same public port (UDP). Optional: on failure
4294-
// the terminator keeps serving HTTP/1.1 and (if built) HTTP/2 over TCP.
4295-
terminator_quic_start(bind_addr, public_port, cert_path);
4366+
// Serve HTTP/3 over QUIC on the first public port (UDP). The QUIC listener is
4367+
// a single socket, so additional TLS ports are TCP-only; Alt-Svc names the
4368+
// HTTP/3 port explicitly, so pointing at it from any of them stays correct.
4369+
// Optional: on failure the terminator keeps serving HTTP/1.1 and HTTP/2.
4370+
terminator_quic_start(listeners[0].addr, listeners[0].port, cert_path);
42964371
#endif
42974372
return true;
42984373
}
@@ -4303,23 +4378,22 @@ void terminator_stop(void)
43034378
terminator_quic_stop();
43044379
#endif
43054380

4306-
if(!running && listen_fd < 0 && ssl_ctx == NULL)
4381+
if(!running && n_listen_fds == 0 && ssl_ctx == NULL)
43074382
return;
43084383

43094384
running = false;
4310-
// Break the blocking accept4() so the accept thread can exit.
4311-
if(listen_fd >= 0)
4312-
shutdown(listen_fd, SHUT_RDWR);
4385+
// The accept loop polls with a bounded timeout, so clearing `running` is
4386+
// enough for it to exit; shutdown() only makes it prompt.
4387+
for(unsigned i = 0; i < n_listen_fds; i++)
4388+
shutdown(listen_fds[i], SHUT_RDWR);
43134389
if(accept_tid_valid)
43144390
{
43154391
pthread_join(accept_tid, NULL);
43164392
accept_tid_valid = false;
43174393
}
4318-
if(listen_fd >= 0)
4319-
{
4320-
close(listen_fd);
4321-
listen_fd = -1;
4322-
}
4394+
for(unsigned i = 0; i < n_listen_fds; i++)
4395+
close(listen_fds[i]);
4396+
n_listen_fds = 0;
43234397
if(ssl_ctx != NULL)
43244398
{
43254399
// Drops our reference only. Each in-flight handler holds its own ref

src/webserver/terminator.h

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,24 @@
1717
#include <stdbool.h>
1818
#include <stddef.h> // size_t
1919

20-
// Start the TLS terminator: bind public_port on bind_addr (NULL/"" = all
21-
// interfaces, else an IPv4/IPv6 literal), terminate TLS with the PEM (cert + key)
22-
// at cert_path, and forward accepted connections as plain HTTP/1.1 to
23-
// 127.0.0.1:backend_port. Spawns an accept thread. Returns true on success; on
24-
// failure nothing is left running.
25-
bool terminator_start(const char *bind_addr, int public_port, int backend_port, const char *cert_path);
20+
// Upper bound on the public TLS ports the terminator serves at once. More
21+
// secure entries than this in webserver.port are reported and ignored.
22+
#define TERMINATOR_MAX_LISTENERS 8
23+
24+
// One public TLS listener: the port and the address it is scoped to (NULL or ""
25+
// for all interfaces, else an IPv4/IPv6 literal).
26+
struct terminator_listener {
27+
const char *addr;
28+
int port;
29+
};
30+
31+
// Start the TLS terminator on every entry of listeners: terminate TLS with the
32+
// PEM (cert + key) at cert_path and forward accepted connections as plain
33+
// HTTP/1.1 to 127.0.0.1:backend_port. Spawns one accept thread serving all of
34+
// them. HTTP/3 is served on the first entry only. Returns true if at least one
35+
// listener came up; on total failure nothing is left running.
36+
bool terminator_start(const struct terminator_listener *listeners, unsigned n_listeners,
37+
int backend_port, const char *cert_path);
2638

2739
// Stop the terminator and free all resources. Safe to call if never started or already stopped.
2840
void terminator_stop(void);

0 commit comments

Comments
 (0)