Skip to content

Commit b3e20c4

Browse files
author
Yury Kirsanov
committed
clusterer_controller: add zero-config multicast HA controller for clusterer
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. - 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.
1 parent 98f5fb4 commit b3e20c4

21 files changed

Lines changed: 7951 additions & 76 deletions

ipc.c

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ int ipc_recv_sync_reply(void **param)
274274
return 0;
275275
}
276276

277+
int ipc_running_rpc_job;
277278
void ipc_handle_job(int fd)
278279
{
279280
ipc_job job;
@@ -301,7 +302,9 @@ void ipc_handle_job(int fd)
301302

302303
/* custom handling for RPC type */
303304
if (job.handler_type==ipc_rpc_type) {
305+
ipc_running_rpc_job = 1;
304306
((ipc_rpc_f*)job.payload1)( job.snd_proc, job.payload2);
307+
ipc_running_rpc_job = 0;
305308
} else {
306309
/* generic registered type */
307310
ipc_handlers[job.handler_type].func( job.snd_proc, job.payload1);

ipc.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,15 @@ void ipc_handle_job(int fd);
120120
void ipc_handle_all_pending_jobs(int fd);
121121

122122

123+
/*
124+
* A way of checking whether a process is already running within a
125+
* ipc_dispatch_rpc() context, in order to avoid double async dispatching,
126+
* which is effectively a waste of resources.
127+
*/
128+
extern int ipc_running_rpc_job;
129+
static inline int ipc_is_async_dispatch(void) { return ipc_running_rpc_job; }
130+
131+
123132
/* internal functions */
124133
int init_ipc(void);
125134

modules/clusterer/api.h

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,5 @@ static inline module_dependency_t *get_deps_clusterer(const param_export_t *para
327327
return alloc_module_dep(MOD_TYPE_DEFAULT, "clusterer", DEP_ABORT);
328328
}
329329

330-
331330
#endif /* CLUSTERER_API_H */
332331

modules/clusterer/clusterer.c

Lines changed: 66 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ void sync_check_timer(utime_t ticks, void *param)
8484
lock_start_read(cl_list_lock);
8585

8686
for (cl = *cluster_list; cl; cl = cl->next) {
87+
if (!cl->current_node) continue;
8788
lock_get(cl->current_node->lock);
8889
if (!(cl->current_node->flags & NODE_STATE_ENABLED)) {
8990
lock_release(cl->current_node->lock);
@@ -106,11 +107,21 @@ void sync_check_timer(utime_t ticks, void *param)
106107
cap->flags &= ~(CAP_SYNC_PENDING|CAP_SYNC_STARTUP);
107108
sr_set_status(cl_srg, STR2CI(cap->reg.sr_id), CAP_SR_SYNCED,
108109
STR2CI(CAP_SR_STATUS_STR(CAP_SR_SYNCED)), 0);
109-
sr_add_report_fmt(cl_srg, STR2CI(cap->reg.sr_id), 0,
110-
"ERROR: Sync request aborted! (no donor found in due time)"
111-
" => fallback to synced state");
112-
LM_ERR("Sync request aborted! (no donor found in due time)"
113-
", falling back to synced state\n");
110+
if (cl->node_list == NULL) {
111+
/* no peers — first/lone node, self-sync is expected */
112+
sr_add_report_fmt(cl_srg, STR2CI(cap->reg.sr_id), 0,
113+
"No peers present — self-synced as first node in cluster");
114+
LM_DBG("No peers in cluster %d, capability '%.*s' "
115+
"self-marked as synced (first/lone node)\n",
116+
cl->cluster_id,
117+
cap->reg.name.len, cap->reg.name.s);
118+
} else {
119+
sr_add_report_fmt(cl_srg, STR2CI(cap->reg.sr_id), 0,
120+
"ERROR: Sync request aborted! (no donor found in due time)"
121+
" => fallback to synced state");
122+
LM_ERR("Sync request aborted! (no donor found in due time)"
123+
", falling back to synced state\n");
124+
}
114125
/* send update about the state of this capability */
115126
send_single_cap_update(cl, cap, 1);
116127

@@ -194,6 +205,9 @@ int cl_set_state(int cluster_id, int node_id, enum cl_node_state state)
194205
return 0;
195206
}
196207

208+
if (!cluster->current_node)
209+
return -1;
210+
197211
lock_get(cluster->current_node->lock);
198212

199213
if (state == STATE_DISABLED && cluster->current_node->flags & NODE_STATE_ENABLED)
@@ -528,7 +542,7 @@ int msg_add_trailer(bin_packet_t *packet, int cluster_id, int dst_id)
528542
{
529543
if (bin_push_int(packet, cluster_id) < 0)
530544
return -1;
531-
if (bin_push_int(packet, current_id) < 0)
545+
if (bin_push_int(packet, GET_CURRENT_ID) < 0)
532546
return -1;
533547
if (bin_push_int(packet, dst_id) < 0)
534548
return -1;
@@ -810,7 +824,7 @@ static void handle_cap_update(bin_packet_t *packet, node_info_t *source)
810824
for (i = 0; i < nr_nodes; i++) {
811825
bin_pop_int(packet, &node_id);
812826

813-
if (node_id == current_id) {
827+
if (node_id == GET_CURRENT_ID) {
814828
bin_pop_int(packet, &nr_cap);
815829
for (j = 0; j < nr_cap; j++) {
816830
bin_pop_str(packet, &cap);
@@ -991,7 +1005,10 @@ static void handle_remove_node(bin_packet_t *packet, cluster_info_t *cl)
9911005
return;
9921006
}
9931007

994-
if (target_node == current_id) {
1008+
if (target_node == GET_CURRENT_ID) {
1009+
if (!cl->current_node)
1010+
return;
1011+
9951012
lock_get(cl->current_node->lock);
9961013

9971014
if (cl->current_node->flags & NODE_STATE_ENABLED) {
@@ -1038,7 +1055,7 @@ void bin_rcv_cl_extra_packets(bin_packet_t *packet, int packet_type,
10381055
LM_DBG("received clusterer message from: %s:%hu with source id: %d and"
10391056
" cluster id: %d\n", ip, port, source_id, cluster_id);
10401057

1041-
if (source_id == current_id) {
1058+
if (source_id == GET_CURRENT_ID) {
10421059
LM_ERR("Received message with bad source - same node id as this instance\n");
10431060
return;
10441061
}
@@ -1094,7 +1111,7 @@ void bin_rcv_cl_extra_packets(bin_packet_t *packet, int packet_type,
10941111
} else
10951112
lock_release(node->lock);
10961113

1097-
if (dest_id != current_id) {
1114+
if (dest_id != GET_CURRENT_ID) {
10981115
/* route the message */
10991116
bin_push_int(packet, cluster_id);
11001117
bin_push_int(packet, source_id);
@@ -1172,7 +1189,7 @@ void bin_rcv_cl_packets(bin_packet_t *packet, int packet_type,
11721189
LM_DBG("received clusterer message from: %s:%hu with source id: %d and "
11731190
"cluster id: %d\n", ip, port, source_id, cl_id);
11741191

1175-
if (source_id == current_id) {
1192+
if (source_id == GET_CURRENT_ID) {
11761193
LM_ERR("Received message with bad source - same node id as this instance\n");
11771194
return;
11781195
}
@@ -1189,6 +1206,17 @@ void bin_rcv_cl_packets(bin_packet_t *packet, int packet_type,
11891206
goto exit;
11901207
}
11911208

1209+
/* current_node is legitimately NULL while this node's identity is being
1210+
* (re)established for a dynamically constructed cluster (clusterer_ctrl
1211+
* update_identity: the cluster can already exist and receive BIN packets
1212+
* before current_node is assigned). Drop the packet instead of
1213+
* dereferencing NULL. */
1214+
if (!cl->current_node) {
1215+
LM_INFO("Received message for cluster [%d] before local identity is "
1216+
"established, ignoring\n", cl_id);
1217+
goto exit;
1218+
}
1219+
11921220
lock_get(cl->current_node->lock);
11931221
if (!(cl->current_node->flags & NODE_STATE_ENABLED)) {
11941222
lock_release(cl->current_node->lock);
@@ -1317,7 +1345,7 @@ static void bin_rcv_mod_packets(bin_packet_t *packet, int packet_type,
13171345
LM_DBG("received bin packet from: %s:%hu with source id: %d and cluster id: %d\n",
13181346
ip, port, source_id, cluster_id);
13191347

1320-
if (source_id == current_id) {
1348+
if (source_id == GET_CURRENT_ID) {
13211349
LM_ERR("Received message with bad source - same node id as this instance\n");
13221350
return;
13231351
}
@@ -1385,7 +1413,7 @@ static void bin_rcv_mod_packets(bin_packet_t *packet, int packet_type,
13851413
} else
13861414
lock_release(node->lock);
13871415

1388-
if (dest_id != current_id) {
1416+
if (dest_id != GET_CURRENT_ID) {
13891417
/* route the message */
13901418
bin_push_int(packet, cluster_id);
13911419
bin_push_int(packet, source_id);
@@ -1460,6 +1488,7 @@ int send_single_cap_update(cluster_info_t *cluster, struct local_cap *cap,
14601488

14611489
timestamp = time(NULL);
14621490

1491+
if (!cluster->current_node) return -1;
14631492
lock_get(cluster->current_node->lock);
14641493

14651494
for (neigh = cluster->current_node->neighbour_list; neigh;
@@ -1478,7 +1507,7 @@ int send_single_cap_update(cluster_info_t *cluster, struct local_cap *cap,
14781507
return -1;
14791508
}
14801509
bin_push_int(&packet, cluster->cluster_id);
1481-
bin_push_int(&packet, current_id);
1510+
bin_push_int(&packet, GET_CURRENT_ID);
14821511

14831512
bin_push_int(&packet, ++cluster->current_node->cap_seq_no);
14841513
bin_push_int(&packet, timestamp);
@@ -1487,7 +1516,7 @@ int send_single_cap_update(cluster_info_t *cluster, struct local_cap *cap,
14871516

14881517
/* only the current node */
14891518
bin_push_int(&packet, 1);
1490-
bin_push_int(&packet, current_id);
1519+
bin_push_int(&packet, GET_CURRENT_ID);
14911520

14921521
/* only a single capability */
14931522
bin_push_int(&packet, 1);
@@ -1497,7 +1526,7 @@ int send_single_cap_update(cluster_info_t *cluster, struct local_cap *cap,
14971526
bin_push_int(&packet, 0); /* don't require reply */
14981527

14991528
bin_push_int(&packet, 1); /* path length is 1, only current node at this point */
1500-
bin_push_int(&packet, current_id);
1529+
bin_push_int(&packet, GET_CURRENT_ID);
15011530
bin_get_buffer(&packet, &bin_buffer);
15021531

15031532
for (i = 0; i < no_dests; i++)
@@ -1545,7 +1574,7 @@ int send_cap_update(node_info_t *dest_node, int require_reply)
15451574
return -1;
15461575
}
15471576
bin_push_int(&packet, dest_node->cluster->cluster_id);
1548-
bin_push_int(&packet, current_id);
1577+
bin_push_int(&packet, GET_CURRENT_ID);
15491578

15501579
lock_get(dest_node->cluster->current_node->lock);
15511580

@@ -1560,7 +1589,7 @@ int send_cap_update(node_info_t *dest_node, int require_reply)
15601589
for (cl_cap = dest_node->cluster->capabilities, nr_cap = 0; cl_cap;
15611590
cl_cap = cl_cap->next, nr_cap++) ;
15621591
if (nr_cap) {
1563-
bin_push_int(&packet, current_id);
1592+
bin_push_int(&packet, GET_CURRENT_ID);
15641593
bin_push_int(&packet, nr_cap);
15651594
for (cl_cap=dest_node->cluster->capabilities;cl_cap;cl_cap=cl_cap->next) {
15661595
bin_push_str(&packet, &cl_cap->reg.name);
@@ -1591,7 +1620,7 @@ int send_cap_update(node_info_t *dest_node, int require_reply)
15911620
bin_push_int(&packet, require_reply);
15921621

15931622
bin_push_int(&packet, 1); /* path length is 1, only current node at this point */
1594-
bin_push_int(&packet, current_id);
1623+
bin_push_int(&packet, GET_CURRENT_ID);
15951624
bin_get_buffer(&packet, &bin_buffer);
15961625

15971626
if (msg_send(dest_node->cluster->send_sock, dest_node->proto, &dest_node->addr,
@@ -1744,9 +1773,24 @@ int cl_register_cap(str *cap, cl_packet_cb_f packet_cb, cl_event_cb_f event_cb,
17441773

17451774
cluster = get_cluster_by_id(cluster_id);
17461775
if (!cluster) {
1747-
LM_ERR("cluster id %d is not defined in the %s\n", cluster_id,
1748-
db_mode ? "DB" : "script");
1749-
return -1;
1776+
if (use_controller) {
1777+
cluster = shm_malloc(sizeof *cluster);
1778+
if (!cluster) { LM_ERR("no shm\n"); return -1; }
1779+
memset(cluster, 0, sizeof *cluster);
1780+
cluster->cluster_id = cluster_id;
1781+
if ((cluster->lock = lock_alloc()) == NULL || !lock_init(cluster->lock)) {
1782+
shm_free(cluster); return -1;
1783+
}
1784+
if (cl_list_lock) lock_start_write(cl_list_lock);
1785+
cluster->next = *cluster_list;
1786+
*cluster_list = cluster;
1787+
if (cl_list_lock) lock_stop_write(cl_list_lock);
1788+
LM_INFO("clusterer: auto-created stub for cluster %d\n", cluster_id);
1789+
} else {
1790+
LM_ERR("cluster id %d is not defined in the %s\n", cluster_id,
1791+
db_mode ? "DB" : "script");
1792+
return -1;
1793+
}
17501794
}
17511795

17521796
new_cl_cap = shm_malloc(sizeof *new_cl_cap + cap->len + CAP_SR_ID_PREFIX_LEN);

0 commit comments

Comments
 (0)