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 29 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 29 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

clusterer_controller — self-forming HA coordination for OpenSIPS

clusterer_controller sits on top of the clusterer module and removes the
static my_node_info / neighbor_node_info wiring: nodes sharing a multicast
group, a cluster_id and a password self-organise — they discover each
other, elect a deterministic master (highest IP, sticky), assign clusterer
node-ids, hold sharing-tags on exactly one node, and fail over automatically —
with an encrypted control plane and zero per-node topology config.

This revision lands the unicast / scalability series (steady-state and
join cost cut from O(N²) → O(N), with anti-entropy repair and reliable
handshakes) and a module-wide cc_cl_ctr_ rename so every symbol
matches the public cl_ctr_* MI/pvar/function surface.


Control plane at a glance

Encrypted UDP on the cluster's multicast group (default 239.0.10.x:3333).
Only a small cleartext header is visible on the wire — magic (2B) +
cluster_id (2B) + nonce — everything else is AEAD-sealed
(XChaCha20-Poly1305 + Argon2id, key agreement via Noise_NNpsk0).

Two key tiers, told apart by the magic byte:

Tier Key Packets
bootstrap 0xCC01 Argon2id(password) JOIN_REQ, KEY_GRANT, ACK, JOIN_REJECT, MASTER_BEACON
session 0xCC00 per-cluster session key MASTER_ALIVE (+liveness bitmap +membership digest), ALIVE, NODE_ASSIGN, MEMBER_LIST, RESYNC, GOODBYE, KEY_HANDOFF

Legend for the diagrams below: -->> unicast (1:1), -) multicast (group).


1. Cluster formation (cold start)

Nodes start together, discover over multicast, and converge on the highest-IP
node as master with no split brain — lower-IP nodes defer self-promotion while
a higher-IP peer is still joining.

sequenceDiagram
    autonumber
    participant N1 as .191
    participant N2 as .192
    participant N3 as .193 highest IP
    Note over N1,N3: simultaneous cold start, no master yet
    N1-)N3: JOIN_REQ (mcast, Noise msg1)
    N2-)N3: JOIN_REQ (mcast, Noise msg1)
    N3-)N1: JOIN_REQ (mcast, Noise msg1)
    Note over N1,N2: higher-IP peer still joining -> defer self-promotion
    Note over N3: highest IP -> self-promote, mint session key
    N3-->>N2: KEY_GRANT (unicast, Noise msg2 + salt)
    N2-->>N3: ACK
    N3-->>N1: KEY_GRANT (unicast)
    N1-->>N3: ACK
    N3-->>N2: NODE_ASSIGN + MEMBER_LIST (unicast)
    N3-->>N1: NODE_ASSIGN + MEMBER_LIST (unicast)
    Note over N1,N3: converged - .193 master, .192 backup, .191 member
Loading

2. Member join into a running cluster

The join is unicast to the joiner: only the newcomer needs the full peer
list, so the master sends it 1:1 instead of multicasting N packets that every
member would decrypt and discard. Only the newcomer's own assignment is
multicast, so existing members learn it. A lost KEY_GRANT is recovered by
ACK + retransmit (§5), a lost snapshot by the JOIN_REQ retry.

sequenceDiagram
    autonumber
    participant J as .191 joiner
    participant E as .192 member
    participant M as .193 master
    J-)M: JOIN_REQ (mcast, Noise msg1 + BIN socket)
    M-->>J: KEY_GRANT (unicast, session key)
    J-->>M: ACK (unicast)
    M-)E: NODE_ASSIGN newcomer .191=id3 (mcast -> all learn it)
    M-->>J: NODE_ASSIGN .193=id1, .192=id2 (unicast, peers)
    M-->>J: MEMBER_LIST 3 members (unicast, snapshot)
    J-)M: ALIVE (joiner now participates)
    Note over J,M: per join the group sees ONE packet, not N
Loading

Captured live on the test cluster (staged stop→rejoin of .191): JOIN_REQ →
unicast KEY_GRANT + ACK → mcast NODE_ASSIGN announce → unicast NODE_ASSIGN×2 +
MEMBER_LIST, all sub-second.

3. Steady-state liveness — master-mediated (O(N²) → O(N))

Previously every node multicast an ALIVE every query_time and every node ran
an election off every one — O(N²) packets and cluster-wide decrypts. Now a settled
member unicasts its ALIVE to the master; the master folds all peers' liveness
into a per-node-id bitmap on its MASTER_ALIVE, and members trust that bitmap
to keep their election windows populated.

sequenceDiagram
    autonumber
    participant A as .191 member
    participant B as .192 backup
    participant M as .193 master
    A-->>M: ALIVE (unicast, ~query_time)
    B-->>M: ALIVE (unicast, ~query_time)
    M-)A: MASTER_ALIVE (mcast, ~1/s) + liveness bitmap + membership digest
    M-)B: MASTER_ALIVE (mcast) + bitmap + digest
    Note over A,B: refresh last_seen from the master's bitmap - no peer-to-peer ALIVE
Loading

4. Anti-entropy — membership digest → RESYNC

Every MASTER_ALIVE carries a digest = active-peer count + order-independent
XOR of FNV-1a(node_id, ip). A member whose computed digest differs has missed a
NODE_ASSIGN (whole peer, or just a node-id) and pulls a rate-limited RESYNC;
the master coalesces a burst into one re-broadcast. This is the multicast
counterpart to the 1:1 ACK path.

sequenceDiagram
    autonumber
    participant Mem as .192 member
    participant M as .193 master
    M-)Mem: MASTER_ALIVE + digest = count + XOR hash
    Note over Mem: local digest != master's, missed a NODE_ASSIGN
    Mem-->>M: RESYNC (unicast, rate-limited)
    M-)Mem: NODE_ASSIGN (all peers) + MEMBER_LIST (re-broadcast)
    Note over Mem: digests match again
Loading

5. Reliable handshake — ACK + bounded retransmit

The 1:1 handshake rides unreliable UDP. A lost KEY_GRANT used to cost the joiner
its whole join window; now the master retransmits until ACKed (bounded), so a
single loss heals in milliseconds.

sequenceDiagram
    autonumber
    participant J as .191 joiner
    participant M as .193 master
    J-)M: JOIN_REQ
    M-->>J: KEY_GRANT (unicast) x lost
    Note over M: no ACK within retransmit timeout
    M-->>J: KEY_GRANT (retransmit)
    J-->>M: ACK
    Note over J,M: recovered in ms, not a full join window
Loading

6. Graceful leave (GOODBYE)

A departing node multicasts GOODBYE. Survivors re-elect locally off the same
event — no MEMBER_LIST re-broadcast (that O(N), fragmenting packet was dropped from
the failover path); MASTER_ALIVE re-asserts and the digest reconciles stragglers.

sequenceDiagram
    autonumber
    participant D as .191 leaving
    participant B as .192 backup
    participant M as .193 master
    D-)M: GOODBYE (mcast)
    D-)B: GOODBYE (mcast)
    Note over B,M: local re-election off the same GOODBYE - no re-broadcast
    Note over M: still master, 2 members - no role change
    M-)B: MASTER_ALIVE + digest (count now 2)
Loading

7. Master failover (crash)

The master goes silent; members detect the missed MASTER_ALIVE keepalives and run
the same deterministic election locally — the backup (highest-IP survivor)
promotes and asserts via MASTER_ALIVE.

sequenceDiagram
    autonumber
    participant Me as .191 member
    participant B as .192 backup
    participant M as .193 master
    Note over M: master crashes x
    Note over Me,B: MASTER_ALIVE keepalives stop -> timeout
    Note over Me,B: identical local re-election -> highest-IP survivor wins
    Note over B: .192 promotes to master
    B-)Me: MASTER_ALIVE (new master asserts) + digest
    Me-->>B: ALIVE (unicast to new master)
    Note over Me,B: converged - .192 master, .191 member
Loading

8. Graceful master handoff (KEY_HANDOFF)

On a planned master shutdown the outgoing master hands the session key to its
successor sealed to the successor's long-lived X25519 pubkey (crypto_box_seal,
learned from ALIVE) — so the successor takes over without a rejoin.

sequenceDiagram
    autonumber
    participant S as .192 successor
    participant M as .193 master leaving
    Note over M: planned shutdown
    M-->>S: KEY_HANDOFF (unicast, session key sealed to S's pubkey)
    M-)S: GOODBYE (mcast)
    Note over S: already holds the key -> promotes with no rejoin
    S-)S: MASTER_ALIVE (asserts as master)
Loading

9. Wrong-password rejection

A node with the wrong password cannot produce a bootstrap-valid JOIN_REQ. After
repeated failures from one source the master emits an authenticated JOIN_REJECT;
independently, a genuinely-wrong-password joiner self-terminates after its defer
budget rather than forming a lone split-brain master.

sequenceDiagram
    autonumber
    participant R as .200 wrong password
    participant M as .193 master
    R-)M: JOIN_REQ (bootstrap AEAD fails to authenticate)
    Note over M: repeated bootstrap-decrypt failures from .200
    M-->>R: JOIN_REJECT (unicast, GCM-authenticated)
    Note over R: in NODE_NEW state -> exit(-1), no split brain
Loading

10. Split-brain merge (MASTER_BEACON)

