Compare commits

...

138 commits
v0.1.0 ... dev

Author SHA1 Message Date
Snider
2470f1ac3d feat(proxy): add log tests, fix nil config panic, complete test triads
- Add log package tests (AccessLog and ShareLog Good/Bad/Ugly triads)
- Fix nil pointer panic in pool.NewStrategyFactory when config is nil
- Add Worker Hashrate Good/Bad/Ugly test triad
- Add ConfigWatcher Start Bad test (nonexistent path)
- Add FailoverStrategy CurrentPools Bad/Ugly, EnabledPools Good/Bad/Ugly,
  and NewStrategyFactory Good/Bad/Ugly test triads
- Improve doc comments on Stats, StatsSummary, Workers, WorkerRecord
  with AX-compliant usage examples

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 08:08:28 +01:00
Snider
31a151d23c feat(proxy): implement RFC test coverage and AX usage-example comments
Add missing Good/Bad/Ugly test triplets per RFC section 22:
- stats_test.go: OnAccept/OnReject/Tick/OnLogin/OnClose tests with
  concurrency race test and top-10 diff slot verification
- ratelimit_test.go: full Good/Bad/Ugly set including ban persistence
  and disabled-limiter edge case
- customdiff_test.go: renamed to Apply_Good/Bad/Ugly convention per RFC
- storage_test.go: full Add_Good/Bad/Ugly set including 256-slot fill,
  overflow rejection, and dead-slot reclamation via SetJob
- job_test.go: added Good/Bad/Ugly for BlobWithFixedByte, DifficultyFromTarget,
  and IsValid

Add Miner.Diff() public getter for the last difficulty sent to miner.

Add AX-compliant usage-example comments (principle 2) to all Miner
accessors, Proxy query methods, EffectiveShareDifficulty, targetFromDifficulty,
MinerSnapshot, and RateLimiter.IsActive.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 07:02:54 +01:00
Virgil
6f0747abc2 fix(reload): apply custom diff updates to active miners
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 03:49:05 +00:00
Virgil
711c4259f7 refactor(proxy): unify monitoring auth checks
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 03:44:35 +00:00
Virgil
8cf01f2618 refactor(proxy): rename server TLS config field
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 03:42:05 +00:00
Virgil
b6b44b1f7b refactor(proxy): name miner timeouts and password mask
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 03:39:19 +00:00
Virgil
e1eadf705d fix(proxy): separate config loading from validation
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 03:36:21 +00:00
Virgil
ea378354de chore(proxy): clarify watcher and limiter names 2026-04-05 03:34:07 +00:00
Virgil
8a9046356e refactor(proxy): centralise monitoring API contract values
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 03:30:40 +00:00
Virgil
d1a899805e perf(proxy): reuse miner send buffer for writes
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 03:25:39 +00:00
Virgil
5680539dbb docs(proxy): tighten config comments
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 03:22:57 +00:00
Virgil
82b2375058 fix(proxy): trim mode and worker identifiers
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 03:19:28 +00:00
Virgil
01a0cc5907 fix(proxy): drain submits before shutdown
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 03:15:17 +00:00
Virgil
031f0c0f17 docs(proxy): tighten AX-oriented comments
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 03:10:41 +00:00
Virgil
75d151b4e5 refactor(proxy): clarify miner login callback naming
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 03:07:41 +00:00
Virgil
70fcbd4d43 docs(nicehash): sharpen storage usage examples
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 03:04:39 +00:00
Virgil
686f4ea54f fix(api): align response type aliases with RFC
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 03:01:41 +00:00
Virgil
af96bfce94 docs: align public API comments with AX guidance
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 02:58:56 +00:00
Virgil
1ae781608c fix(api): honour unrestricted monitoring methods
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 02:55:36 +00:00
Virgil
ee128e944d fix(proxy): make config watcher restartable
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 02:50:55 +00:00
Virgil
1f8ff58b20 fix(login): defer login events until assignment succeeds
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 02:48:03 +00:00
Virgil
bc6113c80d fix(api): enforce GET on monitoring routes
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 02:43:49 +00:00
Virgil
b3fd1fef61 refactor(splitter): clarify mapper ownership names
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 02:40:42 +00:00
Virgil
e518f2df32 refactor(api): clarify monitoring route guards
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 02:37:56 +00:00
Virgil
30ff013158 docs(proxy): sharpen AX usage examples
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 02:34:53 +00:00
Virgil
be47d7afde fix(proxy): align config and login parsing with rfc
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 02:31:37 +00:00
Virgil
65f6c733a0 fix(simple): reset stale job window on pool session change
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 02:28:30 +00:00
Virgil
f4f0081eb0 docs(proxy): align AX comments
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 02:24:39 +00:00
Virgil
f0d5f6ae86 refactor(proxy): clarify public wiring comments
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 02:21:48 +00:00
Virgil
0a7c99264b refactor(errors): add scoped proxy failures
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 02:18:05 +00:00
Virgil
35db5f6840 docs(proxy): sharpen AX usage examples
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 02:12:55 +00:00
Virgil
8a52856719 fix(proxy): reject configs without enabled pools
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 02:09:46 +00:00
Virgil
5d8d82b9b5 docs(proxy): align AX comments
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 02:06:24 +00:00
Virgil
356eb9cec1 fix(proxy): use mtime-based config watching
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 02:04:03 +00:00
Virgil
cbde021d0c docs(proxy): align AX comments
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 02:01:38 +00:00
Virgil
f2f7dfed75 fix(proxy): align config watcher with RFC
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 01:59:28 +00:00
Virgil
ce3b7a50cd fix(proxy): use filesystem config watcher
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 01:56:36 +00:00
Virgil
ecd4130457 docs(proxy): sharpen AX examples
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 01:52:20 +00:00
Virgil
5a3fcf4fab docs(proxy): sharpen AX usage comments
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 01:50:02 +00:00
Virgil
7dd9807a6e chore: improve proxy api comments 2026-04-05 01:46:41 +00:00
Virgil
7b2a7ccd88 docs(proxy): align public AX comments
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 01:43:31 +00:00
Virgil
9f34bc7200 docs(proxy): refine config AX comments 2026-04-05 01:41:00 +00:00
Virgil
a1f47f5792 fix(proxy): align pool failover and simple routing
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 01:38:05 +00:00
Virgil
b5e4a6499f docs(proxy): improve AX usage comments
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 01:33:13 +00:00
Virgil
b9b3c47b4c docs(proxy): align public comments with AX
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 01:30:08 +00:00
Virgil
fefae4b3e5 fix(proxy): classify low difficulty rejects
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 01:26:24 +00:00
Virgil
264479d57b fix(pool): roll back failed submit tracking
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 01:23:50 +00:00
Virgil
d43c8ee4c1 fix: clear stale upstream state on disconnect 2026-04-05 01:19:50 +00:00
Virgil
05b0bb5ea4 docs(ax): improve public route and log examples
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 01:15:03 +00:00
Virgil
2a49caca03 docs(ax): add codex conventions
Clarify reload behaviour for immutable splitter wiring.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 01:12:06 +00:00
Virgil
3cd0909d74 feat(proxy): add share sink fanout
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 01:08:23 +00:00
Virgil
d0ae26a1a2 Align difficulty math with RFC 2026-04-05 01:05:00 +00:00
Virgil
3f9da136e9 docs(ax): align public api comments
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 01:02:31 +00:00
Virgil
f3c5175785 docs(proxy): align AX usage comments
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 00:59:17 +00:00
Virgil
e94616922d Fix simple mapper recovery state 2026-04-05 00:56:17 +00:00
Virgil
d8b4bf2775 refactor(proxy): clarify internal helper naming
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 00:52:34 +00:00
Virgil
3debd08a64 fix(api): enforce monitoring auth on exported routes
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 00:49:26 +00:00
Virgil
eabe9b521d fix(proxy): harden hot-reload config access
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 00:45:39 +00:00
Virgil
a11d5b0969 refactor(proxy): align public comments with AX
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 00:42:12 +00:00
Virgil
766c4d1946 docs(pool): align public comments with AX
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 00:39:08 +00:00
Virgil
8ad123ecab refactor(proxy): improve splitter registry naming
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 00:36:29 +00:00
Virgil
55d44df9c2 Use effective custom diff in share accounting 2026-04-05 00:32:43 +00:00
Virgil
9d2b1f368c docs(proxy): align API comments with AX
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 00:28:03 +00:00
Virgil
2364633afc docs(ax): improve proxy API comments
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 00:25:30 +00:00
Virgil
9460f82738 Align monitoring route handling 2026-04-05 00:21:42 +00:00
Virgil
cf4136c8f0 docs(proxy): align public comments with AX
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 00:18:05 +00:00
Virgil
460aae14fb refactor(api): clarify route registration usage
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 00:15:01 +00:00
Virgil
bbdff60580 Honor worker mode changes on reload 2026-04-05 00:11:43 +00:00
Virgil
a76e6be1c7 docs(proxy): align public entry points with AX examples
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 00:07:27 +00:00
Virgil
96f7f18c96 refactor(log): align logging helpers with AX naming
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 00:04:02 +00:00
Virgil
77435d44fe docs(rfc): add spec path aliases
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-05 00:00:39 +00:00
Virgil
ad069a45d5 fix(proxy): harden monitoring helpers against nil config
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 23:58:09 +00:00
Virgil
7a48e479ec docs(proxy): align public docs with AX examples
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 23:54:23 +00:00
Virgil
fd88492b00 fix(proxy): honour monitoring restriction flag
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 23:51:37 +00:00
Virgil
fd6bc01b87 docs(proxy): align API comments with AX
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 23:48:33 +00:00
Virgil
9e44fb6ea3 refactor(log): add explicit log close lifecycle
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 23:45:11 +00:00
Virgil
fd76640d69 refactor(proxy): clarify monitoring HTTP helpers
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 23:42:26 +00:00
Virgil
fb5453c097 fix(api): enforce GET on monitoring endpoints
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 23:39:29 +00:00
Virgil
34f95071d9 fix(proxy): align difficulty conversion with RFC examples
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 23:36:55 +00:00
Virgil
4e5311215d refactor(proxy): centralise invalid share classification
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 23:33:21 +00:00
Virgil
2d39783dc4 fix(nicehash): align upstream totals with RFC
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 23:30:40 +00:00
Virgil
e2bd10c94f docs(proxy): sharpen AX usage examples
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 23:28:35 +00:00
Virgil
1e6ba01d03 fix(api): mask miner passwords in miners document
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 23:25:46 +00:00
Virgil
c0efdfb0ca refactor(api): unify monitoring documents for AX 2026-04-04 23:23:13 +00:00
Virgil
619b3c500d refactor(proxy): isolate event bus panics
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 23:16:18 +00:00
Virgil
8a321e2467 docs(proxy): align public helpers with AX examples
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 23:13:43 +00:00
Virgil
167ecc2bdc refactor(proxy): clarify agent-facing names
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 23:10:35 +00:00
Virgil
0bb5ce827b fix(proxy): fail fast on HTTP bind errors
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 23:07:43 +00:00
Virgil
6f0f695054 fix(proxy): separate HTTP auth and method errors
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 23:05:18 +00:00
Virgil
4a0213e89f fix(simple): add missing mapper constructor
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 22:57:19 +00:00
Virgil
84362d9dc5 fix(reload): reconnect upstreams on pool config changes
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 22:52:01 +00:00
Virgil
4006f33c1e fix(proxy): reload watcher on watch config changes
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 22:41:16 +00:00
Virgil
9b6a251145 fix(log): align access log formats with RFC
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 22:29:00 +00:00
Virgil
0c746e4ea7 fix(proxy): support OpenSSL TLS cipher aliases
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 22:26:42 +00:00
Virgil
e594b04d7c fix(splitter): reclaim idle nicehash mappers
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 22:23:50 +00:00
Virgil
187a366d74 refactor(proxy): align aggregate field names with RFC
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 22:20:39 +00:00
Virgil
5ba21cb9bf fix(proxy): align miner success wire format
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 22:17:31 +00:00
Virgil
2b8bba790c refactor(proxy): align login flow with RFC order
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 22:13:10 +00:00
Virgil
cfd669e4d2 refactor(proxy): clarify config parameter names
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 22:09:58 +00:00
Virgil
6422a948bf refactor(proxy): clarify runtime config naming
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 22:06:18 +00:00
Virgil
8bde2c14d0 fix(proxy): dispatch login events before splitter assignment
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 22:02:25 +00:00
Virgil
a79b35abaf Improve public API usage examples 2026-04-04 21:58:37 +00:00
Virgil
5e343a7354 fix(proxy): disconnect splitters on shutdown
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 21:55:49 +00:00
Virgil
4c2a0ffab7 fix(log): keep access log columns stable
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 21:52:13 +00:00
Virgil
33d35ed063 refactor(api): name monitoring row payloads
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 21:48:27 +00:00
Virgil
c74f62e6d7 fix(nicehash): avoid double-counting expired submissions
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 21:45:57 +00:00
Virgil
8b47e6a11b refactor(proxy): rename hash totals for clarity
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 21:42:57 +00:00
Virgil
6d6934f37b fix(proxy): resolve login custom diff during handshake
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 21:39:58 +00:00
Virgil
c62f2c86a9 fix(proxy): omit null errors from success replies
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 21:37:15 +00:00
Virgil
1548643c65 fix(nicehash): flag expired shares with previous job
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 21:33:35 +00:00
Virgil
1065b78b7c chore(proxy): remove stale runtime comments 2026-04-04 21:16:21 +00:00
Virgil
9028334d49 Implement miner target capping and RFC line limits 2026-04-04 21:08:28 +00:00
Virgil
186524b3a8 fix(proxy): reject full NiceHash login tables
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 21:03:14 +00:00
Virgil
d9c59c668d fix(proxy): preserve miner remote address in API
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 20:59:02 +00:00
Virgil
8faac7eee6 fix(proxy): apply custom diff before worker login
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 20:56:16 +00:00
Virgil
ce7d3301fc refactor(proxy): clarify config path naming
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 20:53:49 +00:00
Virgil
c7d688ccfa fix(proxy): drain pending submits on stop
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 20:51:14 +00:00
Virgil
d42c21438a fix(proxy): honour invalid custom diff suffixes
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 20:48:10 +00:00
Virgil
86c07943b0 fix(proxy): align watcher and HTTP payloads
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 20:45:37 +00:00
Virgil
35d8c524e4 refactor(proxy): normalise worker timestamps
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 20:35:03 +00:00
Virgil
d47d89af7a Fix submit job validation and custom diff fallback 2026-04-04 20:28:54 +00:00
Virgil
b66739b64f fix(simple): track expired shares
Propagate the submitted job id through simple-mode share handling so accepted shares can be flagged expired when a reply lands after a job rollover. Add coverage for the expired accept event path.\n\nCo-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 20:24:06 +00:00
Virgil
3efa7f34d0 docs(proxy): add AX usage examples to lifecycle APIs
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 20:12:26 +00:00
Virgil
b3ad79d832 feat(proxy): wire share log events
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 20:09:13 +00:00
Virgil
d10a57e377 fix(proxy): close miners during shutdown
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 20:05:54 +00:00
Virgil
6d6da10885 fix(proxy): stabilise worker snapshots
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 20:02:35 +00:00
Virgil
b16ebc1a28 feat(proxy): store miner login algo list
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 19:59:01 +00:00
Virgil
2f59714cce refactor(api): clarify summary ratio helper
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 19:46:48 +00:00
Virgil
21fce78ffe fix(simple): count upstream errors in stats 2026-04-04 19:44:19 +00:00
Virgil
e92c6070be feat(proxy): add custom diff stats and clean failover disconnects
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 19:20:29 +00:00
Virgil
c250a4d6f2 fix(splitter): honour reuse timeout and stale jobs
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 19:15:14 +00:00
Virgil
4a281e6e25 feat(pool): wire keepalive ticks through splitters
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 19:00:18 +00:00
Virgil
1bcbb389e6 feat(proxy): wire worker bus and mapper startup
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 18:53:16 +00:00
Virgil
bc67e73ca0 fix(proxy): tighten listener and limiter lifecycle
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 18:49:03 +00:00
Virgil
31a8ba558f Wire access log config into proxy 2026-04-04 18:42:57 +00:00
Virgil
6f4d7019e2 fix(proxy): align runtime with RFC
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 18:38:28 +00:00
Virgil
64443c41f6 feat(proxy): fill RFC login and watch gaps
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 18:25:36 +00:00
Virgil
7f44596858 fix(proxy): validate config and reload pools
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 18:19:09 +00:00
64 changed files with 7351 additions and 873 deletions

14
CODEX.md Normal file
View file

@ -0,0 +1,14 @@
# CODEX.md
This repository uses `CLAUDE.md` as the detailed source of truth for working conventions.
This file exists so agent workflows that expect `CODEX.md` can resolve the repo rules directly.
## Core Conventions
- Read `docs/RFC.md` before changing behaviour.
- Preserve existing user changes in the worktree.
- Prefer `rg` for search and `apply_patch` for edits.
- Keep names predictable and comments example-driven.
- Run `go test ./...` and `go test -race ./...` before committing when practical.
- Commit with a conventional message and include the required co-author line when requested by repo policy.

121
accesslog_impl.go Normal file
View file

@ -0,0 +1,121 @@
package proxy
import (
"os"
"strconv"
"strings"
"sync"
"time"
)
type accessLogSink struct {
path string
file *os.File
mu sync.Mutex
}
func newAccessLogSink(path string) *accessLogSink {
return &accessLogSink{path: path}
}
func (l *accessLogSink) SetPath(path string) {
if l == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
if l.path == path {
return
}
l.path = path
if l.file != nil {
_ = l.file.Close()
l.file = nil
}
}
func (l *accessLogSink) Close() {
if l == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
if l.file != nil {
_ = l.file.Close()
l.file = nil
}
}
func (l *accessLogSink) OnLogin(e Event) {
if l == nil || e.Miner == nil {
return
}
l.writeConnectLine(e.Miner.IP(), e.Miner.User(), e.Miner.Agent())
}
func (l *accessLogSink) OnClose(e Event) {
if l == nil || e.Miner == nil {
return
}
l.writeCloseLine(e.Miner.IP(), e.Miner.User(), e.Miner.RX(), e.Miner.TX())
}
func (l *accessLogSink) writeConnectLine(ip, user, agent string) {
l.mu.Lock()
defer l.mu.Unlock()
if strings.TrimSpace(l.path) == "" {
return
}
if l.file == nil {
file, err := os.OpenFile(l.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return
}
l.file = file
}
var builder strings.Builder
builder.WriteString(time.Now().UTC().Format(time.RFC3339))
builder.WriteByte(' ')
builder.WriteString("CONNECT")
builder.WriteString(" ")
builder.WriteString(ip)
builder.WriteString(" ")
builder.WriteString(user)
builder.WriteString(" ")
builder.WriteString(agent)
builder.WriteByte('\n')
_, _ = l.file.WriteString(builder.String())
}
func (l *accessLogSink) writeCloseLine(ip, user string, rx, tx uint64) {
l.mu.Lock()
defer l.mu.Unlock()
if strings.TrimSpace(l.path) == "" {
return
}
if l.file == nil {
file, err := os.OpenFile(l.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return
}
l.file = file
}
var builder strings.Builder
builder.WriteString(time.Now().UTC().Format(time.RFC3339))
builder.WriteByte(' ')
builder.WriteString("CLOSE")
builder.WriteString(" ")
builder.WriteString(ip)
builder.WriteString(" ")
builder.WriteString(user)
builder.WriteString(" rx=")
builder.WriteString(formatUint(rx))
builder.WriteString(" tx=")
builder.WriteString(formatUint(tx))
builder.WriteByte('\n')
_, _ = l.file.WriteString(builder.String())
}
func formatUint(value uint64) string {
return strconv.FormatUint(value, 10)
}

102
accesslog_test.go Normal file
View file

@ -0,0 +1,102 @@
package proxy
import (
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestProxy_AccessLog_WritesLifecycleLines(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "access.log")
cfg := &Config{
Mode: "nicehash",
Workers: WorkersByRigID,
AccessLogFile: path,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool.example:3333", Enabled: true}},
}
p, result := New(cfg)
if !result.OK {
t.Fatalf("expected valid proxy, got error: %v", result.Error)
}
miner := &Miner{
ip: "10.0.0.1",
user: "WALLET",
agent: "XMRig/6.21.0",
rx: 512,
tx: 4096,
conn: noopConn{},
state: MinerStateReady,
rpcID: "session",
}
p.events.Dispatch(Event{Type: EventLogin, Miner: miner})
p.events.Dispatch(Event{Type: EventClose, Miner: miner})
p.Stop()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read access log: %v", err)
}
text := string(data)
if !strings.Contains(text, "CONNECT 10.0.0.1 WALLET XMRig/6.21.0") {
t.Fatalf("expected CONNECT line, got %q", text)
}
if !strings.Contains(text, "CLOSE 10.0.0.1 WALLET rx=512 tx=4096") {
t.Fatalf("expected CLOSE line, got %q", text)
}
}
func TestProxy_AccessLog_WritesFixedColumns(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "access.log")
cfg := &Config{
Mode: "nicehash",
Workers: WorkersByRigID,
AccessLogFile: path,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool.example:3333", Enabled: true}},
}
p, result := New(cfg)
if !result.OK {
t.Fatalf("expected valid proxy, got error: %v", result.Error)
}
miner := &Miner{
ip: "10.0.0.1",
user: "WALLET",
conn: noopConn{},
}
p.events.Dispatch(Event{Type: EventLogin, Miner: miner})
p.events.Dispatch(Event{Type: EventClose, Miner: miner})
p.Stop()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read access log: %v", err)
}
text := string(data)
if !strings.Contains(text, "CONNECT 10.0.0.1 WALLET") {
t.Fatalf("expected CONNECT line without counters, got %q", text)
}
if !strings.Contains(text, "CLOSE 10.0.0.1 WALLET rx=0 tx=0") {
t.Fatalf("expected CLOSE line with counters only, got %q", text)
}
}
type noopConn struct{}
func (noopConn) Read([]byte) (int, error) { return 0, os.ErrClosed }
func (noopConn) Write([]byte) (int, error) { return 0, os.ErrClosed }
func (noopConn) Close() error { return nil }
func (noopConn) LocalAddr() net.Addr { return nil }
func (noopConn) RemoteAddr() net.Addr { return nil }
func (noopConn) SetDeadline(time.Time) error { return nil }
func (noopConn) SetReadDeadline(time.Time) error { return nil }
func (noopConn) SetWriteDeadline(time.Time) error { return nil }

View file

@ -1,12 +1,7 @@
// Package api implements the HTTP monitoring endpoints for the proxy. // Package api mounts the monitoring endpoints on an HTTP mux.
// //
// Registered routes: // mux := http.NewServeMux()
// // api.RegisterRoutes(mux, proxyInstance)
// GET /1/summary — aggregated proxy stats
// GET /1/workers — per-worker hashrate table
// GET /1/miners — per-connection state table
//
// proxyapi.RegisterRoutes(apiRouter, p)
package api package api
import ( import (
@ -16,172 +11,52 @@ import (
"dappco.re/go/proxy" "dappco.re/go/proxy"
) )
// Router matches the standard http.ServeMux registration shape. // RouteRegistrar accepts HTTP handler registrations.
type Router interface { //
// mux := http.NewServeMux()
// api.RegisterRoutes(mux, proxyInstance)
type RouteRegistrar interface {
HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request))
} }
// SummaryResponse is the /1/summary JSON body. // mux := http.NewServeMux()
// api.RegisterRoutes(mux, proxyInstance)
// _ = mux
// //
// {"version":"1.0.0","mode":"nicehash","hashrate":{"total":[...]}, ...} // The mounted routes are GET /1/summary, /1/workers, and /1/miners.
type SummaryResponse struct { func RegisterRoutes(router RouteRegistrar, p *proxy.Proxy) {
Version string `json:"version"` if router == nil || p == nil {
Mode string `json:"mode"`
Hashrate HashrateResponse `json:"hashrate"`
Miners MinersCountResponse `json:"miners"`
Workers uint64 `json:"workers"`
Upstreams UpstreamResponse `json:"upstreams"`
Results ResultsResponse `json:"results"`
}
// HashrateResponse carries the per-window hashrate array.
//
// HashrateResponse{Total: [6]float64{12345.67, 11900.00, 12100.00, 11800.00, 12000.00, 12200.00}}
type HashrateResponse struct {
Total [6]float64 `json:"total"`
}
// MinersCountResponse carries current and peak miner counts.
//
// MinersCountResponse{Now: 142, Max: 200}
type MinersCountResponse struct {
Now uint64 `json:"now"`
Max uint64 `json:"max"`
}
// UpstreamResponse carries pool connection state counts.
//
// UpstreamResponse{Active: 1, Sleep: 0, Error: 0, Total: 1, Ratio: 142.0}
type UpstreamResponse struct {
Active uint64 `json:"active"`
Sleep uint64 `json:"sleep"`
Error uint64 `json:"error"`
Total uint64 `json:"total"`
Ratio float64 `json:"ratio"`
}
// ResultsResponse carries share acceptance statistics.
//
// ResultsResponse{Accepted: 4821, Rejected: 3, Invalid: 0, Expired: 12}
type ResultsResponse struct {
Accepted uint64 `json:"accepted"`
Rejected uint64 `json:"rejected"`
Invalid uint64 `json:"invalid"`
Expired uint64 `json:"expired"`
AvgTime uint32 `json:"avg_time"`
Latency uint32 `json:"latency"`
HashesTotal uint64 `json:"hashes_total"`
Best [10]uint64 `json:"best"`
}
// RegisterRoutes wires the monitoring endpoints onto the supplied router.
func RegisterRoutes(r Router, p *proxy.Proxy) {
if r == nil || p == nil {
return return
} }
r.HandleFunc("/1/summary", func(w http.ResponseWriter, req *http.Request) { registerJSONGetRoute(router, p, proxy.MonitoringRouteSummary, func() any { return p.SummaryDocument() })
writeJSON(w, summaryResponse(p)) registerJSONGetRoute(router, p, proxy.MonitoringRouteWorkers, func() any { return p.WorkersDocument() })
}) registerJSONGetRoute(router, p, proxy.MonitoringRouteMiners, func() any { return p.MinersDocument() })
r.HandleFunc("/1/workers", func(w http.ResponseWriter, req *http.Request) { }
writeJSON(w, workersResponse(p))
}) func registerJSONGetRoute(router RouteRegistrar, proxyInstance *proxy.Proxy, pattern string, renderDocument func() any) {
r.HandleFunc("/1/miners", func(w http.ResponseWriter, req *http.Request) { router.HandleFunc(pattern, func(w http.ResponseWriter, request *http.Request) {
writeJSON(w, minersResponse(p)) if status, ok := allowMonitoringRequest(proxyInstance, request); !ok {
switch status {
case http.StatusMethodNotAllowed:
w.Header().Set("Allow", http.MethodGet)
case http.StatusUnauthorized:
w.Header().Set("WWW-Authenticate", "Bearer")
}
w.WriteHeader(status)
return
}
writeJSON(w, renderDocument())
}) })
} }
func summaryResponse(p *proxy.Proxy) SummaryResponse { func allowMonitoringRequest(proxyInstance *proxy.Proxy, request *http.Request) (int, bool) {
summary := p.Summary() if proxyInstance == nil {
now, max := p.MinerCount() return http.StatusServiceUnavailable, false
upstreams := p.Upstreams()
return SummaryResponse{
Version: "1.0.0",
Mode: p.Mode(),
Hashrate: HashrateResponse{
Total: summary.Hashrate,
},
Miners: MinersCountResponse{
Now: now,
Max: max,
},
Workers: uint64(len(p.WorkerRecords())),
Upstreams: UpstreamResponse{
Active: upstreams.Active,
Sleep: upstreams.Sleep,
Error: upstreams.Error,
Total: upstreams.Total,
Ratio: ratio(now, upstreams.Total),
},
Results: ResultsResponse{
Accepted: summary.Accepted,
Rejected: summary.Rejected,
Invalid: summary.Invalid,
Expired: summary.Expired,
AvgTime: summary.AvgTime,
Latency: summary.AvgLatency,
HashesTotal: summary.Hashes,
Best: summary.TopDiff,
},
}
}
func workersResponse(p *proxy.Proxy) any {
records := p.WorkerRecords()
rows := make([]any, 0, len(records))
for _, record := range records {
rows = append(rows, []any{
record.Name,
record.LastIP,
record.Connections,
record.Accepted,
record.Rejected,
record.Invalid,
record.Hashes,
record.LastHashAt.Unix(),
record.Hashrate(60),
record.Hashrate(600),
record.Hashrate(3600),
record.Hashrate(43200),
record.Hashrate(86400),
})
}
return map[string]any{
"mode": string(p.WorkersMode()),
"workers": rows,
}
}
func minersResponse(p *proxy.Proxy) any {
records := p.MinerSnapshots()
rows := make([]any, 0, len(records))
for _, miner := range records {
rows = append(rows, []any{
miner.ID,
miner.IP,
miner.TX,
miner.RX,
miner.State,
miner.Diff,
miner.User,
miner.Password,
miner.RigID,
miner.Agent,
})
}
return map[string]any{
"format": []string{"id", "ip", "tx", "rx", "state", "diff", "user", "password", "rig_id", "agent"},
"miners": rows,
} }
return proxyInstance.AllowMonitoringRequest(request)
} }
func writeJSON(w http.ResponseWriter, payload any) { func writeJSON(w http.ResponseWriter, payload any) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(payload) _ = json.NewEncoder(w).Encode(payload)
} }
func ratio(now, total uint64) float64 {
if total == 0 {
return 0
}
return float64(now) / float64(total)
}

201
api/router_test.go Normal file
View file

@ -0,0 +1,201 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"dappco.re/go/proxy"
)
func TestRegisterRoutes_GETSummary_Good(t *testing.T) {
config := &proxy.Config{
Mode: "nicehash",
Workers: proxy.WorkersByRigID,
Bind: []proxy.BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []proxy.PoolConfig{{URL: "pool.example:3333", Enabled: true}},
}
p, result := proxy.New(config)
if !result.OK {
t.Fatalf("new proxy: %v", result.Error)
}
router := http.NewServeMux()
RegisterRoutes(router, p)
request := httptest.NewRequest(http.MethodGet, "/1/summary", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected %d, got %d", http.StatusOK, recorder.Code)
}
var document proxy.SummaryDocument
if err := json.Unmarshal(recorder.Body.Bytes(), &document); err != nil {
t.Fatalf("decode summary document: %v", err)
}
if document.Mode != "nicehash" {
t.Fatalf("expected mode %q, got %q", "nicehash", document.Mode)
}
if document.Version != "1.0.0" {
t.Fatalf("expected version %q, got %q", "1.0.0", document.Version)
}
}
func TestRegisterRoutes_POSTSummary_Bad(t *testing.T) {
config := &proxy.Config{
Mode: "nicehash",
Workers: proxy.WorkersByRigID,
Bind: []proxy.BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []proxy.PoolConfig{{URL: "pool.example:3333", Enabled: true}},
HTTP: proxy.HTTPConfig{
Restricted: true,
},
}
p, result := proxy.New(config)
if !result.OK {
t.Fatalf("new proxy: %v", result.Error)
}
router := http.NewServeMux()
RegisterRoutes(router, p)
request := httptest.NewRequest(http.MethodPost, "/1/summary", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected %d, got %d", http.StatusMethodNotAllowed, recorder.Code)
}
}
func TestRegisterRoutes_POSTSummary_Unrestricted_Good(t *testing.T) {
config := &proxy.Config{
Mode: "nicehash",
Workers: proxy.WorkersByRigID,
Bind: []proxy.BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []proxy.PoolConfig{{URL: "pool.example:3333", Enabled: true}},
}
p, result := proxy.New(config)
if !result.OK {
t.Fatalf("new proxy: %v", result.Error)
}
router := http.NewServeMux()
RegisterRoutes(router, p)
request := httptest.NewRequest(http.MethodPost, "/1/summary", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected %d, got %d", http.StatusOK, recorder.Code)
}
var document proxy.SummaryDocument
if err := json.Unmarshal(recorder.Body.Bytes(), &document); err != nil {
t.Fatalf("decode summary document: %v", err)
}
if document.Mode != "nicehash" {
t.Fatalf("expected mode %q, got %q", "nicehash", document.Mode)
}
}
func TestRegisterRoutes_GETMiners_Ugly(t *testing.T) {
config := &proxy.Config{
Mode: "simple",
Workers: proxy.WorkersDisabled,
Bind: []proxy.BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []proxy.PoolConfig{{URL: "pool.example:3333", Enabled: true}},
}
p, result := proxy.New(config)
if !result.OK {
t.Fatalf("new proxy: %v", result.Error)
}
router := http.NewServeMux()
RegisterRoutes(router, p)
request := httptest.NewRequest(http.MethodGet, "/1/miners", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected %d, got %d", http.StatusOK, recorder.Code)
}
var document proxy.MinersDocument
if err := json.Unmarshal(recorder.Body.Bytes(), &document); err != nil {
t.Fatalf("decode miners document: %v", err)
}
if len(document.Format) != 10 {
t.Fatalf("expected 10 miner columns, got %d", len(document.Format))
}
if len(document.Miners) != 0 {
t.Fatalf("expected no miners in a new proxy, got %d", len(document.Miners))
}
}
func TestRegisterRoutes_GETSummaryAuthRequired_Bad(t *testing.T) {
config := &proxy.Config{
Mode: "nicehash",
Workers: proxy.WorkersByRigID,
Bind: []proxy.BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []proxy.PoolConfig{{URL: "pool.example:3333", Enabled: true}},
HTTP: proxy.HTTPConfig{
Enabled: true,
Restricted: true,
AccessToken: "secret",
},
}
p, result := proxy.New(config)
if !result.OK {
t.Fatalf("new proxy: %v", result.Error)
}
router := http.NewServeMux()
RegisterRoutes(router, p)
request := httptest.NewRequest(http.MethodGet, "/1/summary", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("expected %d, got %d", http.StatusUnauthorized, recorder.Code)
}
if got := recorder.Header().Get("WWW-Authenticate"); got != "Bearer" {
t.Fatalf("expected bearer challenge, got %q", got)
}
}
func TestRegisterRoutes_GETSummaryAuthGranted_Ugly(t *testing.T) {
config := &proxy.Config{
Mode: "nicehash",
Workers: proxy.WorkersByRigID,
Bind: []proxy.BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []proxy.PoolConfig{{URL: "pool.example:3333", Enabled: true}},
HTTP: proxy.HTTPConfig{
Enabled: true,
Restricted: true,
AccessToken: "secret",
},
}
p, result := proxy.New(config)
if !result.OK {
t.Fatalf("new proxy: %v", result.Error)
}
router := http.NewServeMux()
RegisterRoutes(router, p)
request := httptest.NewRequest(http.MethodGet, "/1/summary", nil)
request.Header.Set("Authorization", "Bearer secret")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected %d, got %d", http.StatusOK, recorder.Code)
}
}

113
api_rows.go Normal file
View file

