clusterer_controller: zero-config HA clusterer control module with automatic sharing tag management via encrypted UDP multicast#4073
Closed
Lt-Flash wants to merge 2 commits into
Conversation
…sterer
(Port to the 3.6 branch: the module needed only the socket_info_full
listeners-list change in cc_discover_bin_sockets; the clusterer-side
integration was re-seated against 3.6's diverged clusterer.c/mod.c.)
Introduces the clusterer_controller module: a zero-configuration high
availability coordinator for the clusterer module. Nodes discover each other
over authenticated, encrypted UDP multicast, elect a master deterministically
and drive clusterer sharing-tag failover automatically - no per-node node_id
lists or static topology configuration required.
Module (modules/clusterer_controller):
- UDP multicast discovery and keepalive protocol. Every packet's payload is
sealed with an AEAD behind a 2-byte cleartext magic (key-tier selector) and a
2-byte cluster_id, both bound into the tag as AAD (so a captured packet can't
be re-stamped onto another cluster_id on a shared multicast+password group).
The cluster_id is also filtered before decryption, so several clusters can
share one multicast group. Two key tiers: a bootstrap key used for the
admission handshake and the split-brain beacon, derived from the shared
password with a memory-hard KDF; and a session key (HKDF over an X25519
ECDH-agreed master salt) for all normal traffic. A startup warning fires if
the password is the default or has low estimated entropy.
- Crypto suite selected at build time: AES-256-GCM + scrypt (N=2^16) via
WolfSSL by default, or XChaCha20-Poly1305 + Argon2id when built against
libsodium (192-bit nonce; detected via pkg-config, linked dynamically). The
two wire formats are not interoperable, so all nodes must be built alike; the
active suite is logged at startup.
- Per-worker X25519 ECDH key agreement. The session key is generated once, when
the first node bootstraps the cluster, and preserved across every master
change - a new master reuses the key every member already holds, so
transitions need no re-keying and no re-JOIN cycle.
- Master / backup / member roles with a master_stickiness modparam (default 1):
a live master is not preempted by a higher-IP node that joins (it becomes the
backup instead); on master failure the backup - the highest-IP survivor - is
promoted immediately. With master_stickiness=0 the highest-IP node always
becomes master.
- Split-brain is prevented and healed by three mechanisms: (1) prevention at
join time - simultaneously-starting nodes see each other's JOIN_REQs, so a
node that has seen a higher-IP starter defers self-promotion and joins it
instead of forming an independent-key lone master; (2) same-key yield - two
masters sharing a session key see each other's MASTER_ALIVE and the lower-IP
one yields; (3) divergent-key merge - masters with different session keys
emit a bootstrap-key MASTER_BEACON, and a node hearing a superior beacon
(larger partition, ties by higher IP) re-joins that master and adopts its key.
- Fast failure detection (MASTER_ALIVE at 1s, 3s timeout) with immediate backup
promotion; graceful KEY_HANDOFF + GOODBYE on clean shutdown.
- Single event-driven worker on the OpenSIPS reactor (epoll). Per-source rate
limiting and 32-bit sequence-number replay protection before/after decrypt;
peer-table exhaustion defence.
- Join authentication: the master sends an encrypted, unforgeable JOIN_REJECT
after repeated bootstrap-decrypt failures from an IP. A wrong-password node
cannot read that reject, so it also self-detects: while joining it counts
undecryptable packets from other peers and, at the join deadline, shuts down
("cannot authenticate - wrong password?") instead of self-promoting into a
lone split-brain master. Undecryptable session packets from anything other
than the current master are ignored, so a wrong-password or malicious node on
the group cannot force the cluster into a re-JOIN churn.
- Config-consistency enforcement: all nodes of a cluster must use identical
manage_shtags, master_stickiness and query_time (a per-node mismatch would
silently break failover / sharing-tag behaviour, e.g. a master with
manage_shtags=0 leaving no active tag holder). Each node advertises these
effective settings in its ALIVE heartbeat and JOIN_REQ; the on_config_mismatch
modparam selects the policy: "reject" (default) - the master refuses the join
and the misconfigured node shuts down with a clear message; "warn" - the node
joins but a single deduplicated CONFIG MISMATCH warning is logged; "adopt" -
the joining node adopts the running cluster's settings at runtime. (clusterer
gains an unset_shtag_managed ctrl bind so adopt can toggle shtag management.)
- Sharing-tag control: normally the master is the sole active holder. An
operator can override this with cc_shtag_force (pin the active tag to a chosen
node) and revert with cc_shtag_auto; the override is carried in MEMBER_LIST,
survives master fail-over and auto-clears if the forced node departs. Each
node logs why its tags were (de)activated.
- Per-cluster configuration via the "cluster" modparam, with global defaults
for password, query_time, manage_shtags and master_stickiness that each
cluster can override. MI commands: cc_list_members, cc_node_info,
cc_list_config (resolved per-cluster settings, including shtag_mode),
cc_shtag_force and cc_shtag_auto.
clusterer integration (modules/clusterer):
- New controller API (clusterer_ctrl.[ch]): dynamic node add/remove, identity
update and sharing-tag control driven by the controller.
- Controller-managed sharing tags: forced to backup on startup with MI/script
tag changes blocked while managed; the active tag follows the elected master.
- Fix a NULL current_node dereference in bin_rcv_cl_packets: with a dynamically
built topology a cluster can receive BIN packets before this node's identity
is established; such packets are now dropped until it is, instead of crashing.
core / event_route:
- ipc: expose ipc_is_async_dispatch() to detect being inside an IPC RPC job.
- event_route: raise events inline in that case, avoiding a redundant async
dispatch.
test/cc_join_reject_test.py: a standalone rogue-joiner security test. From a
non-member host on the multicast segment it sends unauthenticated JOIN_REQs
(expecting a JOIN_REJECT from the master) and a fake-MASTER_ALIVE flood
(expecting the cluster to ignore it), reporting PASS/FAIL. Requires no node
config changes.
Cryptography uses WolfSSL (linked from the tls_wolfssl module) and, optionally,
libsodium for the XChaCha20-Poly1305 + Argon2id build.
Lt-Flash
marked this pull request as ready for review
July 13, 2026 10:06
…N listener An explicit per-cluster bin_socket= that does not correspond to any BIN listener configured with socket=bin: was previously only warned about, then used anyway. That is always a misconfiguration: the bogus URL is both fed to clusterer as this node's identity and advertised to peers, so peers cannot open BIN connections to it and the node cannot resolve its own send socket - the multicast control plane stays healthy while clusterer replication (dialog, dispatcher, usrloc, ...) silently fails. Turn the warning into a hard mod_init failure that lists the discovered BIN sockets, so the misconfiguration is caught at startup instead of surfacing as broken replication later. The auto-discovery path is unaffected (it can only pick a real listener).
Lt-Flash
marked this pull request as draft
July 13, 2026 14:03
Member
|
Thank you for your contribution! However, our policy is to push features only in master - therefore, as this is a new feature, will not get to be backported to 3.6, so the PR will never be merged. So I am closing it. Please do continue this work in the master branch, in PR #4074. Thank you again! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
This PR introduces
clusterer_controller, a new OpenSIPS module that drives the existingclusterermodule's topology automatically at runtime. Rather than replacing the clusterer, it acts as a control plane on top of it — dynamically adding and removing nodes, electing a master, and managing sharing tag failover, without requiring a database, static node lists, or manual configuration.The
clusterermodule itself is unchanged in its normal mode of operation. Whenuse_controller=0(the default), the clusterer behaves exactly as before — DB-backed or static neighbor config, standard sharing tag event routes and MI commands, no multicast involvement whatsoever. The controller is strictly opt-in.Peer discovery is done via authenticated, encrypted UDP multicast (AES-256-GCM by default; XChaCha20-Poly1305 + Argon2id when the module is built against libsodium). Nodes announce themselves on a shared multicast group and elect a master deterministically (master / backup / member roles, see below), and the master assigns node IDs and maintains the member list. All of this is fed into the
clusterermodule at runtime through a newclusterer_ctrlAPI, so every existingclustererconsumer (dialog, usrloc, dispatcher, etc.) works without any changes to how they use the clusterer.By default, multicast operates within a single LAN segment. For multi-site deployments, discovery can be extended across routable networks using multicast routing protocols such as PIM-SM (Protocol Independent Multicast — Sparse Mode) or similar services, provided the network infrastructure supports it.
The result is a fully identical config across all nodes — the only difference between machines is the BIN socket IP address.
How it works
Each node joins a multicast group and broadcasts a
JOIN_REQ. The master responds with aKEY_GRANT(handing the joiner the cluster session key) andNODE_ASSIGNpackets that allocate anode_idto every member and feed it into theclusterermodule via theclusterer_ctrlAPI. The master periodically broadcastsMEMBER_LISTso late joiners and re-joiners converge quickly, and a 1-secondMASTER_ALIVEkeepalive lets peers detect master failure within ~3 seconds.Each cluster has three roles: master (the active coordinator), backup (the standby that takes over on master failure — always the highest-IP non-master) and member. Election is deterministic across nodes and needs no negotiation round-trips. The
master_stickinessparameter (default1) keeps a live master in place: a higher-IP node that joins becomes the backup rather than preempting the master, so joins cause no handover (the previous backup simply drops to member if the newcomer has a higher IP). Withmaster_stickiness=0the highest-IP node always becomes master.On master failure — graceful
GOODBYEor keepalive timeout — the backup is promoted immediately. Because the session key is generated once at cluster bootstrap and preserved across every master change, the new master reuses the key every member already holds: no re-keying and no re-join storm.Split-brain is prevented and healed by three cooperating mechanisms:
JOIN_REQs, so at the join deadline a node that has seen a higher-IP starter defers self-promotion (bounded) and joins it instead. Only the highest-IP starter becomes master, so independent-key lone masters never form in the first place.MASTER_ALIVE; the lower-IP one yields.MASTER_ALIVE, so each emits a bootstrap-keyMASTER_BEACON(readable by any correctly-configured node). On hearing a beacon from a superior partition (larger member count, ties broken by higher IP), a node re-joins that master and adopts its key, converging the whole cluster onto one master and key.Because node identity and peer lists are injected into
clustererat runtime, no DB reads or staticneighbormodparams are needed. All existing clusterer consumers continue to work transparently.New module: clusterer_controller
Key parameters
cluster(repeatable, required) — defines a cluster to participate in. Value is a comma-separated key=value string:id=N— positive integer cluster identifier, must match thecluster_idused by clusterer consumers (dialog, usrloc, etc.)multicast=A.B.C.D:PORT— IPv4 multicast group and UDP port. Address must be in the 224.0.0.0/4 range. Two clusters on the same LAN can share the same address with different ports, or use entirely different addresses — both isolate traffic correctly.password=STRING— per-cluster shared secret; falls back to the globalpasswordmodparam if not setbin_socket=bin:IP:PORT— BIN socket to advertise to clusterer peers for this cluster; required when multiple clusters are definedmanage_shtags=0|1— per-cluster override for automatic sharing tag failover; falls back to the globalmanage_shtagsmodparam if not set, regardless of modparam order in the config filepassword(default:3eCrEt*5629) — the shared cluster secret. A per-cluster bootstrap key is derived from it with a memory-hard KDF (scrypt, or Argon2id in the libsodium build); the session key is agreed via X25519 ECDH (see Security). All nodes in a cluster must use the same password. Change this in production.manage_shtags(default:1) — global default for automatic sharing tag management. Can be overridden per cluster via themanage_shtags=key in theclusterstring.master_stickiness(default:1) — controls whether a live master is kept when a higher-IP node joins.1(sticky) = the master stays and the higher-IP joiner becomes the backup, minimising handovers;0= the highest-IP node always becomes master. Global default, overridable per cluster via themaster_stickiness=key in theclusterstring.query_time(default:5) — heartbeat interval in seconds. Controls the election window (3 × query_time) and the peer purge window (6 × query_time). Valid range: 1–60.on_config_mismatch(default:reject) — policy when a node's consistency-critical settings (manage_shtags,master_stickiness,query_time) differ from the running cluster:reject(refuse the join, node shuts down),warn(join but logCONFIG MISMATCH), oradopt(adopt the cluster's settings at runtime). See Security.my_ip(default: auto-detected) — pin the controller identity to a specific local IPv4 address. Useful on multi-homed hosts.interface(default: auto-detected) — pin the controller to a named network interface (e.g.eth0). Ignored ifmy_ipis set.Automatic sharing tag management (
manage_shtags)When
manage_shtags=1(the default), the controller master is the single authority for sharing tag state in theclusterermodule. No script-level event routes or MI commands are needed to handle failover:backupstate regardless of the=activevalue in the clusterer config. This prevents a restarting node from stealing the active tag from a running cluster.query_time × 3seconds with no master response.manage_shtags=1, theclusterer_set_tag_activeMI command and$shtag()script variable setter are blocked for controller-managed clusters, preventing accidental conflicts with automatic failover.manage_shtagscan be set globally and overridden per cluster, regardless of the order modparams appear in the config file:Minimal config (identical on all nodes, only BIN socket IP differs)
Multiple clusters
Clusters are isolated by their multicast address and/or port, so you can use the same multicast address with different ports, or entirely different addresses — both work:
Each cluster runs an independent controller worker process with its own election, membership, and sharing tag state. Packets from different clusters never mix: every packet carries a 2-byte cluster_id in its cleartext header, and a node silently drops any packet whose cluster_id differs from its own before decryption — so multiple clusters can even share the same multicast address and port without interfering, and foreign traffic never trips the wrong-password machinery. Two clusters merge only if they are configured identically (same address, port, cluster_id and password).
MI commands
cc_list_members— list the current members of each cluster with theirnode_id, status (master/backup/member) and BIN sockets.cc_node_info node_id=N— full info for a specific node across all clusters.cc_list_config— list every configured cluster and its resolved effective settings (multicast, my_ip, bin_socket, query_time, master_stickiness, manage_shtags,shtag_mode, member_count). The password is never exposed. Useful for confirming a per-cluster override took effect.cc_shtag_force cluster_id=N node_id=M— pin the active sharing tag to a chosen node, overriding automatic master-driven allocation (master-only). The override is carried inMEMBER_LIST, survives master fail-over, and auto-clears if the forced node departs.shtag_modethen reportsoverride:M.cc_shtag_auto cluster_id=N— clear the override and resume automatic, master-driven sharing-tag allocation (master-only).Security
cluster_id, and the nonce — everything meaningful (type, seq, IPs, pubkeys, salts, member lists) is inside the sealed payload. The magic andcluster_idare bound into the AEAD tag as AAD, so a captured packet can't be re-stamped onto anothercluster_idon a shared multicast+password group. The AEAD is AES-256-GCM (WolfSSL) by default, or XChaCha20-Poly1305 when built against libsodium (its 192-bit nonce removes any random-nonce collision concern) — a build-time choice; all nodes in a cluster must match, and the active suite is logged at startup.JOIN_REJECTafter repeated bootstrap-decrypt failures. A wrong-password node can't read that reject, so it also self-detects — while joining it counts undecryptable packets from other peers and, at the join deadline, shuts down ("cannot authenticate — wrong password?") instead of self-promoting into a lone split-brain master. An active member ignores JOIN_REJECT, so a correct-password peer can never be evicted. A full peer table rejects new spoofed-IP joins.manage_shtags,master_stickinessandquery_time— a per-node mismatch would otherwise cause silent, inconsistent failover/sharing-tag behaviour (e.g. a master withmanage_shtags=0leaves no node holding the active tag). Each node advertises these effective settings in its ALIVE heartbeat and JOIN_REQ; theon_config_mismatchmodparam picks the policy when a node tries to join an established cluster with different settings:reject(default) — the master returns a JOIN_REJECT and the misconfigured node shuts down with a clear message;warn— the node joins but a single deduplicatedCONFIG MISMATCHwarning is logged;adopt— the joining node adopts the running cluster's settings at runtime (andcc_list_configthen reports the adopted values).Changes to the
clusterermoduleNew controller API (
clusterer_ctrl)A new
clusterer_ctrl.c/.hpair exposes a load-time API (load_clusterer_ctrl_binds) through which an external module can drive theclusterertopology at runtime:set_my_identity(cluster_id, node_id, bin_url)— register this node's identity post-forkadd_node / remove_node— add/remove peers at runtimeupdate_identity— correct node_id after master assignmentsync_current_id— sync process-localcurrent_idafter forkactivate_backup_shtags / force_backup_shtags— sharing tag controlset_shtag_managed— lock a cluster's tags against manual changesuse_controller=1modeA new
use_controllermodparam tellsclustererto create stub clusters at startup without requiring DB or static neighbor config. Identity and peer list are filled in post-fork by the controller. Whenuse_controller=0(the default), the clusterer is completely unaffected — all existing behaviour, DB loading, static neighbors, sharing tag event routes, and MI commands work exactly as before.Bug fixes
cl_set_stateandhandle_remove_node: NULL guard was placed afterlock_get(current_node->lock), making it dead code. Both moved before the lock call.sync_req_timewas left at zero (epoch) before identity was assigned post-fork, causing the seed fallback timer to fire immediately. Fixed by always callinggettimeofday()when queuing a sync request._current_id_shmallocation inmod_init: second allocation inside theuse_controllerblock removed.shtag_activate_all_backup: replaced with a collect-then-activate pattern.current_nodedereference inbin_rcv_cl_packets: when the topology is built dynamically viaclusterer_ctrl, a cluster can exist and receive BIN packets before this node's identity is established. An inbound packet in that window dereferenced a NULLcl->current_nodeand crashed the receiver; such packets are now dropped until the local identity is set.Testing
Five HA scenarios were tested on a 3-node cluster (10.22.23.191–193) with dialog replication and sharing tag failover:
master_stickiness=1it stays master as higher-IP nodes join, each newcomer taking the backup role and the previous backup dropping to member. Tags activated on the master, others in backup ✅kill -9) — the backup (highest-IP survivor) is promoted to master in ~3s using the preserved key (no re-keying, no election loop), a new backup is designated, and its backup tags are activated ✅master_stickiness=0— verified a higher-IP joiner takes over as master immediately (the non-sticky path) ✅test/cc_join_reject_test.py) — a standalone script (no node config changes) run from a non-member host on the multicast segment: it sends unauthenticated JOIN_REQs and confirms the master emits aJOIN_REJECT, then floods fakeMASTER_ALIVEpackets and confirms the cluster ignores them (no re-JOIN churn) ✅cc_shtag_forcepins the active tag onto a non-master node (all nodes reportoverride:N), the override survives a masterkill -9(the new master keeps honouring it), auto-clears when the forced node departs, andcc_shtag_autorestores automatic allocation; error paths (non-master, unknown node/cluster) rejected ✅MASTER_BEACON("superior master … found via beacon") ✅Full test details are documented in
modules/clusterer_controller/doc/clusterer_controller_tests.xml.Limitations (alpha)
query_time— but strict NTP is not required for correctness; replay protection is sequence-number based, not time-window basedRoadmap (not in this PR)
Planned follow-ups (also documented under Planned Features in the admin guide):
Node maintenance mode — an MI command to place a member into maintenance: it becomes fully inert (never master, no elections, holds no shtags — any it holds are released and re-allocated, all cluster activity suspended) while staying visible with a
maintenancestate. A newevict_on_maintenancemodparam (default1) controls the data plane:1evicts the node from clusterer (clean drain, re-sync on exit);0keeps it in the clusterer topology so it keeps replicating usrloc/dialog/etc. and stays warm. When it lands,cc_shtag_forcewill additionally reject pinning a tag onto a maintenance node. It will also expose the state to the config script and event system:cc_in_maintenance()/cc_node_in_maintenance(cluster_id, node_id)script functions;cc_is_evicted()/cc_node_is_evicted(cluster_id, node_id)(distinguishing a node drained out of clusterer from one in maintenance but still warm and replicating); and anE_MAINTENANCE_MODE_CHANGEevent (cluster_id, node_id, ip, state, is_local, evicted) usable from anevent_routeor any event subscriber. Two channels, two purposes: the event (actionable "react now" signal) is distributed over the clusterer BIN protocol, so only non-evicted members raise it and react — a node evicted from clusterer (evict_on_maintenance=1) is outside the BIN mesh and won't receive it (it's inert anyway). The queryable per-peer state (maintenance + evicted flags) is instead propagated over the controller's own multicast, which a maintenance node stays on even when evicted — socc_(node_)in_maintenance()/cc_(node_)is_evicted()keep working on any node, including one evicted from clusterer, for both the local node and other peers. In short: an evicted node can't react to the event, but it can still query other nodes' status.Observability and script integration — surface cluster state through OpenSIPS's standard interfaces rather than logs alone: statistics (role, member count, master changes, joins/leaves,
JOIN_REJECTs, decrypt failures, config mismatches, split-brain merges) viaget_statistics/monitoring; events raised on state transitions (became master, demoted, node joined/left, split-brain merged, config mismatch, auth reject) forevent_route/subscribers; pseudo-variables ($cc_role,$cc_is_master,$cc_master_ip,$cc_member_count, with a cluster_id index) for script branching; and script functions (cc_is_master(), alongside the maintenance accessors).(The previously-listed sharing-tag override, resume-auto-tagging and
shtag_modeitems are now implemented in this PR — see the MI commands section above.)