Skip to content

Nothing limits mining.authorize: budget it per connection and per address - #79

Open
Wired4ncer wants to merge 1 commit into
LayerTwo-Labs:mainfrom
Wired4ncer:up/authorize-budget
Open

Nothing limits mining.authorize: budget it per connection and per address#79
Wired4ncer wants to merge 1 commit into
LayerTwo-Labs:mainfrom
Wired4ncer:up/authorize-budget

Conversation

@Wired4ncer

Copy link
Copy Markdown
Contributor

Problem

mining.authorize has no limit of any kind on it today.

A failed authorize — no worker name, a malformed username, an address that does not decode — costs the pool a on_reject observation and a log line. Nothing bounds how many of those one client can buy, and it can buy them before it has 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.

max_submits_per_sec covers mining.submit. There is no equivalent for the call that comes before it.

Fix

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, and then the connection is closed.
  • Per peer address — an address that has failed three times inside the window is refused at the top of the handler, before the params are read: no decoding, no reject observation, no per-attempt log line. 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 60 in 10 seconds on one connection, each is refused, writes no observation, and spends the failure budget. Sixty rather than something tighter because the ceiling exists to stop deliberate spam, which 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.

The per-address table is fixed and bounded — 1024 slots, linear probing 8 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: losing an entry can never cost anyone a lockout they had not earned.

One thing deliberately not counted: a refusal from the PPS gate. 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 — which is when they are most likely to retry.

This needs the peer's address on the connection, which the tree does not currently keep, so the accept path now records it. IPv4-mapped IPv6 is un-mapped: on a dual-stack listener every IPv4 client arrives mapped, and without that the same client is two different keys on two different listeners, and its log lines stop matching what ss or netstat prints.

Config

Ships on. Unlike max_submits_per_sec it refuses nothing a correct miner does — it only shortens how long a client may keep failing. auth_max_failures = 0 disables both halves.

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, and the suite goes 478 → 509.

The zero-disables test is the negative control: without it, "budget enforced" and "feature switched off" are indistinguishable from the assertions of the other two. The call-ceiling test asserts the count exactly, so the constant cannot move without coming through it.

Mutation-verified, because a green test proves nothing until it is shown to fail:

Mutation Result
per-connection budget never closes 2 checks fail
per-address lockout never fires 3 checks fail
ceiling 60 → 10 3 checks fail

make test is clean and deterministic: 478 -> 509, 509/509 on every run.

One thing worth flagging, which is not mine

While running make asan I hit an intermittent failure in an existing test, test_vardiff_tracks_miner_local_floor (the two floor assertions around tests/test_stratum.c:851). It is a timing margin -- a 1-second vardiff window against an 1,100 ms sleep -- and it only appears when the machine is busy.

Measured rather than guessed, running this branch and pristine main as separate sanitizer binaries:

quiet machine four sanitizer runs at once
this branch 8/8 pass 5/8 pass
pristine main 8/8 pass 7/8 pass

So it fails on main too and is not introduced here, though this branch does seem to tip it over more readily -- most likely just a slower binary with 31 more tests in the same process. CI runs make test, not make asan, so it should not surface there. Mentioning it because it cost me an hour to rule out, and it will cost the next person the same.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant