Skip to content

Commit 97c4bf5

Browse files
committed
Added implementation for multi-bitcoind-nodes with failover & recovery
1 parent 4c00de9 commit 97c4bf5

File tree

10 files changed

+1336
-72
lines changed

10 files changed

+1336
-72
lines changed

README.md

Lines changed: 98 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -177,18 +177,42 @@ blocknotify=wget -q -O /dev/null http://datum-gateway:7152/NOTIFY
177177

178178
### Connecting to a Bitcoin Node
179179

180-
When running the DATUM Gateway in Docker, you need to configure it to connect to your Bitcoin node. The connection method depends on where your Bitcoin node is running:
180+
When running the DATUM Gateway in Docker, you need to configure it to connect to your Bitcoin node(s). The DATUM Gateway supports connecting to multiple Bitcoin nodes for automatic failover and high availability.
181+
182+
#### Multi-Node Configuration
183+
184+
The Gateway supports configuring multiple Bitcoin nodes with priority-based failover. Nodes are tried in order of priority (0 = highest priority), and the Gateway will automatically fail over to backup nodes if the primary node becomes unavailable.
185+
186+
**Configuration options:**
187+
- `nodes`: Array of Bitcoin node configurations (at least one required)
188+
- `rpcurl`: Full RPC URL (e.g., "http://localhost:8332")
189+
- `rpcuser`: RPC username
190+
- `rpcpassword`: RPC password
191+
- `priority`: Node priority (0 = highest, lower numbers tried first)
192+
- `enabled`: Whether this node is enabled (true/false)
193+
- `max_consecutive_failures`: Number of attempts to retry a node before switching to the next node (default: 3, range: 1-100)
194+
- `failover_cooldown_seconds`: Time to wait before retrying a failed node (default: 30, range: 5-300)
195+
- `try_higher_priority_nodes`: Automatically recover to higher priority nodes when available (default: true)
181196

182197
#### 1. Bitcoin Node Running in Docker (Same Network)
183198

184199
If your Bitcoin node is also running in a Docker container on the same network, use the container name as the hostname:
185200

186201
```json
187202
{
188-
"rpc_host": "bitcoin-node",
189-
"rpc_port": 8332,
190-
"rpc_user": "your_rpc_user",
191-
"rpc_pass": "your_rpc_password"
203+
"bitcoind": {
204+
"nodes": [
205+
{
206+
"rpcurl": "http://bitcoin-node:8332",
207+
"rpcuser": "your_rpc_user",
208+
"rpcpassword": "your_rpc_password",
209+
"priority": 0,
210+
"enabled": true
211+
}
212+
],
213+
"failover_cooldown_seconds": 30,
214+
"try_higher_priority_nodes": true
215+
}
192216
}
193217
```
194218

@@ -205,10 +229,17 @@ If your Bitcoin node is running directly on the host system or in a container th
205229
**Option A: Using host.docker.internal (recommended)**
206230
```json
207231
{
208-
"rpc_host": "host.docker.internal",
209-
"rpc_port": 8332,
210-
"rpc_user": "your_rpc_user",
211-
"rpc_pass": "your_rpc_password"
232+
"bitcoind": {
233+
"nodes": [
234+
{
235+
"rpcurl": "http://host.docker.internal:8332",
236+
"rpcuser": "your_rpc_user",
237+
"rpcpassword": "your_rpc_password",
238+
"priority": 0,
239+
"enabled": true
240+
}
241+
]
242+
}
212243
}
213244
```
214245

@@ -222,10 +253,17 @@ docker run --network host -v /path/to/config:/app/config datum_gateway
222253
Then configure using localhost:
223254
```json
224255
{
225-
"rpc_host": "localhost",
226-
"rpc_port": 8332,
227-
"rpc_user": "your_rpc_user",
228-
"rpc_pass": "your_rpc_password"
256+
"bitcoind": {
257+
"nodes": [
258+
{
259+
"rpcurl": "http://localhost:8332",
260+
"rpcuser": "your_rpc_user",
261+
"rpcpassword": "your_rpc_password",
262+
"priority": 0,
263+
"enabled": true
264+
}
265+
]
266+
}
229267
}
230268
```
231269

@@ -240,10 +278,53 @@ If your Bitcoin node is running on a different machine, use the hostname or IP a
240278

