Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
77 commits
Select commit Hold shift + click to select a range
3212368
Watch the other Pi-holes and hand DHCP over when one fails
DL6ER Aug 2, 2026
a8a2149
Keep the nodes' configuration and lists in step
DL6ER Aug 9, 2026
523f0e3
Make synchronization survive an upgrade, an interruption and a node l…
DL6ER Aug 16, 2026
39b5cb4
Decide DHCP and the virtual address on what a node can actually do
DL6ER Aug 23, 2026
b42c54e
Decide on what the cluster can see, not on what one node can poll
DL6ER Sep 5, 2026
1532b52
Synchronize the cluster under the config lock
DL6ER Oct 4, 2026
5583324
Build the cluster document without a connection
DL6ER Oct 19, 2026
e8d3f3c
Rename the cluster's `sha256_hex()` -> `body_sha256_hex()`
DL6ER Oct 19, 2026
2969d4b
Mark the `certificate_pin()` stub const
DL6ER Oct 19, 2026
8d5252d
Keep host-dependent settings off the cluster
DL6ER Oct 19, 2026
f24d0ad
Refuse a virtual IP that belongs to somebody else
DL6ER Oct 19, 2026
2d55fc6
Do not free an empty lease download in `take_leases()`
DL6ER Oct 19, 2026
965b207
Answer an older cluster push after releasing the config lock
DL6ER Oct 19, 2026
cc01dfb
Tell a cluster peer whether its push was taken
DL6ER Oct 19, 2026
b7dfbc4
Document that `?restart=false` does not reach the other cluster nodes
DL6ER Oct 19, 2026
a371e1b
Answer discovery only where `webserver.acl` admits the source
DL6ER Oct 19, 2026
c229737
Name the cluster list archive entry `etc/pihole/gravity.db`
DL6ER Oct 19, 2026
870e1cb
Advertise IPv6 cluster members through DHCPv6
DL6ER Oct 19, 2026
43c0f2c
Keep cluster stamps at full precision and treat equal ones as one ver…
DL6ER Oct 19, 2026
1ef5faf
Correct the cluster OpenAPI specification
DL6ER Oct 19, 2026
f1431ce
Say what clustering needs in the cluster help texts
DL6ER Oct 19, 2026
d4742ff
Retry `waitpid()` of the cluster gravity run on `EINTR`
DL6ER Oct 19, 2026
437e093
Close FTL's descriptors in the cluster gravity run
DL6ER Oct 19, 2026
ceff2c3
Log how a cluster gravity run ended
DL6ER Oct 19, 2026
fe43ad1
Stop the cluster round once FTL is stopping
DL6ER Oct 19, 2026
ee16cde
End the cluster's own gravity run when a stop is pending
DL6ER Oct 19, 2026
b907c6a
Settle a cluster gravity run where its outcome arrives
DL6ER Oct 19, 2026
bbd1c96
Hold cluster restarts while gravity runs
DL6ER Oct 19, 2026
b64024a
Let the cluster thread finish a locked step when FTL stops
DL6ER Oct 19, 2026
e3c2453
Wait for a gravity run before taking or rebuilding lists
DL6ER Oct 19, 2026
de7be44
Date cluster list edits by their API write, not by the round
DL6ER Oct 19, 2026
e45301b
Compare a cluster rebuild with the lists as imported
DL6ER Oct 19, 2026
1cfd1a0
Keep taking and dating lists while a cluster rebuild is owed
DL6ER Oct 19, 2026
e8797aa
Keep a node out of DHCP failover that cannot serve DHCP
DL6ER Oct 19, 2026
1c05caa
Do not hold a DHCP takeover back for the cluster's own gravity run
DL6ER Oct 19, 2026
715f5ef
Rebuild again when the lists stay unreadable after a cluster rebuild
DL6ER Oct 19, 2026
b8e8143
Stamp a configuration change only once `pihole.toml` holds it
DL6ER Oct 19, 2026
6394829
Show cluster problems on the web interface
DL6ER Oct 19, 2026
f177cd5
Stamp every synchronized setting on its own
DL6ER Oct 20, 2026
5e81f6e
Push only changes made here, and say which pushed items were kept
DL6ER Oct 20, 2026
b17c6e5
Read a signed cluster push up to 1 MiB
DL6ER Oct 20, 2026
22eef20
Warn when a peer's lists replace list edits no other node had
DL6ER Oct 20, 2026
bb849fa
Keep DHCP on the serving node and hold it back at start-up
DL6ER Oct 20, 2026
d1a119d
Require a quorum for DHCP and the virtual IP
DL6ER Oct 20, 2026
30d77b5
Announce the virtual IP every round while holding it
DL6ER Oct 20, 2026
e1664c1
Keep the virtual IP across an internal restart
DL6ER Oct 20, 2026
0ab81a3
Require a pin or `cluster.tls.ca` to join a cluster
DL6ER Oct 20, 2026
56fd542
Merge a serving node's leases instead of overwriting the copy
DL6ER Oct 20, 2026
758be4e
Spend a TOTP code once across the cluster
DL6ER Oct 20, 2026
36cb9db
Fix the log line for a push the peer did not take
DL6ER Oct 20, 2026
d9b3504
Explain a list edit by the tables it left, not by the database file
DL6ER Oct 20, 2026
eb1aa36
Warn when a gravity run swaps out a list edit made during it
DL6ER Oct 20, 2026
e2e5856
Defer to a member holding versions when this node holds none
DL6ER Oct 20, 2026
a486ec3
Hand a starting point over once per value
DL6ER Oct 20, 2026
39c9570
Read the configuration generations under the sync lock in `push_round()`
DL6ER Oct 20, 2026
0ffe6d1
Answer a cluster push that `pihole.toml` did not take as a failure
DL6ER Oct 20, 2026
f8cb751
Publish the lists on disk after a pull taken while a rebuild is owed
DL6ER Oct 20, 2026
5a881b5
Do not take a leftover `gravity.db_temp` for a running gravity
DL6ER Oct 20, 2026
2563d6e
Settle a cluster gravity run that finished before a stop could end it
DL6ER Oct 20, 2026
39a1b9c
Say in the `cluster.enabled` help how lists are synchronized
DL6ER Oct 20, 2026
a861360
Guard the frees in `cluster_leases_merge()`
DL6ER Oct 20, 2026
dde04d3
Prefer the serving node's leases when merging
DL6ER Oct 20, 2026
e1e5287
Keep the default IPv6 DNS servers without IPv6 members
DL6ER Oct 20, 2026
81b3a64
Refuse link-local, network and broadcast addresses as virtual IP
DL6ER Oct 20, 2026
456db0c
Hold DHCP back in the running configuration only
DL6ER Oct 20, 2026
35e5054
Count only the other member's polls, and what it serves
DL6ER Oct 20, 2026
4b663a7
Probe the link before claiming the virtual IP
DL6ER Oct 20, 2026
c563209
Move the virtual IP when `cluster.vip.interface` changes
DL6ER Oct 20, 2026
de59922
Mention the start-up hold-back in the failover help
DL6ER Oct 20, 2026
94e6b71
Read a large push only from a sender that signed here before
DL6ER Oct 20, 2026
cc42b00
Keep a password changed while a legacy login is upgraded
DL6ER Oct 21, 2026
837cde4
Tell the peers at once when a TOTP code is used
DL6ER Oct 21, 2026
8b23759
Send what this node serves with the 2FA notice too
DL6ER Oct 21, 2026
f1c82a8
Merge branch 'development' into new/cluster
DL6ER Oct 22, 2026
17e9bae
Merge branch 'development' into new/cluster
DL6ER Oct 26, 2026
980abec
Merge branch 'development' into new/cluster
DL6ER Oct 26, 2026
e0a0cc9
Merge branch 'development' into new/cluster
DL6ER Nov 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM ghcr.io/pi-hole/ftl-build:v2.25
FROM ghcr.io/pi-hole/ftl-build:v2.26.1