If a partition heals and two masters exist, each periodically emits a
bootstrap-keyed MASTER_BEACON (readable across different session keys) carrying
its member_count. The inferior master (smaller count, IP tiebreak) yields and
rejoins the superior one.

sequenceDiagram
    autonumber
    participant A as .191 master, 1 member
    participant B as .193 master, 3 members
    Note over A,B: partition heals - two masters
    B-)A: MASTER_BEACON (bootstrap-keyed, member_count=3)
    A-)B: MASTER_BEACON (member_count=1)
    Note over A: inferior (fewer members) -> demote
    A-)B: JOIN_REQ (rejoin the superior master)
    B-->>A: KEY_GRANT + NODE_ASSIGN + MEMBER_LIST (unicast)
    Note over A,B: single cluster, one master
Loading

11. Shared-port unicast demux (multi-cluster on one node)

When several controller clusters on one node share a multicast port (distinct
groups), the kernel demuxes multicast by group but unicast by port alone — a 1:1
reply can land on the wrong sibling's socket. The receiver recovers it: on a
cluster_id mismatch it forwards the still-encrypted datagram to the correct
local cluster's worker via ipc_send_rpc(), which decrypts it with its own key.

sequenceDiagram
    autonumber
    participant P as peer (cluster 2)
    participant W1 as worker cluster 1
    participant W2 as worker cluster 2
    P-->>W1: unicast reply for cluster 2 (arrives on wrong socket)
    Note over W1: cleartext cluster_id=2 != 1
    W1->>W2: ipc_send_rpc(still-encrypted datagram)
    Note over W2: decrypt with cluster-2 key, process normally
    Note over W1,W2: forwarded once, never re-forwarded - no loop
Loading

Complexity impact

Path before after
steady-state liveness O(N²) packets + decrypts O(N) (member→master unicast + one MASTER_ALIVE bitmap)
join into N-node cluster O(N) group packets, O(N²) decrypts O(1) group + O(N) unicast to the joiner only
failover +O(N) MEMBER_LIST re-broadcast (fragments >~80 nodes) local re-election, no re-broadcast
lost handshake packet whole join window (~seconds) ACK + retransmit (ms)
missed NODE_ASSIGN silently incomplete BIN mesh digest-driven RESYNC repair

Module parameters

Parameter Type Scope Default Description
cluster string, repeatable per-cluster none — required Defines one cluster: id= (required), multicast=A.B.C.D:PORT (required), password= (optional, overrides the global password), bin_socket=bin:IP:PORT (optional, required only when multiple clusters are defined), manage_shtags=0|1 (optional, overrides the global manage_shtags). Repeat for multiple simultaneous clusters.
my_ip string global auto-detected Pins the controller's own identity IP (Mode 1). Takes precedence over interface.
interface string global auto-detected Names the interface whose first IPv4 address becomes the controller's identity IP (Mode 2). Ignored if my_ip is set.
query_time integer global 5 Seconds between ALIVE heartbeats; also sets the election window (3×) and peer purge window (6×). Valid range 1–60.
password string global (per-cluster override via cluster) 3eCrEt*5629 (change in production) Encryption password — stretched with Argon2id for the bootstrap/join key, and fed into HKDF-SHA256 with the master salt for the session key. A startup warning is logged if left at the default or under ~80 bits of entropy.
manage_shtags integer (0/1) global (per-cluster override via cluster) 1 When 1, the controller master automatically manages sharing-tag failover (forces local tags to backup at startup, activates them on bootstrap/failover, blocks manual MI/$shtag() changes on managed clusters). When 0, sharing tags behave exactly as stock clusterer and are left to scripts/MI.
master_stickiness integer (0/1) global (per-cluster override via cluster) 1 1 (sticky): a live master keeps its role when a higher-IP node joins — only the backup slot changes. 0: pure highest-IP election — a higher-IP node takes over as soon as it appears.
on_config_mismatch string: reject|warn|adopt global only reject Policy when a joining node's manage_shtags/master_stickiness/query_time differ from the running cluster's: reject refuses the join (JOIN_REJECT), warn logs once and allows it, adopt makes the joiner take the cluster's values.

Compatibility & deployment

  • The wire format changed across this series (new RESYNC type, ALIVE/MASTER_ALIVE
    layout, unicast routing). All nodes of a cluster must run this build — old and
    new cannot interoperate; deploy with a coordinated cutover. A node that doesn't
    understand the digest simply advertises none and is never asked to resync.
  • Module-wide cc_cl_ctr_ / CC_CL_CTR_ rename (functions, types,
    macros); the KDF label and bootstrap salt were renamed too, which changes the
    derived keys — another reason for the coordinated cutover. Wire magic bytes
    (0xCC…) are unchanged.

Testing

Validated on netns rigs and a live 6-node test cluster (2 controller clusters):
cold start → deterministic single master; single / chained / rapid-double failover;
higher-IP join → sticky backup; two clusters sharing port 3333 → both converge with
misdelivered unicast correctly re-routed; wrong-password node rejected without split
brain; staged stop→rejoin captured on the wire confirming the unicast join path.

@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 5 times, most recently 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 2 times, most recently 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.

@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.

@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.

@Lt-Flash
Lt-Flash force-pushed the feature/clusterer-controller-devel branch from aad3a24 to 5dc976d Compare July 18, 2026 11:31
@Lt-Flash

Lt-Flash commented Jul 28, 2026

Copy link
Copy Markdown
Author

Follow-up already implemented: a consumer messaging API over the controller's encrypted plane

