diff --git a/proxy.conf.example b/proxy.conf.example index f056346..f5cad51 100644 --- a/proxy.conf.example +++ b/proxy.conf.example @@ -110,6 +110,20 @@ vardiff_window_sec = 30 # how often to retarget # behaviour rather than a symptom. max_submits_per_sec = 20000 +# Budget for mining.authorize. A failed authorize is the cheapest write on the +# pool -- a reject observation and a log line -- and until this existed nothing +# bounded how many of them one client could buy before authenticating at all. +# +# The third failure on a connection is answered and the connection is then +# closed. An address that has failed three times inside the lockout window is +# refused before its next attempt is even parsed, until the window passes. A +# successful authorize forgives the address, so a miner that fixes a typo is +# not made to wait. +# +# 0 disables both halves. A correct miner never reaches either. +auth_max_failures = 3 +auth_fail_lockout_sec = 60 + # Idle-connection reaper, in seconds. Two budgets, because the two states are # not the same risk: # diff --git a/src/config.c b/src/config.c index 15c7ee6..af8822e 100644 --- a/src/config.c +++ b/src/config.c @@ -32,6 +32,8 @@ void proxy_config_defaults(proxy_config_t *cfg) { /* Far above any correctly configured miner and far below what one * mismatched connection can otherwise cost. See proxy.conf.example. */ cfg->max_submits_per_sec = 20000; + cfg->auth_max_failures = 3; + cfg->auth_fail_lockout_sec = 60; snprintf(cfg->bitcoind_url, sizeof cfg->bitcoind_url, "%s", "http://127.0.0.1:18443"); /* No default credentials: when bitcoind_user/bitcoind_pass are omitted the @@ -229,6 +231,8 @@ int proxy_config_load(const char *path, proxy_config_t *cfg, else if (strcmp(k, "listen_port") == 0) cfg->listen_port = atoi(v); else if (strcmp(k, "max_conns") == 0) cfg->max_conns = atoi(v); else if (strcmp(k, "max_submits_per_sec") == 0) cfg->max_submits_per_sec = atoi(v); + else if (strcmp(k, "auth_max_failures") == 0) cfg->auth_max_failures = atoi(v); + else if (strcmp(k, "auth_fail_lockout_sec") == 0) cfg->auth_fail_lockout_sec = atoi(v); else if (strcmp(k, "initial_diff") == 0) cfg->initial_diff = atof(v); else if (strcmp(k, "listener") == 0) { /* Repeatable, unlike every other key here: each one adds a port @@ -402,6 +406,23 @@ int proxy_config_load(const char *path, proxy_config_t *cfg, "(0 disables the ceiling)"); return -13; } + if (cfg->auth_max_failures < 0) { + set_err(errbuf, errlen, + "config: 'auth_max_failures' cannot be negative " + "(0 disables the authorize budget)"); + return -13; + } + /* A lockout window of zero with the budget on would expire every entry the + * instant it was written, so the per-address half would silently do + * nothing while the config claimed it was on. Refuse the combination + * rather than ship a limiter that cannot limit. */ + if (cfg->auth_max_failures > 0 && cfg->auth_fail_lockout_sec <= 0) { + set_err(errbuf, errlen, + "config: 'auth_fail_lockout_sec' must be > 0 when " + "'auth_max_failures' is set (set auth_max_failures = 0 to " + "disable the authorize budget)"); + return -13; + } if (cfg->block_interval_sec <= 0) { set_err(errbuf, errlen, "config: 'block_interval_sec' must be > 0 (600 for Bitcoin)"); diff --git a/src/config.h b/src/config.h index 567c534..cfb869c 100644 --- a/src/config.h +++ b/src/config.h @@ -26,6 +26,12 @@ typedef struct { * See stratum.h for why it sits where it does. */ int max_submits_per_sec; + /* Budget for mining.authorize: failures allowed per connection and per + * peer address, and how long an address stays refused once it is spent. + * 0 failures disables both. See stratum.h. */ + int auth_max_failures; + int auth_fail_lockout_sec; + /* vardiff — auto-adjust each connection's difficulty to keep the * share rate near `target_spm` shares/minute. Set vardiff_enabled = 0 * to pin every connection to initial_diff (the legacy behaviour). */ diff --git a/src/main.c b/src/main.c index d14ec91..02213e8 100644 --- a/src/main.c +++ b/src/main.c @@ -1154,6 +1154,8 @@ int main(int argc, char **argv) { stcfg.idle_timeout_sec = cfg.idle_timeout_sec; stcfg.idle_timeout_authorized_sec = cfg.idle_timeout_authorized_sec; stcfg.max_submits_per_sec = cfg.max_submits_per_sec; + stcfg.auth_max_failures = cfg.auth_max_failures; + stcfg.auth_fail_lockout_sec = cfg.auth_fail_lockout_sec; stcfg.listener_count = cfg.listener_count; for (int i = 0; i < cfg.listener_count; ++i) { stcfg.listeners[i] = cfg.listeners[i]; diff --git a/src/stratum.c b/src/stratum.c index f186b1f..5703377 100644 --- a/src/stratum.c +++ b/src/stratum.c @@ -103,6 +103,30 @@ _Static_assert((uint64_t)RECENT_JOBS * 30000u >= RECENT_JOB_TTL_MS, * stopped reading; a healthy miner drains these in microseconds. */ #define SEND_TIMEOUT_SEC 10 +/* Per-address authorize-failure table: fixed, bounded, and never allocated on + * the client's schedule. 1024 slots keyed by peer address, probed linearly up + * to AUTH_FAIL_PROBE deep; a miss with no free slot in the run evicts the + * entry whose window started earliest. A client spraying addresses can push + * others out of the table, which only ever resets THEIR count -- the table is + * a limiter, not a ledger, so an evicted entry can never cost anyone a + * lockout they had not earned. */ +#define AUTH_FAIL_SLOTS 1024 +#define AUTH_FAIL_PROBE 8 + +/* mining.authorize calls per connection per window, success or failure. A + * successful authorize is not free either -- it validates an address and + * seeds the connection's difficulty -- and a client holding one valid address + * can repeat it as fast as it likes. Past the ceiling each call is treated as + * a failure, and the connection's failure budget closes it. + * + * Sixty, not ten: the ceiling exists to stop deliberate spam, and spam is + * hundreds a second, while a proxy that multiplexes many workers over one + * socket and authorizes them in a burst is a legitimate pattern that has to + * clear it. Sixty in ten seconds is far above any burst a proxy needs and + * still two orders of magnitude under a flood. */ +#define AUTH_CALL_WINDOW_MS 10000 +#define AUTH_MAX_CALLS_PER_WINDOW 60 + /* BIP320 reserved version-rolling bits (ASICBoost). Advertised in * mining.configure; only these block-header version bits may be rolled by a * miner, and a per-connection mask (this ANDed with the client's request) is @@ -271,6 +295,15 @@ struct stratum_server { * yields the same hash on both, and PPS would credit it twice. Keying * on the final hash makes the check independent of how the submission * was framed (job id, extranonce2, version rolling). */ + /* Per-address authorize failures. See stratum_cfg_t.auth_max_failures. */ + pthread_mutex_t auth_fail_lock; + struct auth_fail_entry { + char ip[INET6_ADDRSTRLEN]; /* empty = free */ + uint32_t fails; + uint64_t window_start_mono; + int reported; /* the lockout has been logged once */ + } auth_fail[AUTH_FAIL_SLOTS]; + pthread_mutex_t share_dedupe_lock; /* Two structures over one set of keys. The ring is what bounds memory and * decides which hash is forgotten next (the oldest, FIFO). The index is @@ -342,6 +375,18 @@ struct stratum_conn { char pol_label[32]; int subscribed; int authorized; + + /* The peer's address, as text, for the per-address authorize budget and + * for logs. Empty on a test connection, which is what makes the budget's + * per-address half inert there. */ + char peer_ip[INET6_ADDRSTRLEN]; + + /* Authorize budget state (see auth_gate). Touched only by this + * connection's own thread, inside handle_authorize, so no lock. */ + uint32_t auth_failures; /* failures on this connection */ + uint64_t auth_call_window_ms; /* AUTH_CALL_WINDOW_MS accounting */ + uint32_t auth_calls_in_window; + uint32_t version_mask; /* negotiated version-rolling bits; 0 = off */ char worker_name[129]; /* full stratum username (sanitized) */ char payout_address[128]; /* validated bech32/base58 */ @@ -1447,8 +1492,139 @@ static int handle_suggest_difficulty(stratum_server_t *s, stratum_conn_t *c, return 0; } +/* ---- authorize budget --------------------------------------------------- */ + +/* Find the entry for `ip`, or with `create` claim one for it. Caller holds + * auth_fail_lock. An entry whose window has passed counts as free: its count + * is stale by definition. Returns NULL only when !create and absent. */ +static struct auth_fail_entry *auth_fail_find(stratum_server_t *s, const char *ip, + uint64_t now_mono, int create) { + uint64_t lockout_ms = (uint64_t)s->cfg.auth_fail_lockout_sec * 1000u; + size_t home = (size_t)(fnv1a(ip) & (AUTH_FAIL_SLOTS - 1)); + struct auth_fail_entry *free_slot = NULL, *oldest = NULL; + for (size_t k = 0; k < AUTH_FAIL_PROBE; ++k) { + struct auth_fail_entry *e = &s->auth_fail[(home + k) & (AUTH_FAIL_SLOTS - 1)]; + if (e->ip[0] && strcmp(e->ip, ip) == 0) { + if (now_mono - e->window_start_mono >= lockout_ms) { + /* Expired: forget the old count but keep the slot. */ + e->fails = 0; e->window_start_mono = now_mono; e->reported = 0; + } + return e; + } + int is_free = !e->ip[0] || now_mono - e->window_start_mono >= lockout_ms; + if (is_free && !free_slot) free_slot = e; + if (!oldest || e->window_start_mono < oldest->window_start_mono) oldest = e; + } + if (!create) return NULL; + struct auth_fail_entry *e = free_slot ? free_slot : oldest; + snprintf(e->ip, sizeof e->ip, "%s", ip); + e->fails = 0; e->window_start_mono = now_mono; e->reported = 0; + return e; +} + +/* Runs at the top of handle_authorize, before the params are even looked at -- + * which is the point of it. A refusal that decodes an address and writes a + * reject row still costs what the limiter exists to stop paying. + * + * Returns 0 to proceed. Returns -1 having written the refusal into buf: the + * caller passes that straight up, and the connection thread closes the socket + * after writing it. */ +static int auth_gate(stratum_server_t *s, stratum_conn_t *c, cJSON *id, + char **buf, size_t *len, int *over_call_ceiling) { + *over_call_ceiling = 0; + int max_fail = s->cfg.auth_max_failures; + if (max_fail <= 0) return 0; + uint64_t mono = mono_ms(); + + if (c->peer_ip[0]) { + int locked = 0, first = 0; + uint64_t retry_s = 0; + pthread_mutex_lock(&s->auth_fail_lock); + struct auth_fail_entry *e = auth_fail_find(s, c->peer_ip, mono, 0); + if (e && e->fails >= (uint32_t)max_fail) { + locked = 1; + uint64_t lockout_ms = (uint64_t)s->cfg.auth_fail_lockout_sec * 1000u; + uint64_t elapsed = mono - e->window_start_mono; + retry_s = (lockout_ms > elapsed ? lockout_ms - elapsed + 999 : 0) / 1000; + if (!e->reported) { e->reported = 1; first = 1; } + } + pthread_mutex_unlock(&s->auth_fail_lock); + if (locked) { + /* Logged once per lockout, not once per refused attempt -- a + * refusal that costs a journal line is still a per-attempt cost. */ + if (first) { + LOG_WARN("stratum: %s has failed mining.authorize %d times in " + "%ds -- refusing further attempts for %llus", + c->peer_ip, max_fail, s->cfg.auth_fail_lockout_sec, + (unsigned long long)retry_s); + } + char emsg[160]; + snprintf(emsg, sizeof emsg, + "too many failed authorizations from this address; " + "retry in %llus", (unsigned long long)retry_s); + cJSON *err = make_error(24, emsg); + emit_response(buf, len, id, NULL, err); + return -1; + } + } + + if (mono - c->auth_call_window_ms >= AUTH_CALL_WINDOW_MS) { + c->auth_call_window_ms = mono; + c->auth_calls_in_window = 0; + } + if (++c->auth_calls_in_window > AUTH_MAX_CALLS_PER_WINDOW) *over_call_ceiling = 1; + return 0; +} + +/* Every failed authorize ends here with the response already written and `rc` + * its return code. Counts the failure against the connection and the peer + * address, and turns rc into -1 -- close after writing -- once the connection + * has spent its budget. */ +static int auth_failed(stratum_server_t *s, stratum_conn_t *c, int rc) { + int max_fail = s->cfg.auth_max_failures; + if (max_fail <= 0) return rc; + c->auth_failures++; + if (c->peer_ip[0]) { + pthread_mutex_lock(&s->auth_fail_lock); + struct auth_fail_entry *e = auth_fail_find(s, c->peer_ip, mono_ms(), 1); + e->fails++; + pthread_mutex_unlock(&s->auth_fail_lock); + } + if (c->auth_failures >= (uint32_t)max_fail) { + LOG_INFO("stratum: closing %s after %u failed mining.authorize attempts", + c->peer_ip[0] ? c->peer_ip : "(test conn)", c->auth_failures); + return -1; + } + return rc; +} + +/* A successful authorize forgives the address: the miner has proved it can get + * the username right, and a retry budget it could never rebuild would turn two + * typos and a fix into a minute of lockout. */ +static void auth_succeeded(stratum_server_t *s, stratum_conn_t *c) { + c->auth_failures = 0; + if (s->cfg.auth_max_failures <= 0 || !c->peer_ip[0]) return; + pthread_mutex_lock(&s->auth_fail_lock); + struct auth_fail_entry *e = auth_fail_find(s, c->peer_ip, mono_ms(), 0); + if (e) e->ip[0] = '\0'; + pthread_mutex_unlock(&s->auth_fail_lock); +} + +void stratum_conn_set_peer_ip_for_test(stratum_conn_t *c, const char *ip) { + if (!c) return; + snprintf(c->peer_ip, sizeof c->peer_ip, "%s", ip ? ip : ""); +} + static int handle_authorize(stratum_server_t *s, stratum_conn_t *c, cJSON *id, cJSON *params, char **buf, size_t *len) { + int over_ceiling = 0; + if (auth_gate(s, c, id, buf, len, &over_ceiling) < 0) return -1; + if (over_ceiling) { + /* No reject row for this one: it is the limiter speaking, and a row + * per refused call would be the cost the limiter exists to remove. */ + cJSON *err = make_error(24, "too many mining.authorize calls; slow down"); + return auth_failed(s, c, emit_response(buf, len, id, NULL, err)); + } const char *worker = NULL; double pw_diff = 0.0; if (cJSON_IsArray(params) && cJSON_GetArraySize(params) >= 1) { @@ -1465,7 +1641,7 @@ static int handle_authorize(stratum_server_t *s, stratum_conn_t *c, cJSON *id, } if (!worker) { cJSON *err = make_error(24, "missing worker name"); - return emit_response(buf, len, id, NULL, err); + return auth_failed(s, c, emit_response(buf, len, id, NULL, err)); } /* Username format:
[.]. The address part must be @@ -1480,7 +1656,7 @@ static int handle_authorize(stratum_server_t *s, stratum_conn_t *c, cJSON *id, } cJSON *err = make_error(24, "stratum username must be [.]"); - return emit_response(buf, len, id, NULL, err); + return auth_failed(s, c, emit_response(buf, len, id, NULL, err)); } /* Refuse before taking the address: the miner learns at connect time, * which is the only point at which they can still do something about it. */ @@ -1489,6 +1665,10 @@ static int handle_authorize(stratum_server_t *s, stratum_conn_t *c, cJSON *id, s->cfg.on_reject(s->cfg.ctx, worker, now_ms(), "pps accrual suspended (difficulty below floor)"); } + /* Deliberately NOT counted against the authorize budget: the miner did + * nothing wrong and cannot fix this by retrying differently. Charging + * it would lock out every honest miner reconnecting while the pool has + * accrual suspended -- exactly when they are most likely to retry. */ cJSON *err = make_error(24, PPS_GATED_MSG); return emit_response(buf, len, id, NULL, err); } @@ -1515,7 +1695,7 @@ static int handle_authorize(stratum_server_t *s, stratum_conn_t *c, cJSON *id, snprintf(emsg, sizeof emsg, "invalid thunder address in stratum username: %s", derr); cJSON *err = make_error(24, emsg); - return emit_response(buf, len, id, NULL, err); + return auth_failed(s, c, emit_response(buf, len, id, NULL, err)); } } else { uint8_t spk[64]; @@ -1532,11 +1712,12 @@ static int handle_authorize(stratum_server_t *s, stratum_conn_t *c, cJSON *id, snprintf(emsg, sizeof emsg, "invalid payout address in stratum username: %s", derr); cJSON *err = make_error(24, emsg); - return emit_response(buf, len, id, NULL, err); + return auth_failed(s, c, emit_response(buf, len, id, NULL, err)); } } sanitize_worker(worker, c->worker_name, sizeof(c->worker_name)); + auth_succeeded(s, c); c->authorized = 1; if (c->difficulty <= 0) c->difficulty = c->pol_initial_diff; /* A request may have arrived either way round: mining.suggest_difficulty @@ -2475,6 +2656,32 @@ int stratum_conn_idle_budget_for_test(const stratum_server_t *s, return conn_idle_budget_sec(s, c); } +/* The peer's address as text, with IPv4-mapped IPv6 un-mapped. + * + * The un-mapping matters rather than being cosmetic: on a dual-stack listener + * every IPv4 client arrives as an IPv4-mapped IPv6 address, so without it the + * same client reads as "::ffff:198.51.100.7" on one listener and + * "198.51.100.7" on another. That splits the per-address budget across two + * spellings of one client, and makes a log line hard to match against what + * netstat or ss reports. */ +static void peer_ip_from_sockaddr(const struct sockaddr_storage *ss, + char *out, size_t cap) { + if (ss->ss_family == AF_INET6) { + const struct sockaddr_in6 *s6 = (const struct sockaddr_in6 *)(const void *)ss; + if (IN6_IS_ADDR_V4MAPPED(&s6->sin6_addr)) { + struct in_addr v4; + memcpy(&v4, &s6->sin6_addr.s6_addr[12], sizeof v4); + if (inet_ntop(AF_INET, &v4, out, (socklen_t)cap)) return; + } else if (inet_ntop(AF_INET6, &s6->sin6_addr, out, (socklen_t)cap)) { + return; + } + } else if (ss->ss_family == AF_INET) { + const struct sockaddr_in *s4 = (const struct sockaddr_in *)(const void *)ss; + if (inet_ntop(AF_INET, &s4->sin_addr, out, (socklen_t)cap)) return; + } + snprintf(out, cap, "?"); +} + static void *conn_thread(void *arg) { stratum_conn_t *c = arg; stratum_server_t *s = c->server; @@ -2612,6 +2819,7 @@ static void *listener_thread(void *arg) { stratum_conn_t *c = stratum_conn_new_for_test(s); if (!c) { close(fd); continue; } c->fd = fd; + peer_ip_from_sockaddr(&cli, c->peer_ip, sizeof c->peer_ip); /* The port decides the difficulty. Everything after this point reads * the policy off the connection and never looks at the listener. */ conn_apply_listener(c, &ls->pol); @@ -2663,6 +2871,7 @@ int stratum_server_start(const stratum_cfg_t *cfg, stratum_server_t **out) { pthread_rwlock_init(&s->job_lock, NULL); pthread_mutex_init(&s->recent_lock, NULL); pthread_mutex_init(&s->conns_lock, NULL); + pthread_mutex_init(&s->auth_fail_lock, NULL); pthread_mutex_init(&s->share_dedupe_lock, NULL); atomic_init(&s->stop, 0); atomic_init(&s->conn_count, 0); @@ -2850,6 +3059,7 @@ void stratum_server_free(stratum_server_t *s) { pthread_rwlock_destroy(&s->job_lock); pthread_mutex_destroy(&s->recent_lock); pthread_mutex_destroy(&s->conns_lock); + pthread_mutex_destroy(&s->auth_fail_lock); pthread_mutex_destroy(&s->share_dedupe_lock); free(s); } diff --git a/src/stratum.h b/src/stratum.h index c387def..439f91e 100644 --- a/src/stratum.h +++ b/src/stratum.h @@ -241,6 +241,28 @@ typedef struct { * ring tells it everything is fine. */ int max_submits_per_sec; + /* Budget for mining.authorize, in failures. 0 disables both halves. + * + * A failed authorize -- no worker name, a malformed username, an address + * that does not decode -- costs a reject observation and a log line, and + * nothing bounded how many of those one client could buy before it had + * authenticated at all. It is the cheapest write on the pool and it is + * open to anyone who can reach the port. + * + * Two limits from the one number. Per connection: the auth_max_failures-th + * failure is answered, then the connection is closed. Per peer address: an + * address that has failed auth_max_failures times within + * auth_fail_lockout_sec is refused at the TOP of the handler -- no + * decoding, no reject observation, no log line per attempt -- and the + * connection is closed, until the window passes. A successful authorize + * clears the address's record, so a miner that fixes its username is not + * made to wait. + * + * Ships on. Unlike max_submits_per_sec it refuses nothing a correct miner + * does: it only shortens how long a client may keep failing. */ + int auth_max_failures; + int auth_fail_lockout_sec; + void *ctx; share_observer_fn on_share; reject_observer_fn on_reject; @@ -300,6 +322,10 @@ double stratum_conn_difficulty_for_test(const stratum_conn_t *c); * path does when a miner arrives on that port. Exposed so per-port policy can * be tested without binding a fixed port, which in CI is a race with whatever * else is on the box. */ +/* Set the peer address a test connection reports, so the per-address half of + * the authorize budget is reachable without a socket. */ +void stratum_conn_set_peer_ip_for_test(stratum_conn_t *c, const char *ip); + void stratum_conn_apply_listener_for_test(stratum_conn_t *c, const stratum_listener_t *pol); const char *stratum_conn_worker_name_for_test(const stratum_conn_t *c); diff --git a/tests/test_config.c b/tests/test_config.c index 78c10ce..09bdd77 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -289,6 +289,58 @@ static void test_a_nonsense_log_level_warns_and_keeps_the_default(void) { CHECK(cfg.log_level == 1); /* info, the default */ } +/* ---- authorize budget ---------------------------------------------------- */ + +/* Ships on, with the values documented in proxy.conf.example. Pinned because + * "on by default" is the whole security claim: a pool that has never heard of + * this setting is still protected. */ +static void test_the_authorize_budget_is_on_by_default(void) { + proxy_config_t cfg; char err[256] = {0}; + char body[512]; + snprintf(body, sizeof body, "operator_address = %s\n", VALID_ADDR); + CHECK(load_text(body, &cfg, err, sizeof err) == 0); + CHECK(cfg.auth_max_failures == 3); + CHECK(cfg.auth_fail_lockout_sec == 60); +} + +/* Zero disables it; the operator has to be able to say so. */ +static void test_a_zero_authorize_budget_loads(void) { + proxy_config_t cfg; char err[256] = {0}; + char body[512]; + snprintf(body, sizeof body, + "operator_address = %s\nauth_max_failures = 0\n", VALID_ADDR); + CHECK(load_text(body, &cfg, err, sizeof err) == 0); + CHECK(cfg.auth_max_failures == 0); +} + +/* A negative budget is not "off", it is a typo. */ +static void test_a_negative_authorize_budget_is_refused(void) { + proxy_config_t cfg; char err[256] = {0}; + char body[512]; + snprintf(body, sizeof body, + "operator_address = %s\nauth_max_failures = -1\n", VALID_ADDR); + CHECK(load_text(body, &cfg, err, sizeof err) != 0); + CHECK(strstr(err, "auth_max_failures") != NULL); +} + +/* A zero window with the budget on would expire every entry as it was + * written: the per-address half would do nothing while the config said it was + * on. Refused, rather than shipped as a limiter that cannot limit. */ +static void test_a_zero_lockout_window_is_refused(void) { + proxy_config_t cfg; char err[256] = {0}; + char body[512]; + snprintf(body, sizeof body, + "operator_address = %s\nauth_fail_lockout_sec = 0\n", VALID_ADDR); + CHECK(load_text(body, &cfg, err, sizeof err) != 0); + CHECK(strstr(err, "auth_fail_lockout_sec") != NULL); + /* ...unless the budget itself is off, when the window means nothing. */ + proxy_config_t off; char err2[256] = {0}; + snprintf(body, sizeof body, + "operator_address = %s\nauth_max_failures = 0\n" + "auth_fail_lockout_sec = 0\n", VALID_ADDR); + CHECK(load_text(body, &off, err2, sizeof err2) == 0); +} + int main(void) { printf("running test_config...\n"); test_hash_inside_value_is_kept(); @@ -308,6 +360,10 @@ int main(void) { test_pplns_requires_a_pool_address(); test_unknown_mode_names_the_real_ones(); test_pplns_without_a_rail_is_refused(); + test_the_authorize_budget_is_on_by_default(); + test_a_zero_authorize_budget_loads(); + test_a_negative_authorize_budget_is_refused(); + test_a_zero_lockout_window_is_refused(); if (failures) { printf("test_config: %d failed\n", failures); return 1; } printf("test_config: all tests passed\n"); return 0; diff --git a/tests/test_stratum.c b/tests/test_stratum.c index 4b698f6..1f99c42 100644 --- a/tests/test_stratum.c +++ b/tests/test_stratum.c @@ -2797,6 +2797,174 @@ static void test_share_dedupe_index_tracks_the_ring(void) { printf("ok: the share-dedupe index holds exactly the ring's keys\n"); } +/* ---- authorize budget ---------------------------------------------------- */ + +#define BAD_AUTH_LINE(n) \ + "{\"id\":" #n ",\"method\":\"mining.authorize\",\"params\":[\"alice.w1\",\"x\"]}" +#define GOOD_AUTH_LINE(n) \ + "{\"id\":" #n ",\"method\":\"mining.authorize\",\"params\":[\"" TEST_ADDR "\",\"x\"]}" + +static stratum_server_t *auth_test_server(obs_t *obs, int max_fail, int lockout) { + stratum_cfg_t cfg = { .bind_port = 0, .max_conns = 8, .initial_diff = 1.0, + .auth_max_failures = max_fail, + .auth_fail_lockout_sec = lockout, + .ctx = obs, .on_reject = on_reject }; + snprintf(cfg.bind_addr, sizeof(cfg.bind_addr), "127.0.0.1"); + stratum_server_t *s = NULL; + stratum_server_start(&cfg, &s); + return s; +} + +/* The third failure on one connection is answered and then the connection is + * closed (rc -1). Each failure up to the budget still records a reject -- the + * budget is what makes that bounded. */ +static void test_authorize_failures_close_the_connection(void) { + obs_t obs = {0}; + stratum_server_t *s = auth_test_server(&obs, 3, 60); + stratum_conn_t *c = stratum_conn_new_for_test(s); + char *out = NULL; size_t olen = 0; + int rc = stratum_handle_message(s, c, BAD_AUTH_LINE(1), &out, &olen); + CHECK(rc == 0); CHECK(obs.rejects == 1); free(out); out = NULL; olen = 0; + rc = stratum_handle_message(s, c, BAD_AUTH_LINE(2), &out, &olen); + CHECK(rc == 0); CHECK(obs.rejects == 2); free(out); out = NULL; olen = 0; + rc = stratum_handle_message(s, c, BAD_AUTH_LINE(3), &out, &olen); + CHECK(rc == -1); /* close after writing */ + CHECK(obs.rejects == 3); + CHECK(out && strstr(out, "\"error\"") != NULL); /* the answer still went out */ + CHECK(!stratum_conn_authorized_for_test(c)); + free(out); + stratum_conn_free_for_test(c); + stratum_server_free(s); +} + +/* An address that has spent its budget is refused on a NEW connection, at the + * top of the handler: a valid username gets the lockout error, nothing is + * decoded, no reject is recorded, and the connection is closed. Another + * address is unaffected. A success clears the record; the window expiring + * clears it too. */ +static void test_authorize_lockout_is_per_address(void) { + obs_t obs = {0}; + stratum_server_t *s = auth_test_server(&obs, 3, 1); /* 1 s window */ + char *out = NULL; size_t olen = 0; + + stratum_conn_t *a = stratum_conn_new_for_test(s); + stratum_conn_set_peer_ip_for_test(a, "203.0.113.7"); + for (int i = 0; i < 3; ++i) { + stratum_handle_message(s, a, BAD_AUTH_LINE(1), &out, &olen); + free(out); out = NULL; olen = 0; + } + CHECK(obs.rejects == 3); + stratum_conn_free_for_test(a); + + /* Same address, fresh connection, VALID username: locked out. */ + stratum_conn_t *b = stratum_conn_new_for_test(s); + stratum_conn_set_peer_ip_for_test(b, "203.0.113.7"); + int rc = stratum_handle_message(s, b, GOOD_AUTH_LINE(2), &out, &olen); + CHECK(rc == -1); + CHECK(!stratum_conn_authorized_for_test(b)); + CHECK(out && strstr(out, "too many failed authorizations") != NULL); + CHECK(obs.rejects == 3); /* no observation for a refused attempt */ + free(out); out = NULL; olen = 0; + stratum_conn_free_for_test(b); + + /* A different address is not. */ + stratum_conn_t *c = stratum_conn_new_for_test(s); + stratum_conn_set_peer_ip_for_test(c, "203.0.113.8"); + rc = stratum_handle_message(s, c, GOOD_AUTH_LINE(3), &out, &olen); + CHECK(rc == 0); + CHECK(stratum_conn_authorized_for_test(c)); + free(out); out = NULL; olen = 0; + stratum_conn_free_for_test(c); + + /* The window passes and the locked address is welcome again. */ + sleep_ms(1100); + stratum_conn_t *d = stratum_conn_new_for_test(s); + stratum_conn_set_peer_ip_for_test(d, "203.0.113.7"); + rc = stratum_handle_message(s, d, GOOD_AUTH_LINE(4), &out, &olen); + CHECK(rc == 0); + CHECK(stratum_conn_authorized_for_test(d)); + free(out); out = NULL; olen = 0; + stratum_conn_free_for_test(d); + + /* Two failures, then a success, forgives the address: two more failures on + * the next connection do not lock it (that would be four in a row without + * the reset). */ + stratum_conn_t *e = stratum_conn_new_for_test(s); + stratum_conn_set_peer_ip_for_test(e, "203.0.113.9"); + stratum_handle_message(s, e, BAD_AUTH_LINE(5), &out, &olen); + free(out); out = NULL; olen = 0; + stratum_handle_message(s, e, BAD_AUTH_LINE(6), &out, &olen); + free(out); out = NULL; olen = 0; + rc = stratum_handle_message(s, e, GOOD_AUTH_LINE(7), &out, &olen); + CHECK(rc == 0); CHECK(stratum_conn_authorized_for_test(e)); + free(out); out = NULL; olen = 0; + stratum_conn_free_for_test(e); + stratum_conn_t *f = stratum_conn_new_for_test(s); + stratum_conn_set_peer_ip_for_test(f, "203.0.113.9"); + stratum_handle_message(s, f, BAD_AUTH_LINE(8), &out, &olen); + free(out); out = NULL; olen = 0; + rc = stratum_handle_message(s, f, BAD_AUTH_LINE(9), &out, &olen); + CHECK(rc == 0); /* second failure, not locked */ + free(out); out = NULL; olen = 0; + rc = stratum_handle_message(s, f, GOOD_AUTH_LINE(10), &out, &olen); + CHECK(rc == 0); CHECK(stratum_conn_authorized_for_test(f)); + free(out); + stratum_conn_free_for_test(f); + + stratum_server_free(s); +} + +/* 0 disables: a connection may fail forever, exactly as before. The negative + * control for the two tests above -- without it, "budget enforced" and + * "feature switched off" are indistinguishable from their assertions. */ +static void test_authorize_budget_zero_disables(void) { + obs_t obs = {0}; + stratum_server_t *s = auth_test_server(&obs, 0, 60); + stratum_conn_t *c = stratum_conn_new_for_test(s); + stratum_conn_set_peer_ip_for_test(c, "203.0.113.7"); + char *out = NULL; size_t olen = 0; + int closed = 0; + for (int i = 0; i < 12; ++i) { + if (stratum_handle_message(s, c, BAD_AUTH_LINE(1), &out, &olen) < 0) closed = 1; + free(out); out = NULL; olen = 0; + } + CHECK(!closed); + CHECK(obs.rejects == 12); + stratum_conn_free_for_test(c); + stratum_server_free(s); +} + +/* Successful authorizes are budgeted too: sixty in a window, then each one is + * refused without a reject observation and spends the failure budget, which + * closes the connection on the third. The count is asserted exactly, so a + * change to the constant has to come through here. */ +static void test_authorize_call_ceiling(void) { + obs_t obs = {0}; + stratum_server_t *s = auth_test_server(&obs, 3, 60); + stratum_conn_t *c = stratum_conn_new_for_test(s); + char *out = NULL; size_t olen = 0; + int ok = 0; + for (int i = 0; i < 60; ++i) { + int rc = stratum_handle_message(s, c, GOOD_AUTH_LINE(1), &out, &olen); + ok += (rc == 0 && out && strstr(out, "\"result\":true") != NULL); + free(out); out = NULL; olen = 0; + } + CHECK(ok == 60); + int rc = stratum_handle_message(s, c, GOOD_AUTH_LINE(2), &out, &olen); + CHECK(rc == 0); + CHECK(out && strstr(out, "too many mining.authorize calls") != NULL); + free(out); out = NULL; olen = 0; + rc = stratum_handle_message(s, c, GOOD_AUTH_LINE(3), &out, &olen); + CHECK(rc == 0); free(out); out = NULL; olen = 0; + rc = stratum_handle_message(s, c, GOOD_AUTH_LINE(4), &out, &olen); + CHECK(rc == -1); /* third refusal closes */ + free(out); + CHECK(obs.rejects == 0); /* none of it wrote an observation */ + CHECK(stratum_conn_authorized_for_test(c)); /* the earlier success stands */ + stratum_conn_free_for_test(c); + stratum_server_free(s); +} + int main(void) { test_password_diff_raises(); test_password_diff_never_lowers(); @@ -2853,6 +3021,10 @@ int main(void) { test_dual_stack_accepts_ipv6(); test_dual_stack_still_accepts_ipv4(); test_ipv4_default_still_refuses_ipv6(); + test_authorize_failures_close_the_connection(); + test_authorize_lockout_is_per_address(); + test_authorize_budget_zero_disables(); + test_authorize_call_ceiling(); printf("test_stratum: %d passed, %d failed\n", g_pass, g_fail); return g_fail == 0 ? 0 : 1; }