WORKDIR /app

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,6 @@ test/libs/
# Local assistant tooling artifacts
/.claude/settings.local.json
/.claude/test-logs/

# Cluster secret for the local test cluster
test/cluster/cluster_secret
31 changes: 31 additions & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ add_executable(pihole-FTL
$<TARGET_OBJECTS:tools>
$<TARGET_OBJECTS:ntp>
$<TARGET_OBJECTS:dotdoh>
$<TARGET_OBJECTS:cluster>
)
if(STATIC)
set_target_properties(pihole-FTL PROPERTIES LINK_SEARCH_START_STATIC ON)
Expand Down Expand Up @@ -410,6 +411,7 @@ add_subdirectory(config)
add_subdirectory(tools)
add_subdirectory(ntp)
add_subdirectory(dotdoh)
add_subdirectory(cluster)

# Standalone regression harness for the in-memory tar parser (zip/tar.c).
# It #includes tar.c directly to reach the file-internal helpers, stubs the
Expand Down Expand Up @@ -674,6 +676,35 @@ if(LIBSSL AND LIBCRYPTO AND USE_TLS)
message(STATUS "Building FTL with HTTP/2 support: NO (nghttp2 not found)")
endif()

# libcurl is the HTTP client used to talk to the other nodes of a cluster.
# It is built against the same OpenSSL as the rest of FTL
find_library(LIBCURL NAMES libcurl${LIBRARY_SUFFIX} curl)
if(LIBCURL)
message(STATUS "Building FTL with cluster support: YES")
target_compile_definitions(cluster PRIVATE HAVE_CURL)
target_compile_definitions(api PRIVATE HAVE_CURL)
# Inside a group with what it draws on: OpenSSL and nghttp2 are already
# on the link line, but ahead of libcurl, and a static archive only
# answers symbols that are still undefined when the linker reaches it.
# Whether that bites depends on what the archives happened to pull in
# earlier, which is not something to leave to chance
target_link_libraries(pihole-FTL
-Wl,--start-group ${LIBCURL} ${LIBSSL} ${LIBCRYPTO} ${LIBNGHTTP2} -Wl,--end-group)
# Static libcurl pulls in zlib for the content encodings it offers. The
# archive whichever way FTL itself is linked: a dynamic build would
# otherwise take the shared one and gain a runtime dependency
find_library(LIBZ NAMES libz.a libz${LIBRARY_SUFFIX} z)
if(LIBZ)
target_link_libraries(pihole-FTL ${LIBZ})
endif()
# ...and libidn2 for the internationalized hostnames it accepts
if(LIBIDN2)
target_link_libraries(pihole-FTL ${LIBIDN2})
endif()
else()
message(STATUS "Building FTL with cluster support: NO (libcurl not found)")
endif()