241279
```json
242280
{
243-
"rpc_host": "192.168.1.100",
244-
"rpc_port": 8332,
245-
"rpc_user": "your_rpc_user",
246-
"rpc_pass": "your_rpc_password"
281+
"bitcoind": {
282+
"nodes": [
283+
{
284+
"rpcurl": "http://192.168.1.100:8332",
285+
"rpcuser": "your_rpc_user",
286+
"rpcpassword": "your_rpc_password",
287+
"priority": 0,
288+
"enabled": true
289+
}
290+
]
291+
}
292+
}
293+
```
294+
295+
#### 4. Multiple Bitcoin Nodes with Failover
296+
297+
For high availability, you can configure multiple Bitcoin nodes. The Gateway will use the highest priority (lowest number) available node:
298+
299+
```json
300+
{
301+
"bitcoind": {
302+
"nodes": [
303+
{
304+
"rpcurl": "http://192.168.1.100:8332",
305+
"rpcuser": "primary_user",
306+
"rpcpassword": "primary_password",
307+
"priority": 0,
308+
"enabled": true
309+
},
310+
{
311+
"rpcurl": "http://192.168.1.101:8332",
312+
"rpcuser": "backup_user",
313+
"rpcpassword": "backup_password",
314+
"priority": 1,
315+
"enabled": true
316+
},
317+
{
318+
"rpcurl": "http://192.168.1.102:8332",
319+
"rpcuser": "tertiary_user",
320+
"rpcpassword": "tertiary_password",
321+
"priority": 2,
322+
"enabled": true
323+
}
324+
],
325+
"failover_cooldown_seconds": 30,
326+
"try_higher_priority_nodes": true
327+
}
247328
}
248329
```
249330

src/datum_api.c

