From 0f78da127601b5b6564da4d8cd995848d9d0429d Mon Sep 17 00:00:00 2001 From: Wired4ncer <102553581+Wired4ncer@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:44:22 -0600 Subject: [PATCH] stratum: budget mining.authorize per connection and per address 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. 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. Two limits from one config number, auth_max_failures (default 3), with a window, auth_fail_lockout_sec (default 60): per connection the third failure is answered, then the socket is closed; per address an address that has failed three times inside the window is refused at the TOP of the handler -- before the params are read, so no decoding, no reject observation, no log line per attempt -- and the connection is closed. Logged once per lockout, not once per refused attempt, for the same reason. A successful authorize clears the address's record, so a miner that fixes a typo is not made to wait; the window expiring clears it too. Successful calls are budgeted as well: past sixty in ten seconds on one connection each is refused, writes no observation, and spends the failure budget. Sixty because the ceiling exists to stop deliberate spam, which is hundreds a second, while a proxy multiplexing many workers over one socket authorizes them in a burst. The per-address table is fixed and bounded -- 1024 slots, linear probing eight deep, evicting the earliest window in the run -- so a client spraying addresses can only ever reset someone else's count, never grow the table. It is a limiter, not a ledger. A refusal from the PPS gate is deliberately NOT counted: the miner did nothing wrong and cannot fix it by retrying differently, so charging it would lock out honest miners reconnecting exactly when accrual is suspended. This needs the peer address on the connection, which the tree did not keep, so accept() records it. IPv4-mapped IPv6 is un-mapped: on a dual-stack listener every IPv4 client arrives mapped, and without that one client is two keys on two listeners and its log lines stop matching what ss prints. auth_fail_lockout_sec = 0 with the budget on is refused at load -- every entry would expire as it was written, so the per-address half would silently do nothing while the config said it was on. Tests: four in test_stratum, four in test_config. The zero-disables test is the negative control; without it "budget enforced" and "feature switched off" are indistinguishable. The call ceiling is asserted exactly, so the constant cannot move without coming through the test. Mutation-verified: never closing the connection fails 2 checks, never firing the per-address lockout fails 3, and 60 -> 10 fails 3. --- proxy.conf.example | 14 +++ src/config.c | 21 +++++ src/config.h | 6 ++ src/main.c | 2 + src/stratum.c | 218 ++++++++++++++++++++++++++++++++++++++++++- src/stratum.h | 26 ++++++ tests/test_config.c | 56 +++++++++++ tests/test_stratum.c | 172 ++++++++++++++++++++++++++++++++++ 8 files changed, 511 insertions(+), 4 deletions(-) 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; }