# HTTP/3 for the TLS terminator (OpenSSL QUIC + nghttp3). Requires nghttp3
# AND OpenSSL >= 4.0, which provides SSL_get_peer_addr() (the per-connection
# QUIC peer getter used to forward the real client IP). Against an older
Expand Down
33 changes: 33 additions & 0 deletions src/api/2fa.c
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,9 @@ static time_t last_attempt = 0;
// been accepted, that step and any earlier one can never be replayed, even
// though the code stays numerically valid across the -1..+1 acceptance window.
static uint64_t last_counter = 0;
// ...and the last one a login on this node accepted, which the cluster tells
// its peers about at once
static uint64_t local_counter = 0;
static pthread_mutex_t totp_lock = PTHREAD_MUTEX_INITIALIZER;
enum totp_status verifyTOTP(const uint32_t incode)
{
Expand Down Expand Up @@ -310,6 +313,7 @@ enum totp_status verifyTOTP(const uint32_t incode)
const char *which = i == -1 ? "previous" : i == 0 ? "current" : "next";
log_web_debug(DEBUG_API, "2FA code from %s time step is valid", which);
last_counter = counter;
local_counter = counter;
pthread_mutex_unlock(&totp_lock);
return TOTP_CORRECT;
}
Expand All @@ -327,6 +331,35 @@ enum totp_status verifyTOTP(const uint32_t incode)
return TOTP_INVALID;
}