@ -0,0 +1,113 @@
package proxy
const (
// MonitoringRouteSummary documents the summary endpoint path.
//
// http.Get("http://127.0.0.1:8080" + proxy.MonitoringRouteSummary)
MonitoringRouteSummary = "/1/summary"
// MonitoringRouteWorkers documents the workers endpoint path.
//
// http.Get("http://127.0.0.1:8080" + proxy.MonitoringRouteWorkers)
MonitoringRouteWorkers = "/1/workers"
// MonitoringRouteMiners documents the miners endpoint path.
//
// http.Get("http://127.0.0.1:8080" + proxy.MonitoringRouteMiners)
MonitoringRouteMiners = "/1/miners"
// SummaryDocumentVersion is the monitoring API version.
//
// doc := proxy.SummaryDocument{Version: proxy.SummaryDocumentVersion}
SummaryDocumentVersion = "1.0.0"
)
var (
// MinersDocumentFormat defines the fixed /1/miners column order.
//
// doc := proxy.MinersDocument{Format: append([]string(nil), proxy.MinersDocumentFormat...)}
MinersDocumentFormat = []string{"id", "ip", "tx", "rx", "state", "diff", "user", "password", "rig_id", "agent"}
workerHashrateWindows = [5]int{60, 600, 3600, 43200, 86400}
)
// WorkerRow{"rig-alpha", "10.0.0.1", 1, 10, 0, 0, 10000, 1712232000, 1.0, 1.0, 1.0, 1.0, 1.0}
type WorkerRow [13]any
// MinerRow{1, "10.0.0.1:49152", 4096, 512, 2, 10000, "WALLET", maskedPassword, "rig-alpha", "XMRig/6.21.0"}
type MinerRow [10]any
// doc := p.SummaryDocument()
// _ = doc.Results.Accepted
// _ = doc.Upstreams.Ratio
type SummaryDocument struct {
Version string `json:"version"`
Mode string `json:"mode"`
Hashrate HashrateDocument `json:"hashrate"`
Miners MinersCountDocument `json:"miners"`
Workers uint64 `json:"workers"`
Upstreams UpstreamDocument `json:"upstreams"`
Results ResultsDocument `json:"results"`
CustomDiffStats map[uint64]CustomDiffBucketStats `json:"custom_diff_stats,omitempty"`
}
// SummaryResponse is the RFC name for SummaryDocument.
type SummaryResponse = SummaryDocument
// HashrateDocument{Total: [6]float64{12345.67, 11900.00, 12100.00, 11800.00, 12000.00, 12200.00}}
type HashrateDocument struct {
Total [6]float64 `json:"total"`
}
// HashrateResponse is the RFC name for HashrateDocument.
type HashrateResponse = HashrateDocument
// MinersCountDocument{Now: 142, Max: 200}
type MinersCountDocument struct {
Now uint64 `json:"now"`
Max uint64 `json:"max"`
}
// MinersCountResponse is the RFC name for MinersCountDocument.
type MinersCountResponse = MinersCountDocument
// UpstreamDocument{Active: 1, Sleep: 0, Error: 0, Total: 1, Ratio: 142.0}
type UpstreamDocument struct {
Active uint64 `json:"active"`
Sleep uint64 `json:"sleep"`
Error uint64 `json:"error"`
Total uint64 `json:"total"`
Ratio float64 `json:"ratio"`
}
// UpstreamResponse is the RFC name for UpstreamDocument.
type UpstreamResponse = UpstreamDocument
// ResultsDocument{Accepted: 4821, Rejected: 3, Invalid: 0, Expired: 12}
type ResultsDocument struct {
Accepted uint64 `json:"accepted"`
Rejected uint64 `json:"rejected"`
Invalid uint64 `json:"invalid"`
Expired uint64 `json:"expired"`
AvgTime uint32 `json:"avg_time"`
Latency uint32 `json:"latency"`
HashesTotal uint64 `json:"hashes_total"`
Best [10]uint64 `json:"best"`
}
// ResultsResponse is the RFC name for ResultsDocument.
type ResultsResponse = ResultsDocument
// doc := p.WorkersDocument()
// _ = doc.Workers[0][0]
type WorkersDocument struct {
Mode string `json:"mode"`
Workers []WorkerRow `json:"workers"`
}
// doc := p.MinersDocument()
// _ = doc.Miners[0][7]
type MinersDocument struct {
Format []string `json:"format"`
Miners []MinerRow `json:"miners"`
}

View file

@ -1,9 +1,14 @@
package proxy package proxy
// Config is the top-level proxy configuration, loaded from JSON and hot-reloaded on change. // Config is the top-level proxy configuration loaded from JSON.
// //
// cfg, result := proxy.LoadConfig("config.json") // cfg := &proxy.Config{
// if !result.OK { log.Fatal(result.Error) } // Mode: "nicehash",
// Bind: []proxy.BindAddr{{Host: "0.0.0.0", Port: 3333}},
// Pools: []proxy.PoolConfig{{URL: "pool.example:3333", Enabled: true}},
// Watch: true,
// Workers: proxy.WorkersByRigID,
// }
type Config struct { type Config struct {
Mode string `json:"mode"` // "nicehash" or "simple" Mode string `json:"mode"` // "nicehash" or "simple"
Bind []BindAddr `json:"bind"` // listen addresses Bind []BindAddr `json:"bind"` // listen addresses
@ -16,11 +21,13 @@ type Config struct {
AlgoExtension bool `json:"algo-ext"` // forward algo field in jobs AlgoExtension bool `json:"algo-ext"` // forward algo field in jobs
Workers WorkersMode `json:"workers"` // "rig-id", "user", "password", "agent", "ip", "false" Workers WorkersMode `json:"workers"` // "rig-id", "user", "password", "agent", "ip", "false"
AccessLogFile string `json:"access-log-file"` // "" = disabled AccessLogFile string `json:"access-log-file"` // "" = disabled
ShareLogFile string `json:"share-log-file"` // "" = disabled
ReuseTimeout int `json:"reuse-timeout"` // seconds; simple mode upstream reuse ReuseTimeout int `json:"reuse-timeout"` // seconds; simple mode upstream reuse
Retries int `json:"retries"` // pool reconnect attempts Retries int `json:"retries"` // pool reconnect attempts
RetryPause int `json:"retry-pause"` // seconds between retries RetryPause int `json:"retry-pause"` // seconds between retries
Watch bool `json:"watch"` // hot-reload on file change Watch bool `json:"watch"` // hot-reload on file change
RateLimit RateLimit `json:"rate-limit"` // per-IP connection rate limit RateLimit RateLimit `json:"rate-limit"` // per-IP connection rate limit
configPath string
} }
// BindAddr is one TCP listen endpoint. // BindAddr is one TCP listen endpoint.
@ -47,7 +54,7 @@ type PoolConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
} }
// TLSConfig controls inbound TLS on bind addresses that have TLS: true. // TLSConfig controls inbound TLS for miner listeners.
// //
// proxy.TLSConfig{Enabled: true, CertFile: "/etc/proxy/cert.pem", KeyFile: "/etc/proxy/key.pem"} // proxy.TLSConfig{Enabled: true, CertFile: "/etc/proxy/cert.pem", KeyFile: "/etc/proxy/key.pem"}
type TLSConfig struct { type TLSConfig struct {
@ -69,15 +76,20 @@ type HTTPConfig struct {
Restricted bool `json:"restricted"` // true = read-only GET only Restricted bool `json:"restricted"` // true = read-only GET only
} }
// RateLimit controls per-IP connection rate limiting using a token bucket. // RateLimit caps connection attempts per source IP.
// //
// proxy.RateLimit{MaxConnectionsPerMinute: 30, BanDurationSeconds: 300} // limiter := proxy.NewRateLimiter(proxy.RateLimit{
// MaxConnectionsPerMinute: 30,
// BanDurationSeconds: 300,
// })
type RateLimit struct { type RateLimit struct {
MaxConnectionsPerMinute int `json:"max-connections-per-minute"` // 0 = disabled MaxConnectionsPerMinute int `json:"max-connections-per-minute"` // 0 = disabled
BanDurationSeconds int `json:"ban-duration"` // 0 = no ban BanDurationSeconds int `json:"ban-duration"` // 0 = no ban
} }
// WorkersMode controls which login field becomes the worker name. // WorkersMode picks the login field used as the worker name.
//
// cfg.Workers = proxy.WorkersByRigID
type WorkersMode string type WorkersMode string
const ( const (

63
config_load_test.go Normal file
View file

@ -0,0 +1,63 @@
package proxy
import (
"os"
"path/filepath"
"testing"
)
func TestConfig_LoadConfig_Good(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
data := []byte(`{"mode":"nicehash","workers":"rig-id","bind":[{"host":"0.0.0.0","port":3333}],"pools":[{"url":"pool.example:3333","enabled":true}]}`)
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("expected config file write to succeed: %v", err)
}
cfg, result := LoadConfig(path)
if !result.OK {
t.Fatalf("expected load to succeed, got error: %v", result.Error)
}
if cfg == nil {
t.Fatal("expected config to be returned")
}
if got := cfg.Mode; got != "nicehash" {
t.Fatalf("expected mode to round-trip, got %q", got)
}
if got := cfg.configPath; got != path {
t.Fatalf("expected config path to be recorded, got %q", got)
}
}
func TestConfig_LoadConfig_Bad(t *testing.T) {
cfg, result := LoadConfig(filepath.Join(t.TempDir(), "missing.json"))
if result.OK {
t.Fatalf("expected missing config file to fail, got cfg=%+v", cfg)
}
if cfg != nil {
t.Fatalf("expected no config on read failure, got %+v", cfg)
}
}
func TestConfig_LoadConfig_Ugly(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
data := []byte(`{"mode":"invalid","workers":"rig-id","bind":[],"pools":[]}`)
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("expected config file write to succeed: %v", err)
}
cfg, result := LoadConfig(path)
if !result.OK {
t.Fatalf("expected syntactically valid JSON to load, got error: %v", result.Error)
}
if cfg == nil {
t.Fatal("expected config to be returned")
}
if got := cfg.Mode; got != "invalid" {
t.Fatalf("expected invalid mode value to be preserved, got %q", got)
}
if validation := cfg.Validate(); validation.OK {
t.Fatal("expected semantic validation to fail separately from loading")
}
}

90
config_test.go Normal file
View file

@ -0,0 +1,90 @@
package proxy
import "testing"
func TestConfig_Validate_Good(t *testing.T) {
cfg := &Config{
Mode: "nicehash",
Workers: WorkersByRigID,
Bind: []BindAddr{{Host: "0.0.0.0", Port: 3333}},
Pools: []PoolConfig{{URL: "pool.example:3333", Enabled: true}},
}
if result := cfg.Validate(); !result.OK {
t.Fatalf("expected valid config, got error: %v", result.Error)
}
}
func TestConfig_Validate_Bad(t *testing.T) {
cfg := &Config{
Workers: WorkersByRigID,
Bind: []BindAddr{{Host: "0.0.0.0", Port: 3333}},
Pools: []PoolConfig{{URL: "pool.example:3333", Enabled: true}},
}
if result := cfg.Validate(); result.OK {
t.Fatalf("expected missing mode to fail validation")
}
}
func TestConfig_Validate_Ugly(t *testing.T) {
cfg := &Config{
Mode: "nicehash",
Workers: WorkersMode("unknown"),
Bind: []BindAddr{{Host: "0.0.0.0", Port: 3333}},
Pools: []PoolConfig{{URL: "", Enabled: true}},
}
if result := cfg.Validate(); result.OK {
t.Fatalf("expected invalid workers and empty pool url to fail validation")
}
}
func TestConfig_Validate_NoEnabledPool_Good(t *testing.T) {
cfg := &Config{
Mode: "simple",
Workers: WorkersByRigID,
Bind: []BindAddr{{Host: "0.0.0.0", Port: 3333}},
Pools: []PoolConfig{
{URL: "pool-a.example:3333", Enabled: false},
{URL: "pool-b.example:4444", Enabled: false},
},
}
if result := cfg.Validate(); !result.OK {
t.Fatalf("expected config with no enabled pools to be valid, got error: %v", result.Error)
}
}
func TestProxy_New_WhitespaceMode_Good(t *testing.T) {
originalFactory, hadFactory := splitterFactoryForMode("nicehash")
if hadFactory {
t.Cleanup(func() {
RegisterSplitterFactory("nicehash", originalFactory)
})
}
called := false
RegisterSplitterFactory("nicehash", func(*Config, *EventBus) Splitter {
called = true
return &noopSplitter{}
})
cfg := &Config{
Mode: " nicehash ",
Workers: WorkersByRigID,
Bind: []BindAddr{{Host: "0.0.0.0", Port: 3333}},
Pools: []PoolConfig{{URL: "pool.example:3333", Enabled: true}},
}
p, result := New(cfg)
if !result.OK {
t.Fatalf("expected whitespace-padded mode to remain valid, got error: %v", result.Error)
}
if !called {
t.Fatalf("expected trimmed mode lookup to invoke the registered splitter factory")
}
if _, ok := p.splitter.(*noopSplitter); !ok {
t.Fatalf("expected test splitter to be wired, got %#v", p.splitter)
}
}

136
configwatcher_test.go Normal file
View file

@ -0,0 +1,136 @@
package proxy
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestConfigWatcher_New_Good(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := os.WriteFile(path, []byte(`{"mode":"nicehash","workers":"false","bind":[{"host":"127.0.0.1","port":3333}],"pools":[{"url":"pool.example:3333","enabled":true}]}`), 0o644); err != nil {
t.Fatalf("write config file: %v", err)
}
watcher := NewConfigWatcher(path, func(*Config) {})
if watcher == nil {
t.Fatal("expected watcher")
}
if watcher.lastModifiedAt.IsZero() {
t.Fatal("expected last modification time to be initialised from the file")
}
}
func TestConfigWatcher_Start_Good(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
initial := []byte(`{"mode":"nicehash","workers":"false","bind":[{"host":"127.0.0.1","port":3333}],"pools":[{"url":"pool.example:3333","enabled":true}]}`)
if err := os.WriteFile(path, initial, 0o644); err != nil {
t.Fatalf("write initial config file: %v", err)
}
updates := make(chan *Config, 1)
watcher := NewConfigWatcher(path, func(cfg *Config) {
select {
case updates <- cfg:
default:
}
})
if watcher == nil {
t.Fatal("expected watcher")
}
watcher.Start()
defer watcher.Stop()
updated := []byte(`{"mode":"simple","workers":"user","bind":[{"host":"127.0.0.1","port":3333}],"pools":[{"url":"pool.example:3333","enabled":true}]}`)
if err := os.WriteFile(path, updated, 0o644); err != nil {
t.Fatalf("write updated config file: %v", err)
}
now := time.Now()
if err := os.Chtimes(path, now, now.Add(2*time.Second)); err != nil {
t.Fatalf("touch updated config file: %v", err)
}
select {
case cfg := <-updates:
if cfg == nil {
t.Fatal("expected config update")
}
if got := cfg.Mode; got != "simple" {
t.Fatalf("expected updated mode, got %q", got)
}
case <-time.After(5 * time.Second):
t.Fatal("expected watcher to reload updated config")
}
}
// TestConfigWatcher_Start_Bad verifies a watcher with a nonexistent path does not panic
// and does not call the onChange callback.
//
// watcher := proxy.NewConfigWatcher("/nonexistent/config.json", func(cfg *proxy.Config) {
// // never called
// })
// watcher.Start()
// watcher.Stop()
func TestConfigWatcher_Start_Bad(t *testing.T) {
called := make(chan struct{}, 1)
watcher := NewConfigWatcher("/nonexistent/path/config.json", func(*Config) {
select {
case called <- struct{}{}:
default:
}
})
if watcher == nil {
t.Fatal("expected watcher even for a nonexistent path")
}
watcher.Start()
defer watcher.Stop()
select {
case <-called:
t.Fatal("expected no callback for nonexistent config file")
case <-time.After(2 * time.Second):
// expected: no update fired
}
}
func TestConfigWatcher_Start_Ugly(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
initial := []byte(`{"mode":"nicehash","workers":"false","bind":[{"host":"127.0.0.1","port":3333}],"pools":[{"url":"pool.example:3333","enabled":true}]}`)
if err := os.WriteFile(path, initial, 0o644); err != nil {
t.Fatalf("write initial config file: %v", err)
}
updates := make(chan *Config, 1)
watcher := NewConfigWatcher(path, func(cfg *Config) {
select {
case updates <- cfg:
default:
}
})
if watcher == nil {
t.Fatal("expected watcher")
}
watcher.Start()
defer watcher.Stop()
now := time.Now()
if err := os.Chtimes(path, now, now.Add(2*time.Second)); err != nil {
t.Fatalf("touch config file: %v", err)
}
select {
case cfg := <-updates:
if cfg == nil {
t.Fatal("expected config update")
}
if got := cfg.Mode; got != "nicehash" {
t.Fatalf("expected unchanged mode, got %q", got)
}
case <-time.After(5 * time.Second):
t.Fatal("expected watcher to reload touched config")
}
}

View file

@ -3,9 +3,9 @@ package proxy
import ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/binary"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"io" "io"
"math" "math"
"net" "net"
@ -16,81 +16,127 @@ import (
"time" "time"
) )
// Result is a small success/error carrier used by constructors and loaders. // Result is the success/error carrier used by constructors and loaders.
//
// cfg, result := proxy.LoadConfig("config.json")
// if !result.OK {
// return result.Error
// }
type Result struct { type Result struct {
OK bool OK bool
Error error Error error
} }
func successResult() Result { func newSuccessResult() Result {
return Result{OK: true} return Result{OK: true}
} }
func errorResult(err error) Result { func newErrorResult(err error) Result {
return Result{OK: false, Error: err} return Result{OK: false, Error: err}
} }
var splitterRegistryMu sync.RWMutex var splitterFactoriesMu sync.RWMutex
var splitterRegistry = map[string]func(*Config, *EventBus) Splitter{} var splitterFactoriesByMode = map[string]func(*Config, *EventBus) Splitter{}
// RegisterSplitterFactory registers a mode-specific splitter constructor. // RegisterSplitterFactory installs the constructor used for one proxy mode.
// Packages such as splitter/nicehash and splitter/simple call this from init. //
// proxy.RegisterSplitterFactory("simple", func(cfg *proxy.Config, bus *proxy.EventBus) proxy.Splitter {
// return simple.NewSimpleSplitter(cfg, bus, nil)
// })
func RegisterSplitterFactory(mode string, factory func(*Config, *EventBus) Splitter) { func RegisterSplitterFactory(mode string, factory func(*Config, *EventBus) Splitter) {
splitterRegistryMu.Lock() splitterFactoriesMu.Lock()
defer splitterRegistryMu.Unlock() defer splitterFactoriesMu.Unlock()
splitterRegistry[strings.ToLower(mode)] = factory splitterFactoriesByMode[strings.ToLower(strings.TrimSpace(mode))] = factory
} }
func getSplitterFactory(mode string) (func(*Config, *EventBus) Splitter, bool) { func splitterFactoryForMode(mode string) (func(*Config, *EventBus) Splitter, bool) {
splitterRegistryMu.RLock() splitterFactoriesMu.RLock()
defer splitterRegistryMu.RUnlock() defer splitterFactoriesMu.RUnlock()
factory, ok := splitterRegistry[strings.ToLower(mode)] factory, ok := splitterFactoriesByMode[strings.ToLower(strings.TrimSpace(mode))]
return factory, ok return factory, ok
} }
// LoadConfig reads and unmarshals a JSON config file. // cfg, result := proxy.LoadConfig("/etc/proxy.json")
//
// if !result.OK {
// return result.Error
// }
func LoadConfig(path string) (*Config, Result) { func LoadConfig(path string) (*Config, Result) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, errorResult(err) return nil, newErrorResult(NewScopedError("proxy.config", "read config failed", err))
} }
cfg := &Config{} config := &Config{}
if err := json.Unmarshal(data, cfg); err != nil { if err := json.Unmarshal(data, config); err != nil {
return nil, errorResult(err) return nil, newErrorResult(NewScopedError("proxy.config", "parse config failed", err))
} }
config.configPath = path
if cfg.Mode == "" { return config, newSuccessResult()
cfg.Mode = "nicehash"
}
return cfg, cfg.Validate()
} }
// Validate checks that mandatory bind and pool settings are present. // cfg := &proxy.Config{
// Mode: "nicehash",
// Bind: []proxy.BindAddr{{Host: "0.0.0.0", Port: 3333}},
// Pools: []proxy.PoolConfig{{URL: "pool.example:3333", Enabled: true}},
// Workers: proxy.WorkersByRigID,
// }
//
// if result := cfg.Validate(); !result.OK {
// return result
// }
func (c *Config) Validate() Result { func (c *Config) Validate() Result {
if c == nil { if c == nil {
return errorResult(errors.New("config is nil")) return newErrorResult(NewScopedError("proxy.config", "config is nil", nil))
}
if !isValidMode(c.Mode) {
return newErrorResult(NewScopedError("proxy.config", "mode must be \"nicehash\" or \"simple\"", nil))
}
if !isValidWorkersMode(c.Workers) {
return newErrorResult(NewScopedError("proxy.config", "workers must be one of \"rig-id\", \"user\", \"password\", \"agent\", \"ip\", or \"false\"", nil))
} }
if len(c.Bind) == 0 { if len(c.Bind) == 0 {
return errorResult(errors.New("bind list is empty")) return newErrorResult(NewScopedError("proxy.config", "bind list is empty", nil))
} }
if len(c.Pools) == 0 { if len(c.Pools) == 0 {
return errorResult(errors.New("pool list is empty")) return newErrorResult(NewScopedError("proxy.config", "pool list is empty", nil))
} }
for _, pool := range c.Pools { for _, pool := range c.Pools {
if pool.Enabled && strings.TrimSpace(pool.URL) == "" { if pool.Enabled && strings.TrimSpace(pool.URL) == "" {
return errorResult(errors.New("enabled pool url is empty")) return newErrorResult(NewScopedError("proxy.config", "enabled pool url is empty", nil))
} }
} }
return successResult() return newSuccessResult()
} }
// NewEventBus creates an empty synchronous event dispatcher. func isValidMode(mode string) bool {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "nicehash", "simple":
return true
default:
return false
}
}
func isValidWorkersMode(mode WorkersMode) bool {
switch WorkersMode(strings.TrimSpace(string(mode))) {
case WorkersByRigID, WorkersByUser, WorkersByPass, WorkersByAgent, WorkersByIP, WorkersDisabled:
return true
default:
return false
}
}
// bus := proxy.NewEventBus()
//
// bus.Subscribe(proxy.EventLogin, func(e proxy.Event) {
// _ = e.Miner
// })
func NewEventBus() *EventBus { func NewEventBus() *EventBus {
return &EventBus{listeners: make(map[EventType][]EventHandler)} return &EventBus{listeners: make(map[EventType][]EventHandler)}
} }
// Subscribe registers a handler for the given event type. // bus.Subscribe(proxy.EventAccept, stats.OnAccept)
func (b *EventBus) Subscribe(t EventType, h EventHandler) { func (b *EventBus) Subscribe(t EventType, h EventHandler) {
if b == nil || h == nil { if b == nil || h == nil {
return return
@ -103,7 +149,7 @@ func (b *EventBus) Subscribe(t EventType, h EventHandler) {
b.listeners[t] = append(b.listeners[t], h) b.listeners[t] = append(b.listeners[t], h)
} }
// Dispatch calls all registered handlers for the event's type. // bus.Dispatch(proxy.Event{Type: proxy.EventLogin, Miner: miner})
func (b *EventBus) Dispatch(e Event) { func (b *EventBus) Dispatch(e Event) {
if b == nil { if b == nil {
return return
@ -112,16 +158,69 @@ func (b *EventBus) Dispatch(e Event) {
handlers := append([]EventHandler(nil), b.listeners[e.Type]...) handlers := append([]EventHandler(nil), b.listeners[e.Type]...)
b.mu.RUnlock() b.mu.RUnlock()
for _, handler := range handlers { for _, handler := range handlers {
handler(e) func() {
defer func() {
_ = recover()
}()
handler(e)
}()
}
}
type shareSinkGroup struct {
sinks []ShareSink
}
func newShareSinkGroup(sinks ...ShareSink) *shareSinkGroup {
group := &shareSinkGroup{sinks: make([]ShareSink, 0, len(sinks))}
for _, sink := range sinks {
if sink != nil {
group.sinks = append(group.sinks, sink)
}
}
return group
}
func (g *shareSinkGroup) OnAccept(e Event) {
if g == nil {
return
}
for _, sink := range g.sinks {
func() {
defer func() {
_ = recover()
}()
sink.OnAccept(e)
}()
}
}
func (g *shareSinkGroup) OnReject(e Event) {
if g == nil {
return
}
for _, sink := range g.sinks {
func() {
defer func() {
_ = recover()
}()
sink.OnReject(e)
}()
} }
} }
// IsValid returns true when the job contains a blob and job id. // IsValid returns true when the job contains a blob and job id.
//
// if !job.IsValid() {
// return
// }
func (j Job) IsValid() bool { func (j Job) IsValid() bool {
return j.Blob != "" && j.JobID != "" return j.Blob != "" && j.JobID != ""
} }
// BlobWithFixedByte replaces the blob byte at position 39 with fixedByte. // BlobWithFixedByte replaces the blob byte at position 39 with fixedByte.
//
// partitioned := job.BlobWithFixedByte(0x2A)
func (j Job) BlobWithFixedByte(fixedByte uint8) string { func (j Job) BlobWithFixedByte(fixedByte uint8) string {
if len(j.Blob) < 80 { if len(j.Blob) < 80 {
return j.Blob return j.Blob
@ -134,7 +233,9 @@ func (j Job) BlobWithFixedByte(fixedByte uint8) string {
return string(blob) return string(blob)
} }
// DifficultyFromTarget converts the target to a rough integer difficulty. // DifficultyFromTarget converts the 8-char little-endian target into a difficulty.
//
// diff := job.DifficultyFromTarget()
func (j Job) DifficultyFromTarget() uint64 { func (j Job) DifficultyFromTarget() uint64 {
if len(j.Target) != 8 { if len(j.Target) != 8 {
return 0 return 0
@ -147,46 +248,86 @@ func (j Job) DifficultyFromTarget() uint64 {
if target == 0 { if target == 0 {
return 0 return 0
} }
return uint64(math.MaxUint32 / uint64(target)) return uint64(math.MaxUint32) / uint64(target)
}
// targetFromDifficulty converts a difficulty into the 8-char little-endian hex target.
//
// target := targetFromDifficulty(10000) // "b88d0600"
func targetFromDifficulty(diff uint64) string {
if diff <= 1 {
return "ffffffff"
}
maxTarget := uint64(math.MaxUint32)
target := (maxTarget + diff - 1) / diff
if target == 0 {
target = 1
}
if target > maxTarget {
target = maxTarget
}
var raw [4]byte
binary.LittleEndian.PutUint32(raw[:], uint32(target))
return hex.EncodeToString(raw[:])
}
// EffectiveShareDifficulty returns the share difficulty capped by the miner's custom diff.
// If no custom diff is set or the pool diff is already lower, the pool diff is returned.
//
// diff := proxy.EffectiveShareDifficulty(job, miner) // 25000 when customDiff < poolDiff
func EffectiveShareDifficulty(job Job, miner *Miner) uint64 {
diff := job.DifficultyFromTarget()
if miner == nil || miner.customDiff == 0 || diff == 0 || diff <= miner.customDiff {
return diff
}
return miner.customDiff
} }
// NewCustomDiff creates a login-time custom difficulty resolver. // NewCustomDiff creates a login-time custom difficulty resolver.
//
// resolver := proxy.NewCustomDiff(50000)
// resolver.OnLogin(proxy.Event{Miner: miner})
func NewCustomDiff(globalDiff uint64) *CustomDiff { func NewCustomDiff(globalDiff uint64) *CustomDiff {
return &CustomDiff{globalDiff: globalDiff} cd := &CustomDiff{}
cd.globalDiff.Store(globalDiff)
return cd
} }
// OnLogin parses +N suffixes and applies global difficulty fallbacks. // OnLogin normalises the login user once during handshake.
//
// cd.OnLogin(proxy.Event{Miner: &proxy.Miner{user: "WALLET+50000"}})
func (cd *CustomDiff) OnLogin(e Event) { func (cd *CustomDiff) OnLogin(e Event) {
if cd == nil || e.Miner == nil { if cd == nil || e.Miner == nil {
return return
} }
miner := e.Miner if e.Miner.customDiffResolved {
user := miner.user
plus := strings.LastIndex(user, "+")
if plus >= 0 && plus < len(user)-1 {
if parsed, err := strconv.ParseUint(user[plus+1:], 10, 64); err == nil {
miner.user = user[:plus]
miner.customDiff = parsed
}
return return
} }
if cd.globalDiff > 0 { resolved := resolveLoginCustomDiff(e.Miner.user, cd.globalDiff.Load())
miner.customDiff = cd.globalDiff e.Miner.user = resolved.user
} e.Miner.customDiff = resolved.diff
e.Miner.customDiffFromLogin = resolved.fromLogin
e.Miner.customDiffResolved = true
} }
// NewRateLimiter creates a per-IP token bucket limiter. // limiter := proxy.NewRateLimiter(proxy.RateLimit{MaxConnectionsPerMinute: 30, BanDurationSeconds: 300})
func NewRateLimiter(cfg RateLimit) *RateLimiter { //
// if limiter.Allow("203.0.113.42:3333") {
// // first 30 connection attempts per minute are allowed
// }
func NewRateLimiter(config RateLimit) *RateLimiter {
return &RateLimiter{ return &RateLimiter{
cfg: cfg, limit: config,
buckets: make(map[string]*tokenBucket), bucketByHost: make(map[string]*tokenBucket),
banned: make(map[string]time.Time), banUntilByHost: make(map[string]time.Time),
} }
} }
// Allow returns true if the IP address is permitted to open a new connection. // if limiter.Allow("203.0.113.42:3333") {
// // hostOnly("203.0.113.42:3333") == "203.0.113.42"
// }
func (rl *RateLimiter) Allow(ip string) bool { func (rl *RateLimiter) Allow(ip string) bool {
if rl == nil || rl.cfg.MaxConnectionsPerMinute <= 0 { if rl == nil || rl.limit.MaxConnectionsPerMinute <= 0 {
return true return true
} }
host := hostOnly(ip) host := hostOnly(ip)
@ -195,23 +336,23 @@ func (rl *RateLimiter) Allow(ip string) bool {
rl.mu.Lock() rl.mu.Lock()
defer rl.mu.Unlock() defer rl.mu.Unlock()
if until, banned := rl.banned[host]; banned { if until, banned := rl.banUntilByHost[host]; banned {
if now.Before(until) { if now.Before(until) {
return false return false
} }
delete(rl.banned, host) delete(rl.banUntilByHost, host)
} }
bucket, ok := rl.buckets[host] bucket, ok := rl.bucketByHost[host]
if !ok { if !ok {
bucket = &tokenBucket{tokens: rl.cfg.MaxConnectionsPerMinute, lastRefill: now} bucket = &tokenBucket{tokens: rl.limit.MaxConnectionsPerMinute, lastRefill: now}
rl.buckets[host] = bucket rl.bucketByHost[host] = bucket
} }
refillBucket(bucket, rl.cfg.MaxConnectionsPerMinute, now) refillBucket(bucket, rl.limit.MaxConnectionsPerMinute, now)
if bucket.tokens <= 0 { if bucket.tokens <= 0 {
if rl.cfg.BanDurationSeconds > 0 { if rl.limit.BanDurationSeconds > 0 {
rl.banned[host] = now.Add(time.Duration(rl.cfg.BanDurationSeconds) * time.Second) rl.banUntilByHost[host] = now.Add(time.Duration(rl.limit.BanDurationSeconds) * time.Second)
} }
return false return false
} }
@ -222,8 +363,10 @@ func (rl *RateLimiter) Allow(ip string) bool {
} }
// Tick removes expired ban entries and refills token buckets. // Tick removes expired ban entries and refills token buckets.
//
// limiter.Tick()
func (rl *RateLimiter) Tick() { func (rl *RateLimiter) Tick() {
if rl == nil || rl.cfg.MaxConnectionsPerMinute <= 0 { if rl == nil || rl.limit.MaxConnectionsPerMinute <= 0 {
return return
} }
now := time.Now() now := time.Now()
@ -231,64 +374,102 @@ func (rl *RateLimiter) Tick() {
rl.mu.Lock() rl.mu.Lock()
defer rl.mu.Unlock() defer rl.mu.Unlock()
for host, until := range rl.banned { for host, until := range rl.banUntilByHost {
if !now.Before(until) { if !now.Before(until) {
delete(rl.banned, host) delete(rl.banUntilByHost, host)
} }
} }
for _, bucket := range rl.buckets { for _, bucket := range rl.bucketByHost {
refillBucket(bucket, rl.cfg.MaxConnectionsPerMinute, now) refillBucket(bucket, rl.limit.MaxConnectionsPerMinute, now)
} }
} }
// NewConfigWatcher creates a polling watcher for a config file. // watcher := proxy.NewConfigWatcher("config.json", func(cfg *proxy.Config) {
func NewConfigWatcher(path string, onChange func(*Config)) *ConfigWatcher { // p.Reload(cfg)
return &ConfigWatcher{ // })
path: path, //
onChange: onChange, // watcher.Start() // polls once per second and reloads after the file mtime changes
done: make(chan struct{}), func NewConfigWatcher(configPath string, onChange func(*Config)) *ConfigWatcher {
watcher := &ConfigWatcher{
configPath: configPath,
onConfigChange: onChange,
stopCh: make(chan struct{}),
} }
if info, err := os.Stat(configPath); err == nil {
watcher.lastModifiedAt = info.ModTime()
}
return watcher
} }
// Start begins the 1-second polling loop. // watcher.Start()
func (w *ConfigWatcher) Start() { func (w *ConfigWatcher) Start() {
if w == nil || w.path == "" || w.onChange == nil { if w == nil || w.configPath == "" || w.onConfigChange == nil {
return return
} }
w.mu.Lock()
if w.started {
w.mu.Unlock()
return
}
if w.stopCh == nil {
w.stopCh = make(chan struct{})
} else {
select {
case <-w.stopCh:
w.stopCh = make(chan struct{})
default:
}
}
stopCh := w.stopCh
configPath := w.configPath
onConfigChange := w.onConfigChange
w.started = true
w.mu.Unlock()
go func() { go func() {
ticker := time.NewTicker(time.Second) ticker := time.NewTicker(time.Second)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
info, err := os.Stat(w.path) if info, err := os.Stat(configPath); err == nil {
if err != nil { w.mu.Lock()
continue changed := info.ModTime() != w.lastModifiedAt
} if changed {
mod := info.ModTime() w.lastModifiedAt = info.ModTime()
if mod.After(w.lastMod) { }
w.lastMod = mod w.mu.Unlock()
cfg, result := LoadConfig(w.path) if !changed {
if result.OK && cfg != nil { continue
w.onChange(cfg) }
config, result := LoadConfig(configPath)
if result.OK && config != nil {
onConfigChange(config)
} }
} }
case <-w.done: case <-stopCh:
return return
} }
} }
}() }()
} }
// Stop ends the watcher goroutine. // watcher.Stop()
func (w *ConfigWatcher) Stop() { func (w *ConfigWatcher) Stop() {
if w == nil { if w == nil {
return return
} }
w.mu.Lock()
stopCh := w.stopCh
w.started = false
w.mu.Unlock()
if stopCh == nil {
return
}
select { select {
case <-w.done: case <-stopCh:
default: default:
close(w.done) close(stopCh)
} }
} }
@ -311,9 +492,9 @@ func refillBucket(bucket *tokenBucket, limit int, now time.Time) {
} }
return return
} }
interval := time.Duration(60/limit) * time.Second interval := time.Duration(time.Minute) / time.Duration(limit)
if interval <= 0 { if interval <= 0 {
interval = time.Second interval = time.Nanosecond
} }
elapsed := now.Sub(bucket.lastRefill) elapsed := now.Sub(bucket.lastRefill)
if elapsed < interval { if elapsed < interval {

View file

@ -2,7 +2,12 @@ package proxy
import "testing" import "testing"
func TestCustomDiff_OnLogin(t *testing.T) { // TestCustomDiff_Apply_Good verifies a user suffix "+50000" sets customDiff and strips the suffix.
//
// cd := proxy.NewCustomDiff(10000)
// cd.Apply(&proxy.Miner{user: "WALLET+50000"})
// // miner.User() == "WALLET", miner.customDiff == 50000
func TestCustomDiff_Apply_Good(t *testing.T) {
cd := NewCustomDiff(10000) cd := NewCustomDiff(10000)
miner := &Miner{user: "WALLET+50000"} miner := &Miner{user: "WALLET+50000"}
cd.OnLogin(Event{Miner: miner}) cd.OnLogin(Event{Miner: miner})
@ -12,19 +17,67 @@ func TestCustomDiff_OnLogin(t *testing.T) {
if miner.customDiff != 50000 { if miner.customDiff != 50000 {
t.Fatalf("expected custom diff 50000, got %d", miner.customDiff) t.Fatalf("expected custom diff 50000, got %d", miner.customDiff)
} }
}
miner = &Miner{user: "WALLET+abc"} // TestCustomDiff_Apply_Bad verifies "+abc" (non-numeric) leaves user unchanged, customDiff=0.
//
// cd := proxy.NewCustomDiff(10000)
// cd.Apply(&proxy.Miner{user: "WALLET+abc"})
// // miner.User() == "WALLET+abc", miner.customDiff == 0
func TestCustomDiff_Apply_Bad(t *testing.T) {
cd := NewCustomDiff(10000)
miner := &Miner{user: "WALLET+abc"}
cd.OnLogin(Event{Miner: miner}) cd.OnLogin(Event{Miner: miner})
if miner.User() != "WALLET+abc" { if miner.User() != "WALLET+abc" {
t.Fatalf("expected invalid suffix to remain unchanged") t.Fatalf("expected invalid suffix to remain unchanged, got %q", miner.User())
} }
if miner.customDiff != 0 { if miner.customDiff != 0 {
t.Fatalf("expected custom diff 0 for invalid suffix, got %d", miner.customDiff) t.Fatalf("expected invalid suffix to disable custom diff, got %d", miner.customDiff)
} }
}
miner = &Miner{user: "WALLET"}
cd.OnLogin(Event{Miner: miner}) // TestCustomDiff_Apply_Ugly verifies globalDiff=10000 is used when no suffix is present.
if miner.customDiff != 10000 { //
t.Fatalf("expected global diff fallback, got %d", miner.customDiff) // cd := proxy.NewCustomDiff(10000)
// cd.Apply(&proxy.Miner{user: "WALLET"})
// // miner.customDiff == 10000 (falls back to global)
func TestCustomDiff_Apply_Ugly(t *testing.T) {
cd := NewCustomDiff(10000)
miner := &Miner{user: "WALLET"}
cd.OnLogin(Event{Miner: miner})
if miner.customDiff != 10000 {
t.Fatalf("expected global diff fallback 10000, got %d", miner.customDiff)
}
}
// TestCustomDiff_OnLogin_NonNumericSuffix verifies a non-decimal suffix after plus is ignored.
//
// cd := proxy.NewCustomDiff(10000)
// cd.OnLogin(proxy.Event{Miner: &proxy.Miner{user: "WALLET+50000extra"}})
func TestCustomDiff_OnLogin_NonNumericSuffix(t *testing.T) {
cd := NewCustomDiff(10000)
miner := &Miner{user: "WALLET+50000extra"}
cd.OnLogin(Event{Miner: miner})
if miner.User() != "WALLET+50000extra" {
t.Fatalf("expected non-numeric suffix plus segment to remain unchanged, got %q", miner.User())
}
if miner.customDiff != 0 {
t.Fatalf("expected invalid suffix to disable custom diff, got %d", miner.customDiff)
}
}
// TestEffectiveShareDifficulty_CustomDiffCapsPoolDifficulty verifies the cap applied by custom diff.
//
// job := proxy.Job{Target: "01000000"}
// miner := &proxy.Miner{customDiff: 25000}
// proxy.EffectiveShareDifficulty(job, miner) // 25000 (capped)
func TestEffectiveShareDifficulty_CustomDiffCapsPoolDifficulty(t *testing.T) {
job := Job{Target: "01000000"}
miner := &Miner{customDiff: 25000}
if got := EffectiveShareDifficulty(job, miner); got != 25000 {
t.Fatalf("expected capped difficulty 25000, got %d", got)
} }
} }

122
customdiffstats.go Normal file
View file

@ -0,0 +1,122 @@
package proxy
import (
"strings"
"sync"
)
// CustomDiffBucketStats tracks per-custom-difficulty share outcomes.
type CustomDiffBucketStats struct {
Accepted uint64 `json:"accepted"`
Rejected uint64 `json:"rejected"`
Invalid uint64 `json:"invalid"`
Expired uint64 `json:"expired"`
HashesTotal uint64 `json:"hashes_total"`
}
// CustomDiffBuckets groups share totals by the miner's resolved custom difficulty.
//
// buckets := NewCustomDiffBuckets(true)
// buckets.OnAccept(Event{Miner: &Miner{customDiff: 50000}, Diff: 25000})
type CustomDiffBuckets struct {
enabled bool
buckets map[uint64]*CustomDiffBucketStats
mu sync.Mutex
}
// NewCustomDiffBuckets creates a per-difficulty share tracker.
func NewCustomDiffBuckets(enabled bool) *CustomDiffBuckets {
return &CustomDiffBuckets{
enabled: enabled,
buckets: make(map[uint64]*CustomDiffBucketStats),
}
}
// SetEnabled toggles recording without discarding any collected buckets.
func (b *CustomDiffBuckets) SetEnabled(enabled bool) {
if b == nil {
return
}
b.mu.Lock()
defer b.mu.Unlock()
b.enabled = enabled
}
// OnAccept records an accepted share for the miner's custom difficulty bucket.
func (b *CustomDiffBuckets) OnAccept(e Event) {
if b == nil || !b.enabled || e.Miner == nil {
return
}
b.mu.Lock()
defer b.mu.Unlock()
bucket := b.bucketLocked(e.Miner.customDiff)
bucket.Accepted++
if e.Expired {
bucket.Expired++
}
if e.Diff > 0 {
bucket.HashesTotal += e.Diff
}
}
// OnReject records a rejected share for the miner's custom difficulty bucket.
func (b *CustomDiffBuckets) OnReject(e Event) {
if b == nil || !b.enabled || e.Miner == nil {
return
}
b.mu.Lock()
defer b.mu.Unlock()
bucket := b.bucketLocked(e.Miner.customDiff)
bucket.Rejected++
if isInvalidShareReason(e.Error) {
bucket.Invalid++
}
}
// Snapshot returns a copy of the current bucket totals.
//
// summary := buckets.Snapshot()
func (b *CustomDiffBuckets) Snapshot() map[uint64]CustomDiffBucketStats {
if b == nil {
return nil
}
b.mu.Lock()
defer b.mu.Unlock()
if !b.enabled || len(b.buckets) == 0 {
return nil
}
out := make(map[uint64]CustomDiffBucketStats, len(b.buckets))
for diff, bucket := range b.buckets {
if bucket == nil {
continue
}
out[diff] = *bucket
}
return out
}
func (b *CustomDiffBuckets) bucketLocked(diff uint64) *CustomDiffBucketStats {
if b.buckets == nil {
b.buckets = make(map[uint64]*CustomDiffBucketStats)
}
bucket, ok := b.buckets[diff]
if !ok {
bucket = &CustomDiffBucketStats{}
b.buckets[diff] = bucket
}
return bucket
}
func isInvalidShareReason(reason string) bool {
reason = strings.ToLower(reason)
if reason == "" {
return false
}
return strings.Contains(reason, "low diff") ||
strings.Contains(reason, "lowdifficulty") ||
strings.Contains(reason, "low difficulty") ||
strings.Contains(reason, "malformed") ||
strings.Contains(reason, "difficulty") ||
strings.Contains(reason, "invalid") ||
strings.Contains(reason, "nonce")
}

78
customdiffstats_test.go Normal file
View file

@ -0,0 +1,78 @@
package proxy
import "testing"
func TestProxy_CustomDiffStats_Good(t *testing.T) {
cfg := &Config{
Mode: "nicehash",
Workers: WorkersByRigID,
CustomDiffStats: true,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool.example:3333", Enabled: true}},
}
p, result := New(cfg)
if !result.OK {
t.Fatalf("expected valid proxy, got error: %v", result.Error)
}
miner := &Miner{customDiff: 50000}
p.events.Dispatch(Event{Type: EventAccept, Miner: miner, Diff: 75, Expired: true})
summary := p.Summary()
bucket, ok := summary.CustomDiffStats[50000]
if !ok {
t.Fatalf("expected custom diff bucket 50000 to be present")
}
if bucket.Accepted != 1 || bucket.Expired != 1 || bucket.HashesTotal != 75 {
t.Fatalf("unexpected bucket totals: %+v", bucket)
}
}
func TestProxy_CustomDiffStats_Bad(t *testing.T) {
cfg := &Config{
Mode: "nicehash",
Workers: WorkersByRigID,
CustomDiffStats: true,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool.example:3333", Enabled: true}},
}
p, result := New(cfg)
if !result.OK {
t.Fatalf("expected valid proxy, got error: %v", result.Error)
}
miner := &Miner{customDiff: 10000}
p.events.Dispatch(Event{Type: EventReject, Miner: miner, Error: "Low difficulty share"})
p.events.Dispatch(Event{Type: EventReject, Miner: miner, Error: "Malformed share"})
summary := p.Summary()
bucket, ok := summary.CustomDiffStats[10000]
if !ok {
t.Fatalf("expected custom diff bucket 10000 to be present")
}
if bucket.Rejected != 2 || bucket.Invalid != 2 {
t.Fatalf("unexpected bucket totals: %+v", bucket)
}
}
func TestProxy_CustomDiffStats_Ugly(t *testing.T) {
cfg := &Config{
Mode: "nicehash",
Workers: WorkersByRigID,
CustomDiffStats: false,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool.example:3333", Enabled: true}},
}
p, result := New(cfg)
if !result.OK {
t.Fatalf("expected valid proxy, got error: %v", result.Error)
}
miner := &Miner{customDiff: 25000}
p.events.Dispatch(Event{Type: EventAccept, Miner: miner, Diff: 1})
summary := p.Summary()
if len(summary.CustomDiffStats) != 0 {
t.Fatalf("expected custom diff stats to remain disabled, got %+v", summary.CustomDiffStats)
}
}

View file

@ -0,0 +1,5 @@
# go-proxy RFC
This path mirrors the authoritative proxy contract in [`../../../../RFC.md`](../../../../RFC.md).
Use the root RFC for the full implementation contract.

View file

@ -0,0 +1,5 @@
# RFC-CORE-008: Agent Experience
This path mirrors the local AX guidance in [`../../../../.core/reference/RFC-025-AGENT-EXPERIENCE.md`](../../../../.core/reference/RFC-025-AGENT-EXPERIENCE.md).
Use the reference copy for the full design principles.

38
error.go Normal file
View file

@ -0,0 +1,38 @@
package proxy
// ScopedError carries a stable error scope alongside a human-readable message.
//
// err := proxy.NewScopedError("proxy.config", "load failed", io.EOF)
type ScopedError struct {
Scope string
Message string
Cause error
}
// NewScopedError creates an error that keeps a greppable scope token in the failure path.
//
// err := proxy.NewScopedError("proxy.server", "listen failed", cause)
func NewScopedError(scope, message string, cause error) error {
return &ScopedError{
Scope: scope,
Message: message,
Cause: cause,
}
}
func (e *ScopedError) Error() string {
if e == nil {
return ""
}
if e.Cause == nil {
return e.Scope + ": " + e.Message
}
return e.Scope + ": " + e.Message + ": " + e.Cause.Error()
}
func (e *ScopedError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}

43
error_test.go Normal file
View file

@ -0,0 +1,43 @@
package proxy
import (
"errors"
"testing"
)
func TestError_NewScopedError_Good(t *testing.T) {
err := NewScopedError("proxy.config", "bind list is empty", nil)
if err == nil {
t.Fatalf("expected scoped error")
}
if got := err.Error(); got != "proxy.config: bind list is empty" {
t.Fatalf("unexpected scoped error string: %q", got)
}
}
func TestError_NewScopedError_Bad(t *testing.T) {
cause := errors.New("permission denied")
err := NewScopedError("proxy.config", "read config failed", cause)
if err == nil {
t.Fatalf("expected scoped error")
}
if !errors.Is(err, cause) {
t.Fatalf("expected errors.Is to unwrap the original cause")
}
if got := err.Error(); got != "proxy.config: read config failed: permission denied" {
t.Fatalf("unexpected wrapped error string: %q", got)
}
}
func TestError_NewScopedError_Ugly(t *testing.T) {
var scoped *ScopedError
if got := scoped.Error(); got != "" {
t.Fatalf("expected nil scoped error string to be empty, got %q", got)
}
if scoped.Unwrap() != nil {
t.Fatalf("expected nil scoped error to unwrap to nil")
}
}

View file

@ -2,18 +2,21 @@ package proxy
import "sync" import "sync"
// EventBus dispatches proxy lifecycle events to registered listeners. // EventBus dispatches proxy lifecycle events to synchronous listeners.
// Dispatch is synchronous on the calling goroutine. Listeners must not block.
// //
// bus := proxy.NewEventBus() // bus := proxy.NewEventBus()
// bus.Subscribe(proxy.EventLogin, customDiff.OnLogin) // bus.Subscribe(proxy.EventLogin, func(e proxy.Event) {
// _ = e.Miner.User()
// })
// bus.Subscribe(proxy.EventAccept, stats.OnAccept) // bus.Subscribe(proxy.EventAccept, stats.OnAccept)
type EventBus struct { type EventBus struct {
listeners map[EventType][]EventHandler listeners map[EventType][]EventHandler
mu sync.RWMutex mu sync.RWMutex
} }
// EventType identifies the proxy lifecycle event. // EventType identifies one proxy lifecycle event.
//
// proxy.EventLogin
type EventType int type EventType int
const ( const (
@ -24,12 +27,13 @@ const (
) )
// EventHandler is the callback signature for all event types. // EventHandler is the callback signature for all event types.
//
// handler := func(e proxy.Event) { _ = e.Miner }
type EventHandler func(Event) type EventHandler func(Event)
// Event carries the data for any proxy lifecycle event. // Event carries the data for any proxy lifecycle event.
// Fields not relevant to the event type are zero/nil.
// //
// bus.Dispatch(proxy.Event{Type: proxy.EventLogin, Miner: m}) // bus.Dispatch(proxy.Event{Type: proxy.EventLogin, Miner: m})
type Event struct { type Event struct {
Type EventType Type EventType
Miner *Miner // always set Miner *Miner // always set

224
http_auth_test.go Normal file
View file

@ -0,0 +1,224 @@
package proxy
import (
"net"
"net/http"
"net/http/httptest"
"strconv"
"testing"
)
func TestProxy_allowHTTP_Good(t *testing.T) {
p := &Proxy{
config: &Config{
HTTP: HTTPConfig{
Restricted: true,
AccessToken: "secret",
},
},
}
status, ok := p.AllowMonitoringRequest(&http.Request{
Method: http.MethodGet,
Header: http.Header{
"Authorization": []string{"Bearer secret"},
},
})
if !ok {
t.Fatalf("expected authorised request to pass, got status %d", status)
}
if status != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, status)
}
}
func TestProxy_allowHTTP_Bad(t *testing.T) {
p := &Proxy{
config: &Config{
HTTP: HTTPConfig{
Restricted: true,
},
},
}
status, ok := p.AllowMonitoringRequest(&http.Request{Method: http.MethodPost})
if ok {
t.Fatal("expected non-GET request to be rejected")
}
if status != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, status)
}
}
func TestProxy_allowHTTP_Unrestricted_Good(t *testing.T) {
p := &Proxy{
config: &Config{
HTTP: HTTPConfig{},
},
}
status, ok := p.AllowMonitoringRequest(&http.Request{Method: http.MethodGet})
if !ok {
t.Fatalf("expected unrestricted request to pass, got status %d", status)
}
if status != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, status)
}
}
func TestProxy_allowHTTP_Unrestricted_Bad(t *testing.T) {
p := &Proxy{
config: &Config{
HTTP: HTTPConfig{},
},
}
status, ok := p.AllowMonitoringRequest(&http.Request{Method: http.MethodPost})
if !ok {
t.Fatalf("expected unrestricted non-GET request to pass, got status %d", status)
}
if status != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, status)
}
}
func TestProxy_allowHTTP_Ugly(t *testing.T) {
p := &Proxy{
config: &Config{
HTTP: HTTPConfig{
AccessToken: "secret",
},
},
}
status, ok := p.AllowMonitoringRequest(&http.Request{
Method: http.MethodGet,
Header: http.Header{
"Authorization": []string{"Bearer wrong"},
},
})
if ok {
t.Fatal("expected invalid token to be rejected")
}
if status != http.StatusUnauthorized {
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, status)
}
}
func TestProxy_allowHTTP_NilConfig_Ugly(t *testing.T) {
p := &Proxy{}
status, ok := p.AllowMonitoringRequest(&http.Request{Method: http.MethodGet})
if ok {
t.Fatal("expected nil config request to be rejected")
}
if status != http.StatusServiceUnavailable {
t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, status)
}
}
func TestProxy_startHTTP_Good(t *testing.T) {
p := &Proxy{
config: &Config{
HTTP: HTTPConfig{
Enabled: true,
Host: "127.0.0.1",
Port: 0,
},
},
done: make(chan struct{}),
}
if ok := p.startMonitoringServer(); !ok {
t.Fatal("expected HTTP server to start on a free port")
}
p.Stop()
}
func TestProxy_startHTTP_NilConfig_Bad(t *testing.T) {
p := &Proxy{}
if ok := p.startMonitoringServer(); ok {
t.Fatal("expected nil config to skip HTTP server start")
}
}
func TestProxy_startHTTP_Bad(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen on ephemeral port: %v", err)
}
defer listener.Close()
host, port, err := net.SplitHostPort(listener.Addr().String())
if err != nil {
t.Fatalf("split listener addr: %v", err)
}
portNum, err := strconv.Atoi(port)
if err != nil {
t.Fatalf("parse listener port: %v", err)
}
p := &Proxy{
config: &Config{
HTTP: HTTPConfig{
Enabled: true,
Host: host,
Port: uint16(portNum),
},
},
done: make(chan struct{}),
}
if ok := p.startMonitoringServer(); ok {
t.Fatal("expected HTTP server start to fail when the port is already in use")
}
}
func TestProxy_registerMonitoringRoute_MethodNotAllowed_Bad(t *testing.T) {
p := &Proxy{
config: &Config{
HTTP: HTTPConfig{
Restricted: true,
},
},
}
mux := http.NewServeMux()
p.registerMonitoringRoute(mux, "/1/summary", func() any { return map[string]string{"status": "ok"} })
request := httptest.NewRequest(http.MethodPost, "/1/summary", nil)
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, request)
if recorder.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected %d, got %d", http.StatusMethodNotAllowed, recorder.Code)
}
if got := recorder.Header().Get("Allow"); got != http.MethodGet {
t.Fatalf("expected Allow header %q, got %q", http.MethodGet, got)
}
}
func TestProxy_registerMonitoringRoute_Unauthorized_Ugly(t *testing.T) {
p := &Proxy{
config: &Config{
HTTP: HTTPConfig{
AccessToken: "secret",
},
},
}
mux := http.NewServeMux()
p.registerMonitoringRoute(mux, "/1/summary", func() any { return map[string]string{"status": "ok"} })
request := httptest.NewRequest(http.MethodGet, "/1/summary", nil)
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("expected %d, got %d", http.StatusUnauthorized, recorder.Code)
}
if got := recorder.Header().Get("WWW-Authenticate"); got != "Bearer" {
t.Fatalf("expected WWW-Authenticate header %q, got %q", "Bearer", got)
}
}

6
job.go
View file

@ -1,13 +1,15 @@
package proxy package proxy
// Job holds the current work unit received from a pool. Immutable once assigned. // Job holds one pool work unit and its metadata.
// //
// j := proxy.Job{ // j := proxy.Job{
// Blob: "0707d5ef...b01", // Blob: strings.Repeat("0", 160),
// JobID: "4BiGm3/RgGQzgkTI", // JobID: "4BiGm3/RgGQzgkTI",
// Target: "b88d0600", // Target: "b88d0600",
// Algo: "cn/r", // Algo: "cn/r",
// } // }
// _ = j.BlobWithFixedByte(0x2A)
// _ = j.DifficultyFromTarget()
type Job struct { type Job struct {
Blob string // hex-encoded block template (160 hex chars = 80 bytes) Blob string // hex-encoded block template (160 hex chars = 80 bytes)
JobID string // pool-assigned identifier JobID string // pool-assigned identifier

View file

@ -5,7 +5,11 @@ import (
"testing" "testing"
) )
func TestJob_BlobWithFixedByte(t *testing.T) { // TestJob_BlobWithFixedByte_Good verifies nonce patching on a full 160-char blob.
//
// job := proxy.Job{Blob: strings.Repeat("0", 160)}
// result := job.BlobWithFixedByte(0x2A) // chars 78-79 become "2a"
func TestJob_BlobWithFixedByte_Good(t *testing.T) {
job := Job{Blob: strings.Repeat("0", 160)} job := Job{Blob: strings.Repeat("0", 160)}
got := job.BlobWithFixedByte(0x2A) got := job.BlobWithFixedByte(0x2A)
if len(got) != 160 { if len(got) != 160 {
@ -16,9 +20,97 @@ func TestJob_BlobWithFixedByte(t *testing.T) {
} }
} }
func TestJob_DifficultyFromTarget(t *testing.T) { // TestJob_BlobWithFixedByte_Bad verifies a short blob is returned unchanged.
job := Job{Target: "b88d0600"} //
if got := job.DifficultyFromTarget(); got == 0 { // job := proxy.Job{Blob: "0000"}
t.Fatalf("expected non-zero difficulty") // result := job.BlobWithFixedByte(0x2A) // too short, returned as-is
func TestJob_BlobWithFixedByte_Bad(t *testing.T) {
shortBlob := "0000"
job := Job{Blob: shortBlob}
got := job.BlobWithFixedByte(0x2A)
if got != shortBlob {
t.Fatalf("expected short blob to be returned unchanged, got %q", got)
}
}
// TestJob_BlobWithFixedByte_Ugly verifies fixedByte 0xFF renders as lowercase "ff".
//
// job := proxy.Job{Blob: strings.Repeat("0", 160)}
// result := job.BlobWithFixedByte(0xFF) // chars 78-79 become "ff" (not "FF")
func TestJob_BlobWithFixedByte_Ugly(t *testing.T) {
job := Job{Blob: strings.Repeat("0", 160)}
got := job.BlobWithFixedByte(0xFF)
if got[78:80] != "ff" {
t.Fatalf("expected lowercase 'ff', got %q", got[78:80])
}
if len(got) != 160 {
t.Fatalf("expected blob length preserved, got %d", len(got))
}
}
// TestJob_DifficultyFromTarget_Good verifies a known target converts to the expected difficulty.
//
// job := proxy.Job{Target: "b88d0600"}
// diff := job.DifficultyFromTarget() // 10000
func TestJob_DifficultyFromTarget_Good(t *testing.T) {
job := Job{Target: "b88d0600"}
if got := job.DifficultyFromTarget(); got != 10000 {
t.Fatalf("expected difficulty 10000, got %d", got)
}
}
// TestJob_DifficultyFromTarget_Bad verifies a zero target produces difficulty 0 without panic.
//
// job := proxy.Job{Target: "00000000"}
// diff := job.DifficultyFromTarget() // 0 (no divide-by-zero)
func TestJob_DifficultyFromTarget_Bad(t *testing.T) {
job := Job{Target: "00000000"}
if got := job.DifficultyFromTarget(); got != 0 {
t.Fatalf("expected difficulty 0 for zero target, got %d", got)
}
}
// TestJob_DifficultyFromTarget_Ugly verifies the maximum target "ffffffff" yields difficulty 1.
//
// job := proxy.Job{Target: "ffffffff"}
// diff := job.DifficultyFromTarget() // 1
func TestJob_DifficultyFromTarget_Ugly(t *testing.T) {
job := Job{Target: "ffffffff"}
if got := job.DifficultyFromTarget(); got != 1 {
t.Fatalf("expected minimum difficulty 1, got %d", got)
}
}
// TestJob_IsValid_Good verifies a job with blob and job ID is valid.
//
// job := proxy.Job{Blob: "abc", JobID: "job-1"}
// job.IsValid() // true
func TestJob_IsValid_Good(t *testing.T) {
job := Job{Blob: "abc", JobID: "job-1"}
if !job.IsValid() {
t.Fatalf("expected job with blob and job id to be valid")
}
}
// TestJob_IsValid_Bad verifies a job with empty blob or job ID is invalid.
//
// job := proxy.Job{Blob: "", JobID: "job-1"}
// job.IsValid() // false
func TestJob_IsValid_Bad(t *testing.T) {
if (Job{Blob: "", JobID: "job-1"}).IsValid() {
t.Fatalf("expected empty blob to be invalid")
}
if (Job{Blob: "abc", JobID: ""}).IsValid() {
t.Fatalf("expected empty job id to be invalid")
}
}
// TestJob_IsValid_Ugly verifies a zero-value job is invalid.
//
// job := proxy.Job{}
// job.IsValid() // false
func TestJob_IsValid_Ugly(t *testing.T) {
if (Job{}).IsValid() {
t.Fatalf("expected zero-value job to be invalid")
} }
} }

View file

@ -15,11 +15,11 @@ import (
// Line format (connect): 2026-04-04T12:00:00Z CONNECT <ip> <user> <agent> // Line format (connect): 2026-04-04T12:00:00Z CONNECT <ip> <user> <agent>
// Line format (close): 2026-04-04T12:00:00Z CLOSE <ip> <user> rx=<bytes> tx=<bytes> // Line format (close): 2026-04-04T12:00:00Z CLOSE <ip> <user> rx=<bytes> tx=<bytes>
// //
// al, result := log.NewAccessLog("/var/log/proxy-access.log") // al := log.NewAccessLog("/var/log/proxy-access.log")
// bus.Subscribe(proxy.EventLogin, al.OnLogin) // bus.Subscribe(proxy.EventLogin, al.OnLogin)
// bus.Subscribe(proxy.EventClose, al.OnClose) // bus.Subscribe(proxy.EventClose, al.OnClose)
type AccessLog struct { type AccessLog struct {
path string path string
mu sync.Mutex mu sync.Mutex
f *os.File file *os.File
} }

View file

@ -10,32 +10,79 @@ import (
) )
// NewAccessLog creates an append-only access log. // NewAccessLog creates an append-only access log.
//
// al := log.NewAccessLog("/var/log/proxy-access.log")
// defer al.Close()
func NewAccessLog(path string) *AccessLog { func NewAccessLog(path string) *AccessLog {
return &AccessLog{path: path} return &AccessLog{path: path}
} }
// OnLogin writes a CONNECT line. // Close releases the underlying file handle if the log has been opened.
//
// al := log.NewAccessLog("/var/log/proxy-access.log")
// defer al.Close()
func (l *AccessLog) Close() {
if l == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
if l.file != nil {
_ = l.file.Close()
l.file = nil
}
}
// OnLogin writes a connect line such as:
//
// al.OnLogin(proxy.Event{Miner: &proxy.Miner{}})
// // 2026-04-04T12:00:00Z CONNECT 10.0.0.1 WALLET XMRig/6.21.0
func (l *AccessLog) OnLogin(e proxy.Event) { func (l *AccessLog) OnLogin(e proxy.Event) {
if l == nil || e.Miner == nil { if l == nil || e.Miner == nil {
return return
} }
l.writeLine("CONNECT", e.Miner.IP(), e.Miner.User(), e.Miner.Agent(), 0, 0) l.writeConnectLine(e.Miner.IP(), e.Miner.User(), e.Miner.Agent())
} }
// OnClose writes a CLOSE line with byte counts. // OnClose writes a close line such as:
//
// al.OnClose(proxy.Event{Miner: &proxy.Miner{}})
// // 2026-04-04T12:00:00Z CLOSE 10.0.0.1 WALLET rx=512 tx=4096
func (l *AccessLog) OnClose(e proxy.Event) { func (l *AccessLog) OnClose(e proxy.Event) {
if l == nil || e.Miner == nil { if l == nil || e.Miner == nil {
return return
} }
l.writeLine("CLOSE", e.Miner.IP(), e.Miner.User(), "", e.Miner.RX(), e.Miner.TX()) l.writeCloseLine(e.Miner.IP(), e.Miner.User(), e.Miner.RX(), e.Miner.TX())
} }
// NewShareLog creates an append-only share log. // NewShareLog creates an append-only share log.
//
// sl := log.NewShareLog("/var/log/proxy-shares.log")
// defer sl.Close()
func NewShareLog(path string) *ShareLog { func NewShareLog(path string) *ShareLog {
return &ShareLog{path: path} return &ShareLog{path: path}
} }
// OnAccept writes an ACCEPT line. // Close releases the underlying file handle if the log has been opened.
//
// sl := log.NewShareLog("/var/log/proxy-shares.log")
// defer sl.Close()
func (l *ShareLog) Close() {
if l == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
if l.file != nil {
_ = l.file.Close()
l.file = nil
}
}
// OnAccept writes an accept line such as:
//
// sl.OnAccept(proxy.Event{Miner: &proxy.Miner{}, Diff: 100000, Latency: 82})
// // 2026-04-04T12:00:00Z ACCEPT WALLET diff=100000 latency=82ms
func (l *ShareLog) OnAccept(e proxy.Event) { func (l *ShareLog) OnAccept(e proxy.Event) {
if l == nil || e.Miner == nil { if l == nil || e.Miner == nil {
return return
@ -43,7 +90,10 @@ func (l *ShareLog) OnAccept(e proxy.Event) {
l.writeAcceptLine(e.Miner.User(), e.Diff, uint64(e.Latency)) l.writeAcceptLine(e.Miner.User(), e.Diff, uint64(e.Latency))
} }
// OnReject writes a REJECT line. // OnReject writes a reject line such as:
//
// sl.OnReject(proxy.Event{Miner: &proxy.Miner{}, Error: "Invalid nonce"})
// // 2026-04-04T12:00:00Z REJECT WALLET reason="Invalid nonce"
func (l *ShareLog) OnReject(e proxy.Event) { func (l *ShareLog) OnReject(e proxy.Event) {
if l == nil || e.Miner == nil { if l == nil || e.Miner == nil {
return return
@ -51,38 +101,52 @@ func (l *ShareLog) OnReject(e proxy.Event) {
l.writeRejectLine(e.Miner.User(), e.Error) l.writeRejectLine(e.Miner.User(), e.Error)
} }
func (l *AccessLog) writeLine(kind, ip, user, agent string, rx, tx uint64) { func (accessLog *AccessLog) writeConnectLine(ip, user, agent string) {
l.mu.Lock() accessLog.mu.Lock()
defer l.mu.Unlock() defer accessLog.mu.Unlock()
if err := l.ensureFile(); err != nil { if err := accessLog.ensureFile(); err != nil {
return return
} }
var builder strings.Builder var builder strings.Builder
builder.WriteString(time.Now().UTC().Format(time.RFC3339)) builder.WriteString(time.Now().UTC().Format(time.RFC3339))
builder.WriteByte(' ') builder.WriteByte(' ')
builder.WriteString(kind) builder.WriteString("CONNECT")
builder.WriteString(" ") builder.WriteString(" ")
builder.WriteString(ip) builder.WriteString(ip)
builder.WriteString(" ") builder.WriteString(" ")
builder.WriteString(user) builder.WriteString(user)
if agent != "" { builder.WriteString(" ")
builder.WriteString(" ") builder.WriteString(agent)
builder.WriteString(agent)
}
if rx > 0 || tx > 0 {
builder.WriteString(" rx=")
builder.WriteString(strconv.FormatUint(rx, 10))
builder.WriteString(" tx=")
builder.WriteString(strconv.FormatUint(tx, 10))
}
builder.WriteByte('\n') builder.WriteByte('\n')
_, _ = l.f.WriteString(builder.String()) _, _ = accessLog.file.WriteString(builder.String())
} }
func (l *ShareLog) writeAcceptLine(user string, diff uint64, latency uint64) { func (accessLog *AccessLog) writeCloseLine(ip, user string, rx, tx uint64) {
l.mu.Lock() accessLog.mu.Lock()
defer l.mu.Unlock() defer accessLog.mu.Unlock()
if err := l.ensureFile(); err != nil { if err := accessLog.ensureFile(); err != nil {
return
}
var builder strings.Builder
builder.WriteString(time.Now().UTC().Format(time.RFC3339))
builder.WriteByte(' ')
builder.WriteString("CLOSE")
builder.WriteString(" ")
builder.WriteString(ip)
builder.WriteString(" ")
builder.WriteString(user)
builder.WriteString(" rx=")
builder.WriteString(strconv.FormatUint(rx, 10))
builder.WriteString(" tx=")
builder.WriteString(strconv.FormatUint(tx, 10))
builder.WriteByte('\n')
_, _ = accessLog.file.WriteString(builder.String())
}
func (shareLog *ShareLog) writeAcceptLine(user string, diff uint64, latency uint64) {
shareLog.mu.Lock()
defer shareLog.mu.Unlock()
if err := shareLog.ensureFile(); err != nil {
return return
} }
var builder strings.Builder var builder strings.Builder
@ -96,13 +160,13 @@ func (l *ShareLog) writeAcceptLine(user string, diff uint64, latency uint64) {
builder.WriteString(strconv.FormatUint(latency, 10)) builder.WriteString(strconv.FormatUint(latency, 10))
builder.WriteString("ms") builder.WriteString("ms")
builder.WriteByte('\n') builder.WriteByte('\n')
_, _ = l.f.WriteString(builder.String()) _, _ = shareLog.file.WriteString(builder.String())
} }
func (l *ShareLog) writeRejectLine(user, reason string) { func (shareLog *ShareLog) writeRejectLine(user, reason string) {
l.mu.Lock() shareLog.mu.Lock()
defer l.mu.Unlock() defer shareLog.mu.Unlock()
if err := l.ensureFile(); err != nil { if err := shareLog.ensureFile(); err != nil {
return return
} }
var builder strings.Builder var builder strings.Builder
@ -112,29 +176,29 @@ func (l *ShareLog) writeRejectLine(user, reason string) {
builder.WriteString(" reason=\"") builder.WriteString(" reason=\"")
builder.WriteString(reason) builder.WriteString(reason)
builder.WriteString("\"\n") builder.WriteString("\"\n")
_, _ = l.f.WriteString(builder.String()) _, _ = shareLog.file.WriteString(builder.String())
} }
func (l *AccessLog) ensureFile() error { func (accessLog *AccessLog) ensureFile() error {
if l.f != nil { if accessLog.file != nil {
return nil return nil
} }
f, err := os.OpenFile(l.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) f, err := os.OpenFile(accessLog.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil { if err != nil {
return err return err
} }
l.f = f accessLog.file = f
return nil return nil
} }
func (l *ShareLog) ensureFile() error { func (shareLog *ShareLog) ensureFile() error {
if l.f != nil { if shareLog.file != nil {
return nil return nil
} }
f, err := os.OpenFile(l.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) f, err := os.OpenFile(shareLog.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil { if err != nil {
return err return err
} }
l.f = f shareLog.file = f
return nil return nil
} }

341
log/impl_test.go Normal file
View file

@ -0,0 +1,341 @@
package log
import (
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
"dappco.re/go/proxy"
)
// TestAccessLog_OnLogin_Good verifies a CONNECT line is written with the expected columns.
//
// al := log.NewAccessLog("/tmp/test-access.log")
// al.OnLogin(proxy.Event{Miner: miner}) // writes "CONNECT 10.0.0.1 WALLET XMRig/6.21.0"
func TestAccessLog_OnLogin_Good(t *testing.T) {
path := filepath.Join(t.TempDir(), "access.log")
al := NewAccessLog(path)
defer al.Close()
miner := newTestMiner(t)
al.OnLogin(proxy.Event{Miner: miner})
al.Close()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("expected log file to exist: %v", err)
}
line := strings.TrimSpace(string(data))
if !strings.Contains(line, "CONNECT") {
t.Fatalf("expected CONNECT in log line, got %q", line)
}
}
// TestAccessLog_OnLogin_Bad verifies a nil miner event does not panic or write anything.
//
// al := log.NewAccessLog("/tmp/test-access.log")
// al.OnLogin(proxy.Event{Miner: nil}) // no-op
func TestAccessLog_OnLogin_Bad(t *testing.T) {
path := filepath.Join(t.TempDir(), "access.log")
al := NewAccessLog(path)
defer al.Close()
al.OnLogin(proxy.Event{Miner: nil})
al.Close()
if _, err := os.Stat(path); err == nil {
data, _ := os.ReadFile(path)
if len(data) > 0 {
t.Fatalf("expected no output for nil miner, got %q", string(data))
}
}
}
// TestAccessLog_OnLogin_Ugly verifies a nil AccessLog does not panic.
//
// var al *log.AccessLog
// al.OnLogin(proxy.Event{Miner: miner}) // no-op, no panic
func TestAccessLog_OnLogin_Ugly(t *testing.T) {
var al *AccessLog
miner := newTestMiner(t)
al.OnLogin(proxy.Event{Miner: miner})
}
// TestAccessLog_OnClose_Good verifies a CLOSE line includes rx and tx byte counts.
//
// al := log.NewAccessLog("/tmp/test-access.log")
// al.OnClose(proxy.Event{Miner: miner}) // writes "CLOSE <ip> <user> rx=0 tx=0"
func TestAccessLog_OnClose_Good(t *testing.T) {
path := filepath.Join(t.TempDir(), "access.log")
al := NewAccessLog(path)
defer al.Close()
miner := newTestMiner(t)
al.OnClose(proxy.Event{Miner: miner})
al.Close()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("expected log file to exist: %v", err)
}
line := strings.TrimSpace(string(data))
if !strings.Contains(line, "CLOSE") {
t.Fatalf("expected CLOSE in log line, got %q", line)
}
if !strings.Contains(line, "rx=") {
t.Fatalf("expected rx= in log line, got %q", line)
}
if !strings.Contains(line, "tx=") {
t.Fatalf("expected tx= in log line, got %q", line)
}
}
// TestAccessLog_OnClose_Bad verifies a nil miner close event produces no output.
//
// al := log.NewAccessLog("/tmp/test-access.log")
// al.OnClose(proxy.Event{Miner: nil}) // no-op
func TestAccessLog_OnClose_Bad(t *testing.T) {
path := filepath.Join(t.TempDir(), "access.log")
al := NewAccessLog(path)
defer al.Close()
al.OnClose(proxy.Event{Miner: nil})
al.Close()
if _, err := os.Stat(path); err == nil {
data, _ := os.ReadFile(path)
if len(data) > 0 {
t.Fatalf("expected no output for nil miner, got %q", string(data))
}
}
}
// TestAccessLog_OnClose_Ugly verifies close on an empty-path log is a no-op.
//
// al := log.NewAccessLog("")
// al.OnClose(proxy.Event{Miner: miner}) // no-op, empty path
func TestAccessLog_OnClose_Ugly(t *testing.T) {
al := NewAccessLog("")
defer al.Close()
miner := newTestMiner(t)
al.OnClose(proxy.Event{Miner: miner})
}
// TestShareLog_OnAccept_Good verifies an ACCEPT line is written with diff and latency.
//
// sl := log.NewShareLog("/tmp/test-shares.log")
// sl.OnAccept(proxy.Event{Miner: miner, Diff: 100000, Latency: 82})
func TestShareLog_OnAccept_Good(t *testing.T) {
path := filepath.Join(t.TempDir(), "shares.log")
sl := NewShareLog(path)
defer sl.Close()
miner := newTestMiner(t)
sl.OnAccept(proxy.Event{Miner: miner, Diff: 100000, Latency: 82})
sl.Close()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("expected log file to exist: %v", err)
}
line := strings.TrimSpace(string(data))
if !strings.Contains(line, "ACCEPT") {
t.Fatalf("expected ACCEPT in log line, got %q", line)
}
if !strings.Contains(line, "diff=100000") {
t.Fatalf("expected diff=100000 in log line, got %q", line)
}
if !strings.Contains(line, "latency=82ms") {
t.Fatalf("expected latency=82ms in log line, got %q", line)
}
}
// TestShareLog_OnAccept_Bad verifies a nil miner accept event produces no output.
//
// sl := log.NewShareLog("/tmp/test-shares.log")
// sl.OnAccept(proxy.Event{Miner: nil}) // no-op
func TestShareLog_OnAccept_Bad(t *testing.T) {
path := filepath.Join(t.TempDir(), "shares.log")
sl := NewShareLog(path)
defer sl.Close()
sl.OnAccept(proxy.Event{Miner: nil, Diff: 100000})
sl.Close()
if _, err := os.Stat(path); err == nil {
data, _ := os.ReadFile(path)
if len(data) > 0 {
t.Fatalf("expected no output for nil miner, got %q", string(data))
}
}
}
// TestShareLog_OnAccept_Ugly verifies a nil ShareLog does not panic.
//
// var sl *log.ShareLog
// sl.OnAccept(proxy.Event{Miner: miner}) // no-op, no panic
func TestShareLog_OnAccept_Ugly(t *testing.T) {
var sl *ShareLog
miner := newTestMiner(t)
sl.OnAccept(proxy.Event{Miner: miner, Diff: 100000})
}
// TestShareLog_OnReject_Good verifies a REJECT line is written with the rejection reason.
//
// sl := log.NewShareLog("/tmp/test-shares.log")
// sl.OnReject(proxy.Event{Miner: miner, Error: "Low difficulty share"})
func TestShareLog_OnReject_Good(t *testing.T) {
path := filepath.Join(t.TempDir(), "shares.log")
sl := NewShareLog(path)
defer sl.Close()
miner := newTestMiner(t)
sl.OnReject(proxy.Event{Miner: miner, Error: "Low difficulty share"})
sl.Close()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("expected log file to exist: %v", err)
}
line := strings.TrimSpace(string(data))
if !strings.Contains(line, "REJECT") {
t.Fatalf("expected REJECT in log line, got %q", line)
}
if !strings.Contains(line, "Low difficulty share") {
t.Fatalf("expected rejection reason in log line, got %q", line)
}
}
// TestShareLog_OnReject_Bad verifies a nil miner reject event produces no output.
//
// sl := log.NewShareLog("/tmp/test-shares.log")
// sl.OnReject(proxy.Event{Miner: nil}) // no-op
func TestShareLog_OnReject_Bad(t *testing.T) {
path := filepath.Join(t.TempDir(), "shares.log")
sl := NewShareLog(path)
defer sl.Close()
sl.OnReject(proxy.Event{Miner: nil, Error: "Low difficulty share"})
sl.Close()
if _, err := os.Stat(path); err == nil {
data, _ := os.ReadFile(path)
if len(data) > 0 {
t.Fatalf("expected no output for nil miner, got %q", string(data))
}
}
}
// TestShareLog_OnReject_Ugly verifies an empty-path ShareLog silently discards the reject line.
//
// sl := log.NewShareLog("")
// sl.OnReject(proxy.Event{Miner: miner, Error: "reason"}) // no-op, empty path
func TestShareLog_OnReject_Ugly(t *testing.T) {
sl := NewShareLog("")
defer sl.Close()
miner := newTestMiner(t)
sl.OnReject(proxy.Event{Miner: miner, Error: "reason"})
}
// TestAccessLog_Close_Good verifies Close releases the file handle and is safe to call twice.
//
// al := log.NewAccessLog("/tmp/test-access.log")
// al.OnLogin(proxy.Event{Miner: miner})
// al.Close()
// al.Close() // double close is safe
func TestAccessLog_Close_Good(t *testing.T) {
path := filepath.Join(t.TempDir(), "access.log")
al := NewAccessLog(path)
miner := newTestMiner(t)
al.OnLogin(proxy.Event{Miner: miner})
al.Close()
al.Close()
}
// TestAccessLog_Close_Bad verifies Close on a nil AccessLog does not panic.
//
// var al *log.AccessLog
// al.Close() // no-op, no panic
func TestAccessLog_Close_Bad(t *testing.T) {
var al *AccessLog
al.Close()
}
// TestAccessLog_Close_Ugly verifies Close on a never-opened log does not panic.
//
// al := log.NewAccessLog("/nonexistent/dir/access.log")
// al.Close() // no file was ever opened
func TestAccessLog_Close_Ugly(t *testing.T) {
al := NewAccessLog("/nonexistent/dir/access.log")
al.Close()
}
// TestShareLog_Close_Good verifies Close releases the file handle and is safe to call twice.
//
// sl := log.NewShareLog("/tmp/test-shares.log")
// sl.OnAccept(proxy.Event{Miner: miner, Diff: 1000})
// sl.Close()
// sl.Close() // double close is safe
func TestShareLog_Close_Good(t *testing.T) {
path := filepath.Join(t.TempDir(), "shares.log")
sl := NewShareLog(path)
miner := newTestMiner(t)
sl.OnAccept(proxy.Event{Miner: miner, Diff: 1000})
sl.Close()
sl.Close()
}
// TestShareLog_Close_Bad verifies Close on a nil ShareLog does not panic.
//
// var sl *log.ShareLog
// sl.Close() // no-op, no panic
func TestShareLog_Close_Bad(t *testing.T) {
var sl *ShareLog
sl.Close()
}
// TestShareLog_Close_Ugly verifies Close on a never-opened log does not panic.
//
// sl := log.NewShareLog("/nonexistent/dir/shares.log")
// sl.Close() // no file was ever opened
func TestShareLog_Close_Ugly(t *testing.T) {
sl := NewShareLog("/nonexistent/dir/shares.log")
sl.Close()
}
// newTestMiner creates a minimal miner for log testing using a net.Pipe connection.
func newTestMiner(t *testing.T) *proxy.Miner {
t.Helper()
client, server := net.Pipe()
t.Cleanup(func() {
_ = client.Close()
_ = server.Close()
})
miner := proxy.NewMiner(client, 3333, nil)
miner.SetID(1)
return miner
}
// pipeAddr satisfies the net.Addr interface for pipe-based test connections.
type pipeAddr struct{}
func (a pipeAddr) Network() string { return "pipe" }
func (a pipeAddr) String() string { return "pipe" }
// pipeConn wraps an os.Pipe as a net.Conn for tests that need a closeable socket.
type pipeConn struct {
*os.File
}
func (p *pipeConn) RemoteAddr() net.Addr { return pipeAddr{} }
func (p *pipeConn) LocalAddr() net.Addr { return pipeAddr{} }
func (p *pipeConn) SetDeadline(_ time.Time) error { return nil }
func (p *pipeConn) SetReadDeadline(_ time.Time) error { return nil }
func (p *pipeConn) SetWriteDeadline(_ time.Time) error { return nil }

View file

@ -16,5 +16,5 @@ import (
type ShareLog struct { type ShareLog struct {
path string path string
mu sync.Mutex mu sync.Mutex
f *os.File file *os.File
} }

View file

@ -25,35 +25,41 @@ const (
// m := proxy.NewMiner(conn, 3333, nil) // m := proxy.NewMiner(conn, 3333, nil)
// m.Start() // m.Start()
type Miner struct { type Miner struct {
id int64 // monotonically increasing per-process; atomic assignment id int64 // monotonically increasing per-process; atomic assignment
rpcID string // UUID v4 sent to miner as session id rpcID string // UUID v4 sent to miner as session id
state MinerState state MinerState
extAlgo bool // miner sent algo list in login params extAlgo bool // miner sent algo list in login params
extNH bool // NiceHash mode active (fixed byte splitting) loginAlgos []string
ip string // remote IP (without port, for logging) extNH bool // NiceHash mode active (fixed byte splitting)
localPort uint16 algoEnabled bool // proxy is configured to negotiate the algo extension
user string // login params.login (wallet address), custom diff suffix stripped ip string // remote IP (without port, for logging)
password string // login params.pass remoteAddr string
agent string // login params.agent localPort uint16
rigID string // login params.rigid (optional extension) user string // login params.login (wallet address), custom diff suffix stripped
fixedByte uint8 // NiceHash slot index (0-255) password string // login params.pass
mapperID int64 // which NonceMapper owns this miner; -1 = unassigned agent string // login params.agent
routeID int64 // SimpleMapper ID in simple mode; -1 = unassigned rigID string // login params.rigid (optional extension)
customDiff uint64 // 0 = use pool diff; non-zero = cap diff to this value fixedByte uint8 // NiceHash slot index (0-255)
accessPassword string mapperID int64 // which NonceMapper owns this miner; -1 = unassigned
globalDiff uint64 routeID int64 // SimpleMapper ID in simple mode; -1 = unassigned
diff uint64 // last difficulty sent to this miner from the pool customDiff uint64 // 0 = use pool diff; non-zero = cap diff to this value
rx uint64 // bytes received from miner customDiffResolved bool
tx uint64 // bytes sent from miner customDiffFromLogin bool
currentJob Job accessPassword string
connectedAt time.Time globalDiff uint64
lastActivityAt time.Time diff uint64 // last difficulty sent to this miner from the pool
conn net.Conn rx uint64 // bytes received from miner
tlsConn *tls.Conn // nil if plain TCP tx uint64 // bytes sent from miner
sendMu sync.Mutex // serialises writes to conn currentJob Job
buf [16384]byte // per-miner send buffer; avoids per-write allocations connectedAt time.Time
onLogin func(*Miner) lastActivityAt time.Time
onSubmit func(*Miner, *SubmitEvent) conn net.Conn
onClose func(*Miner) tlsConn *tls.Conn // nil if plain TCP
closeOnce sync.Once sendMu sync.Mutex // serialises writes to conn
buf [16384]byte // per-miner send buffer; avoids per-write allocations
onLogin func(*Miner)
onLoginReady func(*Miner)
onSubmit func(*Miner, *SubmitEvent)
onClose func(*Miner)
closeOnce sync.Once
} }

467
miner_login_test.go Normal file
View file

@ -0,0 +1,467 @@
package proxy
import (
"bufio"
"encoding/json"
"net"
"strings"
"testing"
"time"
)
func TestMiner_HandleLogin_Good(t *testing.T) {
minerConn, clientConn := net.Pipe()
defer minerConn.Close()
defer clientConn.Close()
miner := NewMiner(minerConn, 3333, nil)
miner.algoEnabled = true
miner.extNH = true
miner.fixedByte = 0x2a
miner.onLogin = func(m *Miner) {
m.SetMapperID(1)
}
miner.currentJob = Job{
Blob: strings.Repeat("0", 160),
JobID: "job-1",
Target: "b88d0600",
Algo: "cn/r",
Height: 7,
SeedHash: "seed",
}
params, err := json.Marshal(loginParams{
Login: "wallet",
Pass: "x",
Agent: "xmrig",
Algo: []string{"cn/r"},
RigID: "rig-1",
})
if err != nil {
t.Fatalf("marshal login params: %v", err)
}
done := make(chan struct{})
go func() {
miner.handleLogin(stratumRequest{ID: 1, Method: "login", Params: params})
close(done)
}()
line, err := bufio.NewReader(clientConn).ReadBytes('\n')
if err != nil {
t.Fatalf("read login response: %v", err)
}
<-done
var payload struct {
Error json.RawMessage `json:"error"`
Result struct {
ID string `json:"id"`
Status string `json:"status"`
Extensions []string `json:"extensions"`
Job map[string]any `json:"job"`
} `json:"result"`
}
if err := json.Unmarshal(line, &payload); err != nil {
t.Fatalf("unmarshal login response: %v", err)
}
if string(payload.Error) != "null" {
t.Fatalf("expected login response error to be null, got %s", string(payload.Error))
}
if payload.Result.Status != "OK" {
t.Fatalf("expected login success, got %q", payload.Result.Status)
}
if payload.Result.ID == "" {
t.Fatalf("expected rpc id in login response")
}
if len(payload.Result.Extensions) != 1 || payload.Result.Extensions[0] != "algo" {
t.Fatalf("expected algo extension, got %#v", payload.Result.Extensions)
}
if got := miner.LoginAlgos(); len(got) != 1 || got[0] != "cn/r" {
t.Fatalf("expected login algo list to be stored, got %#v", got)
}
if got := payload.Result.Job["job_id"]; got != "job-1" {
t.Fatalf("expected embedded job, got %#v", got)
}
if got := payload.Result.Job["algo"]; got != "cn/r" {
t.Fatalf("expected embedded algo, got %#v", got)
}
blob, _ := payload.Result.Job["blob"].(string)
if blob[78:80] != "2a" {
t.Fatalf("expected fixed-byte patched blob, got %q", blob[78:80])
}
if miner.State() != MinerStateReady {
t.Fatalf("expected miner ready after login reply with job, got %d", miner.State())
}
}
func TestProxy_New_Watch_Good(t *testing.T) {
cfg := &Config{
Mode: "nicehash",
Workers: WorkersByRigID,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool.example:3333", Enabled: true}},
Watch: true,
configPath: "/tmp/proxy.json",
}
proxyInstance, result := New(cfg)
if !result.OK {
t.Fatalf("expected valid proxy, got error: %v", result.Error)
}
if proxyInstance.watcher == nil {
t.Fatalf("expected config watcher when watch is enabled and source path is known")
}
}
func TestMiner_HandleLogin_Ugly(t *testing.T) {
for i := 0; i < 256; i++ {
miner := &Miner{}
miner.SetID(int64(i + 1))
miner.SetMapperID(int64(i + 1))
}
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
defer clientConn.Close()
miner := NewMiner(serverConn, 3333, nil)
miner.extNH = true
miner.onLogin = func(*Miner) {}
params, err := json.Marshal(loginParams{
Login: "wallet",
Pass: "x",
})
if err != nil {
t.Fatalf("marshal login params: %v", err)
}
done := make(chan []byte, 1)
go func() {
line, readErr := bufio.NewReader(clientConn).ReadBytes('\n')
if readErr != nil {
done <- nil
return
}
done <- line
}()
miner.handleLogin(stratumRequest{ID: 2, Method: "login", Params: params})
line := <-done
if line == nil {
t.Fatal("expected login rejection response")
}
var payload struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
Result map[string]any `json:"result"`
}
if err := json.Unmarshal(line, &payload); err != nil {
t.Fatalf("unmarshal login response: %v", err)
}
if payload.Error.Message != "Proxy is full, try again later" {
t.Fatalf("expected full-table error, got %q", payload.Error.Message)
}
if payload.Result != nil {
t.Fatalf("expected no login success payload, got %#v", payload.Result)
}
if miner.MapperID() != -1 {
t.Fatalf("expected rejected miner to remain unassigned, got mapper %d", miner.MapperID())
}
}
func TestMiner_HandleLogin_FailedAssignmentDoesNotDispatchLoginEvent(t *testing.T) {
minerConn, clientConn := net.Pipe()
defer minerConn.Close()
defer clientConn.Close()
proxyInstance := &Proxy{
config: &Config{
Mode: "nicehash",
Workers: WorkersByUser,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool.example:3333", Enabled: true}},
},
events: NewEventBus(),
stats: NewStats(),
workers: NewWorkers(WorkersByUser, nil),
miners: make(map[int64]*Miner),
}
proxyInstance.events.Subscribe(EventLogin, proxyInstance.stats.OnLogin)
proxyInstance.workers.bindEvents(proxyInstance.events)
miner := NewMiner(minerConn, 3333, nil)
miner.extNH = true
miner.onLogin = func(*Miner) {}
miner.onLoginReady = func(m *Miner) {
proxyInstance.events.Dispatch(Event{Type: EventLogin, Miner: m})
}
proxyInstance.miners[miner.ID()] = miner
params, err := json.Marshal(loginParams{
Login: "wallet",
Pass: "x",
})
if err != nil {
t.Fatalf("marshal login params: %v", err)
}
go miner.handleLogin(stratumRequest{ID: 12, Method: "login", Params: params})
line, err := bufio.NewReader(clientConn).ReadBytes('\n')
if err != nil {
t.Fatalf("read login rejection: %v", err)
}
var payload struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(line, &payload); err != nil {
t.Fatalf("unmarshal login rejection: %v", err)
}
if payload.Error.Message != "Proxy is full, try again later" {
t.Fatalf("expected full-table rejection, got %q", payload.Error.Message)
}
if now, max := proxyInstance.MinerCount(); now != 0 || max != 0 {
t.Fatalf("expected failed login not to affect miner counts, got now=%d max=%d", now, max)
}
if records := proxyInstance.WorkerRecords(); len(records) != 0 {
t.Fatalf("expected failed login not to create worker records, got %d", len(records))
}
}
func TestMiner_HandleLogin_CustomDiffCap_Good(t *testing.T) {
minerConn, clientConn := net.Pipe()
defer minerConn.Close()
defer clientConn.Close()
miner := NewMiner(minerConn, 3333, nil)
miner.onLogin = func(m *Miner) {
m.SetRouteID(1)
m.customDiff = 5000
}
miner.currentJob = Job{
Blob: strings.Repeat("0", 160),
JobID: "job-1",
Target: "01000000",
}
params, err := json.Marshal(loginParams{
Login: "wallet",
Pass: "x",
})
if err != nil {
t.Fatalf("marshal login params: %v", err)
}
go miner.handleLogin(stratumRequest{ID: 3, Method: "login", Params: params})
line, err := bufio.NewReader(clientConn).ReadBytes('\n')
if err != nil {
t.Fatalf("read login response: %v", err)
}
var payload struct {
Result struct {
Job struct {
Target string `json:"target"`
} `json:"job"`
} `json:"result"`
}
if err := json.Unmarshal(line, &payload); err != nil {
t.Fatalf("unmarshal login response: %v", err)
}
originalDiff := miner.currentJob.DifficultyFromTarget()
cappedDiff := Job{Target: payload.Result.Job.Target}.DifficultyFromTarget()
if cappedDiff == 0 || cappedDiff > 5000 {
t.Fatalf("expected capped difficulty at or below 5000, got %d", cappedDiff)
}
if cappedDiff >= originalDiff {
t.Fatalf("expected lowered target difficulty below %d, got %d", originalDiff, cappedDiff)
}
if miner.diff != cappedDiff {
t.Fatalf("expected miner diff %d, got %d", cappedDiff, miner.diff)
}
}
func TestMiner_HandleLogin_CustomDiffSuffix_Good(t *testing.T) {
minerConn, clientConn := net.Pipe()
defer minerConn.Close()
defer clientConn.Close()
miner := NewMiner(minerConn, 3333, nil)
miner.onLogin = func(m *Miner) {
m.SetRouteID(1)
}
params, err := json.Marshal(loginParams{
Login: "wallet+50000",
Pass: "x",
})
if err != nil {
t.Fatalf("marshal login params: %v", err)
}
go miner.handleLogin(stratumRequest{ID: 4, Method: "login", Params: params})
line, err := bufio.NewReader(clientConn).ReadBytes('\n')
if err != nil {
t.Fatalf("read login response: %v", err)
}
var payload struct {
Result struct {
Status string `json:"status"`
} `json:"result"`
}
if err := json.Unmarshal(line, &payload); err != nil {
t.Fatalf("unmarshal login response: %v", err)
}
if payload.Result.Status != "OK" {
t.Fatalf("expected login success, got %q", payload.Result.Status)
}
if got := miner.User(); got != "wallet" {
t.Fatalf("expected stripped wallet name, got %q", got)
}
if got := miner.customDiff; got != 50000 {
t.Fatalf("expected custom diff 50000, got %d", got)
}
}
func TestMiner_HandleKeepalived_Good(t *testing.T) {
minerConn, clientConn := net.Pipe()
defer minerConn.Close()
defer clientConn.Close()
miner := NewMiner(minerConn, 3333, nil)
done := make(chan struct{})
go func() {
miner.handleKeepalived(stratumRequest{ID: 9, Method: "keepalived"})
close(done)
}()
line, err := bufio.NewReader(clientConn).ReadBytes('\n')
if err != nil {
t.Fatalf("read keepalived response: %v", err)
}
<-done
var payload map[string]json.RawMessage
if err := json.Unmarshal(line, &payload); err != nil {
t.Fatalf("unmarshal keepalived response: %v", err)
}
if _, ok := payload["error"]; !ok {
t.Fatalf("expected keepalived response to include error field, got %s", string(line))
}
if string(payload["error"]) != "null" {
t.Fatalf("expected keepalived response error to be null, got %s", string(payload["error"]))
}
var result struct {
Status string `json:"status"`
}
if err := json.Unmarshal(payload["result"], &result); err != nil {
t.Fatalf("unmarshal keepalived result: %v", err)
}
if result.Status != "KEEPALIVED" {
t.Fatalf("expected KEEPALIVED status, got %q", result.Status)
}
}
func TestMiner_ReadLoop_RFCLineLimit_Good(t *testing.T) {
minerConn, clientConn := net.Pipe()
defer minerConn.Close()
defer clientConn.Close()
miner := NewMiner(minerConn, 3333, nil)
miner.onLogin = func(m *Miner) {
m.SetRouteID(1)
}
miner.Start()
params, err := json.Marshal(loginParams{
Login: "wallet",
Pass: "x",
Agent: strings.Repeat("a", 5000),
})
if err != nil {
t.Fatalf("marshal login params: %v", err)
}
request, err := json.Marshal(stratumRequest{ID: 4, Method: "login", Params: params})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
if len(request) >= maxStratumLineLength {
t.Fatalf("expected test request below RFC limit, got %d bytes", len(request))
}
if _, err := clientConn.Write(append(request, '\n')); err != nil {
t.Fatalf("write login request: %v", err)
}
_ = clientConn.SetReadDeadline(time.Now().Add(time.Second))
line, err := bufio.NewReader(clientConn).ReadBytes('\n')
if err != nil {
t.Fatalf("read login response: %v", err)
}
if len(line) == 0 {
t.Fatal("expected login response for request under RFC limit")
}
}
func TestMiner_ReadLoop_RFCLineLimit_Ugly(t *testing.T) {
minerConn, clientConn := net.Pipe()
defer minerConn.Close()
defer clientConn.Close()
miner := NewMiner(minerConn, 3333, nil)
miner.Start()
params, err := json.Marshal(loginParams{
Login: "wallet",
Pass: "x",
Agent: strings.Repeat("b", maxStratumLineLength),
})
if err != nil {
t.Fatalf("marshal login params: %v", err)
}
request, err := json.Marshal(stratumRequest{ID: 5, Method: "login", Params: params})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
if len(request) <= maxStratumLineLength {
t.Fatalf("expected test request above RFC limit, got %d bytes", len(request))
}
writeDone := make(chan error, 1)
go func() {
_, writeErr := clientConn.Write(append(request, '\n'))
writeDone <- writeErr
}()
var writeErr error
select {
case writeErr = <-writeDone:
case <-time.After(time.Second):
t.Fatal("timed out writing oversized request")
}
if writeErr == nil {
_ = clientConn.SetReadDeadline(time.Now().Add(time.Second))
line, err := bufio.NewReader(clientConn).ReadBytes('\n')
if err == nil || len(line) > 0 {
t.Fatalf("expected oversized request to close the connection, got line=%q err=%v", string(line), err)
}
return
}
if !strings.Contains(writeErr.Error(), "closed pipe") {
t.Fatalf("expected oversized request to close the connection, got write error %v", writeErr)
}
}

43
miner_wire_test.go Normal file
View file

@ -0,0 +1,43 @@
package proxy
import (
"bufio"
"encoding/json"
"net"
"testing"
)
func TestMiner_Success_WritesNullError_Good(t *testing.T) {
minerConn, clientConn := net.Pipe()
defer minerConn.Close()
defer clientConn.Close()
miner := NewMiner(minerConn, 3333, nil)
done := make(chan struct{})
go func() {
miner.Success(7, "OK")
close(done)
}()
line, err := bufio.NewReader(clientConn).ReadBytes('\n')
if err != nil {
t.Fatalf("read success response: %v", err)
}
<-done
var payload struct {
Error json.RawMessage `json:"error"`
Result struct {
Status string `json:"status"`
} `json:"result"`
}
if err := json.Unmarshal(line, &payload); err != nil {
t.Fatalf("unmarshal success response: %v", err)
}
if string(payload.Error) != "null" {
t.Fatalf("expected success response error to be null, got %s", string(payload.Error))
}
if payload.Result.Status != "OK" {
t.Fatalf("expected success status OK, got %q", payload.Result.Status)
}
}

87
miners_document_test.go Normal file
View file

@ -0,0 +1,87 @@
package proxy
import "testing"
func TestProxy_MinersDocument_Good(t *testing.T) {
p := &Proxy{
miners: map[int64]*Miner{
1: {
id: 1,
ip: "10.0.0.1:49152",
tx: 4096,
rx: 512,
state: MinerStateReady,
diff: 100000,
user: "WALLET",
password: "secret",
rigID: "rig-alpha",
agent: "XMRig/6.21.0",
},
},
}
document := p.MinersDocument()
if len(document.Miners) != 1 {
t.Fatalf("expected one miner row, got %d", len(document.Miners))
}
row := document.Miners[0]
if len(row) != 10 {
t.Fatalf("expected 10 miner columns, got %d", len(row))
}
if row[7] != "********" {
t.Fatalf("expected masked password, got %#v", row[7])
}
}
func TestProxy_MinersDocument_Bad(t *testing.T) {
var p *Proxy
document := p.MinersDocument()
if len(document.Miners) != 0 {
t.Fatalf("expected no miners for a nil proxy, got %d", len(document.Miners))
}
if len(document.Format) != 10 {
t.Fatalf("expected miner format columns to remain stable, got %d", len(document.Format))
}
}
func TestProxy_MinersDocument_Ugly(t *testing.T) {
p := &Proxy{
miners: map[int64]*Miner{
1: {
id: 1,
ip: "10.0.0.1:49152",
tx: 4096,
rx: 512,
state: MinerStateReady,
diff: 100000,
user: "WALLET",
password: "secret-a",
rigID: "rig-alpha",
agent: "XMRig/6.21.0",
},
2: {
id: 2,
ip: "10.0.0.2:49152",
tx: 2048,
rx: 256,
state: MinerStateWaitReady,
diff: 50000,
user: "WALLET2",
password: "secret-b",
rigID: "rig-beta",
agent: "XMRig/6.22.0",
},
},
}
document := p.MinersDocument()
if len(document.Miners) != 2 {
t.Fatalf("expected two miner rows, got %d", len(document.Miners))
}
for i, row := range document.Miners {
if row[7] != "********" {
t.Fatalf("expected masked password in row %d, got %#v", i, row[7])
}
}
}

View file

@ -1,7 +1,9 @@
// Package pool implements the outbound stratum pool client and failover strategy. // Package pool implements the outbound pool client and failover strategy.
// //
// client := pool.NewStratumClient(poolCfg, listener) // client := pool.NewStratumClient(proxy.PoolConfig{URL: "pool.example:3333", User: "WALLET", Pass: "x"}, listener)
// client.Connect() // if result := client.Connect(); result.OK {
// client.Login()
// }
package pool package pool
import ( import (
@ -12,14 +14,13 @@ import (
"dappco.re/go/proxy" "dappco.re/go/proxy"
) )
// StratumClient is one outbound stratum TCP (optionally TLS) connection to a pool. // client := pool.NewStratumClient(poolCfg, listener)
// The proxy presents itself to the pool as a standard stratum miner using the
// wallet address and password from PoolConfig.
// //
// client := pool.NewStratumClient(poolCfg, listener) // if result := client.Connect(); result.OK {
// client.Connect() // client.Login()
// }
type StratumClient struct { type StratumClient struct {
cfg proxy.PoolConfig config proxy.PoolConfig
listener StratumListener listener StratumListener
conn net.Conn conn net.Conn
tlsConn *tls.Conn // nil if plain TCP tlsConn *tls.Conn // nil if plain TCP
@ -32,7 +33,9 @@ type StratumClient struct {
sendMu sync.Mutex sendMu sync.Mutex
} }
// StratumListener receives events from the pool connection. // type listener struct{}
//
// func (listener) OnJob(job proxy.Job) {}
type StratumListener interface { type StratumListener interface {
// OnJob is called when the pool pushes a new job notification or the login reply contains a job. // OnJob is called when the pool pushes a new job notification or the login reply contains a job.
OnJob(job proxy.Job) OnJob(job proxy.Job)

View file

@ -6,7 +6,6 @@ import (
"crypto/tls" "crypto/tls"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"io" "io"
"net" "net"
"strconv" "strconv"
@ -18,16 +17,27 @@ import (
) )
// NewStrategyFactory creates a StrategyFactory for the supplied config. // NewStrategyFactory creates a StrategyFactory for the supplied config.
func NewStrategyFactory(cfg *proxy.Config) StrategyFactory { //
// factory := pool.NewStrategyFactory(&proxy.Config{Pools: []proxy.PoolConfig{{URL: "pool.example:3333", Enabled: true}}})
// strategy := factory(listener)
func NewStrategyFactory(config *proxy.Config) StrategyFactory {
return func(listener StratumListener) Strategy { return func(listener StratumListener) Strategy {
return NewFailoverStrategy(cfg.Pools, listener, cfg) var pools []proxy.PoolConfig
if config != nil {
pools = config.Pools
}
return NewFailoverStrategy(pools, listener, config)
} }
} }
// NewStratumClient constructs a pool client. // client := pool.NewStratumClient(proxy.PoolConfig{URL: "pool.example:3333", User: "WALLET", Pass: "x"}, listener)
func NewStratumClient(cfg proxy.PoolConfig, listener StratumListener) *StratumClient { //
// if result := client.Connect(); result.OK {
// client.Login()
// }
func NewStratumClient(poolConfig proxy.PoolConfig, listener StratumListener) *StratumClient {
return &StratumClient{ return &StratumClient{
cfg: cfg, config: poolConfig,
listener: listener, listener: listener,
pending: make(map[int64]struct{}), pending: make(map[int64]struct{}),
} }
@ -43,20 +53,20 @@ func (c *StratumClient) IsActive() bool {
return c.active return c.active
} }
// Connect dials the pool. // result := client.Connect()
func (c *StratumClient) Connect() proxy.Result { func (c *StratumClient) Connect() proxy.Result {
if c == nil { if c == nil {
return proxy.Result{OK: false, Error: errors.New("client is nil")} return proxy.Result{OK: false, Error: proxy.NewScopedError("proxy.pool.client", "client is nil", nil)}
} }
addr := c.cfg.URL addr := c.config.URL
if addr == "" { if addr == "" {
return proxy.Result{OK: false, Error: errors.New("pool url is empty")} return proxy.Result{OK: false, Error: proxy.NewScopedError("proxy.pool.client", "pool url is empty", nil)}
} }
conn, err := net.Dial("tcp", addr) conn, err := net.Dial("tcp", addr)
if err != nil { if err != nil {
return proxy.Result{OK: false, Error: err} return proxy.Result{OK: false, Error: proxy.NewScopedError("proxy.pool.client", "dial pool failed", err)}
} }
if c.cfg.TLS { if c.config.TLS {
host := addr host := addr
if strings.Contains(addr, ":") { if strings.Contains(addr, ":") {
host, _, _ = net.SplitHostPort(addr) host, _, _ = net.SplitHostPort(addr)
@ -65,18 +75,18 @@ func (c *StratumClient) Connect() proxy.Result {
tlsConn := tls.Client(conn, tlsCfg) tlsConn := tls.Client(conn, tlsCfg)
if err := tlsConn.Handshake(); err != nil { if err := tlsConn.Handshake(); err != nil {
_ = conn.Close() _ = conn.Close()
return proxy.Result{OK: false, Error: err} return proxy.Result{OK: false, Error: proxy.NewScopedError("proxy.pool.tls", "handshake failed", err)}
} }
if fp := strings.TrimSpace(strings.ToLower(c.cfg.TLSFingerprint)); fp != "" { if fp := strings.TrimSpace(strings.ToLower(c.config.TLSFingerprint)); fp != "" {
cert := tlsConn.ConnectionState().PeerCertificates cert := tlsConn.ConnectionState().PeerCertificates
if len(cert) == 0 { if len(cert) == 0 {
_ = tlsConn.Close() _ = tlsConn.Close()
return proxy.Result{OK: false, Error: errors.New("missing certificate")} return proxy.Result{OK: false, Error: proxy.NewScopedError("proxy.pool.tls", "missing certificate", nil)}
} }
sum := sha256.Sum256(cert[0].Raw) sum := sha256.Sum256(cert[0].Raw)
if hex.EncodeToString(sum[:]) != fp { if hex.EncodeToString(sum[:]) != fp {
_ = tlsConn.Close() _ = tlsConn.Close()
return proxy.Result{OK: false, Error: errors.New("tls fingerprint mismatch")} return proxy.Result{OK: false, Error: proxy.NewScopedError("proxy.pool.tls", "tls fingerprint mismatch", nil)}
} }
} }
c.conn = tlsConn c.conn = tlsConn
@ -88,20 +98,22 @@ func (c *StratumClient) Connect() proxy.Result {
return proxy.Result{OK: true} return proxy.Result{OK: true}
} }
// Login sends the miner-style login request to the pool. // client.Login()
//
// A login reply with a job triggers `OnJob` immediately.
func (c *StratumClient) Login() { func (c *StratumClient) Login() {
if c == nil || c.conn == nil { if c == nil || c.conn == nil {
return return
} }
params := map[string]any{ params := map[string]any{
"login": c.cfg.User, "login": c.config.User,
"pass": c.cfg.Pass, "pass": c.config.Pass,
} }
if c.cfg.RigID != "" { if c.config.RigID != "" {
params["rigid"] = c.cfg.RigID params["rigid"] = c.config.RigID
} }
if c.cfg.Algo != "" { if c.config.Algo != "" {
params["algo"] = []string{c.cfg.Algo} params["algo"] = []string{c.config.Algo}
} }
req := map[string]any{ req := map[string]any{
"id": 1, "id": 1,
@ -112,7 +124,7 @@ func (c *StratumClient) Login() {
_ = c.writeJSON(req) _ = c.writeJSON(req)
} }
// Submit forwards a share to the pool. // seq := client.Submit("job-1", "deadbeef", "HASH64HEX", "cn/r")
func (c *StratumClient) Submit(jobID, nonce, result, algo string) int64 { func (c *StratumClient) Submit(jobID, nonce, result, algo string) int64 {
if c == nil { if c == nil {
return 0 return 0
@ -134,18 +146,39 @@ func (c *StratumClient) Submit(jobID, nonce, result, algo string) int64 {
"algo": algo, "algo": algo,
}, },
} }
_ = c.writeJSON(req) if err := c.writeJSON(req); err != nil {
c.mu.Lock()
delete(c.pending, seq)
c.mu.Unlock()
}
return seq return seq
} }
// Disconnect closes the connection and notifies the listener. // client.Keepalive()
func (c *StratumClient) Keepalive() {
if c == nil || c.conn == nil || !c.IsActive() {
return
}
req := map[string]any{
"id": atomic.AddInt64(&c.seq, 1),
"jsonrpc": "2.0",
"method": "keepalived",
"params": map[string]any{
"id": c.sessionID,
},
}
_ = c.writeJSON(req)
}
// client.Disconnect()
func (c *StratumClient) Disconnect() { func (c *StratumClient) Disconnect() {
if c == nil { if c == nil {
return return
} }
c.closedOnce.Do(func() { c.closedOnce.Do(func() {
if c.conn != nil { conn := c.resetConnectionState()
_ = c.conn.Close() if conn != nil {
_ = conn.Close()
} }
if c.listener != nil { if c.listener != nil {
c.listener.OnDisconnect() c.listener.OnDisconnect()
@ -155,26 +188,43 @@ func (c *StratumClient) Disconnect() {
func (c *StratumClient) notifyDisconnect() { func (c *StratumClient) notifyDisconnect() {
c.closedOnce.Do(func() { c.closedOnce.Do(func() {
c.resetConnectionState()
if c.listener != nil { if c.listener != nil {
c.listener.OnDisconnect() c.listener.OnDisconnect()
} }
}) })
} }
func (c *StratumClient) resetConnectionState() net.Conn {
if c == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
conn := c.conn
c.conn = nil
c.tlsConn = nil
c.sessionID = ""
c.active = false
c.pending = make(map[int64]struct{})
return conn
}
func (c *StratumClient) writeJSON(payload any) error { func (c *StratumClient) writeJSON(payload any) error {
c.sendMu.Lock() c.sendMu.Lock()
defer c.sendMu.Unlock() defer c.sendMu.Unlock()
if c.conn == nil { if c.conn == nil {
return errors.New("connection is nil") return proxy.NewScopedError("proxy.pool.client", "connection is nil", nil)
} }
data, err := json.Marshal(payload) data, err := json.Marshal(payload)
if err != nil { if err != nil {
return err return proxy.NewScopedError("proxy.pool.client", "marshal request failed", err)
} }
data = append(data, '\n') data = append(data, '\n')
_, err = c.conn.Write(data) _, err = c.conn.Write(data)
if err != nil { if err != nil {
c.notifyDisconnect() c.notifyDisconnect()
return proxy.NewScopedError("proxy.pool.client", "write request failed", err)
} }
return err return err
} }
@ -251,6 +301,11 @@ func (c *StratumClient) handleMessage(line []byte) {
} }
} }
if len(base.Error) > 0 && requestID(base.ID) == 1 {
c.notifyDisconnect()
return
}
if base.Method == "job" { if base.Method == "job" {
var params struct { var params struct {
Blob string `json:"blob"` Blob string `json:"blob"`
@ -319,37 +374,38 @@ func (c *StratumClient) handleMessage(line []byte) {
} }
// NewFailoverStrategy creates the ordered pool failover wrapper. // NewFailoverStrategy creates the ordered pool failover wrapper.
func NewFailoverStrategy(pools []proxy.PoolConfig, listener StratumListener, cfg *proxy.Config) *FailoverStrategy { func NewFailoverStrategy(pools []proxy.PoolConfig, listener StratumListener, config *proxy.Config) *FailoverStrategy {
return &FailoverStrategy{ return &FailoverStrategy{
pools: pools, pools: pools,
listener: listener, listener: listener,
cfg: cfg, config: config,
} }
} }
// Connect establishes the first reachable pool connection. // strategy.Connect()
func (s *FailoverStrategy) Connect() { func (s *FailoverStrategy) Connect() {
if s == nil { if s == nil {
return return
} }
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
s.closing = false
s.connectLocked(0) s.connectLocked(0)
} }
func (s *FailoverStrategy) connectLocked(start int) { func (s *FailoverStrategy) connectLocked(start int) {
enabled := enabledPools(s.pools) enabled := enabledPools(s.currentPools())
if len(enabled) == 0 { if len(enabled) == 0 {
return return
} }
retries := 1 retries := 1
retryPause := time.Second retryPause := time.Second
if s.cfg != nil { if s.config != nil {
if s.cfg.Retries > 0 { if s.config.Retries > 0 {
retries = s.cfg.Retries retries = s.config.Retries
} }
if s.cfg.RetryPause > 0 { if s.config.RetryPause > 0 {
retryPause = time.Duration(s.cfg.RetryPause) * time.Second retryPause = time.Duration(s.config.RetryPause) * time.Second
} }
} }
for attempt := 0; attempt < retries; attempt++ { for attempt := 0; attempt < retries; attempt++ {
@ -368,7 +424,17 @@ func (s *FailoverStrategy) connectLocked(start int) {
} }
} }
// Submit sends the share through the active client. func (s *FailoverStrategy) currentPools() []proxy.PoolConfig {
if s == nil {
return nil
}
if s.config != nil && len(s.config.Pools) > 0 {
return s.config.Pools
}
return s.pools
}
// seq := strategy.Submit(jobID, nonce, result, algo)
func (s *FailoverStrategy) Submit(jobID, nonce, result, algo string) int64 { func (s *FailoverStrategy) Submit(jobID, nonce, result, algo string) int64 {
if s == nil || s.client == nil { if s == nil || s.client == nil {
return 0 return 0
@ -376,24 +442,51 @@ func (s *FailoverStrategy) Submit(jobID, nonce, result, algo string) int64 {
return s.client.Submit(jobID, nonce, result, algo) return s.client.Submit(jobID, nonce, result, algo)
} }
// Disconnect closes the active client. // strategy.Disconnect()
func (s *FailoverStrategy) Disconnect() { func (s *FailoverStrategy) Disconnect() {
if s == nil { if s == nil {
return return
} }
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() client := s.client
if s.client != nil { s.closing = true
s.client.Disconnect() s.client = nil
s.client = nil s.mu.Unlock()
if client != nil {
client.Disconnect()
} }
} }
// IsActive reports whether the current client has received a job. // strategy.ReloadPools()
func (s *FailoverStrategy) ReloadPools() {
if s == nil {
return
}
s.mu.Lock()
s.current = 0
s.mu.Unlock()
s.Disconnect()
s.Connect()
}
// active := strategy.IsActive()
func (s *FailoverStrategy) IsActive() bool { func (s *FailoverStrategy) IsActive() bool {
return s != nil && s.client != nil && s.client.IsActive() return s != nil && s.client != nil && s.client.IsActive()
} }
// Tick keeps an active pool connection alive when configured.
func (s *FailoverStrategy) Tick(ticks uint64) {
if s == nil || ticks == 0 || ticks%60 != 0 {
return
}
s.mu.Lock()
client := s.client
s.mu.Unlock()
if client != nil && client.config.Keepalive {
client.Keepalive()
}
}
// OnJob forwards the pool job to the outer listener. // OnJob forwards the pool job to the outer listener.
func (s *FailoverStrategy) OnJob(job proxy.Job) { func (s *FailoverStrategy) OnJob(job proxy.Job) {
if s != nil && s.listener != nil { if s != nil && s.listener != nil {
@ -408,11 +501,21 @@ func (s *FailoverStrategy) OnResultAccepted(sequence int64, accepted bool, error
} }
} }
// OnDisconnect retries from the primary pool and forwards the disconnect. // strategy.OnDisconnect()
func (s *FailoverStrategy) OnDisconnect() { func (s *FailoverStrategy) OnDisconnect() {
if s == nil { if s == nil {
return return
} }
s.mu.Lock()
s.client = nil
closing := s.closing
if closing {
s.closing = false
}
s.mu.Unlock()
if closing {
return
}
if s.listener != nil { if s.listener != nil {
s.listener.OnDisconnect() s.listener.OnDisconnect()
} }

168
pool/impl_test.go Normal file
View file

@ -0,0 +1,168 @@
package pool
import (
"testing"
"dappco.re/go/proxy"
)
// TestFailoverStrategy_CurrentPools_Good verifies that currentPools follows the live config.
//
// strategy := pool.NewFailoverStrategy(cfg.Pools, nil, cfg)
// strategy.currentPools() // returns cfg.Pools
func TestFailoverStrategy_CurrentPools_Good(t *testing.T) {
cfg := &proxy.Config{
Mode: "nicehash",
Workers: proxy.WorkersByRigID,
Bind: []proxy.BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []proxy.PoolConfig{{URL: "pool-a.example:3333", Enabled: true}},
}
strategy := NewFailoverStrategy(cfg.Pools, nil, cfg)
if got := len(strategy.currentPools()); got != 1 {
t.Fatalf("expected 1 pool, got %d", got)
}
cfg.Pools = []proxy.PoolConfig{{URL: "pool-b.example:4444", Enabled: true}}
if got := strategy.currentPools(); len(got) != 1 || got[0].URL != "pool-b.example:4444" {
t.Fatalf("expected current pools to follow config reload, got %+v", got)
}
}
// TestFailoverStrategy_CurrentPools_Bad verifies that a nil strategy returns an empty pool list.
//
// var strategy *pool.FailoverStrategy
// strategy.currentPools() // nil
func TestFailoverStrategy_CurrentPools_Bad(t *testing.T) {
var strategy *FailoverStrategy
pools := strategy.currentPools()
if pools != nil {
t.Fatalf("expected nil pools from nil strategy, got %+v", pools)
}
}
// TestFailoverStrategy_CurrentPools_Ugly verifies that a strategy with a nil config
// falls back to the pools passed at construction time.
//
// strategy := pool.NewFailoverStrategy(initialPools, nil, nil)
// strategy.currentPools() // returns initialPools
func TestFailoverStrategy_CurrentPools_Ugly(t *testing.T) {
initialPools := []proxy.PoolConfig{
{URL: "fallback.example:3333", Enabled: true},
{URL: "fallback.example:4444", Enabled: false},
}
strategy := NewFailoverStrategy(initialPools, nil, nil)
got := strategy.currentPools()
if len(got) != 2 {
t.Fatalf("expected 2 pools from constructor fallback, got %d", len(got))
}
if got[0].URL != "fallback.example:3333" {
t.Fatalf("expected constructor pool URL, got %q", got[0].URL)
}
}
// TestFailoverStrategy_EnabledPools_Good verifies that only enabled pools are selected.
//
// enabled := pool.enabledPools(pools) // filters to enabled-only
func TestFailoverStrategy_EnabledPools_Good(t *testing.T) {
pools := []proxy.PoolConfig{
{URL: "active.example:3333", Enabled: true},
{URL: "disabled.example:3333", Enabled: false},
{URL: "active2.example:3333", Enabled: true},
}
got := enabledPools(pools)
if len(got) != 2 {
t.Fatalf("expected 2 enabled pools, got %d", len(got))
}
if got[0].URL != "active.example:3333" || got[1].URL != "active2.example:3333" {
t.Fatalf("expected only enabled pool URLs, got %+v", got)
}
}
// TestFailoverStrategy_EnabledPools_Bad verifies that an empty pool list returns empty.
//
// pool.enabledPools(nil) // empty
func TestFailoverStrategy_EnabledPools_Bad(t *testing.T) {
got := enabledPools(nil)
if len(got) != 0 {
t.Fatalf("expected 0 pools from nil input, got %d", len(got))
}
}
// TestFailoverStrategy_EnabledPools_Ugly verifies that all-disabled pools return empty.
//
// pool.enabledPools([]proxy.PoolConfig{{Enabled: false}}) // empty
func TestFailoverStrategy_EnabledPools_Ugly(t *testing.T) {
pools := []proxy.PoolConfig{
{URL: "a.example:3333", Enabled: false},
{URL: "b.example:3333", Enabled: false},
}
got := enabledPools(pools)
if len(got) != 0 {
t.Fatalf("expected 0 enabled pools when all disabled, got %d", len(got))
}
}
// TestNewStrategyFactory_Good verifies the factory creates a strategy connected to the config.
//
// factory := pool.NewStrategyFactory(cfg)
// strategy := factory(listener) // creates FailoverStrategy
func TestNewStrategyFactory_Good(t *testing.T) {
cfg := &proxy.Config{
Mode: "nicehash",
Workers: proxy.WorkersByRigID,
Bind: []proxy.BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []proxy.PoolConfig{{URL: "pool.example:3333", Enabled: true}},
}
factory := NewStrategyFactory(cfg)
if factory == nil {
t.Fatal("expected a non-nil factory")
}
strategy := factory(nil)
if strategy == nil {
t.Fatal("expected a non-nil strategy from factory")
}
if strategy.IsActive() {
t.Fatal("expected new strategy to be inactive before connecting")
}
}
// TestNewStrategyFactory_Bad verifies a factory created with nil config does not panic.
//
// factory := pool.NewStrategyFactory(nil)
// strategy := factory(nil)
func TestNewStrategyFactory_Bad(t *testing.T) {
factory := NewStrategyFactory(nil)
strategy := factory(nil)
if strategy == nil {
t.Fatal("expected a non-nil strategy even from nil config")
}
}
// TestNewStrategyFactory_Ugly verifies the factory forwards the correct pool list to the strategy.
//
// cfg.Pools = append(cfg.Pools, proxy.PoolConfig{URL: "added.example:3333", Enabled: true})
// strategy := factory(nil)
// // strategy sees the updated pools via the shared config pointer
func TestNewStrategyFactory_Ugly(t *testing.T) {
cfg := &proxy.Config{
Mode: "nicehash",
Workers: proxy.WorkersByRigID,
Bind: []proxy.BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []proxy.PoolConfig{{URL: "pool.example:3333", Enabled: true}},
}
factory := NewStrategyFactory(cfg)
cfg.Pools = append(cfg.Pools, proxy.PoolConfig{URL: "added.example:3333", Enabled: true})
strategy := factory(nil)
fs, ok := strategy.(*FailoverStrategy)
if !ok {
t.Fatal("expected FailoverStrategy")
}
pools := fs.currentPools()
if len(pools) != 2 {
t.Fatalf("expected 2 pools after config update, got %d", len(pools))
}
}

112
pool/keepalive_test.go Normal file
View file

@ -0,0 +1,112 @@
package pool
import (
"bufio"
"encoding/json"
"net"
"testing"
"time"
)
func TestStratumClient_Keepalive_Good(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
defer clientConn.Close()
client := &StratumClient{
conn: clientConn,
active: true,
sessionID: "session-1",
}
done := make(chan struct{})
go func() {
client.Keepalive()
close(done)
}()
line, err := bufio.NewReader(serverConn).ReadBytes('\n')
if err != nil {
t.Fatalf("read keepalive request: %v", err)
}
<-done
var payload map[string]any
if err := json.Unmarshal(line, &payload); err != nil {
t.Fatalf("unmarshal keepalive request: %v", err)
}
if got := payload["method"]; got != "keepalived" {
t.Fatalf("expected keepalived method, got %#v", got)
}
params, ok := payload["params"].(map[string]any)
if !ok {
t.Fatalf("expected params object, got %#v", payload["params"])
}
if got := params["id"]; got != "session-1" {
t.Fatalf("expected session id in keepalive payload, got %#v", got)
}
}
func TestStratumClient_Keepalive_Bad(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
defer clientConn.Close()
client := &StratumClient{
conn: clientConn,
active: false,
}
client.Keepalive()
if err := serverConn.SetReadDeadline(time.Now().Add(50 * time.Millisecond)); err != nil {
t.Fatalf("set deadline: %v", err)
}
buf := make([]byte, 1)
if _, err := serverConn.Read(buf); err == nil {
t.Fatalf("expected no keepalive data while inactive")
}
}
func TestStratumClient_Keepalive_Ugly(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
defer clientConn.Close()
client := &StratumClient{
conn: clientConn,
active: true,
sessionID: "session-2",
}
reader := bufio.NewReader(serverConn)
done := make(chan struct{})
go func() {
client.Keepalive()
client.Keepalive()
close(done)
}()
first, err := reader.ReadBytes('\n')
if err != nil {
t.Fatalf("read first keepalive request: %v", err)
}
second, err := reader.ReadBytes('\n')
if err != nil {
t.Fatalf("read second keepalive request: %v", err)
}
<-done
var firstPayload map[string]any
if err := json.Unmarshal(first, &firstPayload); err != nil {
t.Fatalf("unmarshal first keepalive request: %v", err)
}
var secondPayload map[string]any
if err := json.Unmarshal(second, &secondPayload); err != nil {
t.Fatalf("unmarshal second keepalive request: %v", err)
}
if firstPayload["id"] == secondPayload["id"] {
t.Fatalf("expected keepalive request ids to be unique")
}
}

View file

@ -7,31 +7,39 @@ import (
) )
// FailoverStrategy wraps an ordered slice of PoolConfig entries. // FailoverStrategy wraps an ordered slice of PoolConfig entries.
// It connects to the first enabled pool and fails over in order on error.
// On reconnect it always retries from the primary first.
// //
// strategy := pool.NewFailoverStrategy(cfg.Pools, listener, cfg) // strategy := pool.NewFailoverStrategy([]proxy.PoolConfig{
// {URL: "primary.example:3333", Enabled: true},
// {URL: "backup.example:3333", Enabled: true},
// }, listener, cfg)
// strategy.Connect() // strategy.Connect()
type FailoverStrategy struct { type FailoverStrategy struct {
pools []proxy.PoolConfig pools []proxy.PoolConfig
current int current int
client *StratumClient client *StratumClient
listener StratumListener listener StratumListener
cfg *proxy.Config config *proxy.Config
closing bool
mu sync.Mutex mu sync.Mutex
} }
// StrategyFactory creates a new FailoverStrategy for a given StratumListener. // StrategyFactory creates a FailoverStrategy for a given StratumListener.
// Used by splitters to create per-mapper strategies without coupling to Config.
// //
// factory := pool.NewStrategyFactory(cfg) // factory := pool.NewStrategyFactory(cfg)
// strategy := factory(listener) // each mapper calls this // strategy := factory(listener)
type StrategyFactory func(listener StratumListener) Strategy type StrategyFactory func(listener StratumListener) Strategy
// Strategy is the interface the splitters use to submit shares and check pool state. // Strategy is the interface splitters use to submit shares and inspect pool state.
type Strategy interface { type Strategy interface {
Connect() Connect()
Submit(jobID, nonce, result, algo string) int64 Submit(jobID, nonce, result, algo string) int64
Disconnect() Disconnect()
IsActive() bool IsActive() bool
} }
// ReloadableStrategy re-establishes an upstream connection after config changes.
//
// strategy.ReloadPools()
type ReloadableStrategy interface {
ReloadPools()
}

View file

@ -0,0 +1,148 @@
package pool
import (
"encoding/json"
"net"
"sync/atomic"
"testing"
"time"
"dappco.re/go/proxy"
)
type disconnectSpy struct {
disconnects atomic.Int64
}
func (s *disconnectSpy) OnJob(proxy.Job) {}
func (s *disconnectSpy) OnResultAccepted(int64, bool, string) {}
func (s *disconnectSpy) OnDisconnect() {
s.disconnects.Add(1)
}
func TestFailoverStrategy_Disconnect_Good(t *testing.T) {
spy := &disconnectSpy{}
strategy := &FailoverStrategy{
listener: spy,
client: &StratumClient{listener: nil},
}
strategy.client.listener = strategy
strategy.Disconnect()
time.Sleep(10 * time.Millisecond)
if got := spy.disconnects.Load(); got != 0 {
t.Fatalf("expected intentional disconnect to suppress reconnect, got %d listener calls", got)
}
}
func TestFailoverStrategy_Disconnect_Bad(t *testing.T) {
spy := &disconnectSpy{}
strategy := &FailoverStrategy{listener: spy}
strategy.OnDisconnect()
if got := spy.disconnects.Load(); got != 1 {
t.Fatalf("expected external disconnect to notify listener once, got %d", got)
}
}
func TestFailoverStrategy_Disconnect_Ugly(t *testing.T) {
spy := &disconnectSpy{}
strategy := &FailoverStrategy{
listener: spy,
client: &StratumClient{listener: nil},
}
strategy.client.listener = strategy
strategy.Disconnect()
strategy.Disconnect()
time.Sleep(10 * time.Millisecond)
if got := spy.disconnects.Load(); got != 0 {
t.Fatalf("expected repeated intentional disconnects to remain silent, got %d listener calls", got)
}
}
func TestStratumClient_NotifyDisconnect_ClearsState_Good(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
spy := &disconnectSpy{}
client := &StratumClient{
conn: clientConn,
listener: spy,
sessionID: "session-1",
active: true,
pending: map[int64]struct{}{
7: {},
},
}
client.notifyDisconnect()
if got := spy.disconnects.Load(); got != 1 {
t.Fatalf("expected one disconnect notification, got %d", got)
}
if client.conn != nil {
t.Fatalf("expected pooled connection to be cleared")
}
if client.sessionID != "" {
t.Fatalf("expected session id to be cleared, got %q", client.sessionID)
}
if client.IsActive() {
t.Fatalf("expected client to stop reporting active after disconnect")
}
if len(client.pending) != 0 {
t.Fatalf("expected pending submit state to be cleared, got %d entries", len(client.pending))
}
}
func TestFailoverStrategy_OnDisconnect_ClearsClient_Bad(t *testing.T) {
spy := &disconnectSpy{}
strategy := &FailoverStrategy{
listener: spy,
client: &StratumClient{active: true, pending: make(map[int64]struct{})},
}
strategy.OnDisconnect()
time.Sleep(10 * time.Millisecond)
if strategy.client != nil {
t.Fatalf("expected strategy to drop the stale client before reconnect")
}
if strategy.IsActive() {
t.Fatalf("expected strategy to report inactive while reconnect is pending")
}
if got := spy.disconnects.Load(); got != 1 {
t.Fatalf("expected one disconnect notification, got %d", got)
}
}
func TestStratumClient_HandleMessage_LoginErrorDisconnects_Ugly(t *testing.T) {
spy := &disconnectSpy{}
client := &StratumClient{
listener: spy,
pending: make(map[int64]struct{}),
}
payload, err := json.Marshal(map[string]any{
"id": 1,
"jsonrpc": "2.0",
"error": map[string]any{
"code": -1,
"message": "Invalid payment address provided",
},
})
if err != nil {
t.Fatalf("marshal login error payload: %v", err)
}
client.handleMessage(payload)
if got := spy.disconnects.Load(); got != 1 {
t.Fatalf("expected login failure to disconnect upstream once, got %d", got)
}
}

145
proxy.go
View file

@ -1,45 +1,59 @@
// Package proxy is a CryptoNote stratum mining proxy library. // Package proxy is the mining proxy library.
//
// It accepts miner connections over TCP (optionally TLS), splits the 32-bit nonce
// space across up to 256 simultaneous miners per upstream pool connection (NiceHash
// mode), and presents a small monitoring API.
//
// Full specification: docs/RFC.md
// //
// cfg := &proxy.Config{Mode: "nicehash", Bind: []proxy.BindAddr{{Host: "0.0.0.0", Port: 3333}}, Pools: []proxy.PoolConfig{{URL: "pool.example:3333", Enabled: true}}, Workers: proxy.WorkersByRigID}
// p, result := proxy.New(cfg) // p, result := proxy.New(cfg)
// if result.OK { p.Start() } // if result.OK {
// p.Start()
// }
package proxy package proxy
import ( import (
"net/http" "net/http"
"sync" "sync"
"sync/atomic"
"time" "time"
) )
// Proxy is the top-level orchestrator. It owns the server, splitter, stats, workers, // Proxy wires the configured listeners, splitters, stats, workers, and log sinks.
// event bus, tick goroutine, and optional HTTP API.
// //
// cfg := &proxy.Config{
// Mode: "nicehash",
// Bind: []proxy.BindAddr{{Host: "0.0.0.0", Port: 3333}},
// Pools: []proxy.PoolConfig{{URL: "pool.example:3333", Enabled: true}},
// Workers: proxy.WorkersByRigID,
// }
// p, result := proxy.New(cfg) // p, result := proxy.New(cfg)
// if result.OK { p.Start() } // if result.OK {
// p.Start()
// }
type Proxy struct { type Proxy struct {
config *Config config *Config
splitter Splitter configMu sync.RWMutex
stats *Stats splitter Splitter
workers *Workers shareSink ShareSink
events *EventBus stats *Stats
servers []*Server workers *Workers
ticker *time.Ticker events *EventBus
watcher *ConfigWatcher servers []*Server
done chan struct{} ticker *time.Ticker
stopOnce sync.Once watcher *ConfigWatcher
minersMu sync.RWMutex done chan struct{}
miners map[int64]*Miner stopOnce sync.Once
customDiff *CustomDiff minersMu sync.RWMutex
rateLimit *RateLimiter miners map[int64]*Miner
httpServer *http.Server customDiff *CustomDiff
customDiffBuckets *CustomDiffBuckets
rateLimit *RateLimiter
httpServer *http.Server
accessLog *accessLogSink
shareLog *shareLogSink
submitCount atomic.Int64
} }
// Splitter is the interface both NonceSplitter and SimpleSplitter satisfy. // Splitter routes miner logins, submits, and disconnects to the active upstream strategy.
//
// splitter := nicehash.NewNonceSplitter(cfg, bus, pool.NewStrategyFactory(cfg))
// splitter.Connect()
type Splitter interface { type Splitter interface {
// Connect establishes the first pool upstream connection. // Connect establishes the first pool upstream connection.
Connect() Connect()
@ -57,7 +71,18 @@ type Splitter interface {
Upstreams() UpstreamStats Upstreams() UpstreamStats
} }
// UpstreamStats carries pool connection state counts for monitoring. // ShareSink consumes share outcomes from the proxy event stream.
//
// sink.OnAccept(proxy.Event{Miner: miner, Diff: 100000})
// sink.OnReject(proxy.Event{Miner: miner, Error: "Invalid nonce"})
type ShareSink interface {
OnAccept(Event)
OnReject(Event)
}
// UpstreamStats reports pool connection counts.
//
// stats := proxy.UpstreamStats{Active: 1, Sleep: 0, Error: 0, Total: 1}
type UpstreamStats struct { type UpstreamStats struct {
Active uint64 // connections currently receiving jobs Active uint64 // connections currently receiving jobs
Sleep uint64 // idle connections (simple mode reuse pool) Sleep uint64 // idle connections (simple mode reuse pool)
@ -65,12 +90,16 @@ type UpstreamStats struct {
Total uint64 // Active + Sleep + Error Total uint64 // Active + Sleep + Error
} }
// LoginEvent is dispatched when a miner completes the login handshake. // LoginEvent is dispatched when a miner completes login.
//
// event := proxy.LoginEvent{Miner: miner}
type LoginEvent struct { type LoginEvent struct {
Miner *Miner Miner *Miner
} }
// SubmitEvent is dispatched when a miner submits a share. // SubmitEvent carries one miner share submission.
//
// event := proxy.SubmitEvent{Miner: miner, JobID: "job-1", Nonce: "deadbeef", Result: "HASH", RequestID: 2}
type SubmitEvent struct { type SubmitEvent struct {
Miner *Miner Miner *Miner
JobID string JobID string
@ -80,50 +109,56 @@ type SubmitEvent struct {
RequestID int64 RequestID int64
} }
// CloseEvent is dispatched when a miner TCP connection closes. // CloseEvent is dispatched when a miner connection closes.
//
// event := proxy.CloseEvent{Miner: miner}
type CloseEvent struct { type CloseEvent struct {
Miner *Miner Miner *Miner
} }
// ConfigWatcher polls a config file for mtime changes and calls onChange on modification. // ConfigWatcher polls a config file every second and reloads on modification.
// Uses 1-second polling; does not require fsnotify.
// //
// w := proxy.NewConfigWatcher("config.json", func(cfg *proxy.Config) { // watcher := proxy.NewConfigWatcher("config.json", func(cfg *proxy.Config) {
// p.Reload(cfg) // p.Reload(cfg)
// }) // })
// w.Start() // watcher.Start()
type ConfigWatcher struct { type ConfigWatcher struct {
path string configPath string
onChange func(*Config) onConfigChange func(*Config)
lastMod time.Time lastModifiedAt time.Time
done chan struct{} stopCh chan struct{}
mu sync.Mutex
started bool
} }
// RateLimiter implements per-IP token bucket connection rate limiting. // RateLimiter throttles new connections per source IP.
// Each unique IP has a bucket initialised to MaxConnectionsPerMinute tokens.
// Each connection attempt consumes one token. Tokens refill at 1 per (60/max) seconds.
// An IP that empties its bucket is added to a ban list for BanDurationSeconds.
// //
// rl := proxy.NewRateLimiter(cfg.RateLimit) // limiter := proxy.NewRateLimiter(proxy.RateLimit{
// if !rl.Allow("1.2.3.4") { conn.Close(); return } // MaxConnectionsPerMinute: 30,
// BanDurationSeconds: 300,
// })
// if limiter.Allow("1.2.3.4:3333") {
// // accept the socket
// }
type RateLimiter struct { type RateLimiter struct {
cfg RateLimit limit RateLimit
buckets map[string]*tokenBucket bucketByHost map[string]*tokenBucket
banned map[string]time.Time banUntilByHost map[string]time.Time
mu sync.Mutex mu sync.Mutex
} }
// tokenBucket is a simple token bucket for one IP. // tokenBucket is the per-IP refillable counter.
//
// bucket := tokenBucket{tokens: 30, lastRefill: time.Now()}
type tokenBucket struct { type tokenBucket struct {
tokens int tokens int
lastRefill time.Time lastRefill time.Time
} }
// CustomDiff resolves and applies per-miner difficulty overrides at login time. // CustomDiff applies a login-time difficulty override.
// Resolution order: user-suffix (+N) > Config.CustomDiff > pool difficulty.
// //
// cd := proxy.NewCustomDiff(cfg.CustomDiff) // resolver := proxy.NewCustomDiff(50000)
// bus.Subscribe(proxy.EventLogin, cd.OnLogin) // resolver.Apply(&Miner{user: "WALLET+75000"})
type CustomDiff struct { type CustomDiff struct {
globalDiff uint64 globalDiff atomic.Uint64
} }

View file

@ -1,13 +1,115 @@
package proxy package proxy
import "testing" import (
"testing"
"time"
)
func TestRateLimiter_Allow(t *testing.T) { // TestRateLimiter_Allow_Good verifies the first N calls within budget are allowed.
rl := NewRateLimiter(RateLimit{MaxConnectionsPerMinute: 1, BanDurationSeconds: 1}) //
if !rl.Allow("1.2.3.4:1234") { // limiter := proxy.NewRateLimiter(proxy.RateLimit{MaxConnectionsPerMinute: 10})
t.Fatalf("expected first call to pass") // limiter.Allow("1.2.3.4:3333") // true (first 10 calls)
} func TestRateLimiter_Allow_Good(t *testing.T) {
if rl.Allow("1.2.3.4:1234") { rl := NewRateLimiter(RateLimit{MaxConnectionsPerMinute: 10, BanDurationSeconds: 60})
t.Fatalf("expected second call to fail")
for i := 0; i < 10; i++ {
if !rl.Allow("1.2.3.4:3333") {
t.Fatalf("expected call %d to be allowed", i+1)
}
}
}
// TestRateLimiter_Allow_Bad verifies the 11th call fails when budget is 10/min.
//
// limiter := proxy.NewRateLimiter(proxy.RateLimit{MaxConnectionsPerMinute: 10})
// // calls 1-10 pass, call 11 fails
func TestRateLimiter_Allow_Bad(t *testing.T) {
rl := NewRateLimiter(RateLimit{MaxConnectionsPerMinute: 10, BanDurationSeconds: 60})
for i := 0; i < 10; i++ {
rl.Allow("1.2.3.4:3333")
}
if rl.Allow("1.2.3.4:3333") {
t.Fatalf("expected 11th call to be rejected")
}
}
// TestRateLimiter_Allow_Ugly verifies a banned IP stays banned for BanDurationSeconds.
//
// limiter := proxy.NewRateLimiter(proxy.RateLimit{MaxConnectionsPerMinute: 1, BanDurationSeconds: 300})
// limiter.Allow("1.2.3.4:3333") // true (exhausts budget)
// limiter.Allow("1.2.3.4:3333") // false (banned for 300 seconds)
func TestRateLimiter_Allow_Ugly(t *testing.T) {
rl := NewRateLimiter(RateLimit{MaxConnectionsPerMinute: 1, BanDurationSeconds: 300})
if !rl.Allow("1.2.3.4:3333") {
t.Fatalf("expected first call to pass")
}
if rl.Allow("1.2.3.4:3333") {
t.Fatalf("expected second call to fail")
}
// Verify the IP is still banned even with a fresh bucket
rl.mu.Lock()
rl.bucketByHost["1.2.3.4"] = &tokenBucket{tokens: 100, lastRefill: time.Now()}
rl.mu.Unlock()
if rl.Allow("1.2.3.4:3333") {
t.Fatalf("expected banned IP to remain banned regardless of fresh bucket")
}
}
// TestRateLimiter_Tick_Good verifies Tick removes expired bans.
//
// limiter := proxy.NewRateLimiter(proxy.RateLimit{MaxConnectionsPerMinute: 1, BanDurationSeconds: 1})
// limiter.Tick()
func TestRateLimiter_Tick_Good(t *testing.T) {
rl := NewRateLimiter(RateLimit{MaxConnectionsPerMinute: 1, BanDurationSeconds: 1})
rl.Allow("1.2.3.4:3333")
rl.Allow("1.2.3.4:3333") // triggers ban
// Simulate expired ban
rl.mu.Lock()
rl.banUntilByHost["1.2.3.4"] = time.Now().Add(-time.Second)
rl.mu.Unlock()
rl.Tick()
rl.mu.Lock()
_, banned := rl.banUntilByHost["1.2.3.4"]
rl.mu.Unlock()
if banned {
t.Fatalf("expected expired ban to be removed by Tick")
}
}
// TestRateLimiter_Allow_ReplenishesHighLimits verifies token replenishment at high rates.
//
// limiter := proxy.NewRateLimiter(proxy.RateLimit{MaxConnectionsPerMinute: 120})
func TestRateLimiter_Allow_ReplenishesHighLimits(t *testing.T) {
rl := NewRateLimiter(RateLimit{MaxConnectionsPerMinute: 120, BanDurationSeconds: 1})
rl.mu.Lock()
rl.bucketByHost["1.2.3.4"] = &tokenBucket{
tokens: 0,
lastRefill: time.Now().Add(-30 * time.Second),
}
rl.mu.Unlock()
if !rl.Allow("1.2.3.4:1234") {
t.Fatalf("expected bucket to replenish at 120/min")
}
}
// TestRateLimiter_Disabled_Good verifies a zero-budget limiter allows all connections.
//
// limiter := proxy.NewRateLimiter(proxy.RateLimit{MaxConnectionsPerMinute: 0})
// limiter.Allow("any-ip") // always true
func TestRateLimiter_Disabled_Good(t *testing.T) {
rl := NewRateLimiter(RateLimit{MaxConnectionsPerMinute: 0})
for i := 0; i < 100; i++ {
if !rl.Allow("1.2.3.4:3333") {
t.Fatalf("expected disabled limiter to allow all connections")
}
} }
} }

404
reload_test.go Normal file
View file

@ -0,0 +1,404 @@
package proxy
import (
"bufio"
"encoding/json"
"net"
"strings"
"testing"
"time"
)
type reloadableSplitter struct {
reloads int
}
func (s *reloadableSplitter) Connect() {}
func (s *reloadableSplitter) OnLogin(event *LoginEvent) {}
func (s *reloadableSplitter) OnSubmit(event *SubmitEvent) {}
func (s *reloadableSplitter) OnClose(event *CloseEvent) {}
func (s *reloadableSplitter) Tick(ticks uint64) {}
func (s *reloadableSplitter) GC() {}
func (s *reloadableSplitter) Upstreams() UpstreamStats { return UpstreamStats{} }
func (s *reloadableSplitter) ReloadPools() { s.reloads++ }
func TestProxy_Reload_Good(t *testing.T) {
original := &Config{
Mode: "nicehash",
Workers: WorkersByRigID,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool-a.example:3333", Enabled: true}},
}
p := &Proxy{
config: original,
customDiff: NewCustomDiff(1),
rateLimit: NewRateLimiter(RateLimit{}),
}
updated := &Config{
Mode: "simple",
Workers: WorkersByUser,
Bind: []BindAddr{{Host: "0.0.0.0", Port: 4444}},
Pools: []PoolConfig{{URL: "pool-b.example:4444", Enabled: true}},
CustomDiff: 50000,
AccessPassword: "secret",
CustomDiffStats: true,
AlgoExtension: true,
AccessLogFile: "/tmp/access.log",
ReuseTimeout: 30,
Retries: 5,
RetryPause: 2,
Watch: true,
RateLimit: RateLimit{MaxConnectionsPerMinute: 10, BanDurationSeconds: 60},
}
p.Reload(updated)
if p.config != original {
t.Fatalf("expected reload to preserve the existing config pointer")
}
if got := p.config.Bind[0]; got.Host != "127.0.0.1" || got.Port != 3333 {
t.Fatalf("expected bind addresses to remain unchanged, got %+v", got)
}
if p.config.Mode != "nicehash" {
t.Fatalf("expected mode to remain unchanged, got %q", p.config.Mode)
}
if p.config.Workers != WorkersByUser {
t.Fatalf("expected workers mode to reload, got %q", p.config.Workers)
}
if got := p.config.Pools[0].URL; got != "pool-b.example:4444" {
t.Fatalf("expected pools to reload, got %q", got)
}
if got := p.customDiff.globalDiff.Load(); got != 50000 {
t.Fatalf("expected custom diff to reload, got %d", got)
}
if !p.rateLimit.IsActive() {
t.Fatalf("expected rate limiter to be replaced with active configuration")
}
}
func TestProxy_Reload_WorkersMode_Good(t *testing.T) {
miner := &Miner{id: 7, user: "wallet-a", rigID: "rig-a", ip: "10.0.0.7"}
workers := NewWorkers(WorkersByRigID, nil)
workers.OnLogin(Event{Miner: miner})
p := &Proxy{
config: &Config{
Mode: "nicehash",
Workers: WorkersByRigID,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool-a.example:3333", Enabled: true}},
},
workers: workers,
miners: map[int64]*Miner{miner.id: miner},
}
p.Reload(&Config{
Mode: "nicehash",
Workers: WorkersByUser,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool-a.example:3333", Enabled: true}},
})
if got := p.WorkersMode(); got != WorkersByUser {
t.Fatalf("expected proxy workers mode %q, got %q", WorkersByUser, got)
}
records := p.WorkerRecords()
if len(records) != 1 {
t.Fatalf("expected one rebuilt worker record, got %d", len(records))
}
if got := records[0].Name; got != "wallet-a" {
t.Fatalf("expected worker record to rebuild using user mode, got %q", got)
}
}
func TestProxy_Reload_CustomDiff_Good(t *testing.T) {
minerConn, clientConn := net.Pipe()
defer minerConn.Close()
defer clientConn.Close()
miner := NewMiner(minerConn, 3333, nil)
miner.state = MinerStateReady
miner.globalDiff = 1000
miner.customDiff = 1000
miner.currentJob = Job{
Blob: strings.Repeat("0", 160),
JobID: "job-1",
Target: "01000000",
Algo: "cn/r",
}
p := &Proxy{
config: &Config{
Mode: "nicehash",
Workers: WorkersByRigID,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool.example:3333", Enabled: true}},
CustomDiff: 1000,
},
customDiff: NewCustomDiff(1000),
miners: map[int64]*Miner{miner.ID(): miner},
}
done := make(chan map[string]any, 1)
go func() {
line, err := bufio.NewReader(clientConn).ReadBytes('\n')
if err != nil {
done <- nil
return
}
var payload map[string]any
if err := json.Unmarshal(line, &payload); err != nil {
done <- nil
return
}
done <- payload
}()
p.Reload(&Config{
Mode: "nicehash",
Workers: WorkersByRigID,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool.example:3333", Enabled: true}},
CustomDiff: 5000,
})
select {
case payload := <-done:
if payload == nil {
t.Fatal("expected reload to resend the current job with the new custom diff")
}
params, ok := payload["params"].(map[string]any)
if !ok {
t.Fatalf("expected job params payload, got %#v", payload["params"])
}
target, _ := params["target"].(string)
if got := (Job{Target: target}).DifficultyFromTarget(); got == 0 || got > 5000 {
t.Fatalf("expected resent job difficulty at or below 5000, got %d", got)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for reload job refresh")
}
if miner.customDiff != 5000 {
t.Fatalf("expected active miner custom diff to reload, got %d", miner.customDiff)
}
if miner.globalDiff != 5000 {
t.Fatalf("expected active miner global diff to reload, got %d", miner.globalDiff)
}
}
func TestProxy_Reload_CustomDiff_Bad(t *testing.T) {
miner := &Miner{
id: 9,
state: MinerStateReady,
globalDiff: 1000,
customDiff: 7000,
customDiffFromLogin: true,
currentJob: Job{
Blob: strings.Repeat("0", 160),
JobID: "job-1",
Target: "01000000",
},
}
p := &Proxy{
config: &Config{
Mode: "nicehash",
Workers: WorkersByRigID,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool.example:3333", Enabled: true}},
CustomDiff: 1000,
},
customDiff: NewCustomDiff(1000),
miners: map[int64]*Miner{miner.ID(): miner},
}
p.Reload(&Config{
Mode: "nicehash",
Workers: WorkersByRigID,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool.example:3333", Enabled: true}},
CustomDiff: 5000,
})
if miner.customDiff != 7000 {
t.Fatalf("expected login suffix custom diff to be preserved, got %d", miner.customDiff)
}
if miner.globalDiff != 5000 {
t.Fatalf("expected miner global diff to update for future logins, got %d", miner.globalDiff)
}
}
func TestProxy_Reload_CustomDiff_Ugly(t *testing.T) {
miner := &Miner{
id: 11,
state: MinerStateWaitLogin,
globalDiff: 1000,
customDiff: 1000,
}
p := &Proxy{
config: &Config{
Mode: "nicehash",
Workers: WorkersByRigID,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool.example:3333", Enabled: true}},
CustomDiff: 1000,
},
customDiff: NewCustomDiff(1000),
miners: map[int64]*Miner{miner.ID(): miner},
}
p.Reload(&Config{
Mode: "nicehash",
Workers: WorkersByRigID,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool.example:3333", Enabled: true}},
CustomDiff: 0,
})
if miner.customDiff != 0 {
t.Fatalf("expected reload to clear the global custom diff for unauthenticated miners, got %d", miner.customDiff)
}
if miner.globalDiff != 0 {
t.Fatalf("expected miner global diff to be cleared, got %d", miner.globalDiff)
}
}
func TestProxy_Reload_UpdatesServers(t *testing.T) {
originalLimiter := NewRateLimiter(RateLimit{MaxConnectionsPerMinute: 1})
p := &Proxy{
config: &Config{Mode: "nicehash", Workers: WorkersByRigID},
rateLimit: originalLimiter,
servers: []*Server{
{limiter: originalLimiter},
},
}
p.Reload(&Config{
Mode: "nicehash",
Workers: WorkersByRigID,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool.example:3333", Enabled: true}},
RateLimit: RateLimit{MaxConnectionsPerMinute: 10},
AccessLogFile: "",
})
if got := p.servers[0].limiter; got != p.rateLimit {
t.Fatalf("expected server limiter to be updated")
}
if p.rateLimit == originalLimiter {
t.Fatalf("expected rate limiter instance to be replaced")
}
}
func TestProxy_Reload_WatchEnabled_Good(t *testing.T) {
p := &Proxy{
config: &Config{
Mode: "nicehash",
Workers: WorkersByRigID,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool.example:3333", Enabled: true}},
configPath: "/tmp/proxy.json",
},
}
p.Reload(&Config{
Mode: "nicehash",
Workers: WorkersByRigID,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 4444}},
Pools: []PoolConfig{{URL: "pool.example:3333", Enabled: true}},
Watch: true,
configPath: "/tmp/ignored.json",
})
if p.watcher == nil {
t.Fatalf("expected reload to create a watcher when watch is enabled")
}
if got := p.watcher.configPath; got != "/tmp/proxy.json" {
t.Fatalf("expected watcher to keep the original config path, got %q", got)
}
p.watcher.Stop()
}
func TestProxy_Reload_WatchDisabled_Bad(t *testing.T) {
watcher := NewConfigWatcher("/tmp/proxy.json", func(*Config) {})
p := &Proxy{
config: &Config{
Mode: "nicehash",
Workers: WorkersByRigID,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool.example:3333", Enabled: true}},
configPath: "/tmp/proxy.json",
Watch: true,
},
watcher: watcher,
}
p.Reload(&Config{
Mode: "nicehash",
Workers: WorkersByRigID,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool.example:3333", Enabled: true}},
Watch: false,
configPath: "/tmp/ignored.json",
})
if p.watcher != nil {
t.Fatalf("expected reload to stop and clear the watcher when watch is disabled")
}
select {
case <-watcher.stopCh:
default:
t.Fatalf("expected existing watcher to be stopped")
}
}
func TestProxy_Reload_PoolsChanged_ReloadsSplitter_Good(t *testing.T) {
splitter := &reloadableSplitter{}
p := &Proxy{
config: &Config{
Mode: "nicehash",
Workers: WorkersByRigID,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool-a.example:3333", Enabled: true}},
},
splitter: splitter,
}
p.Reload(&Config{
Mode: "nicehash",
Workers: WorkersByRigID,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool-b.example:3333", Enabled: true}},
})
if splitter.reloads != 1 {
t.Fatalf("expected pool reload to reconnect upstreams once, got %d", splitter.reloads)
}
}
func TestProxy_Reload_PoolsUnchanged_DoesNotReloadSplitter_Ugly(t *testing.T) {
splitter := &reloadableSplitter{}
p := &Proxy{
config: &Config{
Mode: "nicehash",
Workers: WorkersByRigID,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool-a.example:3333", Enabled: true}},
},
splitter: splitter,
}
p.Reload(&Config{
Mode: "nicehash",
Workers: WorkersByRigID,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool-a.example:3333", Enabled: true}},
})
if splitter.reloads != 0 {
t.Fatalf("expected unchanged pool config to skip reconnect, got %d", splitter.reloads)
}
}

View file

@ -7,13 +7,20 @@ import (
// Server listens on one BindAddr and creates a Miner for each accepted connection. // Server listens on one BindAddr and creates a Miner for each accepted connection.
// //
// srv, result := proxy.NewServer(bind, tlsCfg, rateLimiter, onAccept) // srv, result := proxy.NewServer(
// srv.Start() // proxy.BindAddr{Host: "0.0.0.0", Port: 3333, TLS: false},
// nil,
// proxy.NewRateLimiter(proxy.RateLimit{MaxConnectionsPerMinute: 30}),
// func(conn net.Conn, port uint16) { _ = conn; _ = port },
// )
// if result.OK {
// srv.Start()
// }
type Server struct { type Server struct {
addr BindAddr addr BindAddr
tlsCfg *tls.Config // nil for plain TCP tlsConfig *tls.Config // nil for plain TCP
limiter *RateLimiter limiter *RateLimiter
onAccept func(net.Conn, uint16) onAccept func(net.Conn, uint16)
listener net.Listener listener net.Listener
done chan struct{} done chan struct{}
} }

95
sharelog_impl.go Normal file
View file

@ -0,0 +1,95 @@
package proxy
import (
"os"
"strings"
"sync"
"time"
)
type shareLogSink struct {
path string
file *os.File
mu sync.Mutex
}
func newShareLogSink(path string) *shareLogSink {
return &shareLogSink{path: path}
}
func (l *shareLogSink) SetPath(path string) {
if l == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
if l.path == path {
return
}
l.path = path
if l.file != nil {
_ = l.file.Close()
l.file = nil
}
}
func (l *shareLogSink) Close() {
if l == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
if l.file != nil {
_ = l.file.Close()
l.file = nil
}
}
func (l *shareLogSink) OnAccept(e Event) {
if l == nil || e.Miner == nil {
return
}
l.writeLine("ACCEPT", e.Miner.User(), e.Diff, e.Latency, "")
}
func (l *shareLogSink) OnReject(e Event) {
if l == nil || e.Miner == nil {
return
}
l.writeLine("REJECT", e.Miner.User(), 0, 0, e.Error)
}
func (l *shareLogSink) writeLine(kind, user string, diff uint64, latency uint16, reason string) {
l.mu.Lock()
defer l.mu.Unlock()
if strings.TrimSpace(l.path) == "" {
return
}
if l.file == nil {
file, err := os.OpenFile(l.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return
}
l.file = file
}
var builder strings.Builder
builder.WriteString(time.Now().UTC().Format(time.RFC3339))
builder.WriteByte(' ')
builder.WriteString(kind)
builder.WriteString(" ")
builder.WriteString(user)
switch kind {
case "ACCEPT":
builder.WriteString(" diff=")
builder.WriteString(formatUint(diff))
builder.WriteString(" latency=")
builder.WriteString(formatUint(uint64(latency)))
builder.WriteString("ms")
case "REJECT":
builder.WriteString(" reason=\"")
builder.WriteString(reason)
builder.WriteString("\"")
}
builder.WriteByte('\n')
_, _ = l.file.WriteString(builder.String())
}

46
sharelog_test.go Normal file
View file

@ -0,0 +1,46 @@
package proxy
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestProxy_ShareLog_WritesOutcomeLines(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "shares.log")
cfg := &Config{
Mode: "nicehash",
Workers: WorkersByRigID,
ShareLogFile: path,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool.example:3333", Enabled: true}},
}
p, result := New(cfg)
if !result.OK {
t.Fatalf("expected valid proxy, got error: %v", result.Error)
}
miner := &Miner{
user: "WALLET",
conn: noopConn{},
state: MinerStateReady,
}
p.events.Dispatch(Event{Type: EventAccept, Miner: miner, Diff: 1234, Latency: 56})
p.events.Dispatch(Event{Type: EventReject, Miner: miner, Error: "Invalid nonce"})
p.Stop()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read share log: %v", err)
}
text := string(data)
if !strings.Contains(text, "ACCEPT WALLET diff=1234 latency=56ms") {
t.Fatalf("expected ACCEPT line, got %q", text)
}
if !strings.Contains(text, "REJECT WALLET reason=\"Invalid nonce\"") {
t.Fatalf("expected REJECT line, got %q", text)
}
}

View file

@ -0,0 +1,92 @@
package nicehash
import (
"sync"
"testing"
"time"
)
type gcStrategy struct {
mu sync.Mutex
disconnected bool
active bool
}
func (s *gcStrategy) Connect() {}
func (s *gcStrategy) Submit(jobID, nonce, result, algo string) int64 {
return 0
}
func (s *gcStrategy) Disconnect() {
s.mu.Lock()
defer s.mu.Unlock()
s.disconnected = true
}
func (s *gcStrategy) IsActive() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.active
}
func TestNonceSplitter_GC_Good(t *testing.T) {
strategy := &gcStrategy{active: false}
mapper := &NonceMapper{
id: 42,
storage: NewNonceStorage(),
strategy: strategy,
lastUsed: time.Now().Add(-2 * time.Minute),
pending: make(map[int64]SubmitContext),
}
mapper.storage.slots[0] = -1
splitter := &NonceSplitter{
mappers: []*NonceMapper{mapper},
mapperByID: map[int64]*NonceMapper{mapper.id: mapper},
}
splitter.GC()
if len(splitter.mappers) != 0 {
t.Fatalf("expected idle mapper to be reclaimed, got %d mapper(s)", len(splitter.mappers))
}
if _, ok := splitter.mapperByID[mapper.id]; ok {
t.Fatalf("expected reclaimed mapper to be removed from lookup table")
}
if !strategy.disconnected {
t.Fatalf("expected reclaimed mapper strategy to be disconnected")
}
}
func TestNonceSplitter_GC_Bad(t *testing.T) {
var splitter *NonceSplitter
splitter.GC()
}
func TestNonceSplitter_GC_Ugly(t *testing.T) {
strategy := &gcStrategy{active: true}
mapper := &NonceMapper{
id: 99,
storage: NewNonceStorage(),
strategy: strategy,
lastUsed: time.Now().Add(-2 * time.Minute),
pending: make(map[int64]SubmitContext),
}
mapper.storage.slots[0] = 7
splitter := &NonceSplitter{
mappers: []*NonceMapper{mapper},
mapperByID: map[int64]*NonceMapper{mapper.id: mapper},
}
splitter.GC()
if len(splitter.mappers) != 1 {
t.Fatalf("expected active mapper to remain, got %d mapper(s)", len(splitter.mappers))
}
if strategy.disconnected {
t.Fatalf("expected active mapper to stay connected")
}
}

View file

@ -8,20 +8,20 @@ import (
) )
func init() { func init() {
proxy.RegisterSplitterFactory("nicehash", func(cfg *proxy.Config, events *proxy.EventBus) proxy.Splitter { proxy.RegisterSplitterFactory("nicehash", func(config *proxy.Config, eventBus *proxy.EventBus) proxy.Splitter {
return NewNonceSplitter(cfg, events, pool.NewStrategyFactory(cfg)) return NewNonceSplitter(config, eventBus, pool.NewStrategyFactory(config))
}) })
} }
// NewNonceSplitter creates a NiceHash splitter. // NewNonceSplitter creates a NiceHash splitter.
func NewNonceSplitter(cfg *proxy.Config, events *proxy.EventBus, factory pool.StrategyFactory) *NonceSplitter { func NewNonceSplitter(config *proxy.Config, eventBus *proxy.EventBus, factory pool.StrategyFactory) *NonceSplitter {
if factory == nil { if factory == nil {
factory = pool.NewStrategyFactory(cfg) factory = pool.NewStrategyFactory(config)
} }
return &NonceSplitter{ return &NonceSplitter{
byID: make(map[int64]*NonceMapper), mapperByID: make(map[int64]*NonceMapper),
cfg: cfg, config: config,
events: events, events: eventBus,
strategyFactory: factory, strategyFactory: factory,
} }
} }
@ -37,10 +37,7 @@ func (s *NonceSplitter) Connect() {
s.addMapperLocked() s.addMapperLocked()
} }
for _, mapper := range s.mappers { for _, mapper := range s.mappers {
if mapper.strategy != nil { mapper.Start()
mapper.strategy.Connect()
return
}
} }
} }
@ -54,14 +51,14 @@ func (s *NonceSplitter) OnLogin(event *proxy.LoginEvent) {
event.Miner.SetExtendedNiceHash(true) event.Miner.SetExtendedNiceHash(true)
for _, mapper := range s.mappers { for _, mapper := range s.mappers {
if mapper.Add(event.Miner) { if mapper.Add(event.Miner) {
s.byID[mapper.id] = mapper s.mapperByID[mapper.id] = mapper
return return
} }
} }
mapper := s.addMapperLocked() mapper := s.addMapperLocked()
if mapper != nil { if mapper != nil {
_ = mapper.Add(event.Miner) _ = mapper.Add(event.Miner)
s.byID[mapper.id] = mapper s.mapperByID[mapper.id] = mapper
} }
} }
@ -71,7 +68,7 @@ func (s *NonceSplitter) OnSubmit(event *proxy.SubmitEvent) {
return return
} }
s.mu.RLock() s.mu.RLock()
mapper := s.byID[event.Miner.MapperID()] mapper := s.mapperByID[event.Miner.MapperID()]
s.mu.RUnlock() s.mu.RUnlock()
if mapper != nil { if mapper != nil {
mapper.Submit(event) mapper.Submit(event)
@ -84,7 +81,7 @@ func (s *NonceSplitter) OnClose(event *proxy.CloseEvent) {
return return
} }
s.mu.RLock() s.mu.RLock()
mapper := s.byID[event.Miner.MapperID()] mapper := s.mapperByID[event.Miner.MapperID()]
s.mu.RUnlock() s.mu.RUnlock()
if mapper != nil { if mapper != nil {
mapper.Remove(event.Miner) mapper.Remove(event.Miner)
@ -101,13 +98,17 @@ func (s *NonceSplitter) GC() {
now := time.Now() now := time.Now()
next := s.mappers[:0] next := s.mappers[:0]
for _, mapper := range s.mappers { for _, mapper := range s.mappers {
if mapper == nil || mapper.storage == nil {
continue
}
free, dead, active := mapper.storage.SlotCount() free, dead, active := mapper.storage.SlotCount()
if active == 0 && dead == 0 && now.Sub(mapper.lastUsed) > time.Minute { if active == 0 && now.Sub(mapper.lastUsed) > time.Minute {
if mapper.strategy != nil { if mapper.strategy != nil {
mapper.strategy.Disconnect() mapper.strategy.Disconnect()
} }
delete(s.byID, mapper.id) delete(s.mapperByID, mapper.id)
_ = free _ = free
_ = dead
continue continue
} }
next = append(next, mapper) next = append(next, mapper)
@ -116,7 +117,25 @@ func (s *NonceSplitter) GC() {
} }
// Tick is called once per second. // Tick is called once per second.
func (s *NonceSplitter) Tick(ticks uint64) {} func (s *NonceSplitter) Tick(ticks uint64) {
if s == nil {
return
}
strategies := make([]pool.Strategy, 0, len(s.mappers))
s.mu.RLock()
for _, mapper := range s.mappers {
if mapper == nil || mapper.strategy == nil {
continue
}
strategies = append(strategies, mapper.strategy)
}
s.mu.RUnlock()
for _, strategy := range strategies {
if ticker, ok := strategy.(interface{ Tick(uint64) }); ok {
ticker.Tick(ticks)
}
}
}
// Upstreams returns pool connection counts. // Upstreams returns pool connection counts.
func (s *NonceSplitter) Upstreams() proxy.UpstreamStats { func (s *NonceSplitter) Upstreams() proxy.UpstreamStats {
@ -129,40 +148,91 @@ func (s *NonceSplitter) Upstreams() proxy.UpstreamStats {
for _, mapper := range s.mappers { for _, mapper := range s.mappers {
if mapper.strategy != nil && mapper.strategy.IsActive() { if mapper.strategy != nil && mapper.strategy.IsActive() {
stats.Active++ stats.Active++
} else if mapper.suspended > 0 { } else if mapper.suspended > 0 || !mapper.active {
stats.Error++ stats.Error++
} }
} }
stats.Total = uint64(len(s.mappers)) stats.Total = stats.Active + stats.Sleep + stats.Error
return stats return stats
} }
// Disconnect closes all upstream pool connections and forgets the current mapper set.
func (s *NonceSplitter) Disconnect() {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
for _, mapper := range s.mappers {
if mapper != nil && mapper.strategy != nil {
mapper.strategy.Disconnect()
}
}
s.mappers = nil
s.mapperByID = make(map[int64]*NonceMapper)
}
// ReloadPools reconnects each mapper strategy using the updated pool list.
//
// s.ReloadPools()
func (s *NonceSplitter) ReloadPools() {
if s == nil {
return
}
strategies := make([]pool.Strategy, 0, len(s.mappers))
s.mu.RLock()
for _, mapper := range s.mappers {
if mapper == nil || mapper.strategy == nil {
continue
}
strategies = append(strategies, mapper.strategy)
}
s.mu.RUnlock()
for _, strategy := range strategies {
if reloadable, ok := strategy.(pool.ReloadableStrategy); ok {
reloadable.ReloadPools()
}
}
}
func (s *NonceSplitter) addMapperLocked() *NonceMapper { func (s *NonceSplitter) addMapperLocked() *NonceMapper {
id := s.seq id := s.nextMapperID
s.seq++ s.nextMapperID++
mapper := NewNonceMapper(id, s.cfg, nil) mapper := NewNonceMapper(id, s.config, nil)
mapper.events = s.events mapper.events = s.events
mapper.lastUsed = time.Now() mapper.lastUsed = time.Now()
mapper.strategy = s.strategyFactory(mapper) mapper.strategy = s.strategyFactory(mapper)
s.mappers = append(s.mappers, mapper) s.mappers = append(s.mappers, mapper)
if s.byID == nil { if s.mapperByID == nil {
s.byID = make(map[int64]*NonceMapper) s.mapperByID = make(map[int64]*NonceMapper)
} }
s.byID[mapper.id] = mapper s.mapperByID[mapper.id] = mapper
mapper.Start()
return mapper return mapper
} }
// NewNonceMapper creates a mapper for one upstream connection. // NewNonceMapper creates a mapper for one upstream connection.
func NewNonceMapper(id int64, cfg *proxy.Config, strategy pool.Strategy) *NonceMapper { func NewNonceMapper(id int64, config *proxy.Config, strategy pool.Strategy) *NonceMapper {
return &NonceMapper{ return &NonceMapper{
id: id, id: id,
storage: NewNonceStorage(), storage: NewNonceStorage(),
strategy: strategy, strategy: strategy,
pending: make(map[int64]SubmitContext), pending: make(map[int64]SubmitContext),
cfg: cfg, config: config,
} }
} }
// Start connects the mapper's upstream strategy once.
func (m *NonceMapper) Start() {
if m == nil || m.strategy == nil {
return
}
m.startOnce.Do(func() {
m.lastUsed = time.Now()
m.strategy.Connect()
})
}
// Add assigns a miner to a free slot. // Add assigns a miner to a free slot.
func (m *NonceMapper) Add(miner *proxy.Miner) bool { func (m *NonceMapper) Add(miner *proxy.Miner) bool {
if m == nil || miner == nil { if m == nil || miner == nil {
@ -179,7 +249,7 @@ func (m *NonceMapper) Add(miner *proxy.Miner) bool {
job := m.storage.job job := m.storage.job
m.storage.mu.Unlock() m.storage.mu.Unlock()
if job.IsValid() { if job.IsValid() {
miner.ForwardJob(job, job.Algo) miner.SetCurrentJob(job)
} }
} }
return ok return ok
@ -212,14 +282,34 @@ func (m *NonceMapper) Submit(event *proxy.SubmitEvent) {
if jobID == "" { if jobID == "" {
jobID = job.JobID jobID = job.JobID
} }
if jobID == "" || (jobID != job.JobID && jobID != prevJob.JobID) { valid := m.storage.IsValidJobID(jobID)
if jobID == "" || !valid {
m.rejectInvalidJobLocked(event, job)
return return
} }
submissionJob := job
if jobID == prevJob.JobID && prevJob.JobID != "" {
submissionJob = prevJob
}
seq := m.strategy.Submit(jobID, event.Nonce, event.Result, event.Algo) seq := m.strategy.Submit(jobID, event.Nonce, event.Result, event.Algo)
m.pending[seq] = SubmitContext{RequestID: event.RequestID, MinerID: event.Miner.ID(), JobID: jobID} m.pending[seq] = SubmitContext{
RequestID: event.RequestID,
MinerID: event.Miner.ID(),
JobID: jobID,
Diff: proxy.EffectiveShareDifficulty(submissionJob, event.Miner),
StartedAt: time.Now(),
}
m.lastUsed = time.Now() m.lastUsed = time.Now()
} }
func (m *NonceMapper) rejectInvalidJobLocked(event *proxy.SubmitEvent, job proxy.Job) {
event.Miner.ReplyWithError(event.RequestID, "Invalid job id")
if m.events != nil {
jobCopy := job
m.events.Dispatch(proxy.Event{Type: proxy.EventReject, Miner: event.Miner, Job: &jobCopy, Error: "Invalid job id"})
}
}
// IsActive reports whether the mapper has received a valid job. // IsActive reports whether the mapper has received a valid job.
func (m *NonceMapper) IsActive() bool { func (m *NonceMapper) IsActive() bool {
if m == nil { if m == nil {
@ -258,24 +348,40 @@ func (m *NonceMapper) OnResultAccepted(sequence int64, accepted bool, errorMessa
job := m.storage.job job := m.storage.job
prevJob := m.storage.prevJob prevJob := m.storage.prevJob
m.storage.mu.Unlock() m.storage.mu.Unlock()
expired := ctx.JobID != "" && ctx.JobID == prevJob.JobID && ctx.JobID != job.JobID job, expired := resolveSubmissionJob(ctx.JobID, job, prevJob)
m.mu.Unlock() m.mu.Unlock()
if !ok || miner == nil { if !ok || miner == nil {
return return
} }
latency := uint16(0)
if !ctx.StartedAt.IsZero() {
elapsed := time.Since(ctx.StartedAt).Milliseconds()
if elapsed > int64(^uint16(0)) {
latency = ^uint16(0)
} else {
latency = uint16(elapsed)
}
}
if accepted { if accepted {
miner.Success(ctx.RequestID, "OK") miner.Success(ctx.RequestID, "OK")
if m.events != nil { if m.events != nil {
m.events.Dispatch(proxy.Event{Type: proxy.EventAccept, Miner: miner, Job: &job, Diff: job.DifficultyFromTarget(), Latency: 0, Expired: expired}) m.events.Dispatch(proxy.Event{Type: proxy.EventAccept, Miner: miner, Job: &job, Diff: ctx.Diff, Latency: latency, Expired: expired})
} }
return return
} }
miner.ReplyWithError(ctx.RequestID, errorMessage) miner.ReplyWithError(ctx.RequestID, errorMessage)
if m.events != nil { if m.events != nil {
m.events.Dispatch(proxy.Event{Type: proxy.EventReject, Miner: miner, Job: &job, Diff: job.DifficultyFromTarget(), Error: errorMessage}) m.events.Dispatch(proxy.Event{Type: proxy.EventReject, Miner: miner, Job: &job, Diff: ctx.Diff, Error: errorMessage, Latency: latency})
} }
} }
func resolveSubmissionJob(jobID string, currentJob, previousJob proxy.Job) (proxy.Job, bool) {
if jobID != "" && jobID == previousJob.JobID && jobID != currentJob.JobID {
return previousJob, true
}
return currentJob, false
}
func (m *NonceMapper) OnDisconnect() { func (m *NonceMapper) OnDisconnect() {
if m == nil { if m == nil {
return return
@ -286,12 +392,16 @@ func (m *NonceMapper) OnDisconnect() {
m.suspended++ m.suspended++
} }
// NewNonceStorage creates an empty slot table. // NewNonceStorage creates a 256-slot table ready for round-robin miner allocation.
//
// storage := nicehash.NewNonceStorage()
func NewNonceStorage() *NonceStorage { func NewNonceStorage() *NonceStorage {
return &NonceStorage{miners: make(map[int64]*proxy.Miner)} return &NonceStorage{miners: make(map[int64]*proxy.Miner)}
} }
// Add finds the next free slot. // Add assigns the next free slot, such as 0x2a, to one miner.
//
// ok := storage.Add(&proxy.Miner{})
func (s *NonceStorage) Add(miner *proxy.Miner) bool { func (s *NonceStorage) Add(miner *proxy.Miner) bool {
if s == nil || miner == nil { if s == nil || miner == nil {
return false return false
@ -312,7 +422,9 @@ func (s *NonceStorage) Add(miner *proxy.Miner) bool {
return false return false
} }
// Remove marks a slot as dead. // Remove marks one miner's slot as dead until the next SetJob call.
//
// storage.Remove(miner)
func (s *NonceStorage) Remove(miner *proxy.Miner) { func (s *NonceStorage) Remove(miner *proxy.Miner) {
if s == nil || miner == nil { if s == nil || miner == nil {
return return
@ -326,7 +438,9 @@ func (s *NonceStorage) Remove(miner *proxy.Miner) {
delete(s.miners, miner.ID()) delete(s.miners, miner.ID())
} }
// SetJob replaces the current job and sends it to active miners. // SetJob broadcasts one pool job to all active miners and clears dead slots.
//
// storage.SetJob(proxy.Job{Blob: strings.Repeat("0", 160), JobID: "job-1"})
func (s *NonceStorage) SetJob(job proxy.Job) { func (s *NonceStorage) SetJob(job proxy.Job) {
if s == nil || !job.IsValid() { if s == nil || !job.IsValid() {
return return
@ -352,17 +466,31 @@ func (s *NonceStorage) SetJob(job proxy.Job) {
} }
} }
// IsValidJobID returns true if the id matches the current or previous job. // IsValidJobID accepts the current job, or the immediately previous one after a pool roll.
//
// if !storage.IsValidJobID("job-1") { return }
func (s *NonceStorage) IsValidJobID(id string) bool { func (s *NonceStorage) IsValidJobID(id string) bool {
if s == nil { if s == nil {
return false return false
} }
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
return id != "" && (id == s.job.JobID || id == s.prevJob.JobID) if id == "" {
return false
}
if id == s.job.JobID {
return true
}
if id == s.prevJob.JobID && s.prevJob.JobID != "" {
s.expired++
return true
}
return false
} }
// SlotCount returns free, dead, and active counts. // SlotCount returns free, dead, and active slot counts such as 254, 1, 1.
//
// free, dead, active := storage.SlotCount()
func (s *NonceStorage) SlotCount() (free, dead, active int) { func (s *NonceStorage) SlotCount() (free, dead, active int) {
if s == nil { if s == nil {
return 0, 0, 0 return 0, 0, 0

View file

@ -18,11 +18,12 @@ type NonceMapper struct {
storage *NonceStorage storage *NonceStorage
strategy pool.Strategy // manages pool client lifecycle and failover strategy pool.Strategy // manages pool client lifecycle and failover
pending map[int64]SubmitContext // sequence → {requestID, minerID} pending map[int64]SubmitContext // sequence → {requestID, minerID}
cfg *proxy.Config config *proxy.Config
events *proxy.EventBus events *proxy.EventBus
active bool // true once pool has sent at least one job active bool // true once pool has sent at least one job
suspended int // > 0 when pool connection is in error/reconnecting suspended int // > 0 when pool connection is in error/reconnecting
lastUsed time.Time lastUsed time.Time
startOnce sync.Once
mu sync.Mutex mu sync.Mutex
} }
@ -33,4 +34,6 @@ type SubmitContext struct {
RequestID int64 // JSON-RPC id from the miner's submit request RequestID int64 // JSON-RPC id from the miner's submit request
MinerID int64 // miner that submitted MinerID int64 // miner that submitted
JobID string JobID string
Diff uint64
StartedAt time.Time
} }

View file

@ -0,0 +1,243 @@
package nicehash
import (
"bufio"
"encoding/json"
"net"
"sync"
"testing"
"time"
"dappco.re/go/proxy"
)
type startCountingStrategy struct {
mu sync.Mutex
connect int
}
func (s *startCountingStrategy) Connect() {
s.mu.Lock()
defer s.mu.Unlock()
s.connect++
}
func (s *startCountingStrategy) Submit(jobID, nonce, result, algo string) int64 {
return 0
}
func (s *startCountingStrategy) Disconnect() {}
func (s *startCountingStrategy) IsActive() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.connect > 0
}
type discardConn struct{}
func (discardConn) Read([]byte) (int, error) { return 0, nil }
func (discardConn) Write(p []byte) (int, error) { return len(p), nil }
func (discardConn) Close() error { return nil }
func (discardConn) LocalAddr() net.Addr { return nil }
func (discardConn) RemoteAddr() net.Addr { return nil }
func (discardConn) SetDeadline(time.Time) error { return nil }
func (discardConn) SetReadDeadline(time.Time) error { return nil }
func (discardConn) SetWriteDeadline(time.Time) error { return nil }
func TestMapper_Start_Good(t *testing.T) {
strategy := &startCountingStrategy{}
mapper := NewNonceMapper(1, &proxy.Config{}, strategy)
mapper.Start()
if strategy.connect != 1 {
t.Fatalf("expected one connect call, got %d", strategy.connect)
}
}
func TestMapper_Start_Bad(t *testing.T) {
mapper := NewNonceMapper(1, &proxy.Config{}, nil)
mapper.Start()
}
func TestMapper_Start_Ugly(t *testing.T) {
strategy := &startCountingStrategy{}
mapper := NewNonceMapper(1, &proxy.Config{}, strategy)
mapper.Start()
mapper.Start()
if strategy.connect != 1 {
t.Fatalf("expected Start to be idempotent, got %d connect calls", strategy.connect)
}
}
func TestMapper_Submit_InvalidJob_Good(t *testing.T) {
minerConn, clientConn := net.Pipe()
defer minerConn.Close()
defer clientConn.Close()
miner := proxy.NewMiner(minerConn, 3333, nil)
miner.SetID(7)
strategy := &startCountingStrategy{}
mapper := NewNonceMapper(1, &proxy.Config{}, strategy)
mapper.storage.job = proxy.Job{JobID: "job-1", Blob: "blob", Target: "b88d0600"}
done := make(chan struct{})
go func() {
mapper.Submit(&proxy.SubmitEvent{
Miner: miner,
JobID: "job-missing",
Nonce: "deadbeef",
Result: "hash",
RequestID: 42,
})
close(done)
}()
line, err := bufio.NewReader(clientConn).ReadBytes('\n')
if err != nil {
t.Fatalf("read error reply: %v", err)
}
<-done
var payload struct {
ID float64 `json:"id"`
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(line, &payload); err != nil {
t.Fatalf("unmarshal error reply: %v", err)
}
if payload.ID != 42 {
t.Fatalf("expected request id 42, got %v", payload.ID)
}
if payload.Error.Message != "Invalid job id" {
t.Fatalf("expected invalid job error, got %q", payload.Error.Message)
}
if len(mapper.pending) != 0 {
t.Fatalf("expected invalid submit not to create a pending entry")
}
}
func TestMapper_OnResultAccepted_ExpiredUsesPreviousJob(t *testing.T) {
bus := proxy.NewEventBus()
events := make(chan proxy.Event, 1)
bus.Subscribe(proxy.EventAccept, func(e proxy.Event) {
events <- e
})
miner := proxy.NewMiner(discardConn{}, 3333, nil)
miner.SetID(7)
mapper := NewNonceMapper(1, &proxy.Config{}, &startCountingStrategy{})
mapper.events = bus
mapper.storage.job = proxy.Job{JobID: "job-new", Blob: "blob-new", Target: "b88d0600"}
mapper.storage.prevJob = proxy.Job{JobID: "job-old", Blob: "blob-old", Target: "b88d0600"}
mapper.storage.miners[miner.ID()] = miner
if !mapper.storage.IsValidJobID("job-old") {
t.Fatal("expected previous job to validate before result handling")
}
mapper.pending[9] = SubmitContext{
RequestID: 42,
MinerID: miner.ID(),
JobID: "job-old",
StartedAt: time.Now(),
}
mapper.OnResultAccepted(9, true, "")
if got := mapper.storage.expired; got != 1 {
t.Fatalf("expected one expired validation, got %d", got)
}
select {
case event := <-events:
if !event.Expired {
t.Fatalf("expected expired share to be flagged")
}
if event.Job == nil || event.Job.JobID != "job-old" {
t.Fatalf("expected previous job to be attached, got %+v", event.Job)
}
case <-time.After(time.Second):
t.Fatal("expected accept event")
}
}
func TestMapper_Submit_ExpiredJobUsesPreviousDifficulty(t *testing.T) {
miner := proxy.NewMiner(discardConn{}, 3333, nil)
miner.SetID(9)
strategy := &submitCaptureStrategy{}
mapper := NewNonceMapper(1, &proxy.Config{}, strategy)
mapper.storage.job = proxy.Job{JobID: "job-new", Blob: "blob-new", Target: "ffffffff"}
mapper.storage.prevJob = proxy.Job{JobID: "job-old", Blob: "blob-old", Target: "b88d0600"}
mapper.storage.miners[miner.ID()] = miner
mapper.Submit(&proxy.SubmitEvent{
Miner: miner,
JobID: "job-old",
Nonce: "deadbeef",
Result: "hash",
RequestID: 88,
})
ctx, ok := mapper.pending[strategy.seq]
if !ok {
t.Fatal("expected pending submit context for expired job")
}
want := mapper.storage.prevJob.DifficultyFromTarget()
if ctx.Diff != want {
t.Fatalf("expected previous-job difficulty %d, got %d", want, ctx.Diff)
}
}
type submitCaptureStrategy struct {
seq int64
}
func (s *submitCaptureStrategy) Connect() {}
func (s *submitCaptureStrategy) Submit(jobID, nonce, result, algo string) int64 {
s.seq++
return s.seq
}
func (s *submitCaptureStrategy) Disconnect() {}
func (s *submitCaptureStrategy) IsActive() bool { return true }
func TestMapper_OnResultAccepted_CustomDiffUsesEffectiveDifficulty(t *testing.T) {
bus := proxy.NewEventBus()
events := make(chan proxy.Event, 1)
bus.Subscribe(proxy.EventAccept, func(e proxy.Event) {
events <- e
})
miner := proxy.NewMiner(discardConn{}, 3333, nil)
miner.SetID(8)
mapper := NewNonceMapper(1, &proxy.Config{}, &startCountingStrategy{})
mapper.events = bus
mapper.storage.job = proxy.Job{JobID: "job-new", Blob: "blob-new", Target: "b88d0600"}
mapper.storage.miners[miner.ID()] = miner
mapper.pending[10] = SubmitContext{
RequestID: 77,
MinerID: miner.ID(),
JobID: "job-new",
Diff: 25000,
StartedAt: time.Now(),
}
mapper.OnResultAccepted(10, true, "")
select {
case event := <-events:
if event.Diff != 25000 {
t.Fatalf("expected effective difficulty 25000, got %d", event.Diff)
}
case <-time.After(time.Second):
t.Fatal("expected accept event")
}
}

View file

@ -0,0 +1,67 @@
package nicehash
import (
"testing"
"dappco.re/go/proxy"
"dappco.re/go/proxy/pool"
)
type reloadableStrategy struct {
reloads int
}
func (s *reloadableStrategy) Connect() {}
func (s *reloadableStrategy) Submit(jobID, nonce, result, algo string) int64 { return 0 }
func (s *reloadableStrategy) Disconnect() {}
func (s *reloadableStrategy) IsActive() bool { return true }
func (s *reloadableStrategy) ReloadPools() { s.reloads++ }
var _ pool.ReloadableStrategy = (*reloadableStrategy)(nil)
func TestNonceSplitter_ReloadPools_Good(t *testing.T) {
strategy := &reloadableStrategy{}
splitter := &NonceSplitter{
mappers: []*NonceMapper{
{strategy: strategy},
},
}
splitter.ReloadPools()
if strategy.reloads != 1 {
t.Fatalf("expected mapper strategy to reload once, got %d", strategy.reloads)
}
}
func TestNonceSplitter_ReloadPools_Bad(t *testing.T) {
splitter := &NonceSplitter{
mappers: []*NonceMapper{
{strategy: nil},
},
}
splitter.ReloadPools()
}
func TestNonceSplitter_ReloadPools_Ugly(t *testing.T) {
splitter := NewNonceSplitter(&proxy.Config{}, proxy.NewEventBus(), func(listener pool.StratumListener) pool.Strategy {
return &reloadableStrategy{}
})
splitter.mappers = []*NonceMapper{
{strategy: &reloadableStrategy{}},
{strategy: &reloadableStrategy{}},
}
splitter.ReloadPools()
for index, mapper := range splitter.mappers {
strategy, ok := mapper.strategy.(*reloadableStrategy)
if !ok {
t.Fatalf("expected reloadable strategy at mapper %d", index)
}
if strategy.reloads != 1 {
t.Fatalf("expected mapper %d to reload once, got %d", index, strategy.reloads)
}
}
}

View file

@ -23,10 +23,10 @@ import (
// s.Connect() // s.Connect()
type NonceSplitter struct { type NonceSplitter struct {
mappers []*NonceMapper mappers []*NonceMapper
byID map[int64]*NonceMapper mapperByID map[int64]*NonceMapper
cfg *proxy.Config config *proxy.Config
events *proxy.EventBus events *proxy.EventBus
strategyFactory pool.StrategyFactory strategyFactory pool.StrategyFactory
mu sync.RWMutex mu sync.RWMutex
seq int64 nextMapperID int64
} }

View file

@ -20,6 +20,7 @@ type NonceStorage struct {
miners map[int64]*proxy.Miner // minerID → Miner pointer for active miners miners map[int64]*proxy.Miner // minerID → Miner pointer for active miners
job proxy.Job // current job from pool job proxy.Job // current job from pool
prevJob proxy.Job // previous job (for stale submit validation) prevJob proxy.Job // previous job (for stale submit validation)
cursor int // search starts here (round-robin allocation) expired uint64
cursor int // search starts here (round-robin allocation)
mu sync.Mutex mu sync.Mutex
} }

View file

@ -6,21 +6,155 @@ import (
"dappco.re/go/proxy" "dappco.re/go/proxy"
) )
func TestNonceStorage_AddAndRemove(t *testing.T) { // TestStorage_Add_Good verifies 256 sequential Add calls fill all slots with unique FixedByte values.
//
// storage := nicehash.NewNonceStorage()
// for i := 0; i < 256; i++ {
// m := &proxy.Miner{}
// m.SetID(int64(i + 1))
// ok := storage.Add(m) // true for all 256
// }
func TestStorage_Add_Good(t *testing.T) {
storage := NewNonceStorage()
seen := make(map[uint8]bool)
for i := 0; i < 256; i++ {
m := &proxy.Miner{}
m.SetID(int64(i + 1))
ok := storage.Add(m)
if !ok {
t.Fatalf("expected add %d to succeed", i)
}
if seen[m.FixedByte()] {
t.Fatalf("duplicate fixed byte %d at add %d", m.FixedByte(), i)
}
seen[m.FixedByte()] = true
}
}
// TestStorage_Add_Bad verifies the 257th Add returns false when all 256 slots are occupied.
//
// storage := nicehash.NewNonceStorage()
// // fill 256 slots...
// ok := storage.Add(overflowMiner) // false — table is full
func TestStorage_Add_Bad(t *testing.T) {
storage := NewNonceStorage()
for i := 0; i < 256; i++ {
m := &proxy.Miner{}
m.SetID(int64(i + 1))
storage.Add(m)
}
overflow := &proxy.Miner{}
overflow.SetID(257)
if storage.Add(overflow) {
t.Fatalf("expected 257th add to fail when table is full")
}
}
// TestStorage_Add_Ugly verifies that a removed slot (dead) is reclaimed after SetJob clears it.
//
// storage := nicehash.NewNonceStorage()
// storage.Add(miner)
// storage.Remove(miner) // slot becomes dead (-minerID)
// storage.SetJob(job) // dead slots cleared to 0
// storage.Add(newMiner) // reclaimed slot succeeds
func TestStorage_Add_Ugly(t *testing.T) {
storage := NewNonceStorage() storage := NewNonceStorage()
miner := &proxy.Miner{} miner := &proxy.Miner{}
miner.SetID(1) miner.SetID(1)
if !storage.Add(miner) { if !storage.Add(miner) {
t.Fatalf("expected add to succeed") t.Fatalf("expected first add to succeed")
}
if miner.FixedByte() != 0 {
t.Fatalf("expected first slot to be 0, got %d", miner.FixedByte())
} }
storage.Remove(miner) storage.Remove(miner)
free, dead, active := storage.SlotCount() free, dead, active := storage.SlotCount()
if free != 255 || dead != 1 || active != 0 { if dead != 1 || active != 0 {
t.Fatalf("unexpected slot counts: free=%d dead=%d active=%d", free, dead, active) t.Fatalf("expected 1 dead slot, got free=%d dead=%d active=%d", free, dead, active)
}
// SetJob clears dead slots
storage.SetJob(proxy.Job{Blob: "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", JobID: "job-1"})
free, dead, active = storage.SlotCount()
if dead != 0 {
t.Fatalf("expected dead slots cleared after SetJob, got %d", dead)
}
// Reclaim the slot
newMiner := &proxy.Miner{}
newMiner.SetID(2)
if !storage.Add(newMiner) {
t.Fatalf("expected reclaimed slot add to succeed")
}
}
// TestStorage_IsValidJobID_Good verifies the current job ID is accepted.
//
// storage := nicehash.NewNonceStorage()
// storage.SetJob(proxy.Job{JobID: "job-2", Blob: "..."})
// storage.IsValidJobID("job-2") // true
func TestStorage_IsValidJobID_Good(t *testing.T) {
storage := NewNonceStorage()
storage.SetJob(proxy.Job{
JobID: "job-1",
Blob: "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
})
if !storage.IsValidJobID("job-1") {
t.Fatalf("expected current job to be valid")
}
}
// TestStorage_IsValidJobID_Bad verifies an unknown job ID is rejected.
//
// storage := nicehash.NewNonceStorage()
// storage.IsValidJobID("nonexistent") // false
func TestStorage_IsValidJobID_Bad(t *testing.T) {
storage := NewNonceStorage()
storage.SetJob(proxy.Job{
JobID: "job-1",
Blob: "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
})
if storage.IsValidJobID("nonexistent") {
t.Fatalf("expected unknown job id to be invalid")
}
if storage.IsValidJobID("") {
t.Fatalf("expected empty job id to be invalid")
}
}
// TestStorage_IsValidJobID_Ugly verifies the previous job ID is accepted but counts as expired.
//
// storage := nicehash.NewNonceStorage()
// // job-1 is current, job-2 pushes job-1 to previous
// storage.IsValidJobID("job-1") // true (but expired counter increments)
func TestStorage_IsValidJobID_Ugly(t *testing.T) {
storage := NewNonceStorage()
blob160 := "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
storage.SetJob(proxy.Job{JobID: "job-1", Blob: blob160, ClientID: "session-1"})
storage.SetJob(proxy.Job{JobID: "job-2", Blob: blob160, ClientID: "session-1"})
if !storage.IsValidJobID("job-2") {
t.Fatalf("expected current job to be valid")
}
if !storage.IsValidJobID("job-1") {
t.Fatalf("expected previous job to remain valid")
}
if storage.expired != 1 {
t.Fatalf("expected one expired job validation, got %d", storage.expired)
}
}
// TestStorage_SlotCount_Good verifies free/dead/active counts on a fresh storage.
//
// storage := nicehash.NewNonceStorage()
// free, dead, active := storage.SlotCount() // 256, 0, 0
func TestStorage_SlotCount_Good(t *testing.T) {
storage := NewNonceStorage()
free, dead, active := storage.SlotCount()
if free != 256 || dead != 0 || active != 0 {
t.Fatalf("expected 256/0/0, got free=%d dead=%d active=%d", free, dead, active)
} }
} }

View file

@ -0,0 +1,69 @@
package nicehash
import (
"testing"
"dappco.re/go/proxy"
)
type upstreamStateStrategy struct {
active bool
}
func (s *upstreamStateStrategy) Connect() {}
func (s *upstreamStateStrategy) Submit(jobID, nonce, result, algo string) int64 {
return 0
}
func (s *upstreamStateStrategy) Disconnect() {}
func (s *upstreamStateStrategy) IsActive() bool { return s.active }
func TestNonceSplitter_Upstreams_Good(t *testing.T) {
splitter := &NonceSplitter{
mappers: []*NonceMapper{
{strategy: &upstreamStateStrategy{active: true}, active: true},
{strategy: &upstreamStateStrategy{active: false}, active: false, suspended: 1},
},
}
stats := splitter.Upstreams()
if stats.Active != 1 {
t.Fatalf("expected one active upstream, got %d", stats.Active)
}
if stats.Error != 1 {
t.Fatalf("expected one error upstream, got %d", stats.Error)
}
if stats.Total != 2 {
t.Fatalf("expected total to equal active + sleep + error, got %d", stats.Total)
}
}
func TestNonceSplitter_Upstreams_Bad(t *testing.T) {
var splitter *NonceSplitter
stats := splitter.Upstreams()
if stats != (proxy.UpstreamStats{}) {
t.Fatalf("expected zero-value stats for nil splitter, got %+v", stats)
}
}
func TestNonceSplitter_Upstreams_Ugly(t *testing.T) {
splitter := &NonceSplitter{
mappers: []*NonceMapper{
{strategy: &upstreamStateStrategy{active: false}, active: false},
},
}
stats := splitter.Upstreams()
if stats.Error != 1 {
t.Fatalf("expected an unready mapper to be counted as error, got %+v", stats)
}
if stats.Total != 1 {
t.Fatalf("expected total to remain internally consistent, got %+v", stats)
}
}

View file

@ -8,21 +8,21 @@ import (
) )
func init() { func init() {
proxy.RegisterSplitterFactory("simple", func(cfg *proxy.Config, events *proxy.EventBus) proxy.Splitter { proxy.RegisterSplitterFactory("simple", func(config *proxy.Config, eventBus *proxy.EventBus) proxy.Splitter {
return NewSimpleSplitter(cfg, events, pool.NewStrategyFactory(cfg)) return NewSimpleSplitter(config, eventBus, pool.NewStrategyFactory(config))
}) })
} }
// NewSimpleSplitter creates the passthrough splitter. // NewSimpleSplitter creates the passthrough splitter.
func NewSimpleSplitter(cfg *proxy.Config, events *proxy.EventBus, factory pool.StrategyFactory) *SimpleSplitter { func NewSimpleSplitter(config *proxy.Config, eventBus *proxy.EventBus, factory pool.StrategyFactory) *SimpleSplitter {
if factory == nil { if factory == nil {
factory = pool.NewStrategyFactory(cfg) factory = pool.NewStrategyFactory(config)
} }
return &SimpleSplitter{ return &SimpleSplitter{
active: make(map[int64]*SimpleMapper), active: make(map[int64]*SimpleMapper),
idle: make(map[int64]*SimpleMapper), idle: make(map[int64]*SimpleMapper),
cfg: cfg, config: config,
events: events, events: eventBus,
factory: factory, factory: factory,
} }
} }
@ -53,16 +53,20 @@ func (s *SimpleSplitter) OnLogin(event *proxy.LoginEvent) {
} }
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
now := time.Now()
if s.cfg.ReuseTimeout > 0 { if s.config.ReuseTimeout > 0 {
for id, mapper := range s.idle { for id, mapper := range s.idle {
if mapper.strategy != nil && mapper.strategy.IsActive() { if mapper.strategy != nil && mapper.strategy.IsActive() && !mapper.idleAt.IsZero() && now.Sub(mapper.idleAt) <= time.Duration(s.config.ReuseTimeout)*time.Second {
delete(s.idle, id) delete(s.idle, id)
mapper.miner = event.Miner mapper.miner = event.Miner
mapper.idleAt = time.Time{} mapper.idleAt = time.Time{}
mapper.stopped = false mapper.stopped = false
s.active[event.Miner.ID()] = mapper s.active[event.Miner.ID()] = mapper
event.Miner.SetRouteID(mapper.id) event.Miner.SetRouteID(mapper.id)
if mapper.currentJob.IsValid() {
event.Miner.SetCurrentJob(mapper.currentJob)
}
return return
} }
} }
@ -83,7 +87,7 @@ func (s *SimpleSplitter) OnSubmit(event *proxy.SubmitEvent) {
return return
} }
s.mu.Lock() s.mu.Lock()
mapper := s.active[event.Miner.ID()] mapper := s.activeMapperByRouteIDLocked(event.Miner.RouteID())
s.mu.Unlock() s.mu.Unlock()
if mapper != nil { if mapper != nil {
mapper.Submit(event) mapper.Submit(event)
@ -105,7 +109,7 @@ func (s *SimpleSplitter) OnClose(event *proxy.CloseEvent) {
mapper.miner = nil mapper.miner = nil
mapper.idleAt = time.Now() mapper.idleAt = time.Now()
event.Miner.SetRouteID(-1) event.Miner.SetRouteID(-1)
if s.cfg.ReuseTimeout > 0 { if s.config.ReuseTimeout > 0 {
s.idle[mapper.id] = mapper s.idle[mapper.id] = mapper
return return
} }
@ -124,7 +128,7 @@ func (s *SimpleSplitter) GC() {
defer s.mu.Unlock() defer s.mu.Unlock()
now := time.Now() now := time.Now()
for id, mapper := range s.idle { for id, mapper := range s.idle {
if mapper.stopped || (s.cfg.ReuseTimeout > 0 && now.Sub(mapper.idleAt) > time.Duration(s.cfg.ReuseTimeout)*time.Second) { if mapper.stopped || (s.config.ReuseTimeout > 0 && now.Sub(mapper.idleAt) > time.Duration(s.config.ReuseTimeout)*time.Second) {
if mapper.strategy != nil { if mapper.strategy != nil {
mapper.strategy.Disconnect() mapper.strategy.Disconnect()
} }
@ -133,8 +137,31 @@ func (s *SimpleSplitter) GC() {
} }
} }
// Tick is a no-op for simple mode. // Tick advances timeout checks in simple mode.
func (s *SimpleSplitter) Tick(ticks uint64) {} func (s *SimpleSplitter) Tick(ticks uint64) {
if s == nil {
return
}
strategies := make([]pool.Strategy, 0, len(s.active)+len(s.idle))
s.mu.Lock()
for _, mapper := range s.active {
if mapper != nil && mapper.strategy != nil {
strategies = append(strategies, mapper.strategy)
}
}
for _, mapper := range s.idle {
if mapper != nil && mapper.strategy != nil {
strategies = append(strategies, mapper.strategy)
}
}
s.mu.Unlock()
for _, strategy := range strategies {
if ticker, ok := strategy.(interface{ Tick(uint64) }); ok {
ticker.Tick(ticks)
}
}
s.GC()
}
// Upstreams returns active/idle/error counts. // Upstreams returns active/idle/error counts.
func (s *SimpleSplitter) Upstreams() proxy.UpstreamStats { func (s *SimpleSplitter) Upstreams() proxy.UpstreamStats {
@ -144,20 +171,85 @@ func (s *SimpleSplitter) Upstreams() proxy.UpstreamStats {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
var stats proxy.UpstreamStats var stats proxy.UpstreamStats
stats.Active = uint64(len(s.active)) for _, mapper := range s.active {
stats.Sleep = uint64(len(s.idle)) if mapper == nil {
stats.Total = stats.Active + stats.Sleep continue
}
if mapper.stopped || mapper.strategy == nil || !mapper.strategy.IsActive() {
stats.Error++
continue
}
stats.Active++
}
for _, mapper := range s.idle {
if mapper == nil {
continue
}
if mapper.stopped || mapper.strategy == nil || !mapper.strategy.IsActive() {
stats.Error++
continue
}
stats.Sleep++
}
stats.Total = stats.Active + stats.Sleep + stats.Error
return stats return stats
} }
func (s *SimpleSplitter) newMapperLocked() *SimpleMapper { // Disconnect closes every active or idle upstream connection and clears the mapper tables.
id := s.seq func (s *SimpleSplitter) Disconnect() {
s.seq++ if s == nil {
mapper := &SimpleMapper{ return
id: id,
events: s.events,
pending: make(map[int64]*proxy.SubmitEvent),
} }
s.mu.Lock()
defer s.mu.Unlock()
for _, mapper := range s.active {
if mapper != nil && mapper.strategy != nil {
mapper.strategy.Disconnect()
}
}
for _, mapper := range s.idle {
if mapper != nil && mapper.strategy != nil {
mapper.strategy.Disconnect()
}
}
s.active = make(map[int64]*SimpleMapper)
s.idle = make(map[int64]*SimpleMapper)
}
// ReloadPools reconnects each active or idle mapper using the updated pool list.
//
// s.ReloadPools()
func (s *SimpleSplitter) ReloadPools() {
if s == nil {
return
}
strategies := make([]pool.Strategy, 0, len(s.active)+len(s.idle))
s.mu.Lock()
for _, mapper := range s.active {
if mapper == nil || mapper.strategy == nil {
continue
}
strategies = append(strategies, mapper.strategy)
}
for _, mapper := range s.idle {
if mapper == nil || mapper.strategy == nil {
continue
}
strategies = append(strategies, mapper.strategy)
}
s.mu.Unlock()
for _, strategy := range strategies {
if reloadable, ok := strategy.(pool.ReloadableStrategy); ok {
reloadable.ReloadPools()
}
}
}
func (s *SimpleSplitter) newMapperLocked() *SimpleMapper {
id := s.nextMapperID
s.nextMapperID++
mapper := NewSimpleMapper(id, nil)
mapper.events = s.events
mapper.strategy = s.factory(mapper) mapper.strategy = s.factory(mapper)
if mapper.strategy == nil { if mapper.strategy == nil {
mapper.strategy = s.factory(mapper) mapper.strategy = s.factory(mapper)
@ -165,6 +257,18 @@ func (s *SimpleSplitter) newMapperLocked() *SimpleMapper {
return mapper return mapper
} }
func (s *SimpleSplitter) activeMapperByRouteIDLocked(routeID int64) *SimpleMapper {
if s == nil || routeID < 0 {
return nil
}
for _, mapper := range s.active {
if mapper != nil && mapper.id == routeID {
return mapper
}
}
return nil
}
// Submit forwards a share to the pool. // Submit forwards a share to the pool.
func (m *SimpleMapper) Submit(event *proxy.SubmitEvent) { func (m *SimpleMapper) Submit(event *proxy.SubmitEvent) {
if m == nil || event == nil || m.strategy == nil { if m == nil || event == nil || m.strategy == nil {
@ -172,8 +276,36 @@ func (m *SimpleMapper) Submit(event *proxy.SubmitEvent) {
} }
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
seq := m.strategy.Submit(event.JobID, event.Nonce, event.Result, event.Algo) jobID := event.JobID
m.pending[seq] = event if jobID == "" {
jobID = m.currentJob.JobID
}
if jobID == "" || (jobID != m.currentJob.JobID && jobID != m.prevJob.JobID) {
m.rejectInvalidJobLocked(event, m.currentJob)
return
}
submissionJob := m.currentJob
if jobID == m.prevJob.JobID && m.prevJob.JobID != "" {
submissionJob = m.prevJob
}
seq := m.strategy.Submit(jobID, event.Nonce, event.Result, event.Algo)
m.pending[seq] = submitContext{
RequestID: event.RequestID,
Diff: proxy.EffectiveShareDifficulty(submissionJob, event.Miner),
StartedAt: time.Now(),
JobID: jobID,
}
}
func (m *SimpleMapper) rejectInvalidJobLocked(event *proxy.SubmitEvent, job proxy.Job) {
if event == nil || event.Miner == nil {
return
}
event.Miner.ReplyWithError(event.RequestID, "Invalid job id")
if m.events != nil {
jobCopy := job
m.events.Dispatch(proxy.Event{Type: proxy.EventReject, Miner: event.Miner, Job: &jobCopy, Error: "Invalid job id"})
}
} }
// OnJob forwards the latest pool job to the active miner. // OnJob forwards the latest pool job to the active miner.
@ -182,6 +314,13 @@ func (m *SimpleMapper) OnJob(job proxy.Job) {
return return
} }
m.mu.Lock() m.mu.Lock()
m.prevJob = m.currentJob
if m.prevJob.ClientID != job.ClientID {
m.prevJob = proxy.Job{}
}
m.currentJob = job
m.stopped = false
m.idleAt = time.Time{}
miner := m.miner miner := m.miner
m.mu.Unlock() m.mu.Unlock()
if miner == nil { if miner == nil {
@ -196,25 +335,42 @@ func (m *SimpleMapper) OnResultAccepted(sequence int64, accepted bool, errorMess
return return
} }
m.mu.Lock() m.mu.Lock()
ctx := m.pending[sequence] ctx, ok := m.pending[sequence]
delete(m.pending, sequence) if ok {
delete(m.pending, sequence)
}
miner := m.miner miner := m.miner
currentJob := m.currentJob
prevJob := m.prevJob
m.mu.Unlock() m.mu.Unlock()
if ctx == nil || miner == nil { if !ok || miner == nil {
return return
} }
latency := uint16(0)
if !ctx.StartedAt.IsZero() {
elapsed := time.Since(ctx.StartedAt).Milliseconds()
if elapsed > int64(^uint16(0)) {
latency = ^uint16(0)
} else {
latency = uint16(elapsed)
}
}
job := currentJob
expired := false
if ctx.JobID != "" && ctx.JobID == prevJob.JobID && ctx.JobID != currentJob.JobID {
job = prevJob
expired = true
}
if accepted { if accepted {
miner.Success(ctx.RequestID, "OK") miner.Success(ctx.RequestID, "OK")
if m.events != nil { if m.events != nil {
job := miner.CurrentJob() m.events.Dispatch(proxy.Event{Type: proxy.EventAccept, Miner: miner, Diff: ctx.Diff, Job: &job, Latency: latency, Expired: expired})
m.events.Dispatch(proxy.Event{Type: proxy.EventAccept, Miner: miner, Diff: job.DifficultyFromTarget(), Job: &job})
} }
return return
} }
miner.ReplyWithError(ctx.RequestID, errorMessage) miner.ReplyWithError(ctx.RequestID, errorMessage)
if m.events != nil { if m.events != nil {
job := miner.CurrentJob() m.events.Dispatch(proxy.Event{Type: proxy.EventReject, Miner: miner, Diff: ctx.Diff, Job: &job, Error: errorMessage, Latency: latency})
m.events.Dispatch(proxy.Event{Type: proxy.EventReject, Miner: miner, Diff: job.DifficultyFromTarget(), Job: &job, Error: errorMessage})
} }
} }

View file

@ -0,0 +1,377 @@
package simple
import (
"bufio"
"encoding/json"
"io"
"net"
"sync"
"testing"
"time"
"dappco.re/go/proxy"
"dappco.re/go/proxy/pool"
)
type activeStrategy struct{}
func (a activeStrategy) Connect() {}
func (a activeStrategy) Submit(string, string, string, string) int64 { return 0 }
func (a activeStrategy) Disconnect() {}
func (a activeStrategy) IsActive() bool { return true }
type submitRecordingStrategy struct {
submits int
}
func (s *submitRecordingStrategy) Connect() {}
func (s *submitRecordingStrategy) Submit(string, string, string, string) int64 {
s.submits++
return int64(s.submits)
}
func (s *submitRecordingStrategy) Disconnect() {}
func (s *submitRecordingStrategy) IsActive() bool { return true }
func TestSimpleMapper_New_Good(t *testing.T) {
strategy := activeStrategy{}
mapper := NewSimpleMapper(7, strategy)
if mapper == nil {
t.Fatal("expected mapper")
}
if mapper.id != 7 {
t.Fatalf("expected mapper id 7, got %d", mapper.id)
}
if mapper.strategy != strategy {
t.Fatalf("expected strategy to be stored")
}
if mapper.pending == nil {
t.Fatal("expected pending map to be initialised")
}
}
func TestSimpleSplitter_OnLogin_Good(t *testing.T) {
splitter := NewSimpleSplitter(&proxy.Config{ReuseTimeout: 30}, nil, func(listener pool.StratumListener) pool.Strategy {
return activeStrategy{}
})
miner := &proxy.Miner{}
job := proxy.Job{JobID: "job-1", Blob: "blob"}
mapper := &SimpleMapper{
id: 7,
strategy: activeStrategy{},
currentJob: job,
idleAt: time.Now(),
}
splitter.idle[mapper.id] = mapper
splitter.OnLogin(&proxy.LoginEvent{Miner: miner})
if miner.RouteID() != mapper.id {
t.Fatalf("expected reclaimed mapper route id %d, got %d", mapper.id, miner.RouteID())
}
if got := miner.CurrentJob().JobID; got != job.JobID {
t.Fatalf("expected current job to be restored on reuse, got %q", got)
}
}
func TestSimpleSplitter_OnLogin_Ugly(t *testing.T) {
splitter := NewSimpleSplitter(&proxy.Config{ReuseTimeout: 30}, nil, func(listener pool.StratumListener) pool.Strategy {
return activeStrategy{}
})
miner := &proxy.Miner{}
expired := &SimpleMapper{
id: 7,
strategy: activeStrategy{},
idleAt: time.Now().Add(-time.Minute),
}
splitter.idle[expired.id] = expired
splitter.OnLogin(&proxy.LoginEvent{Miner: miner})
if miner.RouteID() == expired.id {
t.Fatalf("expected expired mapper not to be reclaimed")
}
if miner.RouteID() != 0 {
t.Fatalf("expected a new mapper to be allocated, got route id %d", miner.RouteID())
}
if len(splitter.active) != 1 {
t.Fatalf("expected one active mapper, got %d", len(splitter.active))
}
if len(splitter.idle) != 1 {
t.Fatalf("expected expired mapper to remain idle until GC, got %d idle mappers", len(splitter.idle))
}
}
func TestSimpleSplitter_OnSubmit_UsesRouteID_Good(t *testing.T) {
strategy := &submitRecordingStrategy{}
splitter := NewSimpleSplitter(&proxy.Config{ReuseTimeout: 30}, nil, nil)
miner := proxy.NewMiner(discardConn{}, 3333, nil)
miner.SetID(21)
miner.SetRouteID(7)
mapper := &SimpleMapper{
id: 7,
miner: miner,
currentJob: proxy.Job{JobID: "job-1", Blob: "blob", Target: "b88d0600"},
strategy: strategy,
pending: make(map[int64]submitContext),
}
splitter.active[99] = mapper
splitter.OnSubmit(&proxy.SubmitEvent{
Miner: miner,
JobID: "job-1",
Nonce: "deadbeef",
Result: "hash",
RequestID: 11,
})
if strategy.submits != 1 {
t.Fatalf("expected one submit routed by route id, got %d", strategy.submits)
}
if len(mapper.pending) != 1 {
t.Fatalf("expected routed submit to create one pending entry, got %d", len(mapper.pending))
}
}
func TestSimpleSplitter_Upstreams_Good(t *testing.T) {
splitter := NewSimpleSplitter(&proxy.Config{ReuseTimeout: 30}, nil, func(listener pool.StratumListener) pool.Strategy {
return activeStrategy{}
})
splitter.active[1] = &SimpleMapper{id: 1, strategy: activeStrategy{}}
splitter.idle[2] = &SimpleMapper{id: 2, strategy: activeStrategy{}, idleAt: time.Now()}
stats := splitter.Upstreams()
if stats.Active != 1 {
t.Fatalf("expected one active upstream, got %d", stats.Active)
}
if stats.Sleep != 1 {
t.Fatalf("expected one sleeping upstream, got %d", stats.Sleep)
}
if stats.Error != 0 {
t.Fatalf("expected no error upstreams, got %d", stats.Error)
}
if stats.Total != 2 {
t.Fatalf("expected total upstreams to be 2, got %d", stats.Total)
}
}
func TestSimpleSplitter_Upstreams_Ugly(t *testing.T) {
splitter := NewSimpleSplitter(&proxy.Config{ReuseTimeout: 30}, nil, func(listener pool.StratumListener) pool.Strategy {
return activeStrategy{}
})
splitter.active[1] = &SimpleMapper{id: 1, strategy: activeStrategy{}, stopped: true}
splitter.idle[2] = &SimpleMapper{id: 2, strategy: activeStrategy{}, stopped: true, idleAt: time.Now()}
stats := splitter.Upstreams()
if stats.Active != 0 {
t.Fatalf("expected no active upstreams, got %d", stats.Active)
}
if stats.Sleep != 0 {
t.Fatalf("expected no sleeping upstreams, got %d", stats.Sleep)
}
if stats.Error != 2 {
t.Fatalf("expected both upstreams to be counted as error, got %d", stats.Error)
}
if stats.Total != 2 {
t.Fatalf("expected total upstreams to be 2, got %d", stats.Total)
}
}
func TestSimpleSplitter_Upstreams_RecoveryResetsStopped_Good(t *testing.T) {
splitter := NewSimpleSplitter(&proxy.Config{ReuseTimeout: 30}, nil, func(listener pool.StratumListener) pool.Strategy {
return activeStrategy{}
})
mapper := &SimpleMapper{id: 1, strategy: activeStrategy{}, stopped: true}
splitter.active[1] = mapper
before := splitter.Upstreams()
if before.Error != 1 {
t.Fatalf("expected disconnected mapper to count as error, got %+v", before)
}
mapper.OnJob(proxy.Job{JobID: "job-1", Blob: "blob"})
after := splitter.Upstreams()
if after.Active != 1 {
t.Fatalf("expected recovered mapper to count as active, got %+v", after)
}
if after.Error != 0 {
t.Fatalf("expected recovered mapper not to remain in error, got %+v", after)
}
}
type discardConn struct{}
func (discardConn) Read([]byte) (int, error) { return 0, io.EOF }
func (discardConn) Write(p []byte) (int, error) { return len(p), nil }
func (discardConn) Close() error { return nil }
func (discardConn) LocalAddr() net.Addr { return nil }
func (discardConn) RemoteAddr() net.Addr { return nil }
func (discardConn) SetDeadline(time.Time) error { return nil }
func (discardConn) SetReadDeadline(time.Time) error { return nil }
func (discardConn) SetWriteDeadline(time.Time) error { return nil }
func TestSimpleMapper_OnResultAccepted_Expired(t *testing.T) {
bus := proxy.NewEventBus()
events := make(chan proxy.Event, 1)
var once sync.Once
bus.Subscribe(proxy.EventAccept, func(e proxy.Event) {
once.Do(func() {
events <- e
})
})
miner := proxy.NewMiner(discardConn{}, 3333, nil)
miner.SetID(1)
mapper := &SimpleMapper{
miner: miner,
currentJob: proxy.Job{JobID: "job-new", Blob: "blob-new", Target: "b88d0600"},
prevJob: proxy.Job{JobID: "job-old", Blob: "blob-old", Target: "b88d0600"},
events: bus,
pending: map[int64]submitContext{
7: {RequestID: 9, StartedAt: time.Now(), JobID: "job-old"},
},
}
mapper.OnResultAccepted(7, true, "")
select {
case event := <-events:
if !event.Expired {
t.Fatalf("expected expired share to be flagged")
}
if event.Job == nil || event.Job.JobID != "job-old" {
t.Fatalf("expected previous job to be attached, got %+v", event.Job)
}
case <-time.After(time.Second):
t.Fatal("expected accept event")
}
}
func TestSimpleMapper_OnResultAccepted_CustomDiffUsesEffectiveDifficulty(t *testing.T) {
bus := proxy.NewEventBus()
events := make(chan proxy.Event, 1)
var once sync.Once
bus.Subscribe(proxy.EventAccept, func(e proxy.Event) {
once.Do(func() {
events <- e
})
})
miner := proxy.NewMiner(discardConn{}, 3333, nil)
miner.SetID(2)
job := proxy.Job{JobID: "job-new", Blob: "blob-new", Target: "b88d0600"}
mapper := &SimpleMapper{
miner: miner,
currentJob: job,
events: bus,
pending: map[int64]submitContext{
8: {
RequestID: 10,
Diff: 25000,
StartedAt: time.Now(),
JobID: "job-new",
},
},
}
mapper.OnResultAccepted(8, true, "")
select {
case event := <-events:
if event.Diff != 25000 {
t.Fatalf("expected effective difficulty 25000, got %d", event.Diff)
}
case <-time.After(time.Second):
t.Fatal("expected accept event")
}
}
func TestSimpleMapper_OnJob_PreservesPreviousJobForSamePoolSession_Good(t *testing.T) {
mapper := &SimpleMapper{
currentJob: proxy.Job{JobID: "job-1", Blob: "blob-1", ClientID: "session-a"},
}
mapper.OnJob(proxy.Job{JobID: "job-2", Blob: "blob-2", ClientID: "session-a"})
if mapper.currentJob.JobID != "job-2" {
t.Fatalf("expected current job to roll forward, got %q", mapper.currentJob.JobID)
}
if mapper.prevJob.JobID != "job-1" {
t.Fatalf("expected previous job to remain available within one pool session, got %q", mapper.prevJob.JobID)
}
}
func TestSimpleMapper_OnJob_ResetsPreviousJobAcrossPoolSessions_Ugly(t *testing.T) {
mapper := &SimpleMapper{
currentJob: proxy.Job{JobID: "job-1", Blob: "blob-1", ClientID: "session-a"},
prevJob: proxy.Job{JobID: "job-0", Blob: "blob-0", ClientID: "session-a"},
}
mapper.OnJob(proxy.Job{JobID: "job-2", Blob: "blob-2", ClientID: "session-b"})
if mapper.currentJob.JobID != "job-2" {
t.Fatalf("expected current job to advance after session change, got %q", mapper.currentJob.JobID)
}
if mapper.prevJob.JobID != "" {
t.Fatalf("expected previous job history to reset on new pool session, got %q", mapper.prevJob.JobID)
}
}
func TestSimpleMapper_Submit_InvalidJob_Good(t *testing.T) {
minerConn, clientConn := net.Pipe()
defer minerConn.Close()
defer clientConn.Close()
miner := proxy.NewMiner(minerConn, 3333, nil)
mapper := &SimpleMapper{
miner: miner,
currentJob: proxy.Job{JobID: "job-1", Blob: "blob", Target: "b88d0600"},
prevJob: proxy.Job{JobID: "job-0", Blob: "blob", Target: "b88d0600"},
strategy: activeStrategy{},
pending: make(map[int64]submitContext),
}
done := make(chan struct{})
go func() {
mapper.Submit(&proxy.SubmitEvent{
Miner: miner,
JobID: "job-missing",
Nonce: "deadbeef",
Result: "hash",
RequestID: 9,
})
close(done)
}()
line, err := bufio.NewReader(clientConn).ReadBytes('\n')
if err != nil {
t.Fatalf("read error reply: %v", err)
}
<-done
var payload struct {
ID float64 `json:"id"`
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(line, &payload); err != nil {
t.Fatalf("unmarshal error reply: %v", err)
}
if payload.ID != 9 {
t.Fatalf("expected request id 9, got %v", payload.ID)
}
if payload.Error.Message != "Invalid job id" {
t.Fatalf("expected invalid job error, got %q", payload.Error.Message)
}
if len(mapper.pending) != 0 {
t.Fatalf("expected invalid submit not to create a pending entry")
}
}

View file

@ -14,12 +14,32 @@ import (
// //
// m := simple.NewSimpleMapper(id, strategy) // m := simple.NewSimpleMapper(id, strategy)
type SimpleMapper struct { type SimpleMapper struct {
id int64 id int64
miner *proxy.Miner // nil when idle miner *proxy.Miner // nil when idle
strategy pool.Strategy currentJob proxy.Job
idleAt time.Time // zero when active prevJob proxy.Job
stopped bool strategy pool.Strategy
events *proxy.EventBus idleAt time.Time // zero when active
pending map[int64]*proxy.SubmitEvent stopped bool
mu sync.Mutex events *proxy.EventBus
pending map[int64]submitContext
mu sync.Mutex
}
type submitContext struct {
RequestID int64
Diff uint64
StartedAt time.Time
JobID string
}
// NewSimpleMapper creates a passthrough mapper for one pool connection.
//
// m := simple.NewSimpleMapper(7, strategy)
func NewSimpleMapper(id int64, strategy pool.Strategy) *SimpleMapper {
return &SimpleMapper{
id: id,
strategy: strategy,
pending: make(map[int64]submitContext),
}
} }

View file

@ -0,0 +1,68 @@
package simple
import (
"testing"
"dappco.re/go/proxy/pool"
)
type reloadableStrategy struct {
reloads int
}
func (s *reloadableStrategy) Connect() {}
func (s *reloadableStrategy) Submit(jobID, nonce, result, algo string) int64 { return 0 }
func (s *reloadableStrategy) Disconnect() {}
func (s *reloadableStrategy) IsActive() bool { return true }
func (s *reloadableStrategy) ReloadPools() { s.reloads++ }
var _ pool.ReloadableStrategy = (*reloadableStrategy)(nil)
func TestSimpleSplitter_ReloadPools_Good(t *testing.T) {
strategy := &reloadableStrategy{}
splitter := &SimpleSplitter{
active: map[int64]*SimpleMapper{
1: {strategy: strategy},
},
idle: map[int64]*SimpleMapper{},
}
splitter.ReloadPools()
if strategy.reloads != 1 {
t.Fatalf("expected active mapper strategy to reload once, got %d", strategy.reloads)
}
}
func TestSimpleSplitter_ReloadPools_Bad(t *testing.T) {
splitter := &SimpleSplitter{
active: map[int64]*SimpleMapper{
1: {strategy: nil},
},
idle: map[int64]*SimpleMapper{},
}
splitter.ReloadPools()
}
func TestSimpleSplitter_ReloadPools_Ugly(t *testing.T) {
active := &reloadableStrategy{}
idle := &reloadableStrategy{}
splitter := &SimpleSplitter{
active: map[int64]*SimpleMapper{
1: {strategy: active},
},
idle: map[int64]*SimpleMapper{
2: {strategy: idle},
},
}
splitter.ReloadPools()
if active.reloads != 1 {
t.Fatalf("expected active mapper reload, got %d", active.reloads)
}
if idle.reloads != 1 {
t.Fatalf("expected idle mapper reload, got %d", idle.reloads)
}
}

View file

@ -18,11 +18,11 @@ import (
// //
// s := simple.NewSimpleSplitter(cfg, eventBus, strategyFactory) // s := simple.NewSimpleSplitter(cfg, eventBus, strategyFactory)
type SimpleSplitter struct { type SimpleSplitter struct {
active map[int64]*SimpleMapper // minerID → mapper active map[int64]*SimpleMapper // minerID → mapper
idle map[int64]*SimpleMapper // mapperID → mapper (reuse pool, keyed by mapper seq) idle map[int64]*SimpleMapper // mapperID → mapper (reuse pool, keyed by mapper ID)
cfg *proxy.Config config *proxy.Config
events *proxy.EventBus events *proxy.EventBus
factory pool.StrategyFactory factory pool.StrategyFactory
mu sync.Mutex mu sync.Mutex
seq int64 // monotonic mapper sequence counter nextMapperID int64 // monotonic mapper ID counter
} }

File diff suppressed because it is too large Load diff

137
state_stop_test.go Normal file
View file

@ -0,0 +1,137 @@
package proxy
import (
"net"
"testing"
"time"
)
func TestProxy_Stop_Good(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
miner := NewMiner(clientConn, 3333, nil)
splitter := &stubSplitter{}
proxyInstance := &Proxy{
done: make(chan struct{}),
miners: map[int64]*Miner{miner.ID(): miner},
splitter: splitter,
}
done := make(chan error, 1)
go func() {
buf := make([]byte, 1)
_, err := serverConn.Read(buf)
done <- err
}()
time.Sleep(10 * time.Millisecond)
proxyInstance.Stop()
select {
case err := <-done:
if err == nil {
t.Fatalf("expected miner connection to close during Stop")
}
case <-time.After(time.Second):
t.Fatalf("expected miner connection to close during Stop")
}
if !splitter.disconnected {
t.Fatalf("expected splitter to be disconnected during Stop")
}
}
func TestProxy_Stop_Bad(t *testing.T) {
var proxyInstance *Proxy
proxyInstance.Stop()
}
func TestProxy_Stop_Ugly(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
miner := NewMiner(clientConn, 3333, nil)
proxyInstance := &Proxy{
done: make(chan struct{}),
miners: map[int64]*Miner{miner.ID(): miner},
}
proxyInstance.Stop()
proxyInstance.Stop()
buf := make([]byte, 1)
if _, err := serverConn.Read(buf); err == nil {
t.Fatalf("expected closed connection after repeated Stop calls")
}
}
func TestProxy_Stop_WaitsBeforeDisconnectingSubmitPaths(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
miner := NewMiner(clientConn, 3333, nil)
splitter := &blockingStopSplitter{disconnectedCh: make(chan struct{})}
proxyInstance := &Proxy{
done: make(chan struct{}),
miners: map[int64]*Miner{miner.ID(): miner},
splitter: splitter,
}
proxyInstance.submitCount.Store(1)
stopped := make(chan struct{})
go func() {
proxyInstance.Stop()
close(stopped)
}()
select {
case <-splitter.disconnectedCh:
t.Fatalf("expected splitter disconnect to wait for submit drain")
case <-stopped:
t.Fatalf("expected Stop to keep waiting while submits are in flight")
case <-time.After(50 * time.Millisecond):
}
proxyInstance.submitCount.Store(0)
select {
case <-splitter.disconnectedCh:
case <-time.After(time.Second):
t.Fatalf("expected splitter disconnect after submit drain")
}
select {
case <-stopped:
case <-time.After(time.Second):
t.Fatalf("expected Stop to finish after submit drain")
}
}
type stubSplitter struct {
disconnected bool
}
func (s *stubSplitter) Connect() {}
func (s *stubSplitter) OnLogin(event *LoginEvent) {}
func (s *stubSplitter) OnSubmit(event *SubmitEvent) {}
func (s *stubSplitter) OnClose(event *CloseEvent) {}
func (s *stubSplitter) Tick(ticks uint64) {}
func (s *stubSplitter) GC() {}
func (s *stubSplitter) Upstreams() UpstreamStats { return UpstreamStats{} }
func (s *stubSplitter) Disconnect() { s.disconnected = true }
type blockingStopSplitter struct {
disconnectedCh chan struct{}
}
func (s *blockingStopSplitter) Connect() {}
func (s *blockingStopSplitter) OnLogin(event *LoginEvent) {}
func (s *blockingStopSplitter) OnSubmit(event *SubmitEvent) {}
func (s *blockingStopSplitter) OnClose(event *CloseEvent) {}
func (s *blockingStopSplitter) Tick(ticks uint64) {}
func (s *blockingStopSplitter) GC() {}
func (s *blockingStopSplitter) Upstreams() UpstreamStats { return UpstreamStats{} }
func (s *blockingStopSplitter) Disconnect() {
close(s.disconnectedCh)
}

33
state_submit_test.go Normal file
View file

@ -0,0 +1,33 @@
package proxy
import (
"testing"
"time"
)
func TestProxy_Stop_WaitsForSubmitDrain(t *testing.T) {
p := &Proxy{
done: make(chan struct{}),
}
p.submitCount.Store(1)
stopped := make(chan struct{})
go func() {
p.Stop()
close(stopped)
}()
select {
case <-stopped:
t.Fatalf("expected Stop to wait for pending submits")
case <-time.After(50 * time.Millisecond):
}
p.submitCount.Store(0)
select {
case <-stopped:
case <-time.After(time.Second):
t.Fatalf("expected Stop to finish after pending submits drain")
}
}

View file

@ -9,9 +9,11 @@ import (
// Stats tracks global proxy metrics. Hot-path counters are atomic. Hashrate windows // Stats tracks global proxy metrics. Hot-path counters are atomic. Hashrate windows
// use a ring buffer per window size, advanced by Tick(). // use a ring buffer per window size, advanced by Tick().
// //
// s := proxy.NewStats() // stats := proxy.NewStats()
// bus.Subscribe(proxy.EventAccept, s.OnAccept) // bus.Subscribe(proxy.EventAccept, stats.OnAccept)
// bus.Subscribe(proxy.EventReject, s.OnReject) // bus.Subscribe(proxy.EventReject, stats.OnReject)
// stats.Tick()
// summary := stats.Summary()
type Stats struct { type Stats struct {
accepted atomic.Uint64 accepted atomic.Uint64
rejected atomic.Uint64 rejected atomic.Uint64
@ -28,7 +30,6 @@ type Stats struct {
mu sync.Mutex mu sync.Mutex
} }
// Hashrate window sizes in seconds. Index maps to Stats.windows and SummaryResponse.Hashrate.
const ( const (
HashrateWindow60s = 0 // 1 minute HashrateWindow60s = 0 // 1 minute
HashrateWindow600s = 1 // 10 minutes HashrateWindow600s = 1 // 10 minutes
@ -38,7 +39,9 @@ const (
HashrateWindowAll = 5 // all-time (single accumulator, no window) HashrateWindowAll = 5 // all-time (single accumulator, no window)
) )
// tickWindow is a fixed-capacity ring buffer of per-second difficulty sums. // tickWindow is a fixed-capacity ring buffer of per-second difficulty totals.
//
// window := newTickWindow(60)
type tickWindow struct { type tickWindow struct {
buckets []uint64 buckets []uint64
pos int pos int
@ -47,15 +50,17 @@ type tickWindow struct {
// StatsSummary is the serialisable snapshot returned by Summary(). // StatsSummary is the serialisable snapshot returned by Summary().
// //
// summary := stats.Summary() // summary := proxy.NewStats().Summary()
// _ = summary.Hashrate[0] // 60-second window H/s
type StatsSummary struct { type StatsSummary struct {
Accepted uint64 `json:"accepted"` Accepted uint64 `json:"accepted"`
Rejected uint64 `json:"rejected"` Rejected uint64 `json:"rejected"`
Invalid uint64 `json:"invalid"` Invalid uint64 `json:"invalid"`
Expired uint64 `json:"expired"` Expired uint64 `json:"expired"`
Hashes uint64 `json:"hashes_total"` Hashes uint64 `json:"hashes_total"`
AvgTime uint32 `json:"avg_time"` // seconds per accepted share AvgTime uint32 `json:"avg_time"` // seconds per accepted share
AvgLatency uint32 `json:"latency"` // median pool response latency in ms AvgLatency uint32 `json:"latency"` // median pool response latency in ms
Hashrate [6]float64 `json:"hashrate"` // H/s per window (index = HashrateWindow* constants) Hashrate [6]float64 `json:"hashrate"` // H/s per window (index = HashrateWindow* constants)
TopDiff [10]uint64 `json:"best"` TopDiff [10]uint64 `json:"best"`
CustomDiffStats map[uint64]CustomDiffBucketStats `json:"custom_diff_stats,omitempty"`
} }

173
stats_test.go Normal file
View file

@ -0,0 +1,173 @@
package proxy
import (
"sync"
"testing"
)
// TestStats_OnAccept_Good verifies that accepted counter, hashes, and topDiff are updated.
//
// stats := proxy.NewStats()
// stats.OnAccept(proxy.Event{Diff: 100000, Latency: 82})
// summary := stats.Summary()
// _ = summary.Accepted // 1
// _ = summary.Hashes // 100000
func TestStats_OnAccept_Good(t *testing.T) {
stats := NewStats()
stats.OnAccept(Event{Diff: 100000, Latency: 82})
summary := stats.Summary()
if summary.Accepted != 1 {
t.Fatalf("expected accepted 1, got %d", summary.Accepted)
}
if summary.Hashes != 100000 {
t.Fatalf("expected hashes 100000, got %d", summary.Hashes)
}
if summary.TopDiff[0] != 100000 {
t.Fatalf("expected top diff 100000, got %d", summary.TopDiff[0])
}
}
// TestStats_OnAccept_Bad verifies concurrent OnAccept calls do not race.
//
// stats := proxy.NewStats()
// // 100 goroutines each call OnAccept — no data race under -race flag.
func TestStats_OnAccept_Bad(t *testing.T) {
stats := NewStats()
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(diff uint64) {
defer wg.Done()
stats.OnAccept(Event{Diff: diff, Latency: 10})
}(uint64(i + 1))
}
wg.Wait()
summary := stats.Summary()
if summary.Accepted != 100 {
t.Fatalf("expected 100 accepted, got %d", summary.Accepted)
}
}
// TestStats_OnAccept_Ugly verifies that 15 accepts with varying diffs fill all topDiff slots.
//
// stats := proxy.NewStats()
// // 15 accepts with diffs 1..15 → topDiff[9] is 6 (10th highest), not 0
func TestStats_OnAccept_Ugly(t *testing.T) {
stats := NewStats()
for i := 1; i <= 15; i++ {
stats.OnAccept(Event{Diff: uint64(i)})
}
summary := stats.Summary()
// top 10 should be 15, 14, 13, ..., 6
if summary.TopDiff[0] != 15 {
t.Fatalf("expected top diff[0]=15, got %d", summary.TopDiff[0])
}
if summary.TopDiff[9] != 6 {
t.Fatalf("expected top diff[9]=6, got %d", summary.TopDiff[9])
}
}
// TestStats_OnReject_Good verifies that rejected and invalid counters are updated.
//
// stats := proxy.NewStats()
// stats.OnReject(proxy.Event{Error: "Low difficulty share"})
func TestStats_OnReject_Good(t *testing.T) {
stats := NewStats()
stats.OnReject(Event{Error: "Low difficulty share"})
stats.OnReject(Event{Error: "Malformed share"})
summary := stats.Summary()
if summary.Rejected != 2 {
t.Fatalf("expected two rejected shares, got %d", summary.Rejected)
}
if summary.Invalid != 2 {
t.Fatalf("expected two invalid shares, got %d", summary.Invalid)
}
}
// TestStats_OnReject_Bad verifies that a non-invalid rejection increments rejected but not invalid.
//
// stats := proxy.NewStats()
// stats.OnReject(proxy.Event{Error: "Stale share"})
func TestStats_OnReject_Bad(t *testing.T) {
stats := NewStats()
stats.OnReject(Event{Error: "Stale share"})
summary := stats.Summary()
if summary.Rejected != 1 {
t.Fatalf("expected one rejected, got %d", summary.Rejected)
}
if summary.Invalid != 0 {
t.Fatalf("expected zero invalid for non-invalid reason, got %d", summary.Invalid)
}
}
// TestStats_OnReject_Ugly verifies an expired accepted share increments both accepted and expired.
//
// stats := proxy.NewStats()
// stats.OnAccept(proxy.Event{Diff: 1000, Expired: true})
func TestStats_OnReject_Ugly(t *testing.T) {
stats := NewStats()
stats.OnAccept(Event{Diff: 1000, Expired: true})
summary := stats.Summary()
if summary.Accepted != 1 {
t.Fatalf("expected accepted 1, got %d", summary.Accepted)
}
if summary.Expired != 1 {
t.Fatalf("expected expired 1, got %d", summary.Expired)
}
}
// TestStats_Tick_Good verifies that Tick advances the rolling window position.
//
// stats := proxy.NewStats()
// stats.OnAccept(proxy.Event{Diff: 500})
// stats.Tick()
// summary := stats.Summary()
func TestStats_Tick_Good(t *testing.T) {
stats := NewStats()
stats.OnAccept(Event{Diff: 500})
stats.Tick()
summary := stats.Summary()
// After one tick, the hashrate should still include the 500 diff
if summary.Hashrate[HashrateWindow60s] == 0 {
t.Fatalf("expected non-zero 60s hashrate after accept and tick")
}
}
// TestStats_OnLogin_OnClose_Good verifies miner count tracking.
//
// stats := proxy.NewStats()
// stats.OnLogin(proxy.Event{Miner: &proxy.Miner{}})
// stats.OnClose(proxy.Event{Miner: &proxy.Miner{}})
func TestStats_OnLogin_OnClose_Good(t *testing.T) {
stats := NewStats()
m := &Miner{}
stats.OnLogin(Event{Miner: m})
if got := stats.miners.Load(); got != 1 {
t.Fatalf("expected 1 miner, got %d", got)
}
if got := stats.maxMiners.Load(); got != 1 {
t.Fatalf("expected max miners 1, got %d", got)
}
stats.OnClose(Event{Miner: m})
if got := stats.miners.Load(); got != 0 {
t.Fatalf("expected 0 miners after close, got %d", got)
}
if got := stats.maxMiners.Load(); got != 1 {
t.Fatalf("expected max miners to remain 1, got %d", got)
}
}

36
tls_test.go Normal file
View file

@ -0,0 +1,36 @@
package proxy
import (
"crypto/tls"
"testing"
)
func TestTLS_applyTLSCiphers_Good(t *testing.T) {
cfg := &tls.Config{}
applyTLSCiphers(cfg, "ECDHE-RSA-AES128-GCM-SHA256, TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256")
if len(cfg.CipherSuites) != 2 {
t.Fatalf("expected two recognised cipher suites, got %d", len(cfg.CipherSuites))
}
}
func TestTLS_applyTLSCiphers_Bad(t *testing.T) {
cfg := &tls.Config{}
applyTLSCiphers(cfg, "made-up-cipher-one:made-up-cipher-two")
if len(cfg.CipherSuites) != 0 {
t.Fatalf("expected unknown cipher names to be ignored, got %#v", cfg.CipherSuites)
}
}
func TestTLS_applyTLSCiphers_Ugly(t *testing.T) {
cfg := &tls.Config{}
applyTLSCiphers(cfg, " aes128-sha | ECDHE-RSA-AES256-GCM-SHA384 ; tls_ecdhe_ecdsa_with_aes_256_gcm_sha384 ")
if len(cfg.CipherSuites) != 3 {
t.Fatalf("expected mixed separators and casing to be accepted, got %d", len(cfg.CipherSuites))
}
}

View file

@ -8,18 +8,22 @@ import (
// Workers maintains per-worker aggregate stats. Workers are identified by name, // Workers maintains per-worker aggregate stats. Workers are identified by name,
// derived from the miner's login fields per WorkersMode. // derived from the miner's login fields per WorkersMode.
// //
// w := proxy.NewWorkers(proxy.WorkersByRigID, bus) // workers := proxy.NewWorkers(proxy.WorkersByRigID, bus)
// workers.OnLogin(proxy.Event{Miner: miner})
// records := workers.List()
type Workers struct { type Workers struct {
mode WorkersMode mode WorkersMode
entries []WorkerRecord // ordered by first-seen (stable) entries []WorkerRecord // ordered by first-seen (stable)
nameIndex map[string]int // workerName → entries index nameIndex map[string]int // workerName → entries index
idIndex map[int64]int // minerID → entries index idIndex map[int64]int // minerID → entries index
mu sync.RWMutex subscribed bool
mu sync.RWMutex
} }
// WorkerRecord is the per-identity aggregate. // WorkerRecord is the per-identity aggregate with rolling hashrate windows.
// //
// hr60 := record.Hashrate(60) // record := proxy.WorkerRecord{Name: "rig-alpha", Accepted: 10, Hashes: 500000}
// hr60 := record.Hashrate(60) // H/s over the last 60 seconds
type WorkerRecord struct { type WorkerRecord struct {
Name string Name string
LastIP string LastIP string

164
worker_test.go Normal file
View file

@ -0,0 +1,164 @@
package proxy
import "testing"
func TestWorker_NewWorkers_Good(t *testing.T) {
bus := NewEventBus()
workers := NewWorkers(WorkersByRigID, bus)
miner := &Miner{id: 7, user: "wallet", rigID: "rig-1", ip: "10.0.0.1"}
bus.Dispatch(Event{Type: EventLogin, Miner: miner})
records := workers.List()
if len(records) != 1 {
t.Fatalf("expected one worker record, got %d", len(records))
}
if records[0].Name != "rig-1" {
t.Fatalf("expected rig id worker name, got %q", records[0].Name)
}
if records[0].Connections != 1 {
t.Fatalf("expected one connection, got %d", records[0].Connections)
}
}
func TestWorker_NewWorkers_Bad(t *testing.T) {
workers := NewWorkers(WorkersDisabled, nil)
if workers == nil {
t.Fatalf("expected workers instance")
}
if got := workers.List(); len(got) != 0 {
t.Fatalf("expected no worker records, got %d", len(got))
}
}
func TestWorker_NewWorkers_Ugly(t *testing.T) {
bus := NewEventBus()
workers := NewWorkers(WorkersByUser, bus)
workers.bindEvents(bus)
miner := &Miner{id: 11, user: "wallet", ip: "10.0.0.2"}
bus.Dispatch(Event{Type: EventLogin, Miner: miner})
records := workers.List()
if len(records) != 1 {
t.Fatalf("expected one worker record, got %d", len(records))
}
if records[0].Connections != 1 {
t.Fatalf("expected a single subscription path, got %d connections", records[0].Connections)
}
}
// TestWorker_Hashrate_Good verifies that recording an accepted share produces a nonzero
// hashrate reading from the 60-second window.
//
// record := proxy.WorkerRecord{}
// record.Hashrate(60) // > 0.0 after an accepted share
func TestWorker_Hashrate_Good(t *testing.T) {
bus := NewEventBus()
workers := NewWorkers(WorkersByUser, bus)
miner := &Miner{id: 100, user: "hashtest", ip: "10.0.0.10"}
bus.Dispatch(Event{Type: EventLogin, Miner: miner})
bus.Dispatch(Event{Type: EventAccept, Miner: miner, Diff: 50000})
records := workers.List()
if len(records) != 1 {
t.Fatalf("expected one worker record, got %d", len(records))
}
hr := records[0].Hashrate(60)
if hr <= 0 {
t.Fatalf("expected nonzero hashrate for 60-second window after accept, got %f", hr)
}
}
// TestWorker_Hashrate_Bad verifies that an invalid window size returns 0.
//
// record := proxy.WorkerRecord{}
// record.Hashrate(999) // 0.0 (unsupported window)
func TestWorker_Hashrate_Bad(t *testing.T) {
bus := NewEventBus()
workers := NewWorkers(WorkersByUser, bus)
miner := &Miner{id: 101, user: "hashtest-bad", ip: "10.0.0.11"}
bus.Dispatch(Event{Type: EventLogin, Miner: miner})
bus.Dispatch(Event{Type: EventAccept, Miner: miner, Diff: 50000})
records := workers.List()
if len(records) != 1 {
t.Fatalf("expected one worker record, got %d", len(records))
}
hr := records[0].Hashrate(999)
if hr != 0 {
t.Fatalf("expected zero hashrate for unsupported window, got %f", hr)
}
hrZero := records[0].Hashrate(0)
if hrZero != 0 {
t.Fatalf("expected zero hashrate for zero window, got %f", hrZero)
}
hrNeg := records[0].Hashrate(-1)
if hrNeg != 0 {
t.Fatalf("expected zero hashrate for negative window, got %f", hrNeg)
}
}
// TestWorker_Hashrate_Ugly verifies that calling Hashrate on a nil record returns 0
// and that a worker with no accepts also returns 0.
//
// var record *proxy.WorkerRecord
// record.Hashrate(60) // 0.0
func TestWorker_Hashrate_Ugly(t *testing.T) {
var nilRecord *WorkerRecord
if hr := nilRecord.Hashrate(60); hr != 0 {
t.Fatalf("expected zero hashrate for nil record, got %f", hr)
}
bus := NewEventBus()
workers := NewWorkers(WorkersByUser, bus)
miner := &Miner{id: 102, user: "hashtest-ugly", ip: "10.0.0.12"}
bus.Dispatch(Event{Type: EventLogin, Miner: miner})
records := workers.List()
if len(records) != 1 {
t.Fatalf("expected one worker record, got %d", len(records))
}
hr := records[0].Hashrate(60)
if hr != 0 {
t.Fatalf("expected zero hashrate for worker with no accepts, got %f", hr)
}
}
func TestWorker_CustomDiffOrdering_Good(t *testing.T) {
cfg := &Config{
Mode: "nicehash",
Workers: WorkersByUser,
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
Pools: []PoolConfig{{URL: "pool.example:3333", Enabled: true}},
CustomDiff: 50000,
AccessLogFile: "",
}
p, result := New(cfg)
if !result.OK {
t.Fatalf("expected valid proxy, got error: %v", result.Error)
}
miner := &Miner{
id: 21,
user: "WALLET+50000",
ip: "10.0.0.3",
conn: noopConn{},
}
p.events.Dispatch(Event{Type: EventLogin, Miner: miner})
records := p.WorkerRecords()
if len(records) != 1 {
t.Fatalf("expected one worker record, got %d", len(records))
}
if records[0].Name != "WALLET" {
t.Fatalf("expected custom diff login suffix to be stripped before worker registration, got %q", records[0].Name)
}
if miner.User() != "WALLET" {
t.Fatalf("expected miner user to be stripped before downstream consumers, got %q", miner.User())
}
}