(Updated — this comment originally claimed a script could migrate from clusterer's generic messaging "by renaming the call". That is true of the function names and false of the delivery guarantees; the correction and what was done about it are at the end.)

A heads-up on where this module goes next — the work is implemented and tested on a development branch, and will be proposed once this PR lands, since it extends the packet layer introduced here.

The controller's encrypted UDP plane (XChaCha20-Poly1305 under the rotating session key, Noise_NNpsk0 join) is currently private to the module. The follow-up opens it to consumers as a small API, at two levels.

Module tier, bound via load_clctr():

Function Purpose
register_channel(channel, cb) claim a named channel, receive every packet sent on it
send_mcast(cluster, channel, data, flags) one encrypted multicast packet to every member; CLCTR_SEND_TO_SELF additionally delivers locally without touching the wire
send_ucast(cluster, node, channel, data, flags) directed send to one node
send_list(cluster, node_ids, n, channel, data, flags, &unknown) send to a named set of nodes
get_my_node_id(cluster) this node's id as the controller assigned it

Payloads up to 1300 bytes, channel names up to 31 chars; sends are IPC-marshalled to the controller worker so any process can send. Consumer packets carry a cleartext magic so the pre-decrypt rate limiter classifies them against their own budget instead of the deliberately tight join budget — without that separation, the join limiter silently dropped one consumer packet in ten under load.

Script tier — the same three functions and two events clusterer offers, plus a list send: cl_ctr_broadcast_req(), cl_ctr_send_req(), cl_ctr_send_req_list(), cl_ctr_send_rpl(), arriving as E_CL_CTR_REQ_RECEIVED / E_CL_CTR_RPL_RECEIVED with clusterer's exact parameter set. Full tables in the following comment.

Addressing a subset is not the same as filtering one

send_list() sends one unicast per target rather than a single multicast the receivers filter, and the reason is worth stating because the shortcut is tempting: a multicast is decrypted by every member, so "addressed to three of you" would still put the payload in front of the other five. Filtering after decryption is not addressing. The cost is linear in the list — the honest price of naming a subset — and it buys per-target accounting for free.

A broadcast is the opposite case and gets the opposite treatment. There are no unaddressed nodes, so it stays one multicast; sending it as N unicasts would cost roughly twice the packets (2(N−1) against one multicast plus N−1 acknowledgements) for no benefit, which matters at the 256 nodes this module is designed for.

Delivery guarantees, opt-in

Default is best-effort, unordered, at-most-once. CLCTR_SEND_RELIABLE asks for acknowledgement and bounded retransmission, per send rather than per channel, with consumer_retries and consumer_retry_ms settable per cluster — a fleet may run one cluster over a quiet management VLAN and another across a link where retries matter, and one global number cannot be right for both.

A reliable broadcast stays a multicast and repairs by unicast to the nodes that did not answer. Repair rather than rebroadcast is the important part: a duplicate that asked to be acknowledged is acknowledged again, so resending the multicast to fix two missing nodes would have every other member answer a second time.

The subtle case is a lost acknowledgement, not a lost message. The sender resends identical bytes with the same sequence number and the receiver's replay check correctly drops the duplicate — so without more, a receiver whose ACK was lost swallows every retransmission in silence while the sender spends its budget and concludes failure. Hence the re-ACK: the replay check does the deduplication, the re-ACK closes the loop, and delivery stays at most once.

The correction

The original text said a script moves between clusterer's messaging and this one "by renaming the call". The surface is deliberately identical, but the guarantees are not: cl_send_to() is BIN over TCP — reliable and ordered — while this is UDP multicast. Renaming alone silently downgrades delivery, and that should never have been glossed over. CLCTR_SEND_RELIABLE now exists precisely so the choice is explicit, and the documentation states the default semantics rather than implying equivalence.

Two related fixes came out of the same review. Consumer traffic now has its own sequence space: it shared one monotonic counter per sender with the control plane, so on any multipath network a reordered consumer packet could make a MASTER_ALIVE arriving behind it look like a replay — and a missed beacon is how a healthy node gets declared dead. And consumer packets must now come from a known member, checked before the rate limiter, so a flood from an unknown address costs one bounded scan and never reaches the cipher; control traffic is deliberately not filtered, since a JOIN_REQ legitimately comes from a stranger.

One implementation note for reviewers: raising a scriptable event from the controller worker requires the process to be declared with PROC_FLAG_NEEDS_SCRIPT — an event route is script, and raising one from a process not set up to run routes crashes inside route_run() rather than failing politely.

@Lt-Flash

Lt-Flash commented Jul 28, 2026

Copy link
Copy Markdown
Author

The complete script, MI and event surface — including what the follow-up adds

(Updated: adds the list send, the opt-in reliable flag, the per-cluster consumer settings, and a note on how counts are returned.)

Everything this module exposes today, plus what the consumer messaging follow-up adds, so the script- and operator-facing surface can be reviewed as a whole.

Script functions

Current:

Function Returns
cl_ctr_node_is_master([cluster_id]) true when this node currently holds the master role
cl_ctr_node_present(cluster_id, node_id) true when that node is a live member
cl_ctr_get_node_role(cluster_id, node_id, out) master / backup / member
cl_ctr_get_node_ip(cluster_id, node_id, out) that node's IP

Added by the follow-up — deliberately clusterer's generic-message surface, so the shapes are familiar (the delivery guarantees differ; see the previous comment):

Function Purpose
cl_ctr_broadcast_req(cluster_id, msg [, tag [, reliable]]) send a request to every member as one multicast; reliable = 1 asks for acknowledgement and retransmission
cl_ctr_send_req(cluster_id, node_id, msg [, tag [, reliable]]) send a request to one node
cl_ctr_send_req_list(cluster_id, $avp(nodes), msg [, tag [, out_var]]) send to the nodes named in an AVP — one unicast each; out_var receives how many it was sent to
cl_ctr_send_rpl(cluster_id, node_id, msg [, tag]) send a reply, normally from inside the request event route
event_route[E_CL_CTR_REQ_RECEIVED] {
    xlog("req from node $param(src_id): $param(msg)\n");
    cl_ctr_send_rpl($param(cluster_id), $param(src_id), "ack", $param(tag));
}

# reliable broadcast, with a tag the receiving event route will see
cl_ctr_broadcast_req(1, "state changed", "cfg", 1);

# a named subset.  Assigning to the same AVP again adds a value rather than
# replacing one, so this names two nodes - stored newest first, so read back
# 5 then 2, which does not matter here since both get the same message.
$avp(nodes) = 2;
$avp(nodes) = 5;
if (cl_ctr_send_req_list(1, $avp(nodes), "just for you", "tag", $var(sent)))
    xlog("sent to $var(sent) node(s)\n");

On return values. The count comes back in an output variable and the function itself is simply true or false — which is why the call goes inside the if rather than being followed by a test of its return code. It has to be that way round: the core stops the script when a function returns zero (action.c), so a function returning a count would halt the route on the day it reached nobody, which is a real answer a script must be able to see. (The count is nowhere near an integer limit; 256 nodes is not the hazard, zero is.)

What the count is, and is not. It is how many nodes the message was sent to, which is known immediately. How many acknowledged it cannot be known at that point — the answers arrive afterwards — so for a reliable send that result currently goes to the log as it completes, reported against the configured budget:

broadcast seq 41 acknowledged by all 3 member(s) after 0 of the 2 retries configured for this cluster
broadcast seq 44 reached 2 of 3 member(s), giving up after 2 of the 2 retries configured for this cluster

Surfacing that back to the script as a completion event is the remaining piece of this work.

(An earlier revision of this comment documented padding past unused arguments — f(a, b, , , 1) — as a rule to follow. That was the wrong answer to a badly ordered signature; the unused arguments have since been removed, and the third argument is now the tag, which reaches the receiving event route.)

Events

Event Parameters Raised when
E_CL_CTR_REQ_RECEIVED cluster_id, src_id, msg, tag a peer sent a request on the script channel
E_CL_CTR_RPL_RECEIVED same a peer replied

Clusterer's exact parameter set, so existing event routes port unchanged. The module reserves the _script channel for itself during initialisation, before any consumer module can register, so a module cannot claim it by accident.

Pseudo-variables

$cl_ctr_role, $cl_ctr_is_master, $cl_ctr_master_ip, $cl_ctr_backup_ip, $cl_ctr_node_id, $cl_ctr_my_ip, $cl_ctr_members, $cl_ctr_shtag_mode, $cl_ctr_forced_node — each optionally taking a cluster id, for the multi-cluster case.

MI commands

Command Arguments Purpose
cl_ctr_list_members all current members with node_id, status and BIN sockets
cl_ctr_node_info node_id full info for one node across all clusters
cl_ctr_list_config every configured cluster and its resolved settings
cl_ctr_shtag_force cluster_id, node_id pin the active sharing tag to a node (master only); suspends automatic allocation
cl_ctr_shtag_auto cluster_id resume automatic master-driven sharing-tag allocation

Module parameters

Existing: cluster, my_ip, interface, query_time, password, manage_shtags, master_stickiness, on_config_mismatch.

Added for the consumer plane, each a global default that a cluster may override in its own cluster string:

Parameter Default Meaning
consumer_rate_limit 1000 packets per second per source address, for consumer traffic only
consumer_retries 2 how many times an unacknowledged reliable message is sent again
consumer_retry_ms 40 the gap between those attempts
modparam("clusterer_controller", "cluster",
    "id=1,multicast=239.0.10.1:3333,bin_socket=bin:10.0.0.1:5555,"
    "consumer_retries=3,consumer_retry_ms=60")

The per-cluster form follows what manage_shtags and master_stickiness already do — a sentinel at parse time, resolved against the global default at startup — because a node can belong to several clusters and they are not alike: one may run over a quiet management VLAN, another across a link where retries matter.

Module API (for other modules)

Bound with load_clctr(): register_channel, send_mcast, send_ucast, send_list, get_my_node_id. Flags: CLCTR_SEND_TO_SELF (also deliver locally, without a packet on the wire) and CLCTR_SEND_RELIABLE (acknowledge and retransmit). Payloads up to 1300 bytes, channel names up to 31 characters.

A hardening change worth flagging to reviewers

Giving consumer traffic its own rate budget raised what a single source address can push through to the cipher from 20 packets a second to 1000 — the right budget for a real peer, a poor one for a stranger, since the packets fail to decrypt but the node still performs the failed decryption, and the sender needs to know nothing but the port and two cleartext magic bytes.

Consumer packets are therefore now required to come from an address the cluster already knows. That is safe to require because consumer traffic only ever passes between joined members — control traffic, where a stranger legitimately appears (a JOIN_REQ must), is deliberately not filtered. The check runs before the rate limiter, so a flood from an unknown address costs one bounded scan and then nothing: no cipher, no rate-table slot, and no opportunity to evict a real peer's counter from a table only as large as the cluster.

It does not fix address spoofing and does not claim to — a forged source copying a member's address still reaches the limiter. What it removes is the far easier attack of pointing a flood at the port from anywhere.

Verified on a three-node cluster: 399,000 forged consumer packets in three seconds from a non-member address, after which membership was intact, cross-node traffic still worked, and the rate limiter had not logged once — the packets never reached it.

@Lt-Flash

Lt-Flash commented Aug 1, 2026

Copy link
Copy Markdown
Author

Pushed a small follow-up fix: a753af0db1 initializes the trailing aliases field on all 5 mi_export_t entries in mi_cmds[].

mi_export_t (mi/mi.h) ends with const char *aliases[MAX_MI_ALIASES], and these five entries only initialized name/help/flags/init_f/recipes, leaving aliases to -Wmissing-field-initializers. Compiling with a stricter warning set flagged it (-Wmissing-field-initializers on all 5 MI commands). Not a behavioral bug — the field zero-initializes either way — just makes it explicit, matching the EMPTY_MI_EXPORT terminator convention already used at the end of the same array.

@Lt-Flash

Lt-Flash commented Aug 3, 2026

Copy link
Copy Markdown
Author

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.

Hi @razvancrainea Razvan,
I'm not sure how to do it properly for your side to review the feature the best way - should I freeze any updates to this PR or should I squash all the updates to always have the latest code, or should I make separate PRs for each edit? Sorry to ask but these are my first PRs and I'm also actively testing the features in my own environment and sometimes I encounter small bugs or introduce little enchancements. Please advise!

Best regards,
Yury.

@Lt-Flash

Lt-Flash commented Aug 7, 2026

Copy link
Copy Markdown
Author

Found and fixed a real bug while testing this module's actual multi-node discovery for the first time in production (every deployment so far had been single-node, where peer discovery never needed to cross the wire).

cl_ctr_setup_socket() joins the peer-discovery multicast group with mreq.imr_interface.s_addr = INADDR_ANY, leaving the actual interface choice to the kernel's default-route selection instead of the interface the cluster was configured for via the interface modparam (already resolved into my_ip before this runs). setsockopt(IP_ADD_MEMBERSHIP) succeeds either way - no error is logged - so this fails completely silently.

On two production hosts with the interface modparam set to ens18 (internal) but a default route out ens19 (external), the multicast join landed on ens19 instead - confirmed via ip maddr show on both, group present on the external interface, absent on the internal one. Neither node's JOIN_REQ ever reached the other; both timed out and self-elected as sole node instead.

Checked three other multi-node clusters already running elsewhere - same INADDR_ANY code path, but their default route happens to already point out the same interface configured for clustering, so it worked there by coincidence rather than by design.

Fix: bind the join to my_ip explicitly, matching what the send path already does a few lines below (local_if.s_addr = inet_addr(my_ip)) - the two were inconsistent with each other for no reason. Verified live: after the fix, the two nodes found each other, authenticated on the first attempt, and formed a correct master/backup pair.

Pushed to this branch: 0b3af68

@Lt-Flash

Lt-Flash commented Aug 9, 2026

Copy link
Copy Markdown
Author

Restructured into 3 logical commits (28 → 3) — no code change

The old history was 28 commits of my own iteration, which made this look like a
single "new module" drop. It isn't: alongside the new module it also modifies the
existing clusterer module (+1280/−66 across 11 files) and modules/tm
(+39/−13). Those deserve to be reviewable on their own rather than buried, so the
history is now:

commit scope
1 clusterer: controller-support API, gated behind a build-time flag 12 files — the clusterer_ctrl API, the cluster_options modparam and the controller hooks, all under CLUSTERER_CTRL_SUPPORT
2 tm: render the anycast Via cid from the live node id, not a frozen one 2 files — not guarded, so this one changes stock tm unconditionally
3 clusterer_controller: zero-config HA clusterer control module 10 files — the new module, its default build exclusion and the libsodium dependency

Ordering is dependency-first: the clusterer API lands before the module that binds
to it, so the series is bisectable (the old order had the module before the API it
calls). Commit 2 depends on neither and sits between them.

The tree is byte-for-byte identical to the previous head dd0f515c5b — this is
purely a history rewrite. Each commit was built from a clean worktree: commit 1
gives 132 modules / 0 errors, and because the exclusion only arrives in commit 3,
CLUSTERER_CTRL_SUPPORT is on at commit 1 — so the resulting clusterer.so
genuinely carries the guarded code (11 clusterer_ctrl symbols, the
cluster_options modparam) with no controller module present. Commit 2 relinks
tm.so, 0 errors.

Commits referenced in the comments above

Older comments in this thread cite specific hashes. They now map as:

referenced subject now part of
4348580f07 enforce use_controller vs module-load consistency 1 + 3
18c199ef68 gate controller integration behind a build-time flag 1 + 3
a753af0db1 initialize the aliases field in mi_cmds entries 3
0b3af68fd4 join the multicast group on our own interface 3
ce8e805 docs — Noise join handshake, libsodium-only crypto 3 — note this one was already superseded by d1ee636b9c in an earlier rebase
e4cdd2415b tm: live node id instead of a frozen one 2 — likewise already superseded by 016747495b

Every other commit in the old history touched only modules/clusterer_controller/
and is folded into commit 3.

Happy to split it further, or to break out the tm change as its own PR, if either
would make review easier.

@Lt-Flash
Lt-Flash force-pushed the feature/clusterer-controller-devel branch from dd0f515 to 22cb916 Compare August 9, 2026 12:11
Yury Kirsanov added 12 commits August 10, 2026 19:02
Groundwork for clusterer_controller (added later in this series): an API the
controller binds to, a way to declare which clusters it manages, and the hooks
that let a controller-managed cluster take its identity at runtime instead of
from the config.

Declaring a managed cluster uses a per-cluster 'cluster_options' modparam - the
same "key=value, key=value" idiom as my_node_info:

    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, and every other clusterer
setting (db_mode, ping_*, my_node_id, sharing_tag, ...) stays a global modparam.
The interim 'use_controller' / 'cluster_id' int modparams (never released) are
kept registered only to fail with a migration hint.

The managed-id set is exported through the ctrl binds (managed_count /
managed_ids) so the controller can cross-check it pre-fork: a managed id with no
matching 'cluster' config has no BIN socket or crypto params, and a 'cluster'
config for an unmanaged id has nothing legitimate to drive. Either mismatch
aborts at startup naming the offending id, rather than half-forming a cluster.

All of it is compiled only under CLUSTERER_CTRL_SUPPORT:
  - the clusterer_ctrl API (clusterer_ctrl.c) and the cluster_options modparam
    plus the 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), which 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 gated the same way.

