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;
}