Lines changed: 166 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,22 @@ void datum_api_var_STRATUM_JOB_SIGOPS(char *buffer, size_t buffer_size, const T_
223223
void datum_api_var_STRATUM_JOB_TXNCOUNT(char *buffer, size_t buffer_size, const T_DATUM_API_DASH_VARS *vardata) {
224224
snprintf(buffer, buffer_size, "%u", (unsigned)vardata->sjob->block_template->txn_count);
225225
}
226+
void datum_api_var_BITCOIND_ACTIVE_NODE_URL(char *buffer, size_t buffer_size, const T_DATUM_API_DASH_VARS *vardata) {
227+
const int active_index = datum_config.bitcoind_current_node_index;
228+
if (active_index >= 0 && active_index < datum_config.bitcoind_node_count) {
229+
snprintf(buffer, buffer_size, "%s", datum_config.bitcoind_nodes[active_index].rpcurl);
230+
} else {
231+
snprintf(buffer, buffer_size, "N/A");
232+
}
233+
}
234+
void datum_api_var_BITCOIND_ACTIVE_NODE_PRIORITY(char *buffer, size_t buffer_size, const T_DATUM_API_DASH_VARS *vardata) {
235+
const int active_index = datum_config.bitcoind_current_node_index;
236+
if (active_index >= 0 && active_index < datum_config.bitcoind_node_count) {
237+
snprintf(buffer, buffer_size, "%d", datum_config.bitcoind_nodes[active_index].priority);
238+
} else {
239+
snprintf(buffer, buffer_size, "N/A");
240+
}
241+
}
226242

227243

228244
DATUM_API_VarEntry var_entries[] = {
@@ -256,7 +272,10 @@ DATUM_API_VarEntry var_entries[] = {
256272
{"STRATUM_JOB_WEIGHT", datum_api_var_STRATUM_JOB_WEIGHT},
257273
{"STRATUM_JOB_SIGOPS", datum_api_var_STRATUM_JOB_SIGOPS},
258274
{"STRATUM_JOB_TXNCOUNT", datum_api_var_STRATUM_JOB_TXNCOUNT},
259-
275+
276+
{"BITCOIND_ACTIVE_NODE_URL", datum_api_var_BITCOIND_ACTIVE_NODE_URL},
277+
{"BITCOIND_ACTIVE_NODE_PRIORITY", datum_api_var_BITCOIND_ACTIVE_NODE_PRIORITY},
278+
260279
{NULL, NULL} // Mark the end of the array
261280
};
262281

@@ -395,13 +414,32 @@ static void http_resp_prevent_caching(struct MHD_Response * const response) {
395414

396415
static enum MHD_Result datum_api_formdata_to_json_cb(void * const cls, const enum MHD_ValueKind kind, const char * const key, const char * const filename, const char * const content_type, const char * const transfer_encoding, const char * const data, const uint64_t off, const size_t size) {
397416
if (!key) return MHD_YES;
398-
if (off) return MHD_YES;
399-
417+
400418
assert(cls);
401419
json_t * const j = cls;
402-
403-
json_object_set_new(j, key, json_stringn(data, size));
404-
420+
421+
if (off == 0) {
422+
// First chunk - create new field
423+
json_object_set_new(j, key, json_stringn(data, size));
424+
} else {
425+
// Continuation chunk - append to existing field
426+
json_t *existing = json_object_get(j, key);
427+
if (existing && json_is_string(existing)) {
428+
const char *existing_str = json_string_value(existing);
429+
size_t existing_len = json_string_length(existing);
430+
431+
// Create new buffer with combined data
432+
char *combined = malloc(existing_len + size + 1);
433+
if (combined) {
434+
memcpy(combined, existing_str, existing_len);
435+
memcpy(combined + existing_len, data, size);
436+
combined[existing_len + size] = '\0';
437+
json_object_set_new(j, key, json_string(combined));
438+
free(combined);
439+
}
440+
}
441+
}
442+
405443
return MHD_YES;
406444
}
407445

@@ -1076,17 +1114,50 @@ size_t datum_api_fill_config_var(const char *var_start, const size_t var_name_le
10761114
return snprintf(replacement, replacement_max_len, "%d", val);
10771115
}
10781116

1117+
int datum_api_bitcoind_nodes_json(struct MHD_Connection *connection) {
1118+
struct MHD_Response *response;
1119+
json_t *nodes_array = json_array();
1120+
1121+
for (int i = 0; i < datum_config.bitcoind_node_count; i++) {
1122+
T_BITCOIND_NODE_CONFIG *node = &datum_config.bitcoind_nodes[i];
1123+
json_t *node_obj = json_object();
1124+
1125+
json_object_set_new(node_obj, "index", json_integer(i));
1126+
json_object_set_new(node_obj, "rpcurl", json_string(node->rpcurl));
1127+
json_object_set_new(node_obj, "rpcuser", json_string(node->rpcuser));
1128+
json_object_set_new(node_obj, "priority", json_integer(node->priority));
1129+
json_object_set_new(node_obj, "enabled", json_boolean(node->enabled));
1130+
json_object_set_new(node_obj, "is_active", json_boolean(i == datum_config.bitcoind_current_node_index));
1131+
json_object_set_new(node_obj, "consecutive_failures", json_integer(node->consecutive_failures));
1132+
json_object_set_new(node_obj, "total_failures", json_integer(node->total_failures));
1133+
json_object_set_new(node_obj, "total_successes", json_integer(node->total_successes));
1134+
1135+
json_array_append_new(nodes_array, node_obj);
1136+
}
1137+
1138+
char *json_str = json_dumps(nodes_array, JSON_INDENT(2));
1139+
json_decref(nodes_array);
1140+
1141+
if (!json_str) {
1142+
return datum_api_do_error(connection, MHD_HTTP_INTERNAL_SERVER_ERROR);
1143+
}
1144+
1145+
response = MHD_create_response_from_buffer(strlen(json_str), json_str, MHD_RESPMEM_MUST_FREE);
1146+
MHD_add_response_header(response, "Content-Type", "application/json");
1147+
return datum_api_submit_uncached_response(connection, MHD_HTTP_OK, response);
1148+
}
1149+
10791150
int datum_api_config_dashboard(struct MHD_Connection *connection) {
10801151
struct MHD_Response *response;
10811152
size_t sz = 0, max_sz = 0;
10821153
char *output = NULL;
1083-
1154+
10841155
max_sz = www_config_html_sz * 2;
10851156
output = malloc(max_sz);
10861157
if (!output) {
10871158
return MHD_NO;
10881159
}
1089-
1160+
10901161
sz += datum_api_fill_vars(www_config_html, output, max_sz, datum_api_fill_config_var, NULL);
10911162

10921163
response = MHD_create_response_from_buffer(sz, output, MHD_RESPMEM_MUST_FREE);
@@ -1325,6 +1396,91 @@ bool datum_api_config_set(const char * const key, const char * const val, struct
13251396
}
13261397
// TODO: apply change without restarting (and don't interfere with existing jobs)
13271398
status->need_restart = true;
1399+
} else if (0 == strcmp(key, "bitcoind_failover_cooldown_seconds")) {
1400+
const int val_int = datum_atoi_strict(val, strlen(val));
1401+
if (val_int == datum_config.bitcoind_failover_cooldown_sec) return true;
1402+
if (val_int > 300 || val_int < 5) {
1403+
json_array_append_new(errors, json_string_nocheck("failover cooldown must be between 5 and 300 seconds"));
1404+
return false;
1405+
}
1406+
datum_config.bitcoind_failover_cooldown_sec = val_int;
1407+
datum_api_json_modify_new("bitcoind", "failover_cooldown_seconds", json_integer(val_int));
1408+
status->modified_config = true;
1409+
status->need_restart = true;
1410+
} else if (0 == strcmp(key, "bitcoind_max_consecutive_failures")) {
1411+
const int val_int = datum_atoi_strict(val, strlen(val));
1412+
if (val_int == datum_config.bitcoind_max_consecutive_failures) return true;
1413+
if (val_int > 100 || val_int < 1) {
1414+
json_array_append_new(errors, json_string_nocheck("max consecutive failures must be between 1 and 100"));
1415+
return false;
1416+
}
1417+
datum_config.bitcoind_max_consecutive_failures = val_int;
1418+
datum_api_json_modify_new("bitcoind", "max_consecutive_failures", json_integer(val_int));
1419+
status->modified_config = true;
1420+
status->need_restart = true;
1421+
} else if (0 == strcmp(key, "bitcoind_try_higher_priority_nodes")) {
1422+
const bool val_bool = datum_atoi_strict(val, strlen(val));
1423+
if (val_bool == datum_config.bitcoind_try_higher_priority) return true;
1424+
datum_config.bitcoind_try_higher_priority = val_bool;
1425+
datum_api_json_modify_new("bitcoind", "try_higher_priority_nodes", json_boolean(val_bool));
1426+
status->modified_config = true;
1427+
status->need_restart = true;
1428+
} else if (0 == strcmp(key, "bitcoind_nodes_json")) {
1429+
json_error_t jerror;
1430+
json_t * const nodes_array = json_loads(val, 0, &jerror);
1431+
if (!nodes_array || !json_is_array(nodes_array)) {
1432+
DLOG_ERROR("Failed to parse bitcoind nodes JSON: %s (line %d, column %d)", jerror.text, jerror.line, jerror.column);
1433+
json_array_append_new(errors, json_string_nocheck("Invalid bitcoind nodes JSON"));
1434+
return false;
1435+
}
1436+
1437+
// Get existing nodes to preserve passwords marked with ***KEEP_EXISTING***
1438+
json_t * const config = datum_config.config_json;
1439+
json_t *bitcoind_obj = json_object_get(config, "bitcoind");
1440+
json_t *existing_nodes = NULL;
1441+
if (bitcoind_obj && json_is_object(bitcoind_obj)) {
1442+
existing_nodes = json_object_get(bitcoind_obj, "nodes");
1443+
}
1444+
1445+
// Process each node in the new array
1446+
size_t index;
1447+
json_t *node;
1448+
json_array_foreach(nodes_array, index, node) {
1449+
json_t *password = json_object_get(node, "rpcpassword");
1450+
if (password && json_is_string(password)) {
1451+
const char *pass_str = json_string_value(password);
1452+
if (0 == strcmp(pass_str, "***KEEP_EXISTING***")) {
1453+
// Find matching node in existing config by index
1454+
json_t *index_obj = json_object_get(node, "index");
1455+
if (index_obj && json_is_integer(index_obj) && existing_nodes && json_is_array(existing_nodes)) {
1456+
json_int_t node_index = json_integer_value(index_obj);
1457+
if (node_index >= 0 && (size_t)node_index < json_array_size(existing_nodes)) {
1458+
json_t *existing_node = json_array_get(existing_nodes, node_index);
1459+
if (existing_node) {
1460+
// Found matching node by index, copy password
1461+
json_t *existing_pass = json_object_get(existing_node, "rpcpassword");
1462+
if (existing_pass && json_is_string(existing_pass)) {
1463+
json_object_set(node, "rpcpassword", existing_pass);
1464+
}
1465+
}
1466+
}
1467+
}
1468+
}
1469+
}
1470+
// Remove the index field before saving (it's only used for matching)
1471+
json_object_del(node, "index");
1472+
}
1473+
1474+
// Save to config
1475+
if (!bitcoind_obj || !json_is_object(bitcoind_obj)) {
1476+
bitcoind_obj = json_object();
1477+
json_object_set_new(config, "bitcoind", bitcoind_obj);
1478+
}
1479+
json_object_set(bitcoind_obj, "nodes", nodes_array);
1480+
1481+
status->modified_config = true;
1482+
status->need_restart = true;
1483+
json_decref(nodes_array);
13281484
} else if (0 == strcmp(key, "bitcoind_rpcurl")) {
13291485
if (0 == strcmp(val, datum_config.bitcoind_rpcurl)) return true;
13301486
if (strlen(val) > 128) {
@@ -1753,6 +1909,8 @@ enum MHD_Result datum_api_answer(void *cls, struct MHD_Connection *connection, c
17531909
return datum_api_asset(connection, "image/x-icon", www_assets_icons_favicon_ico, www_assets_icons_favicon_ico_sz, www_assets_icons_favicon_ico_etag);
17541910
} else if (!strcmp(url, "/assets/style.css")) {
17551911
return datum_api_asset(connection, "text/css", www_assets_style_css, www_assets_style_css_sz, www_assets_style_css_etag);
1912+
} else if (!strcmp(url, "/api/bitcoind_nodes")) {
1913+
return datum_api_bitcoind_nodes_json(connection);
17561914
}
17571915
break;
17581916
}

0 commit comments

Comments
 (0)