The top-level Makefile exports CLUSTERER_CTRL_SUPPORT=1 iff clusterer_controller
is in the configured build - derived from the include/exclude lists rather than
the current 'modules' subset, so it is stable across 'make all' and a
single-module rebuild - and clusterer/Makefile turns that into the -D.

The point of the gate: 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.
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.
A control plane for the clusterer module: nodes discover each other over
encrypted UDP multicast, elect a master, and are assigned their cluster identity
at runtime, so an HA cluster needs no per-node id in the config and no database
behind it. Native, controller-managed and hybrid topologies coexist - a cluster
is controller-managed only if the clusterer declares it so via cluster_options.

Crypto is libsodium-only, no fallback: XChaCha20-Poly1305 for the payload AEAD,
Argon2id as the bootstrap KDF, and Noise_NNpsk0 for the join handshake, with
X25519 / HKDF-SHA256 / RNG underneath. libsodium is linked dynamically (distro
static libs are usually non-PIC), so target hosts need the runtime package. The
module is added to exclude_modules in Makefile.conf.template, like the other
modules with external-lib dependencies - enable it via include_modules - and
libsodium-dev joins the CI apt requirements.

Membership and liveness:
  - master election, with the invariant "MASTER_ALIVE keepalive armed <=> I am
    the elected master", which is what stops a node demoted purely by election
    from continuing to broadcast and flapping between two masters;
  - master-mediated ALIVE, so liveness costs O(N) messages per round rather than
    every node pinging every other (O(N^2));
  - a membership digest carried in MASTER_ALIVE, with a RESYNC repair path when
    a node's view diverges from the master's;
  - the join handshake is ACKed and retransmitted under a bounded budget, and
    both the KEY_GRANT and the join-time state snapshot are unicast to the
    joining node instead of broadcast to everyone.

Several controller clusters can share one BIN socket - each has its own
multicast group, shared-port unicast is routed by cluster_id, and multicast is
used as the fallback when two clusters collide on a port.

Hardening: the join path is flood-DoS rate-limited and its inputs are validated
and bounds-checked (including the Noise msg2 decrypt output and the zero-length
cipherstate passthrough). Decrypt failures are classified rather than logged
uniformly - a bootstrap-key failure means a wrong password, a foreign cluster or
tampering and warns, while a transient session-key mismatch during a rekey or a
split-brain heal is expected and stays at 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 CL_CTR_JOIN_DEFER_HARDMAX.

Multicast is sent and joined on this node's own interface rather than
INADDR_ANY, which is what makes discovery work on a multi-homed host where the
default route does not carry the cluster network.

Validated end-to-end on a 3-node cluster: controller / native / hybrid modes,
master failover and election stability (staggered and simultaneous starts both
converge to a single stable master), multiple controller clusters on one BIN
socket, the MI surface and its error paths, buffer overrun/underrun fuzzing,
wrong-password rejection and config mismatch. Ships with the admin guide, the HA
test appendix, the generated README and a join-rejection test.
The controller already owns an authenticated, encrypted, rate-limited UDP
plane between the nodes of a cluster, plus the membership that says who is on
it. Any other module wanting to exchange a message between nodes had to build
that transport again from scratch. This exports it instead.

A consumer registers a named channel pre-fork and sends on it:

    clctr_api_t clctr;
    if (load_clctr_api(&clctr) < 0) ...     /* mod_init */
    clctr.register_channel(&ch, my_cb);     /* mod_init - PRE-FORK */
    clctr.send_mcast(cluster_id, &ch, &payload, 0);
    clctr.send_ucast(cluster_id, node_id, &ch, &payload, 0);

and inherits the XChaCha20-Poly1305 group session key and its rotation, the
per-packet receive gauntlet (magic gate, cluster_id filter, size bound,
per-source rate limiting) and the membership view, with no transport code of
its own.

The delivery contract is the part worth reading, because it is what a consumer
gets wrong:

  - the receive callback runs in the CONTROLLER's worker process for that
    cluster, not in the process that called send. A consumer needing to wake a
    different process brings its own mechanism (shm + eventfd, ipc_send_rpc).
    Callbacks run on the cluster's receive path, so they must stay short.
  - sends are marshalled to that same worker over IPC, which is what keeps
    ordering per node and leaves the anti-replay sequence space single-writer.
  - CLCTR_SEND_TO_SELF dispatches locally rather than listening for our own
    packet, and a unicast to our own node id degenerates to exactly that, with
    nothing on the wire.
  - src_node_id is 0 when the sender had not been assigned an id yet.

CLCTR_SEND_RELIABLE asks for acknowledgement and resend while unacknowledged.
It is opt-in per send rather than per channel on purpose: it costs one ACK per
recipient, so a reliable broadcast turns a single packet into N-1 packets back.
Most consumer traffic is better served by being idempotent and retried by its
own logic.

Payload sizing has a deliberate split: CLCTR_MAX_PAYLOAD is a compile-time
lower bound for consumers that size local buffers statically, while the real
runtime limit is cc_max_payload, derived from the interface MTU at mod_init and
larger on jumbo-frame links.

Ships with tests for the consumer sequence, channel filtering, reliable
broadcast, and the script-facing messaging surface.
cl_ctr_handle_member_list() refreshed last_seen for every IP in the list, via
cl_ctr_upsert_peer_locked(). A MEMBER_LIST is a membership announcement: the
master lists a node until it prunes it, whether that node is reachable or not.
Treating it as evidence of liveness makes every receiver believe it has just
heard from peers it has never heard from.

That is what made a dead member immortal. cl_ctr_alive_bitmap() is built from
last_seen, so a backup holding refreshed timestamps for a node that is down
asserts to the whole cluster that it is up the moment that backup becomes
master - and from then on nobody can age it out. It survives its own funeral.

Seen in production: 10.22.20.241 stayed in cluster 243 for about a day, across
a master change, after being rolled back to a build with no controller at all.
Nothing was listening on its BIN port, and the two surviving nodes spent ~3,500
failed connects a day each trying to reach it - roughly 10,668 of one node's
10,801 daily ERROR lines, which buried every other error on the box.

The member-list path now inserts peers it did not know about and leaves
last_seen alone for peers it already tracks. Liveness continues to come only
from direct evidence: a packet sent BY the peer, or the master's alive bitmap,
which the master derives from packets it received itself.

A newly learned peer is still seeded with last_seen = now, so it gets one purge
window to prove itself instead of being dropped on the next tick. This
terminates rather than oscillating: once the master prunes a dead node it stops
listing it, and every receiver ages it out one window later.
A second controller process on the same host takes the same ip:port without
complaint, because the socket needs SO_REUSEADDR and INADDR_ANY to receive
multicast at all. The kernel then treats the two traffic types differently:
a multicast datagram is delivered to EVERY co-bound socket, but a unicast
is delivered to exactly ONE, chosen without reference to the destination
address. Measured on 5.4, 200/200 trials, the winner being the most
recently bound socket; SO_REUSEPORT does not help, it only changes which
socket wins.

Every 1:1 leg therefore lands in the wrong process. The join KEY_GRANT is
unicast, so the joining node never authenticates and dies with

    cannot authenticate ... (wrong password, or a foreign cluster on
    cluster_id N). Shutting down.

which sends the operator hunting a credential problem that does not exist.
Reversing the start order moves the victim to the other instance, which is
how the mechanism was confirmed: it follows bind order, not address.

This does not make that topology work - one controller instance per host
per port is what is supported, and production is unaffected because a node
runs one. It makes the diagnosis available:

  - cl_ctr_setup_socket() probes the port WITHOUT SO_REUSEADDR before
    taking it. That bind fails EADDRINUSE precisely when another process
    already holds it, and succeeds when the port is free; a probe WITH
    SO_REUSEADDR would succeed either way, which is why the real bind
    cannot tell. One warning naming the actual constraint. Advisory, never
    fatal - a restart can briefly race the outgoing process, and a spurious
    warning is cheaper than refusing to start.

  - cl_ctr_maybe_forward()'s "no local cluster for this packet" drop was
    LM_DBG. L_DBG is 4 and deployments run 3, so a whole class of
    "the cluster does not converge" had no trace at any log level anyone
    uses. It is now LM_WARN, rate-limited to one per 30s carrying the
    suppressed count - a misdelivering peer can produce one per packet, and
    a warning that fires at line rate is its own outage.

The in-process case is unchanged: several clusters in ONE process sharing a
port are still recovered by cl_ctr_maybe_forward() through the shared
cluster array, and that path never reaches the new warning.
678bc4c stopped MEMBER_LIST from refreshing last_seen, on the grounds that
a membership announcement says who BELONGS to the cluster, not who is
reachable. NODE_ASSIGN carries exactly the same kind of claim and was still
calling cl_ctr_upsert_peer_locked() on the IP in its payload, so the bug
survived the fix.

This half was worse, because it is a SELF-loop rather than a peer-to-peer
echo. IP_MULTICAST_LOOP means the master receives its own NODE_ASSIGN - the
handler's own doc comment says 'all nodes (including master via loopback)
apply the assignment'. So every roster announcement refreshed last_seen for
every node named in it, including a dead one, and since the master is the node
that runs cl_ctr_prune_stale(), the corpse kept itself alive.

Measured on the 3-node staging RGS cluster before this commit: a member whose
controller plane was isolated with iptables, and which was confirmed silent by
tcpdump on the master, was STILL a member on both survivors after four minutes
against a 30 s purge deadline. Behaviour was identical with and without
678bc4c, which is what showed the earlier fix was only half of it.

Liveness still has two sound sources and both are untouched: the master learns
it from the unicast ALIVE a settled non-master sends it, and backups learn it
from the master's MASTER_ALIVE bitmap, which cl_ctr_apply_alive_bitmap() uses
to set last_seen. A node the master no longer believes in is simply absent
from that bitmap, so receivers age it out instead of being told to keep it.

cl_ctr_rejoin_superior_master() was audited at the same time and deliberately
left alone: it upserts sender_ip from a beacon actually sent by that node,
which is direct evidence.

(cherry picked from commit 9bd7d10)
Removing a node from the topology did not touch the transport. clusterer
sends with msg_send(send_sock, proto, &node->addr) and the core keeps the TCP
connection in its own table keyed by destination, so nothing in
clusterer_ctrl_remove_node() - delete_neighbour, remove_node_list,
CLUSTER_NODE_DOWN, report_node_state - ever closed the socket. It survived
until the TCP layer timed it out or the peer closed first.

That is not academic. A node can be dead to the control plane and still
perfectly reachable on BIN: hung, half-open, or partitioned on one plane only.
Measured on a 3-node staging cluster, with the victim's control plane isolated
and BIN deliberately left reachable: clusterer_controller purged the node at
its 30 s deadline and both survivors still held ESTABLISHED connections to it,
in both directions.

The close lives here rather than in clusterer_controller on purpose. The BIN
transport belongs to clusterer, and the controller drives it through this API
instead of reaching into the core's TCP layer itself. It is exposed as
close_node_conn() for the case where only the transport should be dropped;
remove_node() now does it for you.

Two ordering constraints, both load-bearing:
 - the url is copied out BEFORE remove_node_list(), which frees the node;
 - the close happens AFTER cl_list_lock is released and after the capability
   event_cb callbacks have run, because a callback may still send a final BIN
   packet to the departing node and pulling the socket first would only turn
   that into an error.

get_node_by_id() walks node_list, which does not contain current_node, so this
can never close our own listener.

(cherry picked from commit d1d12a5)
…at happened

Two defects in the previous commit, both found by measuring on a live cluster
rather than by reading the code back.

1. It closed at most ONE connection. There are normally two per peer - the one
   we dialled out to its BIN port and the one it dialled in to ours - and both
   are reachable by the same address, because the core registers an alias for
   the peer's advertised port on an accepted connection as well. That aliasing
   is what lets an inbound connection be reused for outbound sends.
   tcp_close_connection() closes exactly one per call and flags it
   F_CONN_FORCE_CLOSED, which makes the next lookup skip it, so the fix is to
   loop until it reports nothing left. That terminates by construction: each
   pass removes one connection from the candidate set. The cap is paranoia
   against a future core change that stops setting the flag.

2. The logging announced intent, not outcome. It printed 'dropping BIN
   connection' before calling, and only said anything afterwards on a negative
   return - but tcp_close_connection() returns 1 for closed and 0 for nothing
   found, so the interesting case was silent and the log could not distinguish
   'closed it' from 'there was nothing there'. On the first live test that made
   a no-op look like a success. It now reports the count it actually closed,
   and an outright failure is an error rather than a debug line.

(cherry picked from commit e5838d3)
The master's MEMBER_LIST and NODE_ASSIGN are multicast, and IP_MULTICAST_LOOP
delivers them back to the master itself. Both handlers ran the learn path on
the payload IPs, so the master re-inserted every node its own announcement
named - including a peer it had just purged. The re-seeded copy kept the
roster naming the dead node, every receiver re-learned it one window later,
and the purge could never converge. Observed live on a 3-node staging
cluster: 'new peer <ip>' every 35 s, in lockstep with the purge cycle, for a
node whose control plane was provably blocked the whole time.

An announcement we authored was built FROM this table; it cannot teach us
anything. Receivers other than the author still learn membership from these
packets exactly as before - that is the legitimate propagation path, and a
node the master stops listing now ages out everywhere one window later, which
restores the termination argument cl_ctr_learn_peer_locked() was written
around.

This also explains why a resurrected peer escaped removal forever: a
MEMBER_LIST entry carries only IP + is_master, so the re-learned copy had
node_id 0, and cl_ctr_prune_stale() only propagates removal to clusterer for
node_id > 0. 'removed node_id' fired exactly once per incident and never
again.

(cherry picked from commit bbfc93cf57742fc89f11d531299d326f4dac3376)
Membership authority: in a controller-managed cluster the only way in is the
controller's JOIN handshake, after which the controller calls
clctl.add_node(). Wire self-discovery is the zero-config mode's mechanism and
must not apply - but cl_db_mode() deliberately reads 0 for controller-managed
clusters (so the controller's runtime add/remove works), and that same
predicate gated the learn paths, which put controller-managed clusters on
exactly the self-discovery behaviour they must not have.

The consequence, measured on a 3-node staging cluster: a node the controller
had expelled talked its way straight back in within one ping interval -
PING from unknown -> UNKNOWN_ID -> NODE_DESCRIPTION -> add_node() - and the
BIN connections that had just been closed were re-dialled (2 established
grew to 4). A node dead to the control plane but alive on BIN is exactly the
hung / one-plane-partitioned failure the purge exists for.

New cl_ctr_owns_membership() (0 in non-controller builds, so default builds
are unchanged) now gates all three wire-learn sites:
 - handle_full_top_update's two unknown-node learns, alongside cl_db_mode;
 - handle_internal_msg_unknown's NODE_DESCRIPTION add_node - which also no
   longer gossips the stranger's description onward via flood_message.
The UNKNOWN_ID reply to a stranger's ping is kept: telling the node we do not
know it is what prompts its controller to re-join properly. The ignore is
logged at INFO, rate-limited to one line per 30 s, because a live expelled
node re-announces on every ping cycle.

(cherry picked from commit f2903b1)
Split-brain resolution had two merge paths that behaved differently. A master
that learned of a superior via MASTER_BEACON went through
cl_ctr_rejoin_superior_master() - demote, adopt the master, and send a
JOIN_REQ. A master that learned of it via MASTER_ALIVE merely yielded: it
recorded the winner and stopped asserting mastership, and that was all.

Yielding alone is not a merge. The winner learned the yielding node only from
that packet's sender-upsert - a peer entry with node_id 0 - and the ONLY place
a node_id is ever assigned is handle_join_req(). Without a JOIN_REQ the
yielded node sits in the winner's table as id 0 forever: it is never named in
a NODE_ASSIGN, so it is never added to clusterer on any node, and its
membership digest can never match the master's MASTER_ALIVE - which turns
into a RESYNC-per-second livelock as the master keeps 're-broadcasting full
state' that structurally cannot contain the missing node.

Observed live on the 3-node staging cluster after a partition heal: the
controller admitted the returning node as a member everywhere, but the master
held it at node_id=0, clusterer never learned it, and RESYNC fired once a
second indefinitely. This had been masked before the membership-authority
change: clusterer's wire self-discovery quietly re-added the node at the BIN
level, hiding the controller-level livelock.

The yield path now calls cl_ctr_rejoin_superior_master() - identical to the
beacon merge - which demotes, arms the dead watchdog, and sends the JOIN_REQ
(join_pending-guarded, so an exchange already in flight is not stomped). The
function takes the discovery vector as a string so the merge logs say which
path found the superior.

(cherry picked from commit 5084afa)
@Lt-Flash
Lt-Flash force-pushed the feature/clusterer-controller-devel branch from 55e5429 to fc5d332 Compare August 10, 2026 09:12
Yury Kirsanov added 17 commits August 13, 2026 13:31
The flag's note read like a cost trade-off - acks are not free, so opt in when
you want them - and recommended idempotent-and-self-retrying consumers as the
better default. Both true, and both beside the point: on a channel with
concurrent traffic the flag does not work at all.

Every packet carries a sequence number and the anti-replay guard drops
anything not strictly greater than the last accepted from that peer. A
retransmit re-sends cached bytes, so it carries its original seq. Serialised
1:1 - the join handshake this was built for - is safe, because nothing can
overtake it. The moment a second message can, the retransmit lands behind a
higher seq and is discarded, the receiver re-acks it so the sender stops
trying, and neither end says anything above debug level.

Measured on the cross-node cache pull channel: the flag made delivery WORSE,
61% -> 49% under 40% reply loss at two to three times the packets, with zero
retransmitted payloads ever delivered. That change was reverted; this note is
so the next consumer does not repeat it.

Comment only - no functional change.

(cherry picked from commit 321f8cf)
…ates them

cc_max_payload is derived from the interface MTU at mod_init, but the two
buffers it gates were sized from the COMPILE-TIME CLCTR_MAX_PAYLOAD:

    cl_ctr_script_send()       char buf[CLCTR_MAX_PAYLOAD];       /* 1300 */
        guard: 2 + taglen + gen_msg->len > cc_max_payload
    cl_ctr_rpc_consumer_send() char pkt[CL_CTR_CONSUMER_PKT_MAX]; /* 83+1300 */
        guard: cl_ctr_consumer_submit(), payload_len > cc_max_payload

On any link above MTU 1411 the gate admits more than the buffer holds. This is
not a corner case: build host 222 runs enp6s18 at MTU 9000, so cc_max_payload
is 8889 against a 1300-byte stack array, and the prod gateways log
"interface ens18 MTU=1500, max consumer payload=1389". A 1350-byte script
message passes the gate and overruns by 52 bytes; on the 9000 link the ceiling
is 7589.

Fixed by making the buffers honour the bound, NOT by capping the bound. An
upper clamp on cc_max_payload would have been a smaller diff and would have
made the MTU derivation dead code - the capability is wanted, and the host
this was found on is itself on a jumbo link. Both buffers are now pkg_malloc'd
in mod_init from cc_max_payload, after the MTU probe has settled it. mod_init
is pre-fork, so every worker inherits its own copy-on-write copy: no sharing
between processes, and no per-call allocation on a path cachedb_perf's pull
replies use for every message. Neither send function can re-enter itself - a
script function runs to completion inside one route execution, and the
consumer send runs in the cluster worker's own loop.

CL_CTR_PKT_OVERHEAD is split out of CL_CTR_CONSUMER_PKT_MAX so the packet size
can be computed at runtime; the macro stays for the compile-time contract in
api.h.

One real cap added, which does not touch jumbo frames: a datagram still has to
fit a UDP payload, and loopback reports MTU 65536, which computed a payload one
byte past what sendto() accepts. Clamped to CL_CTR_UDP_PAYLOAD_MAX minus the
overhead. At MTU 9000 the bound is unchanged at 8889.

Both sites also now bound against their own buffer as well as against
cc_max_payload - the sibling cmd_cl_ctr_send_req_list() has always guarded with
sizeof(buf), and the gap between the two forms is what this was. The consumer
path drops with an error if the two ever disagree again.

PROVEN fail-then-pass with /dn/task86b, one node, both prefixes built with
-fstack-protector-all as a DETECTOR (a 52-byte overrun of a stack array
otherwise may corrupt nothing observable and the run would prove nothing):

  before  1350 bytes -> *** stack smashing detected ***: terminated,
                        workers alive 0
  after   1350 bytes -> 200, X-T86B: SENT, workers alive 17
          5000 bytes -> 200, X-T86B: SENT, workers alive 17
                        (the jumbo case: proof the headroom is usable, not
                        merely safe)

The rig needs one node because the overrun is in the memcpy that builds the
frame, before anything is sent - no cluster has to form and no peer has to
exist.

Not exposed on the fleet today: none of .241/.242/.243 call cl_ctr_send_req,
cl_ctr_broadcast_req or cl_ctr_send_rpl in their configs. The consumer path is
reachable by any API consumer following the documented cc_max_payload contract
in api.h:98-103.

Full 131-module build, 0 errors, 0 stamp mismatches.

(cherry picked from commit b3227013d03ddb06322ebed77706d705998a07be)
…_send

The flag does not work on a channel carrying concurrent messages, and fails
silently when it does not. cl_ctr_check_and_update_seq() requires
pkt_seq > last_seq, and a retransmit reuses its original seq. On the serialised
1:1 exchange this was built for - the join handshake, KEY_GRANT - nothing else
from that peer is in flight, so the retransmit still carries the highest seq
and is accepted. On a concurrent channel, later messages have already advanced
last_consumer_seq by the time the retransmit arrives, so it is dropped as
out-of-order AND re-ACKed, which stops the sender retransmitting and leaves it
believing it delivered.

Measured, not argued: on the cross-node cache pull channel under 40% reply
loss, three runs each, plain delivered 61.2% and RELIABLE 49.4% - worse, at two
to three times the packets, with not one retransmitted payload ever delivered
and pulls_late_stored at zero across all six runs.

So the default now DROPS the flag rather than refusing the send: a plain send
measurably out-delivers a reliable one here, making this an improvement rather
than a consolation prize. One rate-limited warning per process per minute says
so, and names the condition to check.

Gated by a modparam rather than removed, because the question is answerable by
exactly one party. The only callers that can reach the flag today are the
script functions cmd_cl_ctr_send_req / cmd_cl_ctr_broadcast_req, and they all
share one channel (cl_ctr_script_chan) - concurrent by definition if two routes
fire at once. Whether that happens is a property of the deployment's own
routes: the admin can answer it, a module author cannot be asked via config.

    modparam("clusterer_controller", "enable_reliable_send", 1)

One gate, at cl_ctr_consumer_submit(), because both the script functions and
the consumer API funnel through it - the flag cannot survive by another route.
api.h now documents that the flag is gated, and why.

This is MITIGATION, NOT REPAIR. It stops the broken path being reached by
accident; it does not make RELIABLE reliable. Task OpenSIPS#71 stays open for the real
fix - a fresh seq per retransmit with message identity carried separately, or a
small per-peer window instead of a single high-water mark - or for deleting the
flag, which is defensible since the internal control traffic that genuinely
depends on serialised delivery does not go through it.

Verified on a live node, both directions: with the modparam absent a
cl_ctr_send_req(..., reliable=1) logs the refusal once and sends plain; with
enable_reliable_send=1 it is honoured and nothing is logged. The clctr send
path is otherwise unchanged - the /dn/task86b rig still passes at 1350 and
5000 bytes with 17 workers alive. Full 131-module build, 0 stamp mismatches.

(cherry picked from commit ab7851614c50214d48f95298d22ac7486b9eb25a)
… length

Both consumer enqueue sites cached and re-sent plain_len for a packet that
cl_ctr_seal_and_send() had encrypted in place. What goes on the wire is
CL_CTR_WIRE_HDR_SZ + plain_len + CL_CTR_TAG_SZ, so every repair left 44 bytes
short and every receiver discarded it as "short packet, dropping" before it
could be decrypted. Consumer retransmission had never delivered a single byte.

The control plane's own enqueue (KEY_GRANT) always passed the sealed length,
which is exactly why the join handshake's ARQ worked and this did not.

Measured on a two-node rig under 40% loss: the receiver logged 113 x
"short packet (25 bytes)", 25 being precisely the plain_len of the messages
being repaired.

(cherry picked from commit 7e69636bfe6ae352cabda7b6496b1176e4dfae2d)
cl_ctr_check_and_update_seq() was a bare high-water mark, so it could not tell
"already delivered" from "delivered out of order" and had to reject both.
Two consequences: a retransmit carries its ORIGINAL seq, so once any later
message had been accepted the repair was discarded - and then re-ACKed, which
stopped the sender retransmitting and left it believing it had delivered; and
ordinary reordering, which any multipath network produces, silently dropped
consumer payloads that were never duplicated at all.

Replace it with the standard construction (IPsec RFC 4303 A.2, DTLS RFC 6347
4.1.2.6): the mark plus a 1024-slot circular bitmap of what has already been
accepted at or below it. A seq below the mark whose bit is clear was never
delivered, so its repair is accepted; a seq whose bit is set is a true
duplicate, re-ACKed but not delivered twice. No seq is ever accepted twice, so
replay protection is unchanged.

Below the window the receiver cannot know whether it ever had the message, so
it drops it and deliberately does NOT acknowledge it - an acknowledgement it
cannot justify is the silent failure this is meant to remove. mod_init reports
the per-peer message rate the window spans, since both terms of that bound
(consumer_retries x consumer_retry_ms) are configurable.

(cherry picked from commit f3dded5b6d0dba7709ade6902fa47b1980c02bf6)
cl_ctr_retx_enqueue_bcast() armed the shared timerfd unconditionally to
now + retry_ms on every send. timerfd_settime() REPLACES a pending expiry, so a
steady stream of reliable sends pushed the deadline forward faster than it
could arrive: at fifty messages a second with a 40 ms gap the timer was
re-armed every 20 ms to fire 40 ms later, and never fired at all while traffic
continued. Measured on the rig: 45 lost messages produced 2 retransmits.

Two related defects in the same timer. The sweep armed and re-armed to a flat
CL_CTR_RETX_INTERVAL_US (250 ms) rather than to the earliest pending deadline,
so a consumer entry due in 40 ms waited 250; and it reset next_due_us to
now + 250 ms on every retry, discarding the per-cluster cadence that
cl_ctr_retx_enqueue_consumer() had just set.

Give each entry its own retry_ivl_us and route every arm through
cl_ctr_retx_rearm(), which arms for the earliest deadline in the queue and
disarms when it is empty. Never arm 0 us - that is timerfd's disarm, not
"fire immediately".

(cherry picked from commit c1028110d8dcf7790257f502900f32bafb9e4271)
The cache was one inline CL_CTR_NODE_ASSIGN_MAX_SZ array per entry (~580
bytes), sized for the largest control-plane packet. Any reliable consumer send
above roughly 540 bytes was therefore never queued for repair at all - well
under CLCTR_MAX_PAYLOAD, the size api.h tells consumers they may rely on - and
it degraded in silence, on an LM_DBG.

Sizing it to the compile-time CLCTR_MAX_PAYLOAD instead would cap repair at
1300 bytes on a jumbo-frame link, a seventh of what that link and
cc_max_payload happily carry: the same trap the transmit buffers had before
they were sized from the MTU. So the bound is cl_ctr_retx_pkt_max, derived in
mod_init from cc_max_payload beside cl_ctr_script_buf_sz, and each entry
allocates for the packet it actually holds - the cost then tracks outstanding
reliable messages instead of reserving queue x MTU up front, which at a 9000
MTU would be 1.1 MB per cluster sitting idle.

cl_ctr_retx_release() becomes the only way an entry is cleared, so a buffer
cannot be dropped by a path that merely clears `used`; the flush path releases
each entry rather than memset-ing the array over live pointers. A send too
large to cache still goes out once, but now says so with a rate-limited
warning instead of a debug line.

(cherry picked from commit dd2befdaf0efa0d0fb0c6d34c126ed70327e01c9)
…uffer

It built its payload in a CLCTR_MAX_PAYLOAD stack array while its two
siblings, cl_ctr_send_req() and cl_ctr_broadcast_req(), use the
cc_max_payload-sized cl_ctr_script_buf. On a jumbo-frame link a script could
therefore broadcast an 8 KB message but could not send that same message to a
list of nodes: the list form stopped at a seventh of what the other two
carried, and blamed "the cluster plane" rather than its own buffer.

Share the buffer and the guard.

(cherry picked from commit 25eafda958d0ee8dbaa1c7950dfec3a67baab49d)
…contract

The switch was added hours earlier to gate a mechanism that could not work: a
retransmit reused its seq, the receiver's anti-replay guard was a bare
high-water mark, and the repair was sent truncated to its plaintext length -
so on any channel it was rejected, and on a concurrent one silently re-ACKed.
Measured on the cross-node cache pull channel under 40% reply loss: plain
61.2% delivered, reliable 49.4%, at two to three times the packets.

With the sealed length, the replay window and the deadline-driven retransmit
timer fixed, the flag does what it says. Interleaved A/B on a two-node rig,
40% loss on the consumer data leg, 300 messages at 50/s, three runs each:
57.0/59.0/60.0% before, 93.3/94.0/92.7% after - both matching closed form, for
no working ARQ (1-p) and for two retries (1-p^3). Packet counts corroborate it
independently: before, exactly 300 packets went out for 300 messages, so not
one retransmit ever reached the wire. Separately, with a clean data leg and
35% ACK loss, 128 duplicates arrived and none was delivered twice.

So the default becomes 1. The switch stays as a kill switch, because ARQ has a
cost an operator may not want: a reliable broadcast draws one ACK back from
every member, so on a large cluster one message becomes an event. Turning it
off degrades a reliable send to a plain one - the payload still goes out - and
warns.

api.h and the admin guide are rewritten accordingly: the at-most-once
contract, the fact that consumers are not asked to deduplicate, and the one
real limit - the window spans messages rather than time, so a peer that
outruns it gets a repair that is dropped and deliberately not acknowledged.

(cherry picked from commit 93b1aaef0d08f939adb225fa3ef96325310d46e9)
The replay window compared sequence numbers with plain unsigned arithmetic, so
when a peer's 32-bit counter wrapped, every packet after the wrap read as
2^32-ish behind and was rejected until the next key rotation reset both sides.
Fail-closed, but silent - and the rotation it waits for may never come: a fresh
master_salt is generated only in cl_ctr_on_became_master(), so a cluster with a
stable master never rekeys on its own.

Nor is the wrap remote. Every ACK bumps my_seq, so the control plane advances
at the rate of the consumer traffic it acknowledges, and at the window's own
12,800 msg/s ceiling 2^32 is under four days.

Compare with serial-number arithmetic instead. On its own that would trade the
outage for something worse - a packet captured 2^31 or more messages ago has a
difference that reads back as a large POSITIVE and would be accepted as fresh -
so the forward step is bounded too: CL_CTR_REPLAY_MAX_JUMP (2^24) or more is
refused as implausible rather than believed, with its own verdict and log line
so a real partition is not mistaken for an attack. A peer that genuinely
exceeds it is recovered by the window reset that already runs when it rejoins
or reappears in a MEMBER_LIST.

Note the old code accepted that 2^31-behind packet as well - unsigned
comparison called it a forward jump - so the bound closes a hole rather than
opening one. What remains is inherent to a 32-bit counter: a packet held across
a full 2^32 cycle lands back inside the window.

Proven by /dn/task89, which extracts the window logic from a given revision so
it always tests the real code: against the parent commit the four wrap cases
and the 2^31 replay fail; against this one all fourteen pass.

(cherry picked from commit 09740850b0de9b4dff171de71d0ad5dc34513a44)
…to CLCTR_MAX_PAYLOAD

mod_init derived the maximum consumer payload from the interface MTU and then
raised it back to CLCTR_MAX_PAYLOAD whenever the link was smaller than that.
The effect was the exact inverse of why the MTU is consulted at all: on any
interface under about 1411 - a VPN, a GRE or IPIP tunnel, PPPoE, the usual
1400/1420/1436 - the module advertised a bound ABOVE what the path carries.
Every full-size consumer datagram then IP-fragmented, and a fragmented datagram
is lost entirely if any one fragment is, so the padding manufactured precisely
the losses the retransmit machinery then had to repair.

Honour the link and say so instead.  CLCTR_MAX_PAYLOAD stays what a consumer
sizes a compile-time buffer with, but it stops being a promise the network
cannot keep, and a consumer that cares about the real bound now asks for it.

(cherry picked from commit f0ff9a2dfb, clusterer_controller portion only -
the cachedb_perf half belongs to the cachedb_perf PR)
A consumer needs the MTU-derived bound, not the compile-time constant, and the
only way to reach it was to declare `extern int cc_max_payload`.  That is
defined in clusterer_controller.c, and OpenSIPS modules are dlopen'd, so the
reference leaves an undefined symbol in the consumer's .so that resolves only
if clusterer_controller happens to have been loaded first.  It does not fail at
build time - it fails at startup with

  cachedb_perf.so: undefined symbol: cc_max_payload

and takes the whole config down with it.  Every other cross-module call here
goes through the bound API for exactly this reason, so add get_max_payload()
alongside them and say plainly in api.h not to declare the extern.

(cherry picked from commit bdb52a3ba3, clusterer_controller portion only -
the cachedb_perf half belongs to the cachedb_perf PR)
The MTU is read from the interface the plane runs on and kept in cc_mtu (what
we joined at) and cc_mtu_now (the live reading).  There is deliberately no
modparam for it: a configured value is a second source of truth that can
disagree with the kernel, so the config says 1500 while the link says 9000 and
the node refuses to start while being perfectly consistent with its own
segment.  That is the same defect class as the CLCTR_MAX_PAYLOAD floor.

Because the MTU now decides whether a node may join at all, two places that
used to shrug become fatal:

  - auto-detection that cannot name the interface owning the resolved IP used
    to warn and carry on with the compile-time default.  Both explicit modes
    (my_ip, interface) always yield a name, so this is only reachable from the
    default-route probe, and naming either modparam is the fix.
  - a failed SIOCGIFMTU leaves the node unable to establish whether it belongs
    in the cluster, which is not a state it can serve SIP from.

The value is exported through cl_ctr_list_members, cl_ctr_node_info and
cl_ctr_list_config, because a log line is not something an operator can alert
on - and one gateway in this fleet has no journal at all.

(cherry picked from commit ccfdc3e40f6da9283e3e80ea5372f969b7358eba)
…ter's

JOIN_REQ and ALIVE now carry the sender's MTU, and the master admits only nodes
whose value equals its own.  That single rule is the whole mechanism: by
induction every member carries the master's MTU, so the backup that eventually
becomes master necessarily carries it too and a handover cannot change it.
Nothing has to be inherited, agreed or carried in MEMBER_LIST - membership is
itself the proof of agreement.  A partition inherits the property on both
sides, and a master that drifts removes itself while the backup keeps the
original value, so a drifting node cannot redefine the cluster's MTU on its way
out.

The check has to happen here because the failure is undetectable later: an
oversized datagram is dropped by the small-MTU node's NIC with no ICMP, since
every node is on one L2 segment and there is no router to generate one, and
multicast has no path-MTU discovery in any topology.  The receiver therefore
can never observe the packet it failed to receive.  At join both sides are
still exchanging small handshake packets that do arrive.

It refuses BEFORE KEY_GRANT for key custody, not speed: the joiner holds no
session key until then, so a refused node never obtains the group key at all,
where admit-then-eject would have handed it multicast decryption and needed a
rotation to take it back.  The refusal also drops the peer from the table -
while still NEW a node upserts every joiner it hears, so refusing alone would
leave a node we just turned away being counted as a member.

Unconditional, unlike the config gate: there is no policy knob, because a node
that cannot participate must not serve SIP.  Without shared usrloc, dialog
replication and shtag coordination it answers REGISTERs into a local store and
routes on a partial view while the load balancer keeps feeding it calls.

A joiner that advertises no MTU at all is an older build.  It is admitted with
a warning rather than refused, or a rolling upgrade could never complete: every
not-yet-upgraded node would be turned away by the first upgraded master.

Master election is by highest IP and completely MTU-blind, so on a simultaneous
cold start a single misconfigured host that wins it will refuse an otherwise
healthy fleet.  cl_ctr_mtu_note_reject() says so out loud once the master has
turned away several distinct peers while holding no members of its own.  It
only warns - a master that stepped down because OTHERS disagreed would break
the rule that a node acts on its own condition alone, and would hand anyone
holding the bootstrap key a way to force re-elections.

A refused node self-terminates, naming both MTUs and the interface, but only
after checking the claim against its own kernel reading: a reject quoting our
own MTU back at us contradicts itself and is ignored.  It acts whenever an
admission request is outstanding, not merely while NEW - on a simultaneous cold
start both nodes reach the join deadline, both self-promote, and the loser
merges into the winner, so it is ACTIVE rather than NEW when its JOIN_REQ is
refused.  A settled member with no request outstanding still ignores rejects,
which is what stops one member from evicting another.

(cherry picked from commit ab56c508c22292681bf64582985f107774725563)
Nothing notified the module when its link changed - the MTU was read once at
mod_init and never revisited - so a node could go on believing it matched a
cluster it no longer did.  The ALIVE timer now re-reads it.

Poll rather than netlink RTM_NEWLINK: netlink drops events on ENOBUFS, so a
reconcile poll would have to exist anyway and a missed MTU change is exactly
the silent failure this removes, and it fires on transients a poll harmlessly
misses - a bond failover, a driver reset, a VLAN parent bounce.  Acting on the
first event would kill a healthy node over a blip, hence the strike count as
well.  One ioctl per query_time costs nothing.

Own drift terminates the node rather than warning, because shrinking an MTU
breaks RECEIVE, not send: the kernel still fragments on egress, so the node
keeps emitting heartbeats and looks healthy to every peer while silently no
longer receiving anything larger than its new MTU.  MEMBER_LIST on a real
cluster is well over 1500, so it would sit on stale membership and an
incomplete BIN mesh, invisible to its peers, while the LB kept handing it
calls.  This is the node acting on its OWN reading, which is what makes acting
safe at all - a switch-side change does not move /sys/class/net/X/mtu.

Peer drift only warns, and must: terminating because someone else changed would
turn one `ip link` command into a fleet outage.  That peer detects its own
change and removes itself.

What is advertised is the live reading, not the value we joined at.  Announcing
the joined-at value instead made the peer warning unreachable - a node whose
link changed kept announcing the old number, so no peer could ever observe the
change, and the only account of it was in the log of the node that was about to
disappear.

(cherry picked from commit 5d7f0130257d6d98c7438db0b3e56231c08d1bfc)
The drift poll had two outcomes where it needed three.  cl_ctr_read_iface_mtu()
returns -1 when the ioctl fails, and that fell into the same branch as "the link
agrees again", which did two wrong things at once.

It cleared the strike count, so a read failing at roughly the confirmation
cadence could hold a genuinely drifted node alive indefinitely - the exact
silent failure the poll exists to remove.  And it logged that the MTU was "back
to" a value it had never read: a measurement that did not happen, which is worse
than no line at all, because an operator reading it concludes the link
recovered.

A failed read is neither drift nor recovery.  It is an absence of evidence, so
it now leaves the strike count exactly where it stood and says so - on the first
failure and rarely after, since a poll that has gone blind must not be able to
look like a quiet healthy one.

It is deliberately not fatal, even though mod_init refuses to START without an
MTU.  The asymmetry is the point: at startup the node holds no membership and
loses nothing by refusing, while here it is an established member carrying
calls, and an ioctl we could not complete is evidence about our own visibility
rather than about the link.  The drift this guards against - a host-local `ip
link` change - leaves the interface perfectly readable; a read that fails means
it was renamed or removed, which announces itself far more loudly elsewhere.

The decision moves into cl_ctr_mtu_step() so it can be exercised directly: the
sequences that matter (a failure landing mid-drift, a true transient, a drift
that persists) are awkward to stage against a real interface and trivial to
enumerate.  /dn/task97 extracts the function straight from this file and runs
both the old and new logic over the same inputs - the old one never confirms a
drift once a failed read interrupts it, including when failures alternate with
drift readings forever.

(cherry picked from commit 984e55502008f08759a11af58769b53ae4d3babc)
The fragmentation note claimed "the DF bit is not set so fragmentation occurs
transparently where the network allows it".  That was never measured and is
false.  The socket sets no IP_MTU_DISCOVER at all, so Linux's default applies -
IP_PMTUDISC_WANT - and that default DOES set DF, on unicast and on multicast
alike, confirmed on the wire at 2000, 4000 and 8900 bytes.

What actually carries a 4395-byte MEMBER_LIST across a 1500-byte link is that it
EXCEEDS THE LOCAL MTU: a datagram the kernel must fragment locally cannot also
be doing path-MTU discovery, so those fragments leave with DF clear.  The old
wording described the right outcome by the wrong mechanism, and the mechanism
matters - a datagram that FITS the local MTU goes out with DF SET, so meeting a
smaller-MTU hop it is dropped with an ICMP "fragmentation needed" that this
topology routinely filters, rather than being fragmented and delivered.

The behaviour is left exactly as it is.  Clearing DF would let a jumbo node
reach a peer across a narrow routed hop, but this module's contract is the
interface it runs on, not how the operator carries multicast between segments:
enforcing one MTU across the cluster is what makes the local bound mean
anything, and moving packets between segments is the network's job.

(cherry picked from commit 3c65e429ef88e6613f52ee0008c9f23ecef6225e)
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