// The last accepted time step, for the cluster to publish so a code used on one
// node is known to be spent on the others
uint64_t totp_last_counter(void)
{
pthread_mutex_lock(&totp_lock);
const uint64_t counter = last_counter;
pthread_mutex_unlock(&totp_lock);
return counter;
}

uint64_t totp_local_counter(void)
{
pthread_mutex_lock(&totp_lock);
const uint64_t counter = local_counter;
pthread_mutex_unlock(&totp_lock);
return counter;
}

// A time step a peer has already accepted. Raising this node's guard to it
// makes that step, and any earlier one, unusable here too - the same code
// cannot be replayed from node to node while it is still numerically valid
void totp_observe_counter(const uint64_t counter)
{
pthread_mutex_lock(&totp_lock);
if(counter > last_counter)
last_counter = counter;
pthread_mutex_unlock(&totp_lock);
}

// Print TOTP code to stdout (for CLI use)
int printTOTP(void)
{
Expand Down
1 change: 1 addition & 0 deletions src/api/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ set(sources
api.c
auth.c
auth.h
cluster.c
config.c
dhcp.c
dns.c
Expand Down
26 changes: 26 additions & 0 deletions src/api/api.c
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ static struct {
{ "/api/action/restartdns", "", api_action_restartDNS, { API_PARSE_JSON, 0 }, true, HTTP_POST },
{ "/api/action/flush/logs", "", api_action_flush_logs, { API_PARSE_JSON, 0 }, true, HTTP_POST },
{ "/api/action/flush/network", "", api_action_flush_network, { API_PARSE_JSON, 0 }, true, HTTP_POST },
{ "/api/cluster/lists", "", api_cluster_lists, { API_FLAG_NONE, 0 }, true, HTTP_GET },
{ "/api/cluster/leases", "", api_cluster_leases, { API_FLAG_NONE, 0 }, true, HTTP_GET },
{ "/api/cluster/discover", "", api_cluster_discover, { API_FLAG_NONE, 0 }, true, HTTP_GET },
{ "/api/cluster/enroll", "", api_cluster_enroll, { API_PARSE_JSON, 0 }, true, HTTP_POST },
{ "/api/cluster/join", "", api_cluster_join, { API_PARSE_JSON, 0 }, true, HTTP_POST },
{ "/api/cluster/leave", "", api_cluster_leave, { API_PARSE_JSON, 0 }, true, HTTP_POST },
{ "/api/cluster/status", "", api_cluster_status, { API_PARSE_JSON, 0 }, true, HTTP_GET },
{ "/api/padd", "", api_padd, { API_PARSE_JSON, 0 }, true, HTTP_GET },
{ "/api/docs", "", api_docs, { API_PARSE_JSON, 0 }, false, HTTP_GET },
};
Expand Down Expand Up @@ -215,6 +222,25 @@ int api_handler(struct mg_connection *conn, void *ignored)
break;
}

// A peer of our cluster reads what we publish about
// ourselves and hands us its configuration. It has no
// business anywhere else, so a stolen cluster secret
// reaches neither the password hashes in /api/config nor
// the Teleporter archive that carries them
const bool cluster_read = api.method == HTTP_GET &&
strncmp(api.request->local_uri_raw, "/api/cluster/", sizeof("/api/cluster/") - 1) == 0 &&
strcmp(api.request->local_uri_raw, "/api/cluster/discover") != 0;
const bool cluster_push = api.method == HTTP_PATCH &&
strcmp(api.request->local_uri_raw, "/api/config") == 0;
if(api.session.used && api.session.cluster && !cluster_read && !cluster_push)
{
ret = send_json_error(&api, 403,
"forbidden",
"Cluster requests are confined to synchronization",
"A peer reads /api/cluster and hands us its configuration through PATCH /api/config, and needs nothing beyond that");
break;
}

// Call the API function and get the return code
log_web_debug(DEBUG_API, "Processing %s %s in %s",
api.request->request_method,
Expand Down
21 changes: 20 additions & 1 deletion src/api/api.h
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,12 @@ int get_version_obj(struct ftl_conn *api, cJSON *version);
// Config methods
int api_config(struct ftl_conn *api);
int api_config_properties(struct ftl_conn *api);
int get_json_config(struct ftl_conn *api, cJSON *json, const bool detailed);
// cluster selects the document a peer is handed. encrypted says whether the
// connection it travels over hides it: the credentials are left out when it
// does not, because a signature keeps them from being changed but not from
// being read
int get_json_config(struct ftl_conn *api, cJSON *json, const bool detailed, const bool cluster,
const bool encrypted);
cJSON *addJSONConfValue(const enum conf_type conf_type, union conf_value *val);

// Log methods
Expand Down Expand Up @@ -116,6 +121,11 @@ enum totp_status {
TOTP_RATE_LIMIT
} __attribute__ ((packed));
enum totp_status verifyTOTP(const uint32_t code);
// The last accepted TOTP time step, shared across a cluster so a code accepted
// on one node cannot be replayed on another within its validity window
uint64_t totp_last_counter(void);
uint64_t totp_local_counter(void);
void totp_observe_counter(const uint64_t counter);
int generateTOTP(struct ftl_conn *api);
int printTOTP(void);
int generateAppPw(struct ftl_conn *api);
Expand All @@ -139,6 +149,15 @@ int api_search(struct ftl_conn *api);
int api_dhcp_leases_GET(struct ftl_conn *api);
int api_dhcp_leases_DELETE(struct ftl_conn *api);

// Cluster methods
int api_cluster_status(struct ftl_conn *api);
int api_cluster_discover(struct ftl_conn *api);
int api_cluster_enroll(struct ftl_conn *api);
int api_cluster_join(struct ftl_conn *api);
int api_cluster_lists(struct ftl_conn *api);
int api_cluster_leases(struct ftl_conn *api);
int api_cluster_leave(struct ftl_conn *api);

// PADD methods
int api_padd(struct ftl_conn *api);

Expand Down
11 changes: 11 additions & 0 deletions src/api/auth.c
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

#include "FTL.h"
#include "api/auth.h"
// cluster_verify_request()
#include "cluster/auth.h"
#include "webserver/http-common.h"
#include "webserver/json_macros.h"
#include "api/api.h"
Expand Down Expand Up @@ -185,6 +187,15 @@ static bool known_host(const char *host)
// Returns >= 0 for any valid authentication
int check_client_auth(struct ftl_conn *api, const bool is_api)
{
// A peer of our cluster signs every request with the shared secret
// instead of holding a session. This is checked before anything else so
// it works the same whether or not a web password is set here - but only
// for the API, as the pages of the web interface are not what the
// cluster secret unlocks and they are served without the confinement
// that keeps a peer to the two endpoints it needs
if(is_api && cluster_verify_request(api))
return API_AUTH_CLUSTER;

// When the pwhash is unset, authentication is disabled, but not for
// other websites using the API through a browser on the network
if(config.webserver.api.pwhash.v.s[0] == '\0')
Expand Down
2 changes: 2 additions & 0 deletions src/api/auth.h
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ struct session {
bool used;
bool app;
bool cli;
bool cluster;
long long cluster_seq; // the sequence the peer signed this request with
struct {
bool login;
bool mixed;
Expand Down
Loading
Loading