Skip to content

Add native HA clustering - #3069

Open
DL6ER wants to merge 77 commits into
developmentfrom
new/cluster
Open

DL6ER wants to merge 77 commits into
developmentfrom
new/cluster

Conversation

@DL6ER

@DL6ER DL6ER commented Sep 5, 2026 •

Copy link
Copy Markdown
Member

What does this implement/fix?

Native clustering of several Pi-holes, in FTL itself. The nodes keep their configuration and their lists in step, watch each other, hand DHCP over when the serving node fails, and move a virtual IP address to whichever node the clients should be talking to. There is no primary: every node is equal, a change made on any of them reaches the others within a round, and any node can be the one that is down.

  • The configuration and the seven list tables travel between the nodes. Every setting carries its own timestamp and a node takes only the ones it holds an older version of, so edits of different settings made on two nodes that were apart both survive. The lists travel as one set and the newer one wins, settled by node identity on a tie; a node says so when that replaces list edits only it had. A node that holds no versions at all defers to one that does, so a fresh node added by hand takes the configured node's settings and lists. What may not travel (anything that names one machine or depends on its hardware, the DHCP range, the cluster's own switches, a paused blocking state) is decided by a flag on each item, so pihole.toml stays the place where everything is visible. List changes that did not come through FTL's API (a restored gravity.db, sqlite3, a Teleporter import on the CLI) are foreign to the cluster, and its lists replace them.
  • Every request between nodes is signed with HMAC-SHA256 over a key derived from one shared secret; the answers are signed back. A peer may read /api/cluster/... and push a configuration, and nothing else. Joining needs the fingerprint of the node joined, copied from its cluster page, unless certificates are checked against cluster.tls.ca; from there the nodes learn each other's pins from signed answers. A two-factor code used on one node is refused on the others a moment later.
  • DHCP failover: the serving node keeps DHCP, the others merge its leases into their copy and take over after two missed rounds, but only with a majority of the cluster behind them - in a pair, only once the other node has stopped polling them. A serving node that cannot poll its standby keeps DHCP while the standby polls it and says it serves nothing. Both elections are decided on what the cluster can see rather than on what one node can poll, so a single broken link does not put a second server on the range. Two servers or two address holders that persist are reported.
  • The virtual address follows the node handing out leases, or the leader where nobody does, is announced every round with a gratuitous ARP or an unsolicited neighbor advertisement, and stays in place while FTL restarts itself. An address that a member or another host already uses is refused.
  • Problems that need the administrator, e.g., a failed gravity run, a peer not taking the configuration or a node that cannot serve DHCP, also show in the web interface's message list.

Five commits, one per step: watching and failing over; keeping configuration and lists in step; surviving upgrades, interruptions and a node leaving; deciding DHCP and the address on what a node can actually do; deciding on the merged view. The last carries the fixes of the final review, the smaller commits after them what further reviews found.

The libcurl it needs is in the build image since pi-hole/docker-base-images#188 (ftl-build:v2.26.1).

How to test the change during review

The manual (pi-hole/docs#1429) has a three-container docker-compose.yml under "Trying it out with Docker"; changing a setting on one node and reading it on another is the whole of the first test, and stopping a node, changing something and starting it again is the second. The HA cluster page (pi-hole/web#3864) shows who reaches whom and whose fingerprints differ.

In CI, test/api/test_y_cluster.py covers the status document, signed and refused requests, replay and time-window refusals, that a peer's PATCH /api/config is answered without credentials over TLS, a peer that holds no configuration yet, which items of a push are taken, a join without a fingerprint, reported conflicts, whose polls count for a member, a push that could not be written, a large push from an unknown sender, a two-factor code used on a peer, list edits in a swapped gravity.db, and which virtual addresses are refused. The rest of the suite runs unchanged.

DHCP failover and the virtual address need real machines (a bridge carries neither broadcasts nor a floating address); I have exercised them in containers with NET_ADMIN and NET_RAW only, which reaches the netlink and election halves but not the announcement or a killed FTL leaving an address behind. I reproduced and verified the gravity, shutdown, synchronization and failover fixes with two and three nodes on an internal bridge, with core's gravity.sh and a local list server.

Additional information

Related issue or feature (if applicable): pi-hole/pi-hole#6734 keeps list edits made while gravity runs

Pull request in docs with documentation (if applicable): pi-hole/docs#1429, with the web page in pi-hole/web#3864


By submitting this pull request, I confirm the following:

  1. I have read and understood the contributors guide, as well as this entire template. I understand which branch to base my commits and Pull Requests against.
  2. I have commented my proposed changes within the code.
  3. I am willing to help maintain this change if there are issues with it later.
  4. It is compatible with the EUPL 1.2 license
  5. I have squashed any insignificant commits. (git rebase)
  6. My change does not modify src/dnsmasq/. That tree is a verbatim copy of upstream dnsmasq and we do not carry anything in it that deviates from upstream. Fixes have to go through the dnsmasq-discuss mailing list first, we merge them once they are in dnsmasq master.

Checklist:

  • The code change is tested and works locally.
  • I based my code and PRs against the repository's development branch.
  • I signed off all commits. Pi-hole enforces the DCO for all contributions
  • I signed all my commits. Pi-hole requires signatures to verify authorship
  • I have read the above and my PR is ready for review.

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

This pull request has conflicts, please resolve those before we can evaluate the pull request.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Conflicts have been resolved.

Comment thread src/config/config.c Fixed
Comment thread src/config/password.c Dismissed
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

This pull request has conflicts, please resolve those before we can evaluate the pull request.

1 similar comment
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

This pull request has conflicts, please resolve those before we can evaluate the pull request.

@github-actions

Copy link
Copy Markdown

Conflicts have been resolved.

@github-actions

Copy link
Copy Markdown

This pull request has conflicts, please resolve those before we can evaluate the pull request.

1 similar comment
@github-actions

Copy link
Copy Markdown

This pull request has conflicts, please resolve those before we can evaluate the pull request.

@github-actions

Copy link
Copy Markdown

Conflicts have been resolved.

@github-actions

Copy link
Copy Markdown

This pull request has conflicts, please resolve those before we can evaluate the pull request.

1 similar comment
@github-actions

Copy link
Copy Markdown

This pull request has conflicts, please resolve those before we can evaluate the pull request.

@github-actions

Copy link
Copy Markdown

Conflicts have been resolved.

@github-actions

Copy link
Copy Markdown

This pull request has conflicts, please resolve those before we can evaluate the pull request.

@github-actions

Copy link
Copy Markdown

Conflicts have been resolved.

DL6ER added 5 commits October 21, 2026 01:21
Signed-off-by: DL6ER <dl6er@dl6er.de>
The 1 MiB buffer for a signed `PATCH /api/config` was granted to any request carrying an `X-FTL-Cluster-Sig` header, before anything about it was checked. The header checks now run first, and the larger buffer is kept for a sender that has already presented a valid signature here, addressed to this node with a sequence it has not used yet. Everybody else gets the regular limit.

A peer polls a node before it pushes to it, so the only push this refuses is one above the regular limit that arrives between a restart of this node and the sender's next poll. The catch-up after that poll hands it over.

Signed-off-by: DL6ER <dl6er@dl6er.de>
`upgrade_stored_password()` hashes the password with BALLOON before it takes the lock, and installed the result whatever the stored hash had become meanwhile. A password changed through the API, or taken from a peer, in that window was replaced by the upgraded old one, and the new password stopped working.

The stored hash is now compared with the legacy hash of this password under the lock, and the upgrade is skipped when it no longer matches.

Signed-off-by: DL6ER <dl6er@dl6er.de>
A peer learned the step a node had accepted only from its next poll of that node, so the same code stayed usable on the other nodes for up to one `cluster.interval`. The node that accepts a code now sends the step to its peers in the next tick, as a `totp` parameter of a signed `GET /api/cluster/status`, and a peer adopts it on the same terms as the published one: the second-factor secret is shared and the clocks agree.

Measured with two nodes at interval 5: the same code was accepted on the second node 0.06, 0.2 and 1 s after the first had taken it, and is now refused from about 0.1 s on. The help text of `cluster.sync.credentials` claimed a code spends once across the cluster, it now says what holds.

Signed-off-by: DL6ER <dl6er@dl6er.de>
The notice that a 2FA code was used is a signed status request, which the
peer counts as a poll. Without the `dhcp` and `vip` flags it counted as one
from a node that may serve, so a login on the standby could make a server
that cannot poll it take it for a second server.

Signed-off-by: DL6ER <dl6er@dl6er.de>
Comment thread src/cluster/vip.c
break;

const double until = double_time() + 0.25;
for(double left = 0.25; left > 0.0; left = until - double_time())
@github-actions

Copy link
Copy Markdown

This pull request has conflicts, please resolve those before we can evaluate the pull request.

Signed-off-by: DL6ER <dl6er@dl6er.de>
@github-actions

Copy link
Copy Markdown

Conflicts have been resolved.

@github-actions

Copy link
Copy Markdown

This pull request has conflicts, please resolve those before we can evaluate the pull request.

Signed-off-by: DL6ER <dl6er@dl6er.de>
@github-actions

Copy link
Copy Markdown

Conflicts have been resolved.

@github-actions

Copy link
Copy Markdown

This pull request has conflicts, please resolve those before we can evaluate the pull request.

Signed-off-by: DL6ER <dl6er@dl6er.de>
@github-actions

Copy link
Copy Markdown

Conflicts have been resolved.

@github-actions

github-actions Bot commented Nov 1, 2026

Copy link
Copy Markdown

This pull request has conflicts, please resolve those before we can evaluate the pull request.

1 similar comment
@github-actions

github-actions Bot commented Nov 1, 2026

Copy link
Copy Markdown

This pull request has conflicts, please resolve those before we can evaluate the pull request.

Signed-off-by: DL6ER <dl6er@dl6er.de>
@github-actions

github-actions Bot commented Nov 1, 2026

Copy link
Copy Markdown

Conflicts have been resolved.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants