Skip to content

clusterer_controller: zero-config HA clusterer control module with automatic sharing tag management via encrypted UDP multicast#4074

Open
Lt-Flash wants to merge 12 commits into
OpenSIPS:masterfrom
Lt-Flash:feature/clusterer-controller-devel
Open

clusterer_controller: zero-config HA clusterer control module with automatic sharing tag management via encrypted UDP multicast#4074
Lt-Flash wants to merge 12 commits into
OpenSIPS:masterfrom
Lt-Flash:feature/clusterer-controller-devel

Conversation

@Lt-Flash

@Lt-Flash Lt-Flash commented Jul 13, 2026

Copy link
Copy Markdown

This PR adds clusterer_controller, a module that drives the existing clusterer module's topology automatically at runtime — a zero-config HA control plane. No database, static node lists, or per-node config: every node runs an identical config, the only difference being the BIN socket IP.

It is strictly opt-in, per cluster. A clusterer cluster is controller-managed only when it has a cluster_options "cluster_id=N, use_controller=1" line; every other cluster (DB-backed or static) behaves exactly as before. The module is excluded from the default build (enable via include_modules), and when it is not built the clusterer module is byte-for-byte the stock upstream one (verified with unifdef against the base).

The full reference — every parameter, MI command, script variable/function, the node state machine, and the hybrid topologies — lives in the module docs (clusterer_controller_admin.xml / the regenerated README). This description is the summary.

How it works

Nodes discover each other over authenticated, encrypted UDP multicast and elect a master deterministically (highest IP; a master_stickiness default keeps a live master in place so joins cause no handover). Roles are master / backup / member, the backup always being the highest-IP non-master. The master allocates a node_id to every member and feeds identity + peer list into clusterer at runtime through a new clusterer_ctrl API — so every existing clusterer consumer (dialog, usrloc, dispatcher, …) keeps working with no changes.

The cluster session key is generated once at bootstrap and preserved across every master change, so failover (backup promoted in ~3 s on a GOODBYE or a missed 1 s MASTER_ALIVE) needs no re-keying and no re-join storm.

Split-brain is prevented at join (simultaneous starters defer to the highest-IP one, so independent-key lone masters never form) and healed at runtime (same-key masters yield by IP; divergent-key masters converge via a bootstrap-key MASTER_BEACON).

Minimal config (identical on every node, only the BIN socket IP differs)

socket=bin:10.22.23.191:3857
loadmodule "clusterer.so"
modparam("clusterer", "cluster_options", "cluster_id=1, use_controller=1")
modparam("clusterer", "sharing_tag", "vip1/1=active")
loadmodule "clusterer_controller.so"
modparam("clusterer_controller", "cluster",  "id=1,multicast=239.0.90.1:3333")
modparam("clusterer_controller", "password", "MyStr0ngPassw0rd!")

With manage_shtags=1 (default) the master is the sole authority for sharing-tag failover — no event routes or MI needed; cl_ctr_shtag_force / cl_ctr_shtag_auto give a manual override.

Hybrid & multiple clusters

One instance can run native (DB/static) clusters and controller-managed clusters side by side, and several controller clusters at once (isolated by multicast group and/or port, plus a cleartext cluster_id in every packet, matched before decryption). A cluster_id is exclusively one kind — declaring the same id both ways is rejected at startup. Node identity is per-cluster (a node may be node_id=1 in one cluster and 2 in another), so hybrids and multi-controller-cluster instances replicate correctly — validated live.

Security

  • Each packet's payload is sealed with XChaCha20-Poly1305 (libsodium); the 2-byte magic and cluster_id are bound as AAD, so a captured packet can't be re-stamped onto another cluster on a shared group.
  • Admission uses a Noise_NNpsk0_25519_ChaChaPoly_SHA256 handshake whose pre-shared key is an Argon2id bootstrap key derived from the shared password; the session key is derived (HKDF-SHA256) from the master salt carried in the handshake.
  • Replay protection is per-source monotonic sequence numbers (no clock dependency); rate-limiting runs before decryption; a wrong-password node self-detects at the join deadline and shuts down rather than forming a lone master.
  • Config consistency (manage_shtags / master_stickiness / query_time) is advertised in every heartbeat and enforced by on_config_mismatch (reject / warn / adopt).
  • Trust model: a single shared secret — any holder of the password is a fully trusted member and can legitimately become master; there is no per-node identity or revocation yet (roadmap). An attacker without the password can't influence the cluster at all (forged/replayed packets fail the AEAD tag or the sequence check).

libsodium is a hard build requirement (sodium-only; no wolfSSL linked).

Devel / 4.1 notes

  • Only clusterer_controller and the clusterer module are touched — no core files.
  • Re-seated onto devel's clusterer (coexists with the new inter-cluster bridges feature and clusterer_enable_rerouting); cc_discover_bin_sockets() uses devel's socket_info_full listener list.
  • Startup/log-hygiene fixes so a simultaneous cold start is clean: bootstrap-vs-session decrypt failures separated (0 spurious "wrong password" warnings), no premature self-promotion, JOIN_REQ rate-limiting, and the seed sync-fallback downgraded from ERROR to DBG.
  • Also included: the tm anycast Via cid is now rendered from the live node id (see the pinned comment), so t_anycast_replicate() works under controller-managed clusters where the id is assigned at runtime.

Testing

Built against OpenSIPS 4.1.0-dev and validated end-to-end on a live 3-node cluster (10.22.23.191–193) with dialog replication and sharing-tag failover:

Area Result
Build & load — clean make all, no version-control mismatch, crypto init, no leaks/crashes
Formation & roles — consistent master/backup/member, identical config
Master failover (kill -9) — backup promoted ~3 s on the preserved key, no election loop
Rejoin; master_stickiness 0 and 1
Election stability — staggered + simultaneous starts → one stable master, no oscillation
Split-brain prevention + divergent-key MASTER_BEACON merge
Native mode; hybrid (divergent per-cluster node_ids); multiple clusters on one BIN socket
Wrong-password rejection — defers then shuts down; live cluster undisturbed
Rogue-joiner tool — JOIN_REJECT sent; fake-MASTER_ALIVE flood ignored
Sharing-tag override force/auto; MI outputs + error codes (409 / 404 / -32602)
Buffer overrun/underrun fuzz — MI args + 115-case multicast parser fuzz → 0 crashes/cores
Config-mismatch reject/adopt

Full walk-throughs in modules/clusterer_controller/doc/clusterer_controller_tests.xml.

MI under 4.1: opensips-cli 0.3.3 misparses the 4.1 MI listing (reports "no command" even for uptime); use mi_http until the CLI is updated — a tooling-version issue, not a module/core defect.

Roadmap (not in this PR)

Node maintenance mode; statistics (get_statistics); events (E_CL_CTR_* on state transitions); per-node identity with enrolment/revocation (plus a PAKE to resist offline password guessing on captured bootstrap packets); IPv6 multicast. Detailed under Planned Features in the admin guide.

@Lt-Flash
Lt-Flash marked this pull request as draft July 13, 2026 14:03
@razvancrainea

Copy link
Copy Markdown
Member

Thank you very much for the contribution, I really like the idea behind it. I do see though that it is marked as a Draft - is it still work in progress, or has it reached to its final state? Let us know when it is ready to review.

@Lt-Flash

Copy link
Copy Markdown
Author

Hi,
Thanks a lot, I'm very glad you like the idea! I'm just finishing the latest touches in regards to variables and testing and then today I am planning to convert it to a proper PR!

@Lt-Flash
Lt-Flash force-pushed the feature/clusterer-controller-devel branch 4 times, most recently from 9034536 to 2cf5b36 Compare July 14, 2026 11:07
Port of the clusterer_controller module and its clusterer-module integration
to the devel branch: zero-config HA clusterer control over encrypted UDP
multicast, per-cluster node identity, hybrid native+controller topologies,
cl_ctr_* MI/variables/functions, join flood-DoS hardening, and input validation.

Devel adaptations:
- cc_discover_bin_sockets() iterates protos[PROTO_BIN].listeners as
  struct socket_info_full (next/prev), reading the embedded socket_info.
- The clusterer core edits are re-seated onto devel's clusterer.c /
  clusterer_mod.c / node_info.h, merging cleanly with the new bridges feature
  and the clusterer_enable_rerouting guard.

Robustness / log hygiene:
- cc_decrypt_pkt distinguishes bootstrap-key failures (real wrong-password /
  foreign-cluster / tampering -> WARN) from transient session-key mismatches
  during a (re)key or split-brain heal (-> DBG).
- the split-brain defer budget resets on a fresh higher-IP JOIN_REQ so a
  lower-IP node waits for a live higher-IP peer instead of self-promoting
  (bounded by CC_JOIN_DEFER_HARDMAX); JOIN_REQ sends are rate-limited.
- clusterer: the seed sync-fallback on a fresh start is downgraded from ERROR
  to DBG (the SYNC_IN_PROGRESS partial-sync failure path is unchanged).
- cc_elect_master enforces "MASTER_ALIVE keepalive armed <=> I am the elected
  master": a node demoted purely by election now stops broadcasting
  MASTER_ALIVE, fixing an oscillation where a lower-IP node flapped between two
  masters.

Validated end-to-end on a 3-node cluster: controller / native / hybrid modes,
master failover and election stability (staggered + simultaneous starts
converge to a single stable master), multiple controller clusters sharing one
BIN socket (distinct multicast per cluster), MI outputs and error paths, buffer
overrun/underrun fuzzing, wrong-password rejection, and config-mismatch. Docs
and generated README included (admin guide + HA test appendix).
@Lt-Flash
Lt-Flash force-pushed the feature/clusterer-controller-devel branch from 2cf5b36 to 34f1042 Compare July 14, 2026 11:46
@Lt-Flash
Lt-Flash marked this pull request as ready for review July 14, 2026 11:46
@Lt-Flash

Copy link
Copy Markdown
Author

Now it's ready for review, thanks!

@Lt-Flash
Lt-Flash force-pushed the feature/clusterer-controller-devel branch from 992d376 to f93b82d Compare July 14, 2026 15:27
… consistency

Two startup sanity checks for a previously silent misconfiguration:

- clusterer_controller is only meaningful when the clusterer module has
  use_controller=1 (the global switch that pre-creates the controller-managed
  cluster stubs, sets each one's controller_managed flag so they never touch
  the DB, and arms the guard that stops the controller from hijacking a native
  cluster of the same id). If use_controller=0 there is no controller-managed
  cluster and those safety mechanisms are off, so mod_init now FAILS (refuse to
  start) rather than driving clusters clusterer never authorised. Hybrid setups
  keep use_controller=1 - only the per-cluster kind differs - so this never
  trips them.

- The mirror case, use_controller=1 but no clusterer_controller module bound
  the controller API, logs an ERROR at clusterer child_init (the pre-created
  controller-managed stubs would never obtain an identity). clusterer does NOT
  abort here: its native/hybrid clusters still work; only the controller-stub
  clusters are dead.

Plumbing: the clusterer_ctrl binds struct now carries clusterer's use_controller
value, and load_clusterer_ctrl_binds() sets clusterer_ctrl_bound so clusterer
can tell whether a controller registered. Verified on all permutations: pure
controller, hybrid, native-only, and both mismatches.
@Lt-Flash
Lt-Flash force-pushed the feature/clusterer-controller-devel branch from f93b82d to 4348580 Compare July 14, 2026 15:30
@Lt-Flash

Copy link
Copy Markdown
Author

Follow-up commit 4348580f07 — kept as a separate commit on purpose.

Since this PR is already open for review, I added this as a distinct follow-up commit rather than squashing it into the main one, so the incremental change is easy to review and the existing review isn't disrupted by a force-push of the main commit.

It enforces consistency between clusterer's global use_controller switch and whether clusterer_controller is loaded (admin-guide Dependencies section updated to match):

  • clusterer_controller now refuses to start (mod_init fails) if the clusterer module has use_controller=0. That switch is what pre-creates the controller-managed cluster stubs, marks them controller_managed (so they never touch the DB), and arms the guard that stops the controller from hijacking a native cluster of the same id — with it off, the controller would run with those safety mechanisms disabled.
  • The mirror caseuse_controller=1 but clusterer_controller not loaded — logs an ERROR (the controller-managed stubs would otherwise never obtain an identity), but clusterer does not abort, since its native/hybrid clusters still work.

Hybrid environments are unaffected. use_controller is a single global switch — in a hybrid instance (native + controller-managed clusters side by side) it is always 1; only the per-cluster kind differs (native via DB/static vs. controller via the cluster_id list). So neither check ever trips a hybrid or pure-controller deployment; they only fire on a genuine module/config mismatch. Verified on all permutations: pure controller, hybrid, native-only, and both mismatches.

Yury Kirsanov added 2 commits July 15, 2026 02:18
…ples

Reviewed all config examples so every cluster is clearly and self-containedly
defined, per reviewer feedback:

- Show modparam("clusterer","cluster_id",N) for each controller-managed cluster
  in the full-config and multi-cluster examples (previously several relied on
  the implicit auto-create-on-capability-registration path, so the cluster
  never appeared "defined" in the snippet).
- Hybrid DB example: note that native cluster 10 is defined by rows in the
  clusterer DB table (not a modparam), and fix the misleading "this node id in
  cluster 10" comment (my_node_id is a single global id across all DB clusters).
- Add the missing "Hybrid, no DB" (static) example to the admin guide, with the
  required db_mode=0 (without it my_node_info/neighbor_node_info are ignored).
- Comment use_controller as the global switch throughout.

No behavior change - documentation only. README regenerated.
Add a per-cluster 'cluster_options' modparam - the same "key=value, key=value"
idiom as my_node_info - so a cluster is marked controller-managed with:

    modparam("clusterer", "cluster_options", "cluster_id=1, use_controller=1")

cluster_id is required; use_controller is a 0/1 flag defaulting to 0 (native),
and only use_controller=1 pre-creates the controller-managed stub (which never
touches the DB and is guarded against hijacking a native cluster of the same
id).  Native clusters need no cluster_options line at all.  Every other clusterer
setting (db_mode, ping_*, my_node_id, sharing_tag, ...) stays a global modparam.

The controller-managed ids and the clusterer_controller 'cluster' entries must
match EXACTLY.  clusterer_controller aborts at startup, naming the offending id,
if either side references a cluster the other does not:

  - a managed id with no 'cluster' config has no BIN socket or crypto params;
  - a 'cluster' config for an unmanaged id has nothing legitimate to drive.

The controller loads the clusterer's controller-managed id set through the ctrl
binds (managed_count/managed_ids) to run this check pre-fork.

The interim 'use_controller'/'cluster_id' int modparams (never released) are
kept registered only to fail with a migration hint.

Docs (admin guide, tests appendix, README) rewritten to the cluster_options
form; every example gives each cluster an explicit definition.
@Lt-Flash
Lt-Flash force-pushed the feature/clusterer-controller-devel branch from 82e123e to b7a173e Compare July 14, 2026 17:11
@Lt-Flash

Copy link
Copy Markdown
Author

Config API for controller-managed clusters is now the per-cluster cluster_options modparam on the clusterer side:

modparam("clusterer", "cluster_options", "cluster_id=1, use_controller=1")
modparam("clusterer", "cluster_options", "cluster_id=2, use_controller=1")

Same key=value idiom as my_node_info. cluster_id is required; use_controller is a 0/1 field defaulting to 0 (native), and only use_controller=1 registers the controller-managed stub. Native clusters need no line, and every other clusterer setting (db_mode, ping_*, my_node_id, sharing_tag, …) stays a global modparam.

The controller-managed ids and the clusterer_controller cluster entries must match exactlyclusterer_controller aborts at startup, naming the offending id, if either side references a cluster the other doesn't (a managed id with no cluster config has no BIN socket or crypto params; a cluster config for an unmanaged id has nothing to drive).

Verified locally (the build links wolfSSL, so the controller runs without a node): the new syntax loads, a managed id with no controller config aborts, a controller config for an unmanaged id aborts, and matching config starts. Docs (admin guide, tests appendix, README) and the PR description are updated to this form.

…ude clusterer_controller by default

Make clusterer_controller an opt-in module and ensure the bundled clusterer
module is completely unchanged when it is not built.

Build wiring:
  - Add clusterer_controller to exclude_modules in Makefile.conf.template, so a
    stock build skips it (like the other modules with external-lib deps). Enable
    it via include_modules.
  - The top-level Makefile exports CLUSTERER_CTRL_SUPPORT=1 iff clusterer_controller
    is in the configured build (mirrors the module-selection rule, so it is stable
    across 'make all' and single-module rebuilds); clusterer/Makefile turns that
    into -DCLUSTERER_CTRL_SUPPORT.

clusterer side, all under #ifdef CLUSTERER_CTRL_SUPPORT:
  - the clusterer_ctrl API (clusterer_ctrl.c) and the cluster_options /
    use_controller / cluster_id modparams + load_clusterer_ctrl_binds export;
  - the controller stub pre-create loop, the child_init guard, the shm current_id
    mirror, the on-demand stub, and the shtag_managed / controller_managed logic.
  - The per-cluster identity and hybrid-db_mode accessors (cluster_self_id,
    cl_db_mode, GET_CURRENT_ID, use_controller) get #else fallbacks to the stock
    globals (current_id / db_mode / 0), so their call sites compile to the exact
    upstream object code with no per-site #ifdef. add_node_info's internal self_id
    parameter is likewise gated (falls back to current_id).

Result: a build without clusterer_controller produces the stock clusterer module -
no cluster_options parameter (rejected as unknown), no behavioural change, no added
exports. Verified with unifdef -UCLUSTERER_CTRL_SUPPORT against the base: no semantic
difference from upstream. Enabling the controller rebuilds clusterer with the hooks;
the two are a matched pair.

Docs: new "Building the Module" section (admin guide + README).
@Lt-Flash

Copy link
Copy Markdown
Author

Follow-up 18c199ef68: clusterer_controller is now opt-in, and the stock clusterer module is unchanged unless you build it.

Previously the clusterer-side integration (the clusterer_ctrl API, the cluster_options modparam, and the Phase-0/Phase-1 hooks) was always compiled into the clusterer module. That is now fully decoupled:

  • clusterer_controller is excluded from the default build (added to exclude_modules in Makefile.conf.template), like the other modules with external-library dependencies. Enable it with include_modules= clusterer_controller.
  • The top-level Makefile exports CLUSTERER_CTRL_SUPPORT=1 only when clusterer_controller is part of the build, and clusterer/Makefile turns that into -DCLUSTERER_CTRL_SUPPORT. Every clusterer-side controller hook is behind that flag.

So clusterer_controller can be completely omitted — a build without it produces the stock upstream clusterer module: no cluster_options parameter (it's rejected as unknown), no behavioural change, no added exports. The per-cluster identity / hybrid-db_mode accessors get #else fallbacks to the upstream globals (cluster_self_id(cl)current_id, cl_db_mode(cl)db_mode, etc.), so call sites compile to the exact upstream object code.

Verified with unifdef -UCLUSTERER_CTRL_SUPPORT diffed against the base branch: no semantic difference in the clusterer module. Built and checked both ways — stock (clusterer.so has zero cluster_options strings, native config loads, cluster_options rejected) and with the controller (cluster_options parses, the exact-match guard fires). Enabling the controller automatically rebuilds clusterer with the hooks; the two are a matched pair. New "Building the Module" section added to the admin guide/README.

@Lt-Flash

Copy link
Copy Markdown
Author

Crypto is libsodium-only (see ce8e805)

Worth stressing for reviewers: as of ce8e805 the module's cryptography settled on libsodium, and the earlier wolfSSL / OpenSSL-based paths were dropped entirely — there is no TLS-library fallback anymore.

clusterer_controller now uses XChaCha20-Poly1305 + Argon2id for the shared-secret / at-rest key material and a Noise_NNpsk0 (Curve25519 / ChaCha20-Poly1305 / SHA-256) handshake for the join, all on libsodium primitives.

Why the switch:

  • a small, single, audited primitive set instead of pulling in a full TLS library for a handful of AEAD/KDF calls;
  • one consistent crypto suite across every build (all nodes in a cluster must match), rather than "wolfSSL here, sodium there";
  • it removes the wolfSSL build flakiness.

libsodium is therefore a hard build dependency of the module now; there is no --with-openssl / wolfSSL variant of this code.

@Lt-Flash

Copy link
Copy Markdown
Author

Why this PR also touches tm

The top commit (e4cdd2415b) is a small change under modules/tm, which looks out of place in a clusterer_controller PR. It is included here because TM anycast cannot work under a controller-managed cluster without it.

Background. In an anycast setup (tm_replication_cluster + t_anycast_replicate()), TM stamps this node's clusterer id into the cid Via parameter, so that a reply or CANCEL that lands on a different anycast member can be relayed to the node that actually holds the transaction. tm_init_cluster() read get_my_id() once, at mod_init, and froze it into both the ;cid= string and a cached tm_node_id.

The problem. That assumes the node id is known and stable at startup. It is, for a statically configured clusterer — but not for a controller-managed one, where the id is assigned at runtime (after the node joins the cluster) and can change on re-election. So mod_init froze the still-unassigned id (-1, rendered in its unsigned form as 18446744073709551615) into every outgoing Via, and every node then compared incoming cids against -1. The net effect is that t_anycast_replicate() can never route a reply to the owning node — anycast reply routing is silently broken on a controller-managed cluster.

The fix. Read the id live instead of caching it: only the fixed ;<param>= prefix is built at init, and tm_via_cid() appends the current get_my_id() per request (advertising no cid while the node still has no id); the incoming comparison uses the live id too. This needs nothing from the caller, because cl_get_my_id() already returns the runtime id, and it self-corrects across re-elections. It is also a strict improvement for any clusterer with runtime-assigned ids, not only this module.

Verified on a 3-node anycast test cluster: each node now emits ;cid=<its own node_id> (e.g. the node with id 2 sends cid=2) instead of the frozen placeholder, and t_anycast_replicate() routes replies correctly.

Yury Kirsanov added 4 commits July 18, 2026 21:31
libsodium is a hard build requirement: the module is built sodium-only
(XChaCha20-Poly1305 + Argon2id, plus X25519 / HKDF-SHA256 / RNG from
libsodium), and the wolfSSL AES-256-GCM/scrypt fallback is not built.
This keeps the module binary small and gives one consistent crypto
suite across every build.
No behaviour change; a code-quality pass on the single-file module.

- cc_seal_and_send(): every one of the ten packet senders repeated the same
  ~13-line tail (encrypt in place, build the multicast sockaddr, sendto, error
  log).  Factor it into one helper.  The multicast destination is now resolved
  once per cluster in mod_init (main process) into cl->mcast_dest and inherited
  by the forked workers - so it no longer rebuilds inet_addr()/htons() on every
  send, and mod_destroy's GOODBYE (which runs in the main process) uses the same
  path.  Senders keep only their own success log via the helper's return value.

- cc_peer_by_ip_locked(): the "find a peer entry by IP" scan was open-coded in
  many places; add a helper and use it where the lookup is standalone
  (cc_upsert_peer_locked, cc_update_peer_bin_locked).  The fused election /
  member-list / prune loops are left as-is.

- cc_recv_one(): reuse the is_bootstrap flag instead of re-memcmp'ing the packet
  magic three more times; drop the unreachable payload_len < 0 check (the
  minimum-length gate at the top already guarantees it is non-negative).

Verified on a local two-node sodium cluster: join via KEY_GRANT + NODE_ASSIGN,
sticky backup designation, master-death failover promotion, and graceful
GOODBYE on shutdown - zero controller errors.  Both build flavors (sodium-only
and wolfSSL fallback) compile clean.
…se_NNpsk0

The join handshake used a bespoke construction: an ephemeral X25519 ECDH whose
shared secret was fed, with the password and a per-exchange nonce, into HKDF to
derive a key that XOR-wrapped the master_salt (cc_wrap_salt), plus a manual
nonce echo to bind the exchange.  Replace it with a standard, analysable
handshake:

  Noise_NNpsk0_25519_ChaChaPoly_SHA256, PSK = the Argon2id bootstrap key.
    -> psk, e     JOIN_REQ  carries Noise message 1 (fresh ephemeral)
    <- e, ee      KEY_GRANT carries Noise message 2; its AEAD payload = master_salt

A compact Noise core (SymmetricState + CipherState + the NNpsk0 read/write) is
added on libsodium primitives (X25519, ChaCha20-Poly1305, SHA-256, HMAC-SHA256);
it was validated against the RFC 5869 HKDF vector and for self-consistency,
tamper-detection and wrong-PSK rejection in a standalone harness before wiring
in.  The handshake hash binds the whole transcript, so the manual join_nonce
echo, the cc_peer_t.join_nonce field, and CC_JOIN_NONCE_SZ are removed - a stale
KEY_GRANT for a superseded JOIN_REQ now simply fails Noise msg-2 decryption.

KEY_HANDOFF (a one-shot, which NNpsk0's 2-message shape does not fit) switches
to an anonymous crypto_box_seal of the master_salt to the next master's
long-lived X25519 key (learned via ALIVE); sender authenticity still comes from
the session-key envelope.  cc_wrap_salt and cc_ecdh_shared are deleted.

The multicast transport, outer AEAD framing (bootstrap/session magic), sequence
replay check and rate limiter are unchanged; wrong-password nodes are still
rejected at the bootstrap envelope before the handshake runs.  Verified on a
local two-node cluster: join, sticky backup, master-death failover, GOODBYE, and
wrong-password shutdown - all clean.
Update the admin guide (and regenerated README) for the crypto changes:
  - crypto is now libsodium-only (XChaCha20-Poly1305 + Argon2id); the wolfSSL
    fallback and its AES-256-GCM/scrypt description are removed, and the build
    now requires libsodium;
  - the Phase 1 join handshake is described as Noise_NNpsk0_25519_ChaChaPoly_
    SHA256 (PSK = bootstrap key) instead of the hand-rolled ECDH-and-XOR wrap;
  - JOIN_REQ / KEY_GRANT carry Noise messages 1 / 2; KEY_HANDOFF uses a
    crypto_box sealed to the next master's key;
  - password / bootstrap-key wording updated (Argon2id, Noise PSK).
Yury Kirsanov added 3 commits July 18, 2026 21:31
The election result was only visible as a cluster-wide roles list; a node
could not see its OWN transition on its own line - e.g. a rejoining
higher-IP peer would silently demote this backup to a plain member with
nothing marking the change.

Track the last-known self role (enum cc_role) per cluster and, at the end
of cc_elect_master(), emit a single "my role changed: X -> Y" line
whenever it differs. This covers every election trigger (goodbye, join,
timer) in one place. Zero-initialised to member, matching a not-yet-joined
node. The post-goodbye "re-election complete" line drops its now-redundant
role clause and just names the elected master.
tm_init_cluster() read this node cluster id once, at mod_init, and baked
it into the ";cid=<id>" Via parameter (and into a cached tm_node_id used
to decide whether an anycast reply is ours). That assumes the id is known
and stable by mod_init, which holds for a statically configured clusterer
but not for a controller-managed one: there the id is assigned at runtime,
after the node joins, and can change on re-election. So mod_init froze the
still-unassigned id (-1, printed as its unsigned form 18446744073709551615)
into every outgoing Via, and every node compared incoming cids against -1
- t_anycast_replicate() could never route a reply to the owning node.

Read the id live instead: pre-build only the fixed ";<param>=" prefix at
init, and have tm_via_cid() append the current get_my_id() per request
(returning no parameter while the node still has no id); compare incoming
cids against the live get_my_id() too. cl_get_my_id() already returns the
runtime id, so this needs nothing from the caller and self-corrects across
re-elections.
The block flagged the sharing-tag override, its clearing, and the per-node
shtag status as future work, but all three shipped: cl_ctr_shtag_force,
cl_ctr_shtag_auto, and the shtag_mode field of cl_ctr_list_config. Reduce
it to the one item still outstanding - node maintenance mode - and note
that it would build on the shtag commands already in place, so the source
no longer reads as unfinished where it is not.
@Lt-Flash
Lt-Flash force-pushed the feature/clusterer-controller-devel branch from aad3a24 to 5dc976d Compare July 18, 2